pax_global_header00006660000000000000000000000064142346524460014524gustar00rootroot0000000000000052 comment=b27e84b144ac5d8aea40360305a1372a1d603429 qpageview-0.6.2/000077500000000000000000000000001423465244600135215ustar00rootroot00000000000000qpageview-0.6.2/.gitignore000066400000000000000000000034071423465244600155150ustar00rootroot00000000000000# Byte-compiled / optimized / DLL files __pycache__/ *.py[cod] *$py.class # C extensions *.so # Distribution / packaging .Python build/ develop-eggs/ dist/ downloads/ eggs/ .eggs/ lib/ lib64/ parts/ sdist/ var/ wheels/ pip-wheel-metadata/ share/python-wheels/ *.egg-info/ .installed.cfg *.egg MANIFEST # PyInstaller # Usually these files are written by a python script from a template # before PyInstaller builds the exe, so as to inject date/other infos into it. *.manifest *.spec # Installer logs pip-log.txt pip-delete-this-directory.txt # Unit test / coverage reports htmlcov/ .tox/ .nox/ .coverage .coverage.* .cache nosetests.xml coverage.xml *.cover *.py,cover .hypothesis/ .pytest_cache/ # Translations *.mo *.pot # Django stuff: *.log local_settings.py db.sqlite3 db.sqlite3-journal # Flask stuff: instance/ .webassets-cache # Scrapy stuff: .scrapy # Sphinx documentation docs/_build/ # PyBuilder target/ # Jupyter Notebook .ipynb_checkpoints # IPython profile_default/ ipython_config.py # pyenv .python-version # pipenv # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. # However, in case of collaboration, if having platform-specific dependencies or dependencies # having no cross-platform support, pipenv may install dependencies that don't work, or not # install all needed dependencies. #Pipfile.lock # PEP 582; used by e.g. github.com/David-OConnor/pyflow __pypackages__/ # Celery stuff celerybeat-schedule celerybeat.pid # SageMath parsed files *.sage.py # Environments .env .venv env/ venv/ ENV/ env.bak/ venv.bak/ # Spyder project settings .spyderproject .spyproject # Rope project settings .ropeproject # mkdocs documentation /site # mypy .mypy_cache/ .dmypy.json dmypy.json # Pyre type checker .pyre/ qpageview-0.6.2/ChangeLog000066400000000000000000000023461423465244600153000ustar00rootroot00000000000000ChangeLog ========= 2022-05-05: qpageview-0.6.2 * Maintainance release * Kept another implicit float->int conversion from happening by having Scrollarea.remainingScrollTime() returning an int * Some robustness improvements * Documentation improvements 2021-11-11: qpageview 0.6.1 * View.strictPagingEnabled always lets PgUp/PgDn scroll a page instead of a screenful * Don't depend on implicit float->int conversions, which were deprecated since Python 3.8 and not supported anymore by Python 3.10 * Fixed initial zoomfactor for ImageView when fitNaturalSizeEnabled is True 2021-01-07: qpageview 0.6.0 * added view.View.pages() method (#2) * added view.View.setPages() method (inspired by #4) 2020-04-25: qpageview 0.5.1 * Many documentation updates * Add PagerAction.setButtonSymbols() * fix flickering mouse cursor on rubberband * make it easier to manipulate the edge/corner of the rubberband 2020-04-19: qpageview 0.5.0 Initial release. The qpageview module was developed by me, Wilbert Berendsen, as a replacement of the qpopplerview module inside Frescobaldi, the LilyPond sheet music text editor. I decided that it would be best if qpageview became its own project, to make it easier to use this package in other applications. qpageview-0.6.2/INSTALL.rst000066400000000000000000000005031423465244600153570ustar00rootroot00000000000000Installing qpageview ==================== This package installs one Python package, ``qpageview``, in the usual location for Python modules. You can install qpageview without downloading it first via ``pip``:: pip install qpageview You can also install from the source directory:: python3 setup.py install qpageview-0.6.2/LICENSE000066400000000000000000001050461423465244600145340ustar00rootroot00000000000000GNU General Public License ========================== *Version 3, 29 June 2007* *Copyright © 2007 Free Software Foundation, Inc* Everyone is permitted to copy and distribute verbatim copies of this license document, but changing it is not allowed. Preamble -------- The GNU General Public License is a free, copyleft license for software and other kinds of works. The licenses for most software and other practical works are designed to take away your freedom to share and change the works. By contrast, the GNU General Public License is intended to guarantee your freedom to share and change all versions of a program--to make sure it remains free software for all its users. We, the Free Software Foundation, use the GNU General Public License for most of our software; it applies also to any other work released this way by its authors. You can apply it to your programs, too. When we speak of free software, we are referring to freedom, not price. Our General Public Licenses are designed to make sure that you have the freedom to distribute copies of free software (and charge for them if you wish), that you receive source code or can get it if you want it, that you can change the software or use pieces of it in new free programs, and that you know you can do these things. To protect your rights, we need to prevent others from denying you these rights or asking you to surrender the rights. Therefore, you have certain responsibilities if you distribute copies of the software, or if you modify it: responsibilities to respect the freedom of others. For example, if you distribute copies of such a program, whether gratis or for a fee, you must pass on to the recipients the same freedoms that you received. You must make sure that they, too, receive or can get the source code. And you must show them these terms so they know their rights. Developers that use the GNU GPL protect your rights with two steps: **(1)** assert copyright on the software, and **(2)** offer you this License giving you legal permission to copy, distribute and/or modify it. For the developers' and authors' protection, the GPL clearly explains that there is no warranty for this free software. For both users' and authors' sake, the GPL requires that modified versions be marked as changed, so that their problems will not be attributed erroneously to authors of previous versions. Some devices are designed to deny users access to install or run modified versions of the software inside them, although the manufacturer can do so. This is fundamentally incompatible with the aim of protecting users' freedom to change the software. The systematic pattern of such abuse occurs in the area of products for individuals to use, which is precisely where it is most unacceptable. Therefore, we have designed this version of the GPL to prohibit the practice for those products. If such problems arise substantially in other domains, we stand ready to extend this provision to those domains in future versions of the GPL, as needed to protect the freedom of users. Finally, every program is threatened constantly by software patents. States should not allow patents to restrict development and use of software on general-purpose computers, but in those that do, we wish to avoid the special danger that patents applied to a free program could make it effectively proprietary. To prevent this, the GPL assures that patents cannot be used to render the program non-free. The precise terms and conditions for copying, distribution and modification follow. TERMS AND CONDITIONS -------------------- 0. Definitions ~~~~~~~~~~~~~~ "This License" refers to version 3 of the GNU General Public License. "Copyright" also means copyright-like laws that apply to other kinds of works, such as semiconductor masks. "The Program" refers to any copyrightable work licensed under this License. Each licensee is addressed as "you". "Licensees" and "recipients" may be individuals or organizations. To "modify" a work means to copy from or adapt all or part of the work in a fashion requiring copyright permission, other than the making of an exact copy. The resulting work is called a "modified version" of the earlier work or a work "based on" the earlier work. A "covered work" means either the unmodified Program or a work based on the Program. To "propagate" a work means to do anything with it that, without permission, would make you directly or secondarily liable for infringement under applicable copyright law, except executing it on a computer or modifying a private copy. Propagation includes copying, distribution (with or without modification), making available to the public, and in some countries other activities as well. To "convey" a work means any kind of propagation that enables other parties to make or receive copies. Mere interaction with a user through a computer network, with no transfer of a copy, is not conveying. An interactive user interface displays "Appropriate Legal Notices" to the extent that it includes a convenient and prominently visible feature that **(1)** displays an appropriate copyright notice, and **(2)** tells the user that there is no warranty for the work (except to the extent that warranties are provided), that licensees may convey the work under this License, and how to view a copy of this License. If the interface presents a list of user commands or options, such as a menu, a prominent item in the list meets this criterion. 1. Source Code ~~~~~~~~~~~~~~ The "source code" for a work means the preferred form of the work for making modifications to it. "Object code" means any non-source form of a work. A "Standard Interface" means an interface that either is an official standard defined by a recognized standards body, or, in the case of interfaces specified for a particular programming language, one that is widely used among developers working in that language. The "System Libraries" of an executable work include anything, other than the work as a whole, that **(a)** is included in the normal form of packaging a Major Component, but which is not part of that Major Component, and **(b)** serves only to enable use of the work with that Major Component, or to implement a Standard Interface for which an implementation is available to the public in source code form. A "Major Component", in this context, means a major essential component (kernel, window system, and so on) of the specific operating system (if any) on which the executable work runs, or a compiler used to produce the work, or an object code interpreter used to run it. The "Corresponding Source" for a work in object code form means all the source code needed to generate, install, and (for an executable work) run the object code and to modify the work, including scripts to control those activities. However, it does not include the work's System Libraries, or general-purpose tools or generally available free programs which are used unmodified in performing those activities but which are not part of the work. For example, Corresponding Source includes interface definition files associated with source files for the work, and the source code for shared libraries and dynamically linked subprograms that the work is specifically designed to require, such as by intimate data communication or control flow between those subprograms and other parts of the work. The Corresponding Source need not include anything that users can regenerate automatically from other parts of the Corresponding Source. The Corresponding Source for a work in source code form is that same work. 2. Basic Permissions ~~~~~~~~~~~~~~~~~~~~ All rights granted under this License are granted for the term of copyright on the Program, and are irrevocable provided the stated conditions are met. This License explicitly affirms your unlimited permission to run the unmodified Program. The output from running a covered work is covered by this License only if the output, given its content, constitutes a covered work. This License acknowledges your rights of fair use or other equivalent, as provided by copyright law. You may make, run and propagate covered works that you do not convey, without conditions so long as your license otherwise remains in force. You may convey covered works to others for the sole purpose of having them make modifications exclusively for you, or provide you with facilities for running those works, provided that you comply with the terms of this License in conveying all material for which you do not control copyright. Those thus making or running the covered works for you must do so exclusively on your behalf, under your direction and control, on terms that prohibit them from making any copies of your copyrighted material outside their relationship with you. Conveying under any other circumstances is permitted solely under the conditions stated below. Sublicensing is not allowed; section 10 makes it unnecessary. 3. Protecting Users' Legal Rights From Anti-Circumvention Law ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ No covered work shall be deemed part of an effective technological measure under any applicable law fulfilling obligations under article 11 of the WIPO copyright treaty adopted on 20 December 1996, or similar laws prohibiting or restricting circumvention of such measures. When you convey a covered work, you waive any legal power to forbid circumvention of technological measures to the extent such circumvention is effected by exercising rights under this License with respect to the covered work, and you disclaim any intention to limit operation or modification of the work as a means of enforcing, against the work's users, your or third parties' legal rights to forbid circumvention of technological measures. 4. Conveying Verbatim Copies ~~~~~~~~~~~~~~~~~~~~~~~~~~~~ You may convey verbatim copies of the Program's source code as you receive it, in any medium, provided that you conspicuously and appropriately publish on each copy an appropriate copyright notice; keep intact all notices stating that this License and any non-permissive terms added in accord with section 7 apply to the code; keep intact all notices of the absence of any warranty; and give all recipients a copy of this License along with the Program. You may charge any price or no price for each copy that you convey, and you may offer support or warranty protection for a fee. 5. Conveying Modified Source Versions ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ You may convey a work based on the Program, or the modifications to produce it from the Program, in the form of source code under the terms of section 4, provided that you also meet all of these conditions: * **a)** The work must carry prominent notices stating that you modified it, and giving a relevant date. * **b)** The work must carry prominent notices stating that it is released under this License and any conditions added under section 7. This requirement modifies the requirement in section 4 to "keep intact all notices". * **c)** You must license the entire work, as a whole, under this License to anyone who comes into possession of a copy. This License will therefore apply, along with any applicable section 7 additional terms, to the whole of the work, and all its parts, regardless of how they are packaged. This License gives no permission to license the work in any other way, but it does not invalidate such permission if you have separately received it. * **d)** If the work has interactive user interfaces, each must display Appropriate Legal Notices; however, if the Program has interactive interfaces that do not display Appropriate Legal Notices, your work need not make them do so. A compilation of a covered work with other separate and independent works, which are not by their nature extensions of the covered work, and which are not combined with it such as to form a larger program, in or on a volume of a storage or distribution medium, is called an "aggregate" if the compilation and its resulting copyright are not used to limit the access or legal rights of the compilation's users beyond what the individual works permit. Inclusion of a covered work in an aggregate does not cause this License to apply to the other parts of the aggregate. 6. Conveying Non-Source Forms ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ You may convey a covered work in object code form under the terms of sections 4 and 5, provided that you also convey the machine-readable Corresponding Source under the terms of this License, in one of these ways: * **a)** Convey the object code in, or embodied in, a physical product (including a physical distribution medium), accompanied by the Corresponding Source fixed on a durable physical medium customarily used for software interchange. * **b)** Convey the object code in, or embodied in, a physical product (including a physical distribution medium), accompanied by a written offer, valid for at least three years and valid for as long as you offer spare parts or customer support for that product model, to give anyone who possesses the object code either **(1)** a copy of the Corresponding Source for all the software in the product that is covered by this License, on a durable physical medium customarily used for software interchange, for a price no more than your reasonable cost of physically performing this conveying of source, or **(2)** access to copy the Corresponding Source from a network server at no charge. * **c)** Convey individual copies of the object code with a copy of the written offer to provide the Corresponding Source. This alternative is allowed only occasionally and noncommercially, and only if you received the object code with such an offer, in accord with subsection 6b. * **d)** Convey the object code by offering access from a designated place (gratis or for a charge), and offer equivalent access to the Corresponding Source in the same way through the same place at no further charge. You need not require recipients to copy the Corresponding Source along with the object code. If the place to copy the object code is a network server, the Corresponding Source may be on a different server (operated by you or a third party) that supports equivalent copying facilities, provided you maintain clear directions next to the object code saying where to find the Corresponding Source. Regardless of what server hosts the Corresponding Source, you remain obligated to ensure that it is available for as long as needed to satisfy these requirements. * **e)** Convey the object code using peer-to-peer transmission, provided you inform other peers where the object code and Corresponding Source of the work are being offered to the general public at no charge under subsection 6d. A separable portion of the object code, whose source code is excluded from the Corresponding Source as a System Library, need not be included in conveying the object code work. A "User Product" is either **(1)** a "consumer product", which means any tangible personal property which is normally used for personal, family, or household purposes, or **(2)** anything designed or sold for incorporation into a dwelling. In determining whether a product is a consumer product, doubtful cases shall be resolved in favor of coverage. For a particular product received by a particular user, "normally used" refers to a typical or common use of that class of product, regardless of the status of the particular user or of the way in which the particular user actually uses, or expects or is expected to use, the product. A product is a consumer product regardless of whether the product has substantial commercial, industrial or non-consumer uses, unless such uses represent the only significant mode of use of the product. "Installation Information" for a User Product means any methods, procedures, authorization keys, or other information required to install and execute modified versions of a covered work in that User Product from a modified version of its Corresponding Source. The information must suffice to ensure that the continued functioning of the modified object code is in no case prevented or interfered with solely because modification has been made. If you convey an object code work under this section in, or with, or specifically for use in, a User Product, and the conveying occurs as part of a transaction in which the right of possession and use of the User Product is transferred to the recipient in perpetuity or for a fixed term (regardless of how the transaction is characterized), the Corresponding Source conveyed under this section must be accompanied by the Installation Information. But this requirement does not apply if neither you nor any third party retains the ability to install modified object code on the User Product (for example, the work has been installed in ROM). The requirement to provide Installation Information does not include a requirement to continue to provide support service, warranty, or updates for a work that has been modified or installed by the recipient, or for the User Product in which it has been modified or installed. Access to a network may be denied when the modification itself materially and adversely affects the operation of the network or violates the rules and protocols for communication across the network. Corresponding Source conveyed, and Installation Information provided, in accord with this section must be in a format that is publicly documented (and with an implementation available to the public in source code form), and must require no special password or key for unpacking, reading or copying. 7. Additional Terms ~~~~~~~~~~~~~~~~~~~ "Additional permissions" are terms that supplement the terms of this License by making exceptions from one or more of its conditions. Additional permissions that are applicable to the entire Program shall be treated as though they were included in this License, to the extent that they are valid under applicable law. If additional permissions apply only to part of the Program, that part may be used separately under those permissions, but the entire Program remains governed by this License without regard to the additional permissions. When you convey a copy of a covered work, you may at your option remove any additional permissions from that copy, or from any part of it. (Additional permissions may be written to require their own removal in certain cases when you modify the work.) You may place additional permissions on material, added by you to a covered work, for which you have or can give appropriate copyright permission. Notwithstanding any other provision of this License, for material you add to a covered work, you may (if authorized by the copyright holders of that material) supplement the terms of this License with terms: * **a)** Disclaiming warranty or limiting liability differently from the terms of sections 15 and 16 of this License; or * **b)** Requiring preservation of specified reasonable legal notices or author attributions in that material or in the Appropriate Legal Notices displayed by works containing it; or * **c)** Prohibiting misrepresentation of the origin of that material, or requiring that modified versions of such material be marked in reasonable ways as different from the original version; or * **d)** Limiting the use for publicity purposes of names of licensors or authors of the material; or * **e)** Declining to grant rights under trademark law for use of some trade names, trademarks, or service marks; or * **f)** Requiring indemnification of licensors and authors of that material by anyone who conveys the material (or modified versions of it) with contractual assumptions of liability to the recipient, for any liability that these contractual assumptions directly impose on those licensors and authors. All other non-permissive additional terms are considered "further restrictions" within the meaning of section 10. If the Program as you received it, or any part of it, contains a notice stating that it is governed by this License along with a term that is a further restriction, you may remove that term. If a license document contains a further restriction but permits relicensing or conveying under this License, you may add to a covered work material governed by the terms of that license document, provided that the further restriction does not survive such relicensing or conveying. If you add terms to a covered work in accord with this section, you must place, in the relevant source files, a statement of the additional terms that apply to those files, or a notice indicating where to find the applicable terms. Additional terms, permissive or non-permissive, may be stated in the form of a separately written license, or stated as exceptions; the above requirements apply either way. 8. Termination ~~~~~~~~~~~~~~ You may not propagate or modify a covered work except as expressly provided under this License. Any attempt otherwise to propagate or modify it is void, and will automatically terminate your rights under this License (including any patent licenses granted under the third paragraph of section 11). However, if you cease all violation of this License, then your license from a particular copyright holder is reinstated **(a)** provisionally, unless and until the copyright holder explicitly and finally terminates your license, and **(b)** permanently, if the copyright holder fails to notify you of the violation by some reasonable means prior to 60 days after the cessation. Moreover, your license from a particular copyright holder is reinstated permanently if the copyright holder notifies you of the violation by some reasonable means, this is the first time you have received notice of violation of this License (for any work) from that copyright holder, and you cure the violation prior to 30 days after your receipt of the notice. Termination of your rights under this section does not terminate the licenses of parties who have received copies or rights from you under this License. If your rights have been terminated and not permanently reinstated, you do not qualify to receive new licenses for the same material under section 10. 9. Acceptance Not Required for Having Copies ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ You are not required to accept this License in order to receive or run a copy of the Program. Ancillary propagation of a covered work occurring solely as a consequence of using peer-to-peer transmission to receive a copy likewise does not require acceptance. However, nothing other than this License grants you permission to propagate or modify any covered work. These actions infringe copyright if you do not accept this License. Therefore, by modifying or propagating a covered work, you indicate your acceptance of this License to do so. 10. Automatic Licensing of Downstream Recipients ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ Each time you convey a covered work, the recipient automatically receives a license from the original licensors, to run, modify and propagate that work, subject to this License. You are not responsible for enforcing compliance by third parties with this License. An "entity transaction" is a transaction transferring control of an organization, or substantially all assets of one, or subdividing an organization, or merging organizations. If propagation of a covered work results from an entity transaction, each party to that transaction who receives a copy of the work also receives whatever licenses to the work the party's predecessor in interest had or could give under the previous paragraph, plus a right to possession of the Corresponding Source of the work from the predecessor in interest, if the predecessor has it or can get it with reasonable efforts. You may not impose any further restrictions on the exercise of the rights granted or affirmed under this License. For example, you may not impose a license fee, royalty, or other charge for exercise of rights granted under this License, and you may not initiate litigation (including a cross-claim or counterclaim in a lawsuit) alleging that any patent claim is infringed by making, using, selling, offering for sale, or importing the Program or any portion of it. 11. Patents ~~~~~~~~~~~ A "contributor" is a copyright holder who authorizes use under this License of the Program or a work on which the Program is based. The work thus licensed is called the contributor's "contributor version". A contributor's "essential patent claims" are all patent claims owned or controlled by the contributor, whether already acquired or hereafter acquired, that would be infringed by some manner, permitted by this License, of making, using, or selling its contributor version, but do not include claims that would be infringed only as a consequence of further modification of the contributor version. For purposes of this definition, "control" includes the right to grant patent sublicenses in a manner consistent with the requirements of this License. Each contributor grants you a non-exclusive, worldwide, royalty-free patent license under the contributor's essential patent claims, to make, use, sell, offer for sale, import and otherwise run, modify and propagate the contents of its contributor version. In the following three paragraphs, a "patent license" is any express agreement or commitment, however denominated, not to enforce a patent (such as an express permission to practice a patent or covenant not to sue for patent infringement). To "grant" such a patent license to a party means to make such an agreement or commitment not to enforce a patent against the party. If you convey a covered work, knowingly relying on a patent license, and the Corresponding Source of the work is not available for anyone to copy, free of charge and under the terms of this License, through a publicly available network server or other readily accessible means, then you must either **(1)** cause the Corresponding Source to be so available, or **(2)** arrange to deprive yourself of the benefit of the patent license for this particular work, or **(3)** arrange, in a manner consistent with the requirements of this License, to extend the patent license to downstream recipients. "Knowingly relying" means you have actual knowledge that, but for the patent license, your conveying the covered work in a country, or your recipient's use of the covered work in a country, would infringe one or more identifiable patents in that country that you have reason to believe are valid. If, pursuant to or in connection with a single transaction or arrangement, you convey, or propagate by procuring conveyance of, a covered work, and grant a patent license to some of the parties receiving the covered work authorizing them to use, propagate, modify or convey a specific copy of the covered work, then the patent license you grant is automatically extended to all recipients of the covered work and works based on it. A patent license is "discriminatory" if it does not include within the scope of its coverage, prohibits the exercise of, or is conditioned on the non-exercise of one or more of the rights that are specifically granted under this License. You may not convey a covered work if you are a party to an arrangement with a third party that is in the business of distributing software, under which you make payment to the third party based on the extent of your activity of conveying the work, and under which the third party grants, to any of the parties who would receive the covered work from you, a discriminatory patent license **(a)** in connection with copies of the covered work conveyed by you (or copies made from those copies), or **(b)** primarily for and in connection with specific products or compilations that contain the covered work, unless you entered into that arrangement, or that patent license was granted, prior to 28 March 2007. Nothing in this License shall be construed as excluding or limiting any implied license or other defenses to infringement that may otherwise be available to you under applicable patent law. 12. No Surrender of Others' Freedom ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ If conditions are imposed on you (whether by court order, agreement or otherwise) that contradict the conditions of this License, they do not excuse you from the conditions of this License. If you cannot convey a covered work so as to satisfy simultaneously your obligations under this License and any other pertinent obligations, then as a consequence you may not convey it at all. For example, if you agree to terms that obligate you to collect a royalty for further conveying from those to whom you convey the Program, the only way you could satisfy both those terms and this License would be to refrain entirely from conveying the Program. 13. Use with the GNU Affero General Public License ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ Notwithstanding any other provision of this License, you have permission to link or combine any covered work with a work licensed under version 3 of the GNU Affero General Public License into a single combined work, and to convey the resulting work. The terms of this License will continue to apply to the part which is the covered work, but the special requirements of the GNU Affero General Public License, section 13, concerning interaction through a network will apply to the combination as such. 14. Revised Versions of this License ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ The Free Software Foundation may publish revised and/or new versions of the GNU General Public License from time to time. Such new versions will be similar in spirit to the present version, but may differ in detail to address new problems or concerns. Each version is given a distinguishing version number. If the Program specifies that a certain numbered version of the GNU General Public License "or any later version" applies to it, you have the option of following the terms and conditions either of that numbered version or of any later version published by the Free Software Foundation. If the Program does not specify a version number of the GNU General Public License, you may choose any version ever published by the Free Software Foundation. If the Program specifies that a proxy can decide which future versions of the GNU General Public License can be used, that proxy's public statement of acceptance of a version permanently authorizes you to choose that version for the Program. Later license versions may give you additional or different permissions. However, no additional obligations are imposed on any author or copyright holder as a result of your choosing to follow a later version. 15. Disclaimer of Warranty ~~~~~~~~~~~~~~~~~~~~~~~~~~ THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF ALL NECESSARY SERVICING, REPAIR OR CORRECTION. 16. Limitation of Liability ~~~~~~~~~~~~~~~~~~~~~~~~~~~ IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF SUCH DAMAGES. 17. Interpretation of Sections 15 and 16 ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ If the disclaimer of warranty and limitation of liability provided above cannot be given local legal effect according to their terms, reviewing courts shall apply local law that most closely approximates an absolute waiver of all civil liability in connection with the Program, unless a warranty or assumption of liability accompanies a copy of the Program in return for a fee. *END OF TERMS AND CONDITIONS* How to Apply These Terms to Your New Programs --------------------------------------------- If you develop a new program, and you want it to be of the greatest possible use to the public, the best way to achieve this is to make it free software which everyone can redistribute and change under these terms. To do so, attach the following notices to the program. It is safest to attach them to the start of each source file to most effectively state the exclusion of warranty; and each file should have at least the "copyright" line and a pointer to where the full notice is found. Copyright (C) This program is free software: you can redistribute it and/or modify it under the terms of the GNU General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details. You should have received a copy of the GNU General Public License along with this program. If not, see . Also add information on how to contact you by electronic and paper mail. If the program does terminal interaction, make it output a short notice like this when it starts in an interactive mode: Copyright (C) This program comes with ABSOLUTELY NO WARRANTY; for details type 'show w'. This is free software, and you are welcome to redistribute it under certain conditions; type 'show c' for details. The hypothetical commands `show w` and `show c` should show the appropriate parts of the General Public License. Of course, your program's commands might be different; for a GUI interface, you would use an "about box". You should also get your employer (if you work as a programmer) or school, if any, to sign a "copyright disclaimer" for the program, if necessary. For more information on this, and how to apply and follow the GNU GPL, see . The GNU General Public License does not permit incorporating your program into proprietary programs. If your program is a subroutine library, you may consider it more useful to permit linking proprietary applications with the library. If this is what you want to do, use the GNU Lesser General Public License instead of this License. But first, please read . qpageview-0.6.2/MANIFEST.in000066400000000000000000000002471423465244600152620ustar00rootroot00000000000000include README.rst INSTALL.rst ChangeLog LICENSE graft docs prune docs/build prune docs/web/build recursive-include tests *.py global-exclude *~ *.py[cod] __pycache__ qpageview-0.6.2/README.rst000066400000000000000000000031761423465244600152170ustar00rootroot00000000000000The qpageview module ==================== *qpageview* provides a page based document viewer widget for Qt5/PyQt5. It has a flexible architecture potentionally supporting many formats. Currently, it supports SVG documents, images, and, using the Poppler-Qt5 binding, PDF documents. :: import qpageview from PyQt5.Qt import * a = QApplication([]) v = qpageview.View() v.show() v.loadPdf("path/to/afile.pdf") `Homepage `_ • `Development `_ • `Download `_ • `Documentation `_ • `License `_ Features ~~~~~~~~ * Versatile View widget with many optional mixin classes to cater for anything between basic or powerful functionality * Rendering in a background thread, with smart priority control, so display of large PDF documents remains fast and smooth * Almost infinite zooming thanks to tile-based rendering and caching * Magnifier glass * Printing functionality, directly to cups or via Qt/QPrinter * Can display pages originating from different documents at the same time * Can show the difference between pages that are almost the same via color composition * And much more! And...all classes are extendable and heavily customizable, so it is easy to inherit and add any functionality you want. Dependencies ~~~~~~~~~~~~ * Python 3.6+ * Qt5 * PyQt5 * python-poppler-qt5 (needed for display of PDF documents) * pycups (optionally, needed to print to a local CUPS server) qpageview-0.6.2/docs/000077500000000000000000000000001423465244600144515ustar00rootroot00000000000000qpageview-0.6.2/docs/Makefile000066400000000000000000000021051423465244600161070ustar00rootroot00000000000000# Minimal makefile for Sphinx documentation # # You can set these variables from the command line. SPHINXOPTS = SPHINXBUILD = sphinx-build SPHINXPROJ = qpageview SOURCEDIR = source BUILDDIR = build # Put it first so that "make" without argument is like "make help". help: @$(SPHINXBUILD) -M help "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) .PHONY: help Makefile # Website and Upload targets (added by WB) # This uses the conf.py in the web/ directory WEBDIR = web WEBBUILDDIR = web/build website: @$(SPHINXBUILD) -M clean "$(SOURCEDIR)" "$(WEBBUILDDIR)" -c "$(WEBDIR)" $(SPHINXOPTS) $(O) @$(SPHINXBUILD) -M html "$(SOURCEDIR)" "$(WEBBUILDDIR)" -c "$(WEBDIR)" $(SPHINXOPTS) $(O) upload: website rsync -ave ssh --exclude '*~' \ "$(WEBBUILDDIR)"/html/* \ wilbertb@qpageview.org:/home/wilbertb/domains/qpageview.org/public_html/ # Catch-all target: route all unknown targets to Sphinx using the new # "make mode" option. $(O) is meant as a shortcut for $(SPHINXOPTS). %: Makefile @$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) qpageview-0.6.2/docs/source/000077500000000000000000000000001423465244600157515ustar00rootroot00000000000000qpageview-0.6.2/docs/source/_static/000077500000000000000000000000001423465244600173775ustar00rootroot00000000000000qpageview-0.6.2/docs/source/_static/custom.css000066400000000000000000000016701423465244600214270ustar00rootroot00000000000000/* * Some css adaptions to the alabaster theme for parce.info. */ /* make font size in source code view smaller */ div.body > div.highlight { font-size: 14px; } /* add extra spacing above class and function definitions */ @media screen { div.section > dl.class { margin-top: 40px; } div.section > dl.function { margin-top: 30px; } } /* decrease font size in sidebar */ div.sphinxsidebar ul li.toctree-l1 > a { font-size: 100%; } div.sphinxsidebar ul li.toctree-l2 > a { font-size: 90%; } /* make sidebar contents scrollable */ div.sphinxsidebar { max-height: 100%; overflow-y: auto; } /* hide the image but show a home link. */ @media screen and (max-width: 875px) { div.sphinxsidebar p.logo { display: unset; } div.sphinxsidebar p.logo ::after { content: "[home]"; font-size: 20px; } div.sphinxsidebar p.logo img { display: none; } } qpageview-0.6.2/docs/source/_static/qp.png000066400000000000000000000514101423465244600205260ustar00rootroot00000000000000PNG  IHDR n*rzTXtRaw profile type exifxڭi9snrfs9|JU6뱑T;ӝ_Vz)_i5|֏ Vkg9oPﻺi }>9V7P㎾/?_Ε7St<1}?O΋Bj=ΟڹR8_/([y acvv<ȅ*P{7^8YV]~w~7qhN~/z 90 }]E9X}{\I!na;""joǟy+4>y^ ݟB*uCb֊JnCӟ]S{~aKMD27pĴ+ҋsu `lfB"d_c! <'fqwMJന=5Fo-RIP(+g#jnаdٙYjͺJ.VJE5jZ-V{-ܬV[k{¬^|҃w^1ƌ3|-qr:l;ɏ1IM[&-G_ܔG'ۺ1V~h^r,1q]JZ2BҫZs ~n:v3ZnY\:+f^?y@rNV&+Ne,ULBVcױJW]) R[bi&0<-p!@M HX"mVekQ*D> A*s!m&wԑ.BP0z0Ss7;xC =pVQ2ҖNF锾 s$C 9eyKx[ 8)qafTJicmH{[v2 l'[C!9Vey0Ӹ;lYXroT}'j n$y?+eg$w+lhĻ/:-BjnBС؋,-P bԇTHrA| 6Kr5(kQ4[Vy\ʿZJ~B^mRYyUPJOT Z ޤŪuM{<'&W<: q4X}qybilISn9$O" | q}KsSDHI2He-A`rNa-OlY` Vևwtx#"ޔ!WU->nP6uh^?!^k%*C!klnij!VdFAw7(bnIpB*/{Jñ*=p Mh#޸c} o #jvbR5 CDok0 evg5CCABpRV6|)^ %X`dyL|~2PPS2™s\O^ ֠ϋF.~*"}IK6#,nz`A袻 Ks ;n^' ze6rmT |q(+Py\bvӐD k=r,|<7 YP^R#ǀ[|R*Q+ϔ@+N#qgDF hXުxD2;=ȒV\xb[/N(,%HN~x{ #ƼlHk`a]%xЉhdA 5nGR sChS 'R f%&jkxFix8bAMF@:a,v|W݈8|x0/{KQ޾ap`v01d452ۂX%E`x ԄAAkǑҚ^ޏ >([d*6uk<F|&rElB 4a(ό.:HZ }% ҋQp51=兩!:֣ny+D(k I1@>Bu X+-U#ēN*8)Qy nXns9ঞ!u!JnT'X8u Ѹ9V"#=;Q>ز"ƑR$Ѫ[Y"x`q, "tW`E$XkTx6ګ p`Tfp[..\<)Ml(JY14nK>U79k-w5$,`48B="7:53mv!l֍;Q`CU65c0<x(Eu5N2 $ƺGFoBn`KdQvf[x }Y IF0h.$VHM˒"ٱ;kEo …5)aD%_Qru$2RЕ\xӬA\.:Re !_My;mJS,7d,J.R>]0Ge 8RjaYdn&D_gEm2-; k~nP!z8 $H$yfPýGAг,Sy'[@`HsKM|w|ǂEr@Pd'{:Ħ^>7*ٷ6Ҿ Ac'jQS/eOE)m>⺶ayȍO :/n /THyAMhrM\@M ;sP+'LP!̆@wnhDKOW*_zB*ʋ?p@M \a'RF.TYj y[E3UR5jQ^$> 48rl]. Vƌ>tՋ!ŠFj hxugyk0ˣ9=.J7sڌ0lp 냬Ғ*o :Ȼ$d6Wtm)7٣GS.צ"[v`ڤ~0;E]\=6"6IU$=-e. 7(Z"*+d%E2xq *[,fbGU?ӫ.A9 G' Da(,F)VD^m NRk"ںV>}Fͳ3Sۦ#.!%ܛ2hs]U>9i%PpAgT eȟiSKs! "[ v$<;LOYi=%X~4t87{SЂ73S~}j1p޼gc֠`!@gT*R`<jvaTk/hȚ@E`-zj~#)XV^R|*pٝܙM ~,`L#ЖAʑ`un O2> [u8t4b$YyMT/ťarTFFc(_(3Zjd=PG 8m ]i( ? sj* >vvS?OQ͑<BkvLPR;LM3Q0/DAjfxH-K eG$uMU-hvHQ&ݨ/?a \󼭎%|0pQM;:3 3S׌T|& Gm@L;?X 7+15Iwn7V(pd DT,2K[ Pm<,:>khcg4R)I0c7 $rzErAj 9, 1@Dߠ⃉KN:5nBjDhhg!.2ckBKB5"UEiW5iL"Q= '8}Qb#*OFnܜ&XPa*A$Grf𫎥 D$p7jPuL,@g"}rkn&s^ D|*Mh ՜<8CǾ|X%Ym 0I'GMondA''rWע[ᐓ5mP. R2i @9;:X \m 1? ײRI˄pa( uo}cClP_#do[Oe'۽hMuF@ccZ 7I#T7 k`UDCHīW ̻5PÕi(@G1E'V,?>V?*A:7RN_6&֊I0dnWsrWL!JۡeYIC q+)Yej1p\ڛd *H"5,p3(¨/EѴҹNh2^(y?ԫOC% A@@FUal >BxV$O-$JL(C! "6'rڹԤ!OL߾Lkt('i } >]uwȽfN#$|^S6Xx@Lǫ,£Qh:VL`!w8X<~ )56ړ0!I"]C GMSCLDnf_ֲ'[}xipD6_OYƩUȴ^nӝ+G<.E&ui uՂk8ޱLa^אI-uA΍duNNao/i3ɯ[y XUut_%{VH+uCw"|DkkP۲Ty5'y~I !H+| N Elf tnh5i/aV_[Zڿ:puwe/M 6cEFN7}߳[]l2 @*dγ}F yp\5N! Ɵ"IrK;5hGn;"ٱ:$XVGT D64i{4ͷ W TsBӡ5 }x[VXљ;^?pўiH6`JrQW0e= vaĄDBNA_PԇZ#ԧ: (t\ 8bd0^Y98UQ3!13x)< HVWAG5u+ w[IY"߹ D\ms[jI4[4ItM伞lFO Y,VB>wb45oybP՛ `\4O"Zq cձ'O*S;IMOKOy.v_T't@ekV~0X F@bvt  +rNmOj9tkvήZ ڿOf%b`װ >խ@p}? >d4F!C]X3L%i#Ј&tр:/,KE)!3sE MF 00,lI>5ڌGm {fD`Cxdt?@&GCWiHh .c:;"k,I$)4ҬA,7zvŋF8.5jjye5`45?b:V= UMㄟkh;2·N~)`M-.Vh *^ͬ!W5d 'GCC[dfp;+*+IR}szqor`:lګ>z eV7t<aC8Xޔ&tAƫGbRH&Hh#_(oD\GMWo5t6+d,M2i:,,9gWַmlWi!%uo0Nea݇"N-M2}h`,`~1fҖg) &P2-ހӚo{;|Ӧv’b(bёQ'P cIN!f}Qr|ba3G&{PIVP)|3pQtr 5߾,zMhv+9QM|Mεu!P85W&f{4ZuiY*Q2YKt6;htTr"'Sv䬹%)cvnCĘm_KG'^叓gCZaz`$$BLk@6aGS8(a!mQaFe2X~FUrη{@jhҵנjA5 B[rًtb_T O ||M:!釃$&kn8Xܡ4qQ/o";IGECn[8u!yVG,w鈷5rZ /b$}l G*PI+r*}tk^u4vJaMp53\m`~|Yge/P& @\h4098rXs@@qF9uu Rk%1n KUy0u}y/fh K178uЁh ǑڢԈ&ʄQHPƖ~8F`$e4zȹviT_4#蟧@ty\Q#.-)ĉ8OXS)tPmƷ ͻ_hw](&[]Š ]ucty9E,&" v:;t-!g>M./D3ڭ<Xv,u"'Wzoį9.`e l:+PTB rs?X_~u"KbKGD̿ pHYs  tIME'2eթ IDATxwx\[WeWWl tbB )HH| T\ rnVKt{jw%h;sߙ3s挊se.I_ %qD+?o{벲En'J?E_^;eyO%B/&R ;bj@qKZUĜ33ܠS[E8s+Ō{&yPeKWchѴ:=&#:q~ڸ;7ݾ:\~\CάIa>24IZg! D56襎(+3.wX?{4HSgS>ռiqâ60x"G/izJޠC#?h=f/ϭwԊXJQrGσﲍN=71Ϭ--kI 1fa %m'n[r^8k&͵S_+# i4#5dUz @h~L>,%5I>^ pL!Za,+68V> }K/v^nu kW@3.aɷ27"rykBgNE0j:8/27asB 1Dao\"b*̿Sk6]}?Dd7Zu߆7hZ<+DM N%vK+߉!ò^&L#?R潴핏?i'> s׏O@Ԉ-ϊTÑZs6.*m>䔁wŀP?z mLbxJ2ɚ8_6&f{(g ʙ^z깯Q`!lRMy,5MYAD>t]D1 K)q螄͋Y +]xڎ?#Tc@&_*-uS ǘKy$540?1ͣbS PL{xj0Ŭei ej#D78! 5:1*h":uDJ*Pbs2 ln2kliPktAJ?c҉$z"1D+qX8c \2LGXWCǬ|0sqKN{ @|K 1 L!H0rl+fulO?A+_r9Pz#˹n!.ฎ8twp Kb@F q6E~ b1N$gj^O^!jO]2_,?HFq<,Vqӆ h$ωFM@+>`AO0~LP7cѧbX^ruJ>1˩y[g#5J)/ S[(gM6vg*ҋ!N$XtK_4 !^Gƴğ//p_hBask@܍  F&,'fnTTY3$/ &Nw uwf[,5>aVbqmP5XQ;es NW]N|EW0\y!ng?a!6ƟAi#]^Va ־NV \0;OWPOr 6f$rj+yfof|ԠT  qXYCu(mG^җe@xLVD)d~IguK)r_l)Ƭ.Cý^xj@@O4fh <̿GL/C)? Kr;wf;e{en|[r({'egOO>Z1Q&r 0Q:Tb!{|aa1FW孲|.mΨRAJ{7LjMU$^2|*INJ#y@^L x|//d5!n%c{?syS1W =[QCc0X4H=VSЇ+RV!p;S ̡}R0=Gs|ǡq=3\#S/cAX7dSO1{xiܘgv,1@2>e@`ڏ );(]<o%ՃgLv`'`lUbb/H52j ShtRX%)Z"0luo8/&uv=yn=.O \AY.7/[bYKk;˼T_C+MTN-*b'<9Cf4) :' uxH}htE@% b%5v|2h]+VСdPv(.z3]c,MU$:Wj̥ PPL U^ nL#a.F`L]<I2%^b@%Neʦ_zZB 2}Zw^\+ɭ&~ĨQDHnHD "܊`[i.O(Eߛ.rV8j)]βz cy}vP& y0)aKЅک_zQXY{]ih*v =,0`}B~E@YPKΊe !3ɔ{ H,o)\!%Jv 9'?gј`Rolvy#dHp,%@9KDQ'Qp- aApk~A&Jq`+Wމ 4E@$L,RĺA_n/;?tagp5^xk@=6apxb=8ݤ-;4#$ҩ溨i}/f`ٗ5iȯC;D׬RFXL Ц#WE`lh Cv6.ƛhАbƞQI#SdJڎnfˌgC|xgYK<^2p!K[~u(Q?*[9u@|b F;lYxi^X|Ό1$ 'PHDcXdhwEfEtzX s<{cI….87Y]urQ/5KG|_G:=sɊVJ_] 6 4H#^U<^fZ3/D!?k#)4(^'q.zsBTd46{EE;:ٱ.grN/WS6@4\%] = CSkq\#5gNr7܀R_:dZ%Jr H:< f&2_"NSҡ)phhxqc<#GZ8YHb@9·%sdVX*AdPt`k%7|;^epSRV'XPQʸϩ1Eqp`" akvVMu^&j:yًuFe&P\-wÃmMNk(˸LE@tER{(áq@b 2FmbwܕDn@>?u(׎S ܯ}h^h?l%t,*52ydt;dj`A..V; w[ƹxQLj-s0|$4cuRd5(\-LN$]$q=e"mvLEٚV5lg\Kh+֧VJgF (6"Y_p;.ug˩R]Ek #"6@pޥ|SQZ.r3-Ky&UuVYuAR[B3`Wy/ >(ȹGȏdKsÑ=P%400bepVt@#ft )葙;&%Y#bjldjv|a@"TE@Bʇ$s2YX}@[(jm.HBJ)=F@Hsv>% ?9BnKZ')^BG" W%Y EV ӦЉ#LV'tp\7fP U \֓ӸY%GɕQ7.7&zp>X%4 l"RjgX5`Eas>vXf.|^?>ˬ>EE-N0{/k hH`ݽ:/k\rƋgpji(Iah L3&b`+;K>T;>`pL@a,uQ?ܿ^{VR$ݦC째)QA"cJ'@]o L+=IaIaS׮&9Ëb':AQN.oO'1OO~, T#a7ffbg?~rP&1>MX@G*L|BۈYFy!MqK8FhB(uF5ϥ(ٸy 8 BX8Zp縇42|{3U!#0S,]c1c꘶t'tBq+X/fgXAujG9DC"/4Z. SwIgw O;2;՘E(!t)P [<$64l2(d~>J|&$O$FB G2 ̇%hKQ$L7Ql jBф89$tCW < !!@<~RHEy Jc2%gDwk솯\@PEO/QO# 6T404đEDa$PB4F*BE`xc ɳG88K.$5_S4-D#uN[OIPî ]O頝vh tJ'f|ITq"A@B%5[Wd- .(D DDBcKF+ՔqB>0K8X #'ʗԐNtKDV1SE3#;Às"9m49$ܬD7yq c (0V(5 2 D/%<+c;6DUL#+2ɤ^lF-'(`','% c؊ b=D7h!$ߺ,JC=8CɄ  "R&$\r7+$-y\x؈$)L4<"cvE늾ܰg1J}N$9!l^~Npr@tsO6a@IC.T50x~E#͜?thq}ׅbR ">i|<\ y#ݑ@^>褞jr%d7襌}l'cE?qTkjv#PJ5GHB 3D+ot!=la#+e:t@+ETRZHa \ߓ#5 z|8y`@G) h=$A 1DzDzjGq6X]c#ge]%ZhBPaϨ&Xb)g߻$r8ܬL0#ljJΗ,@3rY3A)6WN+€\F?䰉Dg}l]KzH#CC&Z1!Tc$•v,/i&ry~V2 ~'hf퀆ʎ Ih>O}NsE5RƵ|#CĠ2q^_[|O1g3Ј@O%"d. =:袍^颕fZ !#aFbV ĘsYM;v-,wJ?vDz0>ïbfb'g ĸ=k3ܘx4N3v`y<ͭV ͘L? xG-hQG}2@-QI1}L1I* $fdl ~ {e}{sc~wRO5쥒,‡(D,#ĞfIĵbxӒ4N(̣6`)IDAT*b xRIĈ Bp]tA+-XE`%#$M֍nC|FO;91RMsYO ^2 d<ާ+C6ې^ I?6Th-#^__H`RvRf`0n@%I1h$*h9!XAX(zѡŊ/X%#%bNt`&: dB+Y@(4 ZB4tQ\.SC9|A*#YIIz44T_HH|z2 }ς~Ÿ$i#4E}NCs q3 >tXH_ )Uq/l#G'ca yj*(3SY7iWYB9w>Izbͩ i:nT،|H6aRD ߏiʁL}KdVs +YE9IJ>mwR@Rqv ['b,#]AzB Z$Vb(`=?c;2J >#|@PƟE(o0vr1Tp!43 r!~S.x=O~f;s*΀y9 8qc! iǂZj9k-/( d osxP:G&> H֏yFQ1ZKa RG-wp7y"8"mS*؏/ e6yg>~+`Da;!«C&O b!{*SZ!R 5IRB06 Gql`"@ys-Ueb ORMW2FYásky">hIݟV!> l0KYO$)hB=MTR):Y,#)Od6M\xȠ>K> iL֙N.m Q}ՄAk:M!}rW_*_!>a&ӘB]頇~fP"h颉z縖xNah}'eYf(^tCO0u|ְ͜]*l"N135l졖|^e~=*~of>5`u0 UcY3x`3U5jb?r(R3eD>#sZ*+p<PaLI fpͧuiPW9jWzd @z%AB:V0E =]8Do1` objects, which optionally can belong to a :class:`~document.Document` object. The convenience methods :meth:`View.loadPdf() `, :meth:`View.loadImages() ` and :meth:`View.loadSvgs() `, create Document objects containing the pages, and then call :meth:`View.setDocument() ` to display the pages in the view. You can also use the module global functions like :func:`loadPdf` which return a Document, and then load that Document in the View:: v = qpageview.View() v.show() doc = qpageview.loadPdf("file.pdf") v.setDocument(doc) This way you can keep a document in memory, and you can load it, then load something else in the view and later load the same document again, without the need to load it again from disk or network. When creating a Document using one of the global `load` functions, nothing is really loaded until you request the :meth:`~document.AbstractSourceDocument.pages` of the Document, and even then, some Page types only load themselves really when their content is requested to be rendered in the View. The list of individual Page objects in a document is returned by the :meth:`~document.AbstractSourceDocument.pages` method of the Document class. The current Page object (the current page number points to) is available through :meth:`View.currentPage() `. Page and PageLayout ~~~~~~~~~~~~~~~~~~~ The View does not do very much with the Document it displays, rather it cares for the Page objects that are displayed. The pages are in the PageLayout of the View, which inherits from the Python :class:`list ` type. Get the :class:`~qpageview.layout.PageLayout` of a View using :meth:`View.pageLayout() `. Using the regular ``list`` methods you can add or remove Page objects to the layout. Then you need to call :meth:`View.updatePageLayout() ` to update the PageLayout, which will adjust size and position of the Pages. Instead of the above, and maybe even better and easier, you can use the :meth:`~view.View.modifyPages` context manager of View, which will automatically update the layout when it exits:: with v.modifyPages() as pages: del pages[0] # remove the first page pages.append(another_page) # append another This context manager yields the pages list, and when it exits it puts the pages in the layout, and updates the page layout. Note that in the layout, and in this ``pages`` list, the first page is at index 0. This way, it is very easy to display Page objects originating from different sources:: import qpageview.image page1 = qpageview.image.ImagePage.load("image.jpg") page2 = qpageview.loadPdf("file.pdf").pages()[2] with v.modifyPages() as pages: pages[:] = [page1, page2] # [:] replaces the current contents Controlling a view with ViewActions ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ Normally, in a Qt application, you create QActions to perform tasks and put those in a menu or toolbar. The *qpageview* package provides the :mod:`~qpageview.viewactions` module to help you with that. If you create a :class:`~viewactions.ViewActions` object and connect it to a View, all actions can readily be used to control the View, and they automatically update their state according to the View's state. The actions (QAction objects) are in the attributes of the ViewActions object. For example, to add some actions to a menu:: import qpageview.viewactions a = qpageview.viewactions.ViewActions() a.setView(v) menu = Qmenu() menu.addAction(a.fit_width) menu.addAction(a.fit_height) menu.addAction(a.fit_both) menu.addSeparator() menu.addAction(a.zoom_in) menu.addAction(a.zoom_out) menu.popup(QCursor.pos()) The ``pager`` action fits well in a toolbar, it displays a spinbox where you can cycle through the pages, and the ``zoomer`` action displays a combobox with different zoom levels. The full list of available action names is returned by the :meth:`~viewactions.ViewActions.names` classmethod. You can set icons to the actions as you like, and replace the texts. It is also easy to inherit from ViewActions and add actions or change existing actions. This is the list of actions that are currently available in a :class:`~viewactions.ViewActions` object: .. list-table:: :header-rows: 1 :widths: 10 10 80 * - Name - Text - Action * - ``print`` - Print - Open a print dialog * - ``fit_width`` - Fit Width - Zoom to fit pages in the width of the View * - ``fit_height`` - Fit Height - Zoom to fit pages in the height of the View * - ``fit_both`` - Fit Both - Zoom to fit the full page in the View * - ``zoom_natural`` - Natural Size - Zoom to a "natural" size (Page dpi/screen dpi) * - ``zoom_original`` - Original Size - Set zoom factor to 1.0 * - ``zoom_in`` - Zoom in - * - ``zoom_out`` - Zoom out - * - ``zoomer`` - (none) - Display a :class:`zoom widget ` in a toolbar * - ``rotate_left`` - Rotate Left - Rotate the pages 90° counter-clockwise * - ``rotate_right`` - Rotate Right - Rotate the pages 90° clockwise * - ``layout_single`` - Single Pages - Show single pages in a row * - ``layout_double_right`` - Two Pages (first page right) - Show page 1 alone, to the right, then the rest two by two * - ``layout_double_left`` - Two Pages (first page left) - Show pages two by two * - ``layout_raster`` - Raster - Show pages in a grid * - ``vertical`` - Vertical - Show the pages in a vertical row * - ``horizontal`` - Horizontal - Show the pages in a horizontal row * - ``continuous`` - Continuous - Checkbox, if checked shows all pages * - ``reload`` - Reload - Reload pages from their files if possible * - ``previous_page`` - Previous Page - Go to the previous page * - ``next_page`` - Next Page - Go to the next page * - ``pager`` - (none) - Display a :class:`pager widget ` in a toolbar * - ``magnifier`` - Magnifier - Toggle the Magnifier visibility Lazy View instantiation ----------------------- It is possible to create a ViewActions object first and populate menus and toolbars with the actions, while the View is not yet created (e.g. when the View is in a dock widget that's only created when first shown). In this case, you want to instantiate the dock widget and View as soon as an action is triggered. To do this, connect to the :meth:`viewRequested` signal of the ViewActions object. The connected method must create widgets as needed and then call :meth:`~viewactions.ViewActions.setView()` on the ViewActions object, so the action can be performed. Managing View settings ~~~~~~~~~~~~~~~~~~~~~~ All display settings (preferences) of a View can be stored in a QSettings object using :meth:`View.writeProperties() `, and read with :meth:`View.readProperties() `. These properties are: ``position``, ``rotation``, ``zoomFactor``, ``viewMode``, ``orientation``, ``continuousMode`` and ``pageLayoutMode``. Under the hood, this is done using a :class:`~view.ViewProperties` object, which handles the saving and loading of properties, and getting/setting them from/to a View. If you want the View to remember the position, zoom factor etc. on a per-document basis, you can install a :class:`~view.DocumentPropertyStore` in the View. This automatically stores the view properties for the current Document as soon as you load a different Document (using :meth:`View.setDocument() `). If you switch back to the former document, the View restores its position and other display settings for that document. To use a DocumentPropertyStore:: v = qpageview.View() store = qpageview.view.DocumentPropertyStore() v.documentPropertyStore = store By setting a mask it is possible to influence which properties are remembered. In this example, only zoom factor and position are remembered when switching documents:: store.mask = ['position', 'zoomFactor'] *Lazy View instantiation*: It is also possible to initialize the *ViewActions* from your settings, even if you have not yet created a View (for example, when the View is in a not yet created dock widget that is lazily instantiated). This way, you application's user interface already reflects the correct settings for the yet-not-created view. Use the View.properties() static method to get an uninitialized ViewProperties object, set some defaults and then add settings read from a QSettings object. Finally update the state of the actions in the ViewActions object, *before* connecting to the ``ViewActions.viewRequested`` signal. All methods of ViewProperties return self, so these calls can be easily chained:: settings = QSettings() props = qpageview.View.properties().setdefaults().load(settings) actions = qpageview.viewactions.ViewActions() actions.updateFromProperties(props) actions.viewRequested.connect(createView) Later, when you really instantiate the View, you should also load the View settings; the ViewActions object does not actively update the View when connecting (rather, the actions are adjusted to the View when connecting):: def createView(): # creating the View.... v = qpageview.View() settings = QSettings() v.readProperties(settings) actions.setView(v) Using View Mixins ~~~~~~~~~~~~~~~~~ The View as defined in the :mod:`qpageview` module is a class composed of the basic View class in :class:`view.View` and some View Mixin classes that extend the functionality of the basic View. This is a list of the currently available View Mixin classes: :class:`link.LinkViewMixin` Adds functionality to click on links, e.g. in PDF pages :class:`highlight.HighlightViewMixin` Adds functionality to highlight rectangular regions :class:`shadow.ShadowViewMixin` Draws a nice shadow border around the pages :class:`util.LongMousePressMixin` Handles long mouse presses (can be mixed in with any QWidget) :class:`imageview.ImageViewMixin` A View targeted to the display of one single image (see also the :class:`~imageview.ImageView`) :class:`selector.SelectorViewMixin` Adds functionality to make pages selectable with a checkbox :class:`widgetoverlay.WidgetOverlayViewMixin` Adds functionality to display QWidgets on Pages that scroll and optionally zoom along and the user can interact with So, depending on your needs, you can create your own View subclass, mixing in only the functionality you need. Put the main View class at the end, for example:: class View( qpageview.link.LinkViewMixin, # other mixins here qpageview.view.View): """My View with some enhancements.""" pass # my own extensions and new funcionality def myMethod(self): pass Specialized View subclasses ~~~~~~~~~~~~~~~~~~~~~~~~~~~ There are already some specialized View subclasses available, those are: :class:`~imageview.ImageView` A View that is tailored to show one image (from file, data or a QImage) :class:`~sidebarview.SidebarView` A View that shows selectable thumbnails of all pages in a connected View, usable as a sidebar for a normal View. qpageview-0.6.2/docs/source/backgroundjob.rst000066400000000000000000000002251423465244600213140ustar00rootroot00000000000000The backgroundjob module ======================== .. automodule:: qpageview.backgroundjob :members: :undoc-members: :show-inheritance: qpageview-0.6.2/docs/source/cache.rst000066400000000000000000000001751423465244600175510ustar00rootroot00000000000000The cache module ================ .. automodule:: qpageview.cache :members: :undoc-members: :show-inheritance: qpageview-0.6.2/docs/source/changelog.rst000066400000000000000000000000471423465244600204330ustar00rootroot00000000000000:orphan: .. include:: ../../ChangeLog qpageview-0.6.2/docs/source/conf.py000066400000000000000000000141211423465244600172470ustar00rootroot00000000000000#!/usr/bin/env python3 # -*- coding: utf-8 -*- # # *qpageview* documentation build configuration file. # # This file is execfile()d with the current directory set to its # containing dir. # # Note that not all possible configuration values are present in this # autogenerated file. # # All configuration values have a default; values that are commented out # serve to show the default. # If extensions (or modules to document with autodoc) are in another directory, # add these directories to sys.path here. If the directory is relative to the # documentation root, use os.path.abspath to make it absolute, like shown here. # #autodoc_mock_imports = [ # 'sip', # 'PyQt5', # 'popplerqt5', #] import os import sys sys.path.insert(0, os.path.abspath('../..')) # avoid importing the full module (depends on PyQt5 etc) pkginfo = {} exec(open("../../qpageview/pkginfo.py").read(), {}, pkginfo) # -- General configuration ------------------------------------------------ # If your documentation needs a minimal Sphinx version, state it here. # # needs_sphinx = '1.0' # Add any Sphinx extension module names here, as strings. They can be # extensions coming with Sphinx (named 'sphinx.ext.*') or your custom # ones. extensions = [ 'sphinx.ext.autodoc', # 'sphinx.ext.doctest', # 'sphinx.ext.coverage', 'sphinx.ext.viewcode', 'sphinx.ext.intersphinx', ] # autodoc autodoc_member_order = 'bysource' autodoc_default_options = { 'member-order': 'bysource', } # If true, the current module name will be prepended to all description # unit titles (such as .. function::). add_module_names = False # intersphinx intersphinx_mapping = {'python': ('https://docs.python.org/3', None)} # Add any paths that contain templates here, relative to this directory. templates_path = ['_templates'] # The suffix(es) of source filenames. # You can specify multiple suffix as a list of string: # # source_suffix = ['.rst', '.md'] source_suffix = '.rst' # The master toctree document. master_doc = 'index' # General information about the project. project = pkginfo["name"] copyright = pkginfo["copyright_year"] + ', ' + pkginfo["maintainer"] author = pkginfo["maintainer"] # The version info for the project you're documenting, acts as replacement for # |version| and |release|, also used in various other places throughout the # built documents. # # The short X.Y version. version = '.'.join(map(format, pkginfo["version"])) # The full version, including alpha/beta/rc tags. release = pkginfo["version_string"] # The language for content autogenerated by Sphinx. Refer to documentation # for a list of supported languages. # # This is also used if you do content translation via gettext catalogs. # Usually you set "language" from the command line for these cases. language = None # List of patterns, relative to source directory, that match files and # directories to ignore when looking for source files. # This patterns also effect to html_static_path and html_extra_path exclude_patterns = [] # The name of the Pygments (syntax highlighting) style to use. #pygments_style = 'sphinx' # If true, `todo` and `todoList` produce output, else they produce nothing. todo_include_todos = False # -- Options for HTML output ---------------------------------------------- # The theme to use for HTML and HTML Help pages. See the documentation for # a list of builtin themes. # html_theme = 'alabaster' # Theme options are theme-specific and customize the look and feel of a theme # further. For a list of options available for each theme, see the # documentation. # html_theme_options = { 'logo': 'qp.png', 'fixed_sidebar': 'true', 'github_user': 'frescobaldi', 'github_repo': 'qpageview', # 'show_related': 'true', 'show_relbar_bottom': 'true', 'description': pkginfo["description"], 'extra_nav_links': { 'qpageview@Github': 'https://github.com/frescobaldi/qpageview', 'qpageview@PyPi': 'https://pypi.org/project/qpageview', }, } # Add any paths that contain custom static files (such as style sheets) here, # relative to this directory. They are copied after the builtin static files, # so a file named "default.css" will overwrite the builtin "default.css". html_static_path = ['_static'] # Custom sidebar templates, must be a dictionary that maps document names # to template names. # # This is required for the alabaster theme # refs: http://alabaster.readthedocs.io/en/latest/installation.html#sidebars html_sidebars = { '**': [ 'about.html', 'navigation.html', 'relations.html', # needs 'show_related': True theme option to display 'searchbox.html', ] } # -- Options for HTMLHelp output ------------------------------------------ # Output file base name for HTML help builder. htmlhelp_basename = 'quicklydoc' # -- Options for LaTeX output --------------------------------------------- latex_elements = { # The paper size ('letterpaper' or 'a4paper'). # # 'papersize': 'letterpaper', # The font size ('10pt', '11pt' or '12pt'). # # 'pointsize': '10pt', # Additional stuff for the LaTeX preamble. # # 'preamble': '', # Latex figure (float) alignment # # 'figure_align': 'htbp', } # Grouping the document tree into LaTeX files. List of tuples # (source start file, target name, title, # author, documentclass [howto, manual, or own class]). latex_documents = [ (master_doc, 'qpageview.tex', 'qpageview Documentation', 'Wilbert Berendsen', 'manual'), ] # -- Options for manual page output --------------------------------------- # One entry per manual page. List of tuples # (source start file, name, description, authors, manual section). man_pages = [ (master_doc, 'qpageview', 'qpageview Documentation', [author], 1) ] # -- Options for Texinfo output ------------------------------------------- # Grouping the document tree into Texinfo files. List of tuples # (source start file, target name, title, author, # dir menu entry, description, category) texinfo_documents = [ (master_doc, 'qpageview', 'qpageview Documentation', author, 'qpageview', 'One line description of project.', 'Miscellaneous'), ] qpageview-0.6.2/docs/source/constants.rst000066400000000000000000000002111423465244600205110ustar00rootroot00000000000000The constants module ==================== .. automodule:: qpageview.constants :members: :undoc-members: :show-inheritance: qpageview-0.6.2/docs/source/cupsprinter.rst000066400000000000000000000002171423465244600210610ustar00rootroot00000000000000The cupsprinter module ====================== .. automodule:: qpageview.cupsprinter :members: :undoc-members: :show-inheritance: qpageview-0.6.2/docs/source/diff.rst000066400000000000000000000001721423465244600174130ustar00rootroot00000000000000The diff module =============== .. automodule:: qpageview.diff :members: :undoc-members: :show-inheritance: qpageview-0.6.2/docs/source/document.rst000066400000000000000000000002061423465244600203170ustar00rootroot00000000000000The document module =================== .. automodule:: qpageview.document :members: :undoc-members: :show-inheritance: qpageview-0.6.2/docs/source/export.rst000066400000000000000000000002001423465244600200140ustar00rootroot00000000000000The export module ================= .. automodule:: qpageview.export :members: :undoc-members: :show-inheritance: qpageview-0.6.2/docs/source/highlight.rst000066400000000000000000000002111423465244600204440ustar00rootroot00000000000000The highlight module ==================== .. automodule:: qpageview.highlight :members: :undoc-members: :show-inheritance: qpageview-0.6.2/docs/source/image.rst000066400000000000000000000001751423465244600175700ustar00rootroot00000000000000The image module ================ .. automodule:: qpageview.image :members: :undoc-members: :show-inheritance: qpageview-0.6.2/docs/source/imageview.rst000066400000000000000000000002111423465244600204520ustar00rootroot00000000000000The imageview module ==================== .. automodule:: qpageview.imageview :members: :undoc-members: :show-inheritance: qpageview-0.6.2/docs/source/index.rst000066400000000000000000000007601423465244600176150ustar00rootroot00000000000000Welcome to qpageview! ===================== .. include:: ../../README.rst :start-line: 2 This manual documents `qpageview` version |release|. Last update: |today|. .. toctree:: :maxdepth: 2 :caption: Contents usage.rst advanced.rst interacting.rst rendering.rst modoverview.rst .. toctree:: :maxdepth: 1 :caption: About installing.rst changelog.rst license.rst Indices and tables ================== * :ref:`genindex` * :ref:`modindex` qpageview-0.6.2/docs/source/installing.rst000066400000000000000000000000511423465244600206430ustar00rootroot00000000000000:orphan: .. include:: ../../INSTALL.rst qpageview-0.6.2/docs/source/interacting.rst000066400000000000000000000202431423465244600210130ustar00rootroot00000000000000Interacting with pages ====================== .. currentmodule:: qpageview.page Coordinate systems ~~~~~~~~~~~~~~~~~~ A Page can display text or graphics, have clickable links, etc. A Page also has certain dimensions and its own notion of natural size, via the ``dpi`` attribute of the Page (sub)class. There are three ways of determining a position on a Page: 1. The pixel position on the Page in a View, e.g. where a mouse button is pressed. A Page knows its current dimensions in pixels: in the Page's ``width`` and ``height`` instance attributes, and as a QSize via the :meth:`~AbstractPage.size` method. If a Page is rotated 90 or 270 degrees, then the Page's original height now corresponds to the displayed page's width in pixels. In most cases this is called "page coordinates." Page coordinates are always integer values. 2. A position on the Page in its default size and without rotation. The original size of a Page is independent of the current zoomFactor of the View, and rather determined by the underlying image, SVG or PDF file. This is used e.g. when printing or converting to vector formats. The original size is accessible via the ``pageWidth`` and ``pageHeight`` attributes, and as a QSizeF via the :meth:`~AbstractPage.pageSize` method. This is called "original page coordinates." Normally these are floating point values. (When the ``dpi`` Page class attribute is the same as the current DPI setting of the computer's display, then the displayed size of a Page at zoom factor 1.0 in pixels is the same as the default size.) 3. A position where both horizontal and vertical offset are floating point, in the range 0..1, without rotation. This is used to determine the position of links, rectangular areas to highlight, and to position overlay widgets by the widget overlay view mixin. :class:`Page ` has the method :meth:`~AbstractPage.transform` to get a QTransform matrix that can map between page coordinates and original or 0..1 coordinates. The methods :meth:`~AbstractPage.mapToPage` and :meth:`~AbstractPage.mapFromPage` return helper objects that can convert QPoints and QRects from and to original page coordinates. These matrices take into account the page's scaling and current rotation, and they always return floating point values for original or 0..1 range coordinates, and integers for page coordinates. Page position and Layout position ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ Many methods neatly hide the computations between mouse cursor position and position in original page coordinates on a particular page, but it is still nice to understand it a bit. A PageLayout is just a large rectangular (virtual) area, large enough so that all Pages in the layout can be set to a position and size so that they do not overlap. Every Page is assigned a ``pos()`` on the layout. The geometry() of the layout is the rectangle encompassing all visible pages on the layout. :meth:`View.layoutPosition() ` returns the position of the layout relative to the top-left corner of the View's viewport. You can find the pages that are currently visible using :meth:`View.visiblePages() `. To find the Page the mouse cursor points at, use:: # pos is mouse position in viewport pos_on_layout = pos - view.layoutPosition() page = view.pageLayout().pageAt(pos) pos_on_page = pos_on_layout - page.pos() # translate the pixel position to original page coordinates pos = page.mapFromPage().point(pos_on_page) Links on a page ~~~~~~~~~~~~~~~ A Page can contain clickable links, which are collected in a Links object that is available under the :meth:`~AbstractPage.links` method of Page. .. currentmodule:: qpageview Every :class:`~link.Link` has at least an ``url`` property and an ``area`` property, which contains the rectangle of the clickable area in four coordinates in the 0..1 range. You could use the above logic to access links on the page, but if you use the LinkViewMixin class in your View class, there are simple methods: For example, :meth:`View.linkAt() ` returns the link at the specified mouse cursor position, if any. To get an understanding of how things work under the hood is here the implementation of that method:: class View: # (...) def linkAt(self, pos): """If the pos (in the viewport) is over a link, return a (page, link) tuple. Otherwise returns (None, None). """ pos = pos - self.layoutPosition() page = self.pageLayout().pageAt(pos) if page: links = page.linksAt(pos - page.pos()) if links: return page, links[0] return None, None We see that first the mouse cursor position is translated to the layout's position, and then the layout is asked for a page on that position (:meth:`PageLayout.pageAt() `). If a page is there, the position is translated to the page: ``pos - page.pos()`` (coordinates (0, 0) is the top-left corner of the Page). Then the page is asked for links at that position. Let's look at the implementation of :meth:`Page.linksAt() `:: class Page: # (...) def linksAt(self, point): """Return a list() of zero or more links touched by QPoint point. The point is in page coordinates. The list is sorted with the smallest rectangle first. """ # Link objects have their area ranging # in width and height from 0.0 to 1.0 ... pos = self.mapFromPage(1, 1).point(point) links = self.links() return sorted(links.at(pos.x(), pos.y()), key=links.width) We see that a matrix is used to map from page pixel coordinates to original coordinates, but in the 0..1 range. Then the Links object is queried for links at that position, sorted on width. The smallest one at that position is ultimately returned by :meth:`View.linkAt() `. Both PageLayout and Links internally use :class:`rectangles.Rectangles` to manage possibly large groups of rectangular objects and quickly find intersections with those objects and a point or rectangle. Links in a Document ------------------- All links in a Document can be requested with :meth:`Document.urls() `. This method returns a dictionary where the url is the key, and the value is a dictionary mapping page number to a list of rectangular areas of all links with that url on that page. Getting text from a page ~~~~~~~~~~~~~~~~~~~~~~~~ Besides links, depending on the Page type, a page can also contain text, such as PDF pages do. You can get the text with the :meth:`Page.text() ` method, which returns the text in a rectangle in page coordinates:: page = view.currentPage() # get the text in some rectangle text = page.text(some_rect) # get the full text by using the page's rectangle full_text = page.text(page.rect()) # using the rubberband selection text = view.rubberband().selectedText() Getting image data from a page ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ You can get pixel data using :meth:`Page.image() `:: image = page.image() This method returns a QImage. See the documentation for the arguments to this function, to adjust the resolution and the area (which defaults to the whole page). You can also get graphic data in :meth:`PDF `, :meth:`EPS ` or :meth:`SVG ` format. For document formats that are vector based, this graphic data wil also be vector based. For example:: page.pdf("filename.pdf") page.svg("filename.svg") page.eps("filename.eps") # using the rubberband selection: page, rect = view.rubberband.selectedPage() if page: page.pdf("filename.pdf", rect) See the method's documentation for more information about possible arguments to these functions. Instead of a filename, you can also give a QIODevice object. All these functions return True if they were successful. For more advanced methods to get image data, see the :mod:`~qpageview.export` module. qpageview-0.6.2/docs/source/layout.rst000066400000000000000000000002001423465244600200100ustar00rootroot00000000000000The layout module ================= .. automodule:: qpageview.layout :members: :undoc-members: :show-inheritance: qpageview-0.6.2/docs/source/license.rst000066400000000000000000000003131423465244600201220ustar00rootroot00000000000000:orphan: License ======= The *qpageview* package is licensed under the General Public License v3. GNU General Public License ~~~~~~~~~~~~~~~~~~~~~~~~~~ .. include:: ../../LICENSE :start-line: 3 qpageview-0.6.2/docs/source/link.rst000066400000000000000000000001721423465244600174400ustar00rootroot00000000000000The link module =============== .. automodule:: qpageview.link :members: :undoc-members: :show-inheritance: qpageview-0.6.2/docs/source/locking.rst000066400000000000000000000002031423465244600201240ustar00rootroot00000000000000The locking module ================== .. automodule:: qpageview.locking :members: :undoc-members: :show-inheritance: qpageview-0.6.2/docs/source/magnifier.rst000066400000000000000000000002111423465244600204360ustar00rootroot00000000000000The magnifier module ==================== .. automodule:: qpageview.magnifier :members: :undoc-members: :show-inheritance: qpageview-0.6.2/docs/source/modoverview.rst000066400000000000000000000011041423465244600210450ustar00rootroot00000000000000Overview of all modules ======================= .. toctree:: :maxdepth: 1 qpageview.rst backgroundjob.rst cache.rst constants.rst cupsprinter.rst diff.rst document.rst export.rst highlight.rst image.rst imageview.rst layout.rst link.rst locking.rst magnifier.rst multipage.rst page.rst pkginfo.rst poppler.rst printing.rst rectangles.rst render.rst rubberband.rst scrollarea.rst selector.rst shadow.rst sidebarview.rst svg.rst util.rst viewactions.rst view.rst widgetoverlay.rst qpageview-0.6.2/docs/source/multipage.rst000066400000000000000000000002111423465244600204640ustar00rootroot00000000000000The multipage module ==================== .. automodule:: qpageview.multipage :members: :undoc-members: :show-inheritance: qpageview-0.6.2/docs/source/page.rst000066400000000000000000000001721423465244600174170ustar00rootroot00000000000000The page module =============== .. automodule:: qpageview.page :members: :undoc-members: :show-inheritance: qpageview-0.6.2/docs/source/pkginfo.rst000066400000000000000000000002031423465244600201330ustar00rootroot00000000000000The pkginfo module ================== .. automodule:: qpageview.pkginfo :members: :undoc-members: :show-inheritance: qpageview-0.6.2/docs/source/poppler.rst000066400000000000000000000002031423465244600201570ustar00rootroot00000000000000The poppler module ================== .. automodule:: qpageview.poppler :members: :undoc-members: :show-inheritance: qpageview-0.6.2/docs/source/printing.rst000066400000000000000000000002061423465244600203330ustar00rootroot00000000000000The printing module =================== .. automodule:: qpageview.printing :members: :undoc-members: :show-inheritance: qpageview-0.6.2/docs/source/qpageview.rst000066400000000000000000000002111423465244600204650ustar00rootroot00000000000000The main qpageview module ========================= .. automodule:: qpageview :members: :undoc-members: :show-inheritance: qpageview-0.6.2/docs/source/rectangles.rst000066400000000000000000000002141423465244600206270ustar00rootroot00000000000000The rectangles module ===================== .. automodule:: qpageview.rectangles :members: :undoc-members: :show-inheritance: qpageview-0.6.2/docs/source/render.rst000066400000000000000000000002001423465244600177520ustar00rootroot00000000000000The render module ================= .. automodule:: qpageview.render :members: :undoc-members: :show-inheritance: qpageview-0.6.2/docs/source/rendering.rst000066400000000000000000000067051423465244600204700ustar00rootroot00000000000000.. currentmodule:: qpageview How rendering works =================== To render Page objects graphically, a Page class should implement three methods: :meth:`paint() `, :meth:`print() ` and :meth:`image() `. * ``paint()`` is used to paint the image in the View, in page coordinates. If painting is expensive, this method should return immediately and schedule a pixmap to be drawn in a background thread (see below). * ``print()`` is used to paint the image to a QPainter on any QPaintDevice, in original coordinates (i.e. the used QPainter has already been transformed to the original page size without rotation). * ``image()`` is used to get a rendered QImage. Most Page classes depend on a :class:`Renderer ` that implements the actual rendering. The base Renderer class has functionality for caching and for tile-based rendering in a background thread, so when you zoom in very far, only a small portion of the original page is drawn on a pixmap to be displayed on the screen. Awaiting the rendering, the View scales another image from the cache of the same region (if available) to display instead. It is not necessary to specify a renderer directly, although it can be useful. All builtin page classes install a default renderer. Page types that use a renderer inherit from :class:`page.AbstractRenderedPage`. Available Page types ~~~~~~~~~~~~~~~~~~~~ These are the currently available Page types, and their corresponding Document types: .. list-table:: :header-rows: 1 :widths: 10 25 25 40 * - Module - Page type - Document type - Displays * - :mod:`~qpageview.image` - :class:`~image.ImagePage` - :class:`~image.ImageDocument` - all image formats supported by QImage * - :mod:`~qpageview.svg` - :class:`~svg.SvgPage` - :class:`~svg.SvgDocument` - SVG images, one file per page * - :mod:`~qpageview.poppler` - :class:`~poppler.PopplerPage` - :class:`~poppler.PopplerDocument` - PDF documents, multiple pages per file * - :mod:`~qpageview.diff` - :class:`~diff.DiffPage` - :class:`~diff.DiffDocument` - color composites other pages of any type Implementing a new page type ~~~~~~~~~~~~~~~~~~~~~~~~~~~~ If you study the source of the :mod:`~qpageview.svg` module, you can see that there is only very little code needed to implement a rendered Page type. For the rendered Page, :meth:`Page.paint() ` calls :meth:`Renderer.paint() `, which schedules an image to be generated. The image is generated by :meth:`Renderer.render() `, which by default calls :meth:`Renderer.draw() `, which does the actual drawing work. Also :meth:`Page.print() ` calls :meth:`Renderer.draw() ` directly, while :meth:`Page.image() ` simply calls :meth:`Renderer.image() `, which also calls :meth:`Renderer.render() `, which in turns calls :meth:`Renderer.draw() `. So you actually only need to implement :meth:`Renderer.draw() ` :-) But, depending on the characteristics of the underlying graphics type, other strategies may be combined to achieve a well-working Page type. qpageview-0.6.2/docs/source/rubberband.rst000066400000000000000000000002141423465244600206060ustar00rootroot00000000000000The rubberband module ===================== .. automodule:: qpageview.rubberband :members: :undoc-members: :show-inheritance: qpageview-0.6.2/docs/source/scrollarea.rst000066400000000000000000000002141423465244600206270ustar00rootroot00000000000000The scrollarea module ===================== .. automodule:: qpageview.scrollarea :members: :undoc-members: :show-inheritance: qpageview-0.6.2/docs/source/selector.rst000066400000000000000000000002061423465244600203210ustar00rootroot00000000000000The selector module =================== .. automodule:: qpageview.selector :members: :undoc-members: :show-inheritance: qpageview-0.6.2/docs/source/shadow.rst000066400000000000000000000002001423465244600177600ustar00rootroot00000000000000The shadow module ================= .. automodule:: qpageview.shadow :members: :undoc-members: :show-inheritance: qpageview-0.6.2/docs/source/sidebarview.rst000066400000000000000000000002171423465244600210070ustar00rootroot00000000000000The sidebarview module ====================== .. automodule:: qpageview.sidebarview :members: :undoc-members: :show-inheritance: qpageview-0.6.2/docs/source/svg.rst000066400000000000000000000001671423465244600173060ustar00rootroot00000000000000The svg module ============== .. automodule:: qpageview.svg :members: :undoc-members: :show-inheritance: qpageview-0.6.2/docs/source/usage.rst000066400000000000000000000130641423465244600176130ustar00rootroot00000000000000Basic usage =========== .. currentmodule:: qpageview Creating the View widget ~~~~~~~~~~~~~~~~~~~~~~~~ Just import :mod:`qpageview` and create a View. As the :class:`~view.View` is a QWidget, you need to create a QApplication object, just as for all Qt-based applications:: from PyQt5.QtWidgets import QApplication import qpageview app = QApplication([]) v = qpageview.View() v.resize(900, 500) v.show() Loading contents ~~~~~~~~~~~~~~~~ Load a PDF file with:: v.loadPdf("path/to/a_file.pdf") or images, or SVG files:: import glob v.loadImages(glob.glob("*.jpg")) v.loadSvgs(glob.glob("*.svg")) It is also possible to display pages originating from different sources at the same time in a View, see :doc:`advanced`. To clear the View again:: v.clear() Navigating in the View ~~~~~~~~~~~~~~~~~~~~~~ The View numbers pages starting from 1, like printed documents do. You can programmatically navigate through the View:: v.pageCount() # get the number of pages v.setCurrentPageNumber(11) # go to page 11 v.currentPageNumber() # get the current page number v.gotoNextPage() # go to the next page v.gotoPreviousPage() # go to the previous page If the page you want to go to is not completely visible, it is scrolled into View. Controlling the display ~~~~~~~~~~~~~~~~~~~~~~~ You can interact in the normal way with the widget, scrolling and zooming. Note the almost infinite zoom, thanks to the tile-based rendering engine. There are various methods to change things, like *rotation*:: v.rotateRight() v.rotateLeft() v.setRotation(2) # or v.setRotation(qpageview.Rotate_180) or *zooming*:: v.zoomIn() v.zoomOut() v.setZoomFactor(2.0) or *how* to fit the document while resizing the View widget:: v.setViewMode(qpageview.FitWidth) # fits the page(s) in the width v.setViewMode(qpageview.FitHeight) # fits the page's height v.setViewMode(qpageview.FitBoth) # shows the full page v.setViewMode(qpageview.FixedScale) # don't adjust zoom to the widget Setting the zoomFactor automatically switches to the FixedScale mode. Change the *orientation*:: v.setOrientation(qpageview.Vertical) v.setOrientation(qpageview.Horizontal) Change the *continuous* mode:: v.setContinuousMode(False) # only display the current page(s) v.setContinuousMode(True) # display all pages Change the *layout mode*:: v.setPageLayoutMode("double_right") # Two pages, first page right v.setPageLayoutMode("double_left") # Two pages, first page left v.setPageLayoutMode("single") # Single pages v.setPageLayoutMode("raster") # Shows pages in a grid (The method :meth:`~view.View.pageLayoutModes` returns a dictionary mapping the available layout mode names to the constructors of their corresponding layout engines. By making new :class:`~layout.LayoutEngine` subclasses, you can implement more layout modes, and you can reimplement ``pageLayoutModes()`` to include them.) All these properties have "getter" couterparts, like ``viewMode()``, ``orientation()``, etc. The Magnifier ~~~~~~~~~~~~~ You can add a :class:`~qpageview.magnifier.Magnifier`:: from qpageview.magnifier import Magnifier m = Magnifier() v.setMagnifier(m) Now, Ctrl+click in the View, and the Magnifier appears. You can also show the Magnifier programmatically with:: m.show() # or v.magnifier().show() Now you can only get it away with:: m.hide() :kbd:`Ctrl+Wheel` in the magnifier zooms the magnifier instead of the whole View. :kbd:`Shift+Ctrl+Wheel` resizes the magnifier. The Rubberband ~~~~~~~~~~~~~~ You can add a :class:`~rubberband.Rubberband`, to select a square range:: from qpageview.rubberband import Rubberband r = Rubberband() v.setRubberband(r) By default with the right mousebutton you can select a region. The rubberband has various methods to access the selected area, just the rectangle, or the rectangle of every page the selection touches, or the selected square as an image or, depending on the underlying page type, the text or clickable links that fall in the selected region. Controlling the behaviour ~~~~~~~~~~~~~~~~~~~~~~~~~ Scrolling --------- By default, the View has smooth and kinetic scrolling. Kinetic scrolling means that the View does not move the pages at once, but always scrolls with a decreasing speed to the desired location, which is easier on the eyes. If you want to disable kinetic scrolling altogether, set the :attr:`~scrollarea.ScrollArea.kineticScrollingEnabled` attribute of the View to False. If you only want to disable kinetic scrolling when paging through the document using the methods mentioned under `Navigating in the View`_, you can leave :attr:`~scrollarea.ScrollArea.kineticScrollingEnabled` to True, but set :attr:`~view.View.kineticPagingEnabled` to False. Zooming ------- The user can zoom in and out with Ctrl+Mousewheel, which is expected behaviour. You can disable wheel zooming by setting the :attr:`~view.View.wheelZoomingEnabled` attribute of View to False. The minimum and maximum zoom factor can be set in the :attr:`~view.View.MIN_ZOOM` and :attr:`~view.View.MAX_ZOOM` attributes. By default you can zoom out to 5% and zoom in to 6400%. Paging ------ By default, the :kbd:`PageUp` and :kbd:`PageDown` keys just scroll the View up or down ca. 90%. If you set the :attr:`~view.View.strictPagingEnabled` attribute to True, in non-continuous mode those keys call the :meth:`~view.View.gotoPreviousPage` and :meth:`~view.View.gotoNextPage` methods, respectively. qpageview-0.6.2/docs/source/util.rst000066400000000000000000000001721423465244600174600ustar00rootroot00000000000000The util module =============== .. automodule:: qpageview.util :members: :undoc-members: :show-inheritance: qpageview-0.6.2/docs/source/view.rst000066400000000000000000000001721423465244600174550ustar00rootroot00000000000000The view module =============== .. automodule:: qpageview.view :members: :undoc-members: :show-inheritance: qpageview-0.6.2/docs/source/viewactions.rst000066400000000000000000000002171423465244600210360ustar00rootroot00000000000000The viewactions module ====================== .. automodule:: qpageview.viewactions :members: :undoc-members: :show-inheritance: qpageview-0.6.2/docs/source/widgetoverlay.rst000066400000000000000000000002251423465244600213670ustar00rootroot00000000000000The widgetoverlay module ======================== .. automodule:: qpageview.widgetoverlay :members: :undoc-members: :show-inheritance: qpageview-0.6.2/docs/web/000077500000000000000000000000001423465244600152265ustar00rootroot00000000000000qpageview-0.6.2/docs/web/_static/000077500000000000000000000000001423465244600166545ustar00rootroot00000000000000qpageview-0.6.2/docs/web/_static/qp.png000066400000000000000000000514101423465244600200030ustar00rootroot00000000000000PNG  IHDR n*rzTXtRaw profile type exifxڭi9snrfs9|JU6뱑T;ӝ_Vz)_i5|֏ Vkg9oPﻺi }>9V7P㎾/?_Ε7St<1}?O΋Bj=ΟڹR8_/([y acvv<ȅ*P{7^8YV]~w~7qhN~/z 90 }]E9X}{\I!na;""joǟy+4>y^ ݟB*uCb֊JnCӟ]S{~aKMD27pĴ+ҋsu `lfB"d_c! <'fqwMJന=5Fo-RIP(+g#jnаdٙYjͺJ.VJE5jZ-V{-ܬV[k{¬^|҃w^1ƌ3|-qr:l;ɏ1IM[&-G_ܔG'ۺ1V~h^r,1q]JZ2BҫZs ~n:v3ZnY\:+f^?y@rNV&+Ne,ULBVcױJW]) R[bi&0<-p!@M HX"mVekQ*D> A*s!m&wԑ.BP0z0Ss7;xC =pVQ2ҖNF锾 s$C 9eyKx[ 8)qafTJicmH{[v2 l'[C!9Vey0Ӹ;lYXroT}'j n$y?+eg$w+lhĻ/:-BjnBС؋,-P bԇTHrA| 6Kr5(kQ4[Vy\ʿZJ~B^mRYyUPJOT Z ޤŪuM{<'&W<: q4X}qybilISn9$O" | q}KsSDHI2He-A`rNa-OlY` Vևwtx#"ޔ!WU->nP6uh^?!^k%*C!klnij!VdFAw7(bnIpB*/{Jñ*=p Mh#޸c} o #jvbR5 CDok0 evg5CCABpRV6|)^ %X`dyL|~2PPS2™s\O^ ֠ϋF.~*"}IK6#,nz`A袻 Ks ;n^' ze6rmT |q(+Py\bvӐD k=r,|<7 YP^R#ǀ[|R*Q+ϔ@+N#qgDF hXުxD2;=ȒV\xb[/N(,%HN~x{ #ƼlHk`a]%xЉhdA 5nGR sChS 'R f%&jkxFix8bAMF@:a,v|W݈8|x0/{KQ޾ap`v01d452ۂX%E`x ԄAAkǑҚ^ޏ >([d*6uk<F|&rElB 4a(ό.:HZ }% ҋQp51=兩!:֣ny+D(k I1@>Bu X+-U#ēN*8)Qy nXns9ঞ!u!JnT'X8u Ѹ9V"#=;Q>ز"ƑR$Ѫ[Y"x`q, "tW`E$XkTx6ګ p`Tfp[..\<)Ml(JY14nK>U79k-w5$,`48B="7:53mv!l֍;Q`CU65c0<x(Eu5N2 $ƺGFoBn`KdQvf[x }Y IF0h.$VHM˒"ٱ;kEo …5)aD%_Qru$2RЕ\xӬA\.:Re !_My;mJS,7d,J.R>]0Ge 8RjaYdn&D_gEm2-; k~nP!z8 $H$yfPýGAг,Sy'[@`HsKM|w|ǂEr@Pd'{:Ħ^>7*ٷ6Ҿ Ac'jQS/eOE)m>⺶ayȍO :/n /THyAMhrM\@M ;sP+'LP!̆@wnhDKOW*_zB*ʋ?p@M \a'RF.TYj y[E3UR5jQ^$> 48rl]. Vƌ>tՋ!ŠFj hxugyk0ˣ9=.J7sڌ0lp 냬Ғ*o :Ȼ$d6Wtm)7٣GS.צ"[v`ڤ~0;E]\=6"6IU$=-e. 7(Z"*+d%E2xq *[,fbGU?ӫ.A9 G' Da(,F)VD^m NRk"ںV>}Fͳ3Sۦ#.!%ܛ2hs]U>9i%PpAgT eȟiSKs! "[ v$<;LOYi=%X~4t87{SЂ73S~}j1p޼gc֠`!@gT*R`<jvaTk/hȚ@E`-zj~#)XV^R|*pٝܙM ~,`L#ЖAʑ`un O2> [u8t4b$YyMT/ťarTFFc(_(3Zjd=PG 8m ]i( ? sj* >vvS?OQ͑<BkvLPR;LM3Q0/DAjfxH-K eG$uMU-hvHQ&ݨ/?a \󼭎%|0pQM;:3 3S׌T|& Gm@L;?X 7+15Iwn7V(pd DT,2K[ Pm<,:>khcg4R)I0c7 $rzErAj 9, 1@Dߠ⃉KN:5nBjDhhg!.2ckBKB5"UEiW5iL"Q= '8}Qb#*OFnܜ&XPa*A$Grf𫎥 D$p7jPuL,@g"}rkn&s^ D|*Mh ՜<8CǾ|X%Ym 0I'GMondA''rWע[ᐓ5mP. R2i @9;:X \m 1? ײRI˄pa( uo}cClP_#do[Oe'۽hMuF@ccZ 7I#T7 k`UDCHīW ̻5PÕi(@G1E'V,?>V?*A:7RN_6&֊I0dnWsrWL!JۡeYIC q+)Yej1p\ڛd *H"5,p3(¨/EѴҹNh2^(y?ԫOC% A@@FUal >BxV$O-$JL(C! "6'rڹԤ!OL߾Lkt('i } >]uwȽfN#$|^S6Xx@Lǫ,£Qh:VL`!w8X<~ )56ړ0!I"]C GMSCLDnf_ֲ'[}xipD6_OYƩUȴ^nӝ+G<.E&ui uՂk8ޱLa^אI-uA΍duNNao/i3ɯ[y XUut_%{VH+uCw"|DkkP۲Ty5'y~I !H+| N Elf tnh5i/aV_[Zڿ:puwe/M 6cEFN7}߳[]l2 @*dγ}F yp\5N! Ɵ"IrK;5hGn;"ٱ:$XVGT D64i{4ͷ W TsBӡ5 }x[VXљ;^?pўiH6`JrQW0e= vaĄDBNA_PԇZ#ԧ: (t\ 8bd0^Y98UQ3!13x)< HVWAG5u+ w[IY"߹ D\ms[jI4[4ItM伞lFO Y,VB>wb45oybP՛ `\4O"Zq cձ'O*S;IMOKOy.v_T't@ekV~0X F@bvt  +rNmOj9tkvήZ ڿOf%b`װ >խ@p}? >d4F!C]X3L%i#Ј&tр:/,KE)!3sE MF 00,lI>5ڌGm {fD`Cxdt?@&GCWiHh .c:;"k,I$)4ҬA,7zvŋF8.5jjye5`45?b:V= UMㄟkh;2·N~)`M-.Vh *^ͬ!W5d 'GCC[dfp;+*+IR}szqor`:lګ>z eV7t<aC8Xޔ&tAƫGbRH&Hh#_(oD\GMWo5t6+d,M2i:,,9gWַmlWi!%uo0Nea݇"N-M2}h`,`~1fҖg) &P2-ހӚo{;|Ӧv’b(bёQ'P cIN!f}Qr|ba3G&{PIVP)|3pQtr 5߾,zMhv+9QM|Mεu!P85W&f{4ZuiY*Q2YKt6;htTr"'Sv䬹%)cvnCĘm_KG'^叓gCZaz`$$BLk@6aGS8(a!mQaFe2X~FUrη{@jhҵנjA5 B[rًtb_T O ||M:!釃$&kn8Xܡ4qQ/o";IGECn[8u!yVG,w鈷5rZ /b$}l G*PI+r*}tk^u4vJaMp53\m`~|Yge/P& @\h4098rXs@@qF9uu Rk%1n KUy0u}y/fh K178uЁh ǑڢԈ&ʄQHPƖ~8F`$e4zȹviT_4#蟧@ty\Q#.-)ĉ8OXS)tPmƷ ͻ_hw](&[]Š ]ucty9E,&" v:;t-!g>M./D3ڭ<Xv,u"'Wzoį9.`e l:+PTB rs?X_~u"KbKGD̿ pHYs  tIME'2eթ IDATxwx\[WeWWl tbB )HH| T\ rnVKt{jw%h;sߙ3s挊se.I_ %qD+?o{벲En'J?E_^;eyO%B/&R ;bj@qKZUĜ33ܠS[E8s+Ō{&yPeKWchѴ:=&#:q~ڸ;7ݾ:\~\CάIa>24IZg! D56襎(+3.wX?{4HSgS>ռiqâ60x"G/izJޠC#?h=f/ϭwԊXJQrGσﲍN=71Ϭ--kI 1fa %m'n[r^8k&͵S_+# i4#5dUz @h~L>,%5I>^ pL!Za,+68V> }K/v^nu kW@3.aɷ27"rykBgNE0j:8/27asB 1Dao\"b*̿Sk6]}?Dd7Zu߆7hZ<+DM N%vK+߉!ò^&L#?R潴핏?i'> s׏O@Ԉ-ϊTÑZs6.*m>䔁wŀP?z mLbxJ2ɚ8_6&f{(g ʙ^z깯Q`!lRMy,5MYAD>t]D1 K)q螄͋Y +]xڎ?#Tc@&_*-uS ǘKy$540?1ͣbS PL{xj0Ŭei ej#D78! 5:1*h":uDJ*Pbs2 ln2kliPktAJ?c҉$z"1D+qX8c \2LGXWCǬ|0sqKN{ @|K 1 L!H0rl+fulO?A+_r9Pz#˹n!.ฎ8twp Kb@F q6E~ b1N$gj^O^!jO]2_,?HFq<,Vqӆ h$ωFM@+>`AO0~LP7cѧbX^ruJ>1˩y[g#5J)/ S[(gM6vg*ҋ!N$XtK_4 !^Gƴğ//p_hBask@܍  F&,'fnTTY3$/ &Nw uwf[,5>aVbqmP5XQ;es NW]N|EW0\y!ng?a!6ƟAi#]^Va ־NV \0;OWPOr 6f$rj+yfof|ԠT  qXYCu(mG^җe@xLVD)d~IguK)r_l)Ƭ.Cý^xj@@O4fh <̿GL/C)? Kr;wf;e{en|[r({'egOO>Z1Q&r 0Q:Tb!{|aa1FW孲|.mΨRAJ{7LjMU$^2|*INJ#y@^L x|//d5!n%c{?syS1W =[QCc0X4H=VSЇ+RV!p;S ̡}R0=Gs|ǡq=3\#S/cAX7dSO1{xiܘgv,1@2>e@`ڏ );(]<o%ՃgLv`'`lUbb/H52j ShtRX%)Z"0luo8/&uv=yn=.O \AY.7/[bYKk;˼T_C+MTN-*b'<9Cf4) :' uxH}htE@% b%5v|2h]+VСdPv(.z3]c,MU$:Wj̥ PPL U^ nL#a.F`L]<I2%^b@%Neʦ_zZB 2}Zw^\+ɭ&~ĨQDHnHD "܊`[i.O(Eߛ.rV8j)]βz cy}vP& y0)aKЅک_zQXY{]ih*v =,0`}B~E@YPKΊe !3ɔ{ H,o)\!%Jv 9'?gј`Rolvy#dHp,%@9KDQ'Qp- aApk~A&Jq`+Wމ 4E@$L,RĺA_n/;?tagp5^xk@=6apxb=8ݤ-;4#$ҩ溨i}/f`ٗ5iȯC;D׬RFXL Ц#WE`lh Cv6.ƛhАbƞQI#SdJڎnfˌgC|xgYK<^2p!K[~u(Q?*[9u@|b F;lYxi^X|Ό1$ 'PHDcXdhwEfEtzX s<{cI….87Y]urQ/5KG|_G:=sɊVJ_] 6 4H#^U<^fZ3/D!?k#)4(^'q.zsBTd46{EE;:ٱ.grN/WS6@4\%] = CSkq\#5gNr7܀R_:dZ%Jr H:< f&2_"NSҡ)phhxqc<#GZ8YHb@9·%sdVX*AdPt`k%7|;^epSRV'XPQʸϩ1Eqp`" akvVMu^&j:yًuFe&P\-wÃmMNk(˸LE@tER{(áq@b 2FmbwܕDn@>?u(׎S ܯ}h^h?l%t,*52ydt;dj`A..V; w[ƹxQLj-s0|$4cuRd5(\-LN$]$q=e"mvLEٚV5lg\Kh+֧VJgF (6"Y_p;.ug˩R]Ek #"6@pޥ|SQZ.r3-Ky&UuVYuAR[B3`Wy/ >(ȹGȏdKsÑ=P%400bepVt@#ft )葙;&%Y#bjldjv|a@"TE@Bʇ$s2YX}@[(jm.HBJ)=F@Hsv>% ?9BnKZ')^BG" W%Y EV ӦЉ#LV'tp\7fP U \֓ӸY%GɕQ7.7&zp>X%4 l"RjgX5`Eas>vXf.|^?>ˬ>EE-N0{/k hH`ݽ:/k\rƋgpji(Iah L3&b`+;K>T;>`pL@a,uQ?ܿ^{VR$ݦC째)QA"cJ'@]o L+=IaIaS׮&9Ëb':AQN.oO'1OO~, T#a7ffbg?~rP&1>MX@G*L|BۈYFy!MqK8FhB(uF5ϥ(ٸy 8 BX8Zp縇42|{3U!#0S,]c1c꘶t'tBq+X/fgXAujG9DC"/4Z. SwIgw O;2;՘E(!t)P [<$64l2(d~>J|&$O$FB G2 ̇%hKQ$L7Ql jBф89$tCW < !!@<~RHEy Jc2%gDwk솯\@PEO/QO# 6T404đEDa$PB4F*BE`xc ɳG88K.$5_S4-D#uN[OIPî ]O頝vh tJ'f|ITq"A@B%5[Wd- .(D DDBcKF+ՔqB>0K8X #'ʗԐNtKDV1SE3#;Às"9m49$ܬD7yq c (0V(5 2 D/%<+c;6DUL#+2ɤ^lF-'(`','% c؊ b=D7h!$ߺ,JC=8CɄ  "R&$\r7+$-y\x؈$)L4<"cvE늾ܰg1J}N$9!l^~Npr@tsO6a@IC.T50x~E#͜?thq}ׅbR ">i|<\ y#ݑ@^>褞jr%d7襌}l'cE?qTkjv#PJ5GHB 3D+ot!=la#+e:t@+ETRZHa \ߓ#5 z|8y`@G) h=$A 1DzDzjGq6X]c#ge]%ZhBPaϨ&Xb)g߻$r8ܬL0#ljJΗ,@3rY3A)6WN+€\F?䰉Dg}l]KzH#CC&Z1!Tc$•v,/i&ry~V2 ~'hf퀆ʎ Ih>O}NsE5RƵ|#CĠ2q^_[|O1g3Ј@O%"d. =:袍^颕fZ !#aFbV ĘsYM;v-,wJ?vDz0>ïbfb'g ĸ=k3ܘx4N3v`y<ͭV ͘L? xG-hQG}2@-QI1}L1I* $fdl ~ {e}{sc~wRO5쥒,‡(D,#ĞfIĵbxӒ4N(̣6`)IDAT*b xRIĈ Bp]tA+-XE`%#$M֍nC|FO;91RMsYO ^2 d<ާ+C6ې^ I?6Th-#^__H`RvRf`0n@%I1h$*h9!XAX(zѡŊ/X%#%bNt`&: dB+Y@(4 ZB4tQ\.SC9|A*#YIIz44T_HH|z2 }ς~Ÿ$i#4E}NCs q3 >tXH_ )Uq/l#G'ca yj*(3SY7iWYB9w>Izbͩ i:nT،|H6aRD ߏiʁL}KdVs +YE9IJ>mwR@Rqv ['b,#]AzB Z$Vb(`=?c;2J >#|@PƟE(o0vr1Tp!43 r!~S.x=O~f;s*΀y9 8qc! iǂZj9k-/( d osxP:G&> H֏yFQ1ZKa RG-wp7y"8"mS*؏/ e6yg>~+`Da;!«C&O b!{*SZ!R 5IRB06 Gql`"@ys-Ueb ORMW2FYásky">hIݟV!> l0KYO$)hB=MTR):Y,#)Od6M\xȠ>K> iL֙N.m Q}ՄAk:M!}rW_*_!>a&ӘB]頇~fP"h颉z縖xNah}'eYf(^tCO0u|ְ͜]*l"N135l졖|^e~=*~of>5`u0 UcY3x`3U5jb?r(R3eD>#sZ*+p<PaLI fpͧuiPW9jWzd @z%AB:V0E =]8Do1 self.maxsize e = d[tile] = ImageEntry(image) self.currentsize += e.bcount if not purgeneeded: return # purge old images is needed, # cache groups may have disappeared so count all images entries = iter(sorted( ((entry.time, entry.bcount, group, ident, key, tile) for group, identd in self._cache.items() for ident, keyd in identd.items() for key, tiled in keyd.items() for tile, entry in tiled.items()), key=(lambda item: item[:2]), reverse=True)) # now count the newest images until maxsize ... currentsize = 0 for time, bcount, group, ident, key, tile in entries: currentsize += bcount if currentsize > self.maxsize: break self.currentsize = currentsize # ... and delete the remaining images, deleting empty dicts as well for time, bcount, group, ident, key, tile in entries: del self._cache[group][ident][key][tile] if not self._cache[group][ident][key]: del self._cache[group][ident][key] if not self._cache[group][ident]: del self._cache[group][ident] if not self._cache[group]: del self._cache[group] def closest(self, key): """Iterate over suitable image tilesets but with a different size. Yields (width, height, tileset) tuples. This can be used for interim display while the real image is being rendered. """ # group and ident must be there. try: keyd = self._cache[key.group][key.ident] except KeyError: return () # prevent returning images that are too small minwidth = min(100, key.width / 2) suitable = [ (k[1], k[2], tileset) for k, tileset in keyd.items() if k[0] == key.rotation and k[1] != key.width and k[1] > minwidth] return sorted(suitable, key=lambda s: abs(1 - s[0] / key.width)) qpageview-0.6.2/qpageview/constants.py000066400000000000000000000027631423465244600201070ustar00rootroot00000000000000# -*- coding: utf-8 -*- # # This file is part of the qpageview package. # # Copyright (c) 2016 - 2019 by Wilbert Berendsen # # This program is free software; you can redistribute it and/or # modify it under the terms of the GNU General Public License # as published by the Free Software Foundation; either version 2 # of the License, or (at your option) any later version. # # This program is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU General Public License for more details. # # You should have received a copy of the GNU General Public License # along with this program; if not, write to the Free Software # Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA # See http://www.gnu.org/licenses/ for more information. """ Constant values. """ # rotation: Rotate_0 = 0 #: normal Rotate_90 = 1 #: 90° rotated clockwise Rotate_180 = 2 #: 180° rotated Rotate_270 = 3 #: 270° rotated (90° couter clockwise) # viewModes: FixedScale = 0 #: the scale is not adjusted to the widget size FitWidth = 1 #: scale so that the page's width fits in the widget FitHeight = 2 #: scale so that the page's height fits in the widget FitBoth = FitHeight | FitWidth #: fit the whole page # orientation: Horizontal = 1 #: arrange the pages in horizontal order Vertical = 2 #: arrange the pages in vertical order qpageview-0.6.2/qpageview/cupsprinter.py000066400000000000000000000321361423465244600204460ustar00rootroot00000000000000# -*- coding: utf-8 -*- # # This file is part of the qpageview package. # # Copyright (c) 2014 - 2019 by Wilbert Berendsen # # This program is free software; you can redistribute it and/or # modify it under the terms of the GNU General Public License # as published by the Free Software Foundation; either version 2 # of the License, or (at your option) any later version. # # This program is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU General Public License for more details. # # You should have received a copy of the GNU General Public License # along with this program; if not, write to the Free Software # Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA # See http://www.gnu.org/licenses/ for more information. r""" A simple module using CUPS to send a document directly to a printer described by a QPrinter. This is especially useful with PDF documents. Uses the `cups` module, although it elegantly fails when that module is not present. The cups module can be found in the pycups package at https://pypi.org/project/pycups/ . There are two methods to send a document to a printer: 1. Using the `lp` shell command 2. Using the cups module, which uses libcups to directly contact the server. This module provides both possibilities. Use `CmdHandle.create()` to get a CmdHandle, if `lp` is available, or use `IppHandle.create()` to get a IppHandle, if the cups module is available and a connection to the server can be established. A function `handle()` is available; that tries first to get an IppHandle and then a LprHandle. Usage of this module is this simple:: import qpageview.cupsprinter h = qpageview.cupsprinter.handle() if h: h.printFile('/path/to/document.pdf') You can supply a QPrinter instance (that'd be the normal workflow :-) :: h = qpageview.cupsprinter.handle(printer) if h: h.printFile('/path/to/document.pdf') In this case all options that are set in the QPrinter object will be used when sending the document to the printer. If `printFile()` returns True, printing is considered successful. If False, you can read the `status` and `error` attributes:: if not h.printFile('/path/to/document.pdf'): QMessageBox.warning(None, "Printing failure", "There was an error:\n{0} (status: {1})".format(h.error, h.status)) To print a list of files in one job, use `printFiles()`. """ import os import shutil import subprocess from PyQt5.QtPrintSupport import QPrintEngine, QPrinter class Handle: """Shared implementation of a handle that can send documents to a printer.""" def __init__(self, printer=None): self._printer = printer def setPrinter(self, printer): """Use the specified QPrinter.""" self._printer = printer def printer(self): """Return the QPrinter given on init, or a new default QPrinter instance.""" if self._printer == None: self._printer = QPrinter() return self._printer def options(self): """Return the dict of CUPS options read from the printer object.""" return options(self.printer()) def title(self, filenames): """Return a sensible job title based on the list of filenames. This method is called when the user did not specify a job title. """ maxlen = 5 titles = [os.path.basename(f) for f in filenames[:maxlen]] more = len(filenames) - maxlen if more > 0: titles.append("(+{0} more)".format(more)) return ", ".join(titles) def printFile(self, filename, title=None, options=None): """Print the file.""" return self.printFiles([filename], title, options) def printFiles(self, filenames, title=None, options=None): """Print a list of files. If the title is None, the basename of the filename is used. Options may be a dictionary of CUPS options. All keys and values should be strings. Returns True if the operation was successful. Returns False if there was an error; after the call to printFile(), the status and error attributes contain the returncode of the operation and the error message. """ if filenames: if all(f and not f.isspace() and f != "-" for f in filenames): if not title: title = self.title(filenames) o = self.options() if options: o.update(options) printerName = self.printer().printerName() self.status, self.error = self._doPrintFiles(printerName, filenames, title, o) else: self.status, self.error = 2, "Not a valid filename" else: self.status, self.error = 2, "No filenames specified" return self.status == 0 def _doPrintFiles(self, printerName, filenames, title, options): """Implement this to perform the printing. Should return a tuple (status, error). If status is 0, the operation is considered to be successful. If not, the operation is considered to have failed, and the `error` message should contain some more information. """ return 0, "" class CmdHandle(Handle): """Print a document using the `lp` shell command.""" def __init__(self, command, server="", port=0, user="", printer=None): self._command = command self._server = server self._port = port self._user = user super().__init__(printer) @classmethod def create(cls, printer=None, server="", port=0, user="", cmd="lp"): """Create a handle to print using a shell command, if available.""" cmd = shutil.which(cmd) if cmd: return cls(cmd, server, port, user, printer) def _doPrintFiles(self, printerName, filenames, title, options): """Print filenames using the `lp` shell command.""" cmd = [self._command] if self._server: if self._port: cmd.extend(['-h', "{0}:{1}".format(self._server, self._port)]) else: cmd.extend(['-h', self._server]) if self._user: cmd.extend(['-U', self._user]) cmd.extend(['-d', printerName]) cmd.extend(['-t', title]) if options: for option, value in options.items(): cmd.extend(['-o', '{0}={1}'.format(option, value)]) if any(f.startswith('-') for f in filenames): cmd.append('--') cmd.extend(filenames) try: p = subprocess.Popen(cmd, stdin=subprocess.DEVNULL, stderr=subprocess.PIPE) except OSError as e: return e.errno, e.strerror message = p.communicate()[1].decode('UTF-8', 'replace') return p.wait(), message class IppHandle(Handle): """Print a document using a connection to the CUPS server.""" def __init__(self, connection, printer=None): super().__init__(printer) self._connection = connection @classmethod def create(cls, printer=None, server="", port=0, user=""): """Return a handle to print using a connection to the (local) CUPS server, if available.""" try: import cups except ImportError: return cups.setServer(server or "") cups.setPort(port or 0) cups.setUser(user or "") try: c = cups.Connection() except RuntimeError: return h = cls(c, printer) if h.printer().printerName() in c.getPrinters(): return h def _doPrintFiles(self, printerName, filenames, title, options): """Print filenames using a connection to the CUPS server.""" import cups # cups.Connection.printFiles() behaves flaky: version 1.9.74 can # silently fail (without returning an error), and after having fixed # that, there are strange error messages on some options. # Therefore we use cups.printFile() for every file. for filename in filenames: try: self._connection.printFile(printerName, filename, title, options) except cups.IPPError as err: return err.args return 0, "" def handle(printer=None, server="", port=0, user=""): """Return the first available handle to print a document to a CUPS server.""" return (IppHandle.create(printer, server, port, user) or CmdHandle.create(printer, server, port, user)) def options(printer): """Return the dict of CUPS options read from the QPrinter object.""" o = {} # cups options that can be set in QPrintDialog on unix # I found this in qt5/qtbase/src/printsupport/kernel/qcups.cpp. # Esp. options like page-set even/odd do make sense. props = printer.printEngine().property(0xfe00) if props and isinstance(props, list) and len(props) % 2 == 0: for key, value in zip(props[0::2], props[1::2]): if value and isinstance(key, str) and isinstance(value, str): o[key] = value o['copies'] = format(printer.copyCount()) if printer.collateCopies(): o['collate'] = 'true' # TODO: in Qt5 >= 5.11 page-ranges support is more fine-grained! if printer.printRange() == QPrinter.PageRange: o['page-ranges'] = '{0}-{1}'.format(printer.fromPage(), printer.toPage()) # page order if printer.pageOrder() == QPrinter.LastPageFirst: o['outputorder'] = 'reverse' # media size media = [] size = printer.paperSize() if size == QPrinter.Custom: media.append('Custom.{0}x{1}mm'.format(printer.heightMM(), printer.widthMM())) elif size in PAGE_SIZES: media.append(PAGE_SIZES[size]) # media source source = printer.paperSource() if source in PAPER_SOURCES: media.append(PAPER_SOURCES[source]) if media: o['media'] = ','.join(media) # page margins if printer.printEngine().property(QPrintEngine.PPK_PageMargins): left, top, right, bottom = printer.getPageMargins(QPrinter.Point) o['page-left'] = format(left) o['page-top'] = format(top) o['page-right'] = format(right) o['page-bottom'] = format(bottom) # orientation landscape = printer.orientation() == QPrinter.Landscape if landscape: o['landscape'] = 'true' # double sided duplex = printer.duplex() o['sides'] = ( 'two-sided-long-edge' if duplex == QPrinter.DuplexLongSide or (duplex == QPrinter.DuplexAuto and not landscape) else 'two-sided-short-edge' if duplex == QPrinter.DuplexShortSide or (duplex == QPrinter.DuplexAuto and landscape) else 'one-sided') # grayscale if printer.colorMode() == QPrinter.GrayScale: o['print-color-mode'] = 'monochrome' return o def clearPageSetSetting(printer): """Remove 'page-set' even/odd cups options from the printer's CUPS options. Qt's QPrintDialog fails to reset the 'page-set' option back to 'all pages', so a previous value (even or odd) could remain in the print options, even if the user has selected All Pages in the print dialog. This function clears the page-set setting from the cups options. If the user selects or has selected even or odd pages, it will be added again by the dialog. So call this function on a QPrinter, just before showing a QPrintDialog. """ # see qt5/qtbase/src/printsupport/kernel/qcups.cpp opts = printer.printEngine().property(0xfe00) if opts and isinstance(opts, list) and len(opts) % 2 == 0: try: i = opts.index('page-set') except ValueError: return if i % 2 == 0: del opts[i:i+2] printer.printEngine().setProperty(0xfe00, opts) PAGE_SIZES = { QPrinter.A0: "A0", QPrinter.A1: "A1", QPrinter.A2: "A2", QPrinter.A3: "A3", QPrinter.A4: "A4", QPrinter.A5: "A5", QPrinter.A6: "A6", QPrinter.A7: "A7", QPrinter.A8: "A8", QPrinter.A9: "A9", QPrinter.B0: "B0", QPrinter.B1: "B1", QPrinter.B10: "B10", QPrinter.B2: "B2", QPrinter.B3: "B3", QPrinter.B4: "B4", QPrinter.B5: "B5", QPrinter.B6: "B6", QPrinter.B7: "B7", QPrinter.B8: "B8", QPrinter.B9: "B9", QPrinter.C5E: "C5", # Correct Translation? QPrinter.Comm10E: "Comm10", # Correct Translation? QPrinter.DLE: "DL", # Correct Translation? QPrinter.Executive: "Executive", QPrinter.Folio: "Folio", QPrinter.Ledger: "Ledger", QPrinter.Legal: "Legal", QPrinter.Letter: "Letter", QPrinter.Tabloid: "Tabloid", } PAPER_SOURCES = { QPrinter.Cassette: "Cassette", QPrinter.Envelope: "Envelope", QPrinter.EnvelopeManual: "EnvelopeManual", QPrinter.FormSource: "FormSource", QPrinter.LargeCapacity: "LargeCapacity", QPrinter.LargeFormat: "LargeFormat", QPrinter.Lower: "Lower", QPrinter.MaxPageSource: "MaxPageSource", QPrinter.Middle: "Middle", QPrinter.Manual: "Manual", QPrinter.OnlyOne: "OnlyOne", QPrinter.Tractor: "Tractor", QPrinter.SmallFormat: "SmallFormat", } qpageview-0.6.2/qpageview/diff.py000066400000000000000000000104031423465244600167710ustar00rootroot00000000000000# -*- coding: utf-8 -*- # # This file is part of the qpageview package. # # Copyright (c) 2019 - 2019 by Wilbert Berendsen # # This program is free software; you can redistribute it and/or # modify it under the terms of the GNU General Public License # as published by the Free Software Foundation; either version 2 # of the License, or (at your option) any later version. # # This program is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU General Public License for more details. # # You should have received a copy of the GNU General Public License # along with this program; if not, write to the Free Software # Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA # See http://www.gnu.org/licenses/ for more information. """ A Page intended to display the visual difference between other pages. """ import itertools from PyQt5.QtCore import Qt from PyQt5.QtGui import QColor, QPainter, QPixmap from . import multipage from . import page class DiffPage(page.ImagePrintPageMixin, multipage.MultiPage): """A Page that shows the difference between sub pages. DiffPage inherits from MultiPage; the pages are to be added in the pages attribute. The first page is considered to be the "default" page, shown the normal way; the others are added in configurable colors and intensity. """ opaquePages = False @classmethod def createPages(cls, pageLists, renderer=None, pad=page.BlankPage): """Reimplemented to adapt the page sizes.""" it = itertools.zip_longest(*pageLists) if pad else zip(*pageLists) for pages in it: page = cls(renderer) # copy the dimensions from the first non-blank page for p in pages: if p: page.dpi = p.dpi page.pageWidth = p.pageWidth page.pageHeight = p.pageHeight break # set that dimensions also to blank pages. def padpage(): p = pad() p.dpi = page.dpi p.pageWidth = page.pageWidth p.pageHeight = page.pageHeight return p page.pages[:] = (p if p else padpage() for p in pages) yield page class DiffDocument(multipage.MultiPageDocument): """A Document showing the differences between documents, set as sources.""" pageClass = DiffPage class DiffRenderer(multipage.MultiPageRenderer): """Renders the pages by calling their own renderer. How the difference is displayed can be configured using this renderer. Up to four different pages can be displayed, the colors to render them are taken from the colors instance variable, which is a list. The alpha channel of each color determines the visiblity of the corresponding sub page. This renderer works best with pages that are mostly black on a white background. """ def __init__(self): # we don't use a cache so no need to call super init self.colors = [ QColor(Qt.black), QColor(Qt.red), QColor(Qt.green), QColor(Qt.blue), ] def combine(self, painter, images): """Paint images on the painter. We draw bottom-up, using Darken composition mode, so the lower images remain visible. """ for color, (pos, image) in zip(self.colors, images): # take the alpha component intensity = 255 - color.alpha() if intensity == 255: continue # the image would appear white anyway color = color.rgb() color |= 0x010101 * intensity color |= 0xFF000000 p = QPainter(image) p.setCompositionMode(QPainter.CompositionMode_Lighten) p.fillRect(image.rect(), QColor(color)) p.end() if isinstance(image, QPixmap): painter.drawPixmap(pos, image) else: painter.drawImage(pos, image) painter.setCompositionMode(QPainter.CompositionMode_Darken) # install a default renderer, so DiffPage can be used directly DiffPage.renderer = DiffRenderer() qpageview-0.6.2/qpageview/document.py000066400000000000000000000162011423465244600177010ustar00rootroot00000000000000# -*- coding: utf-8 -*- # # This file is part of the qpageview package. # # Copyright (c) 2019 - 2019 by Wilbert Berendsen # # This program is free software; you can redistribute it and/or # modify it under the terms of the GNU General Public License # as published by the Free Software Foundation; either version 2 # of the License, or (at your option) any later version. # # This program is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU General Public License for more details. # # You should have received a copy of the GNU General Public License # along with this program; if not, write to the Free Software # Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA # See http://www.gnu.org/licenses/ for more information. """ Document, a simple class representing a group of pages. It is certainly not necessary to use a Document to handle pages in a View, but it might be convenient in some cases. The Document class can be used to manually build a document consisting of a group of pages, that can be specified on construction or added to the list returned by the Document.pages() method. Then two subtypes exist, SingleSourceDocument and MultiSourceDocument, that can be subclassed into document types that either load every Page from a single file or, respectively, load all pages from one filename. Instead of a filename, any object can be used as data source. Depending on the page type, a QIODevice or QByteArray could be used. Instantiating a Document is very fast, as nothing is loaded or computed on instantiation. Only when pages() is called for the first time, file contents are loaded, which normally happens when a Document is shown in a View using View.setDocument(). """ class Document: """A Document represents a group of pages that belong together in some way. Add pages on creation or by manipulating the list returned by pages(). """ def __init__(self, pages=()): self._pages = [] self._pages.extend(pages) def count(self): """Return the number of pages.""" return len(self.pages()) def pages(self): """Return the list of pages.""" return self._pages def clear(self): """Empties the document.""" self._pages.clear() def filename(self): """Return the filename of the document. The default implementation returns an empty string. """ return "" def filenames(self): """Return the list of filenames, for multi-file documents. The default implementation returns an empty list. """ return [] def urls(self): """Return a dict, mapping URLs (str) to areas on pages. This method queries the links of all pages, and if they have a URL, the area attribute of that link is added to a list for every page, and every unique URL is mapped to a dict, that maps page number to the list of areas on that page (page numbers start with 0). In the returned dict you can quickly find the areas in which a URL appears in a link. """ urls = {} for n, p in enumerate(self.pages()): for link in p.links(): url = link.url if url: urls.setdefault(url, {}).setdefault(n, []).append(link.area) return urls def addUrls(self, urls): """Read the dict (such as returned by urls()) and make clickable links. This can be used to add url-links to a document from another document, e.g. when a document represents the same content, but has no clickable links (e.g. images). Links on pages with a higher number than our number of pages are skipped. """ from .link import Link for url, dests in urls.items(): for n, areas in dests.items(): if 0 <= n < self.count(): links = self.pages()[n].links() links.bulk_add(Link(*area, url=url) for area in areas) class AbstractSourceDocument(Document): """A Document that loads pages from external source, such as a file. The pages are loaded on first request, and invalidate can be called to trigger a reload. """ def __init__(self, renderer=None): self.renderer = renderer self._pages = None self._urls = None def pages(self): """Return the list of Pages, creating them at first call.""" if self._pages is None: self._pages = list(self.createPages()) return self._pages def invalidate(self): """Delete all cached pages, except for filename(s) or source object(s). Also called internally by clear(). """ self._pages = None self._urls = None def clear(self): """Delete all cached pages, and clear filename(s) or source object(s).""" self.invalidate() def createPages(self): """Implement this method to create and yield the pages. This method is only called once. After altering filename,-s or source,-s, or invalidate(), it is called again. """ return NotImplemented def urls(self): """Reimplemented to cache the urls returned by Document.urls().""" if self._urls == None: self._urls = super().urls() return self._urls class SingleSourceDocument(AbstractSourceDocument): """A Document that loads its pages from a single file or source.""" def __init__(self, source=None, renderer=None): super().__init__(renderer) self._source = source def source(self): """Return a data object that might be set for the whole document.""" return self._source def setSource(self, source): """Set the data object for the whole document. Invalidates the document.""" self.clear() self._source = source def filename(self): """Return the file name applying to the whole document.""" return self._source if isinstance(self._source, str) else "" setFilename = setSource def clear(self): """Delete all cached pages, and clear filename or source object.""" self.invalidate() self._source = None class MultiSourceDocument(AbstractSourceDocument): """A Document that loads every page from its own file or source.""" def __init__(self, sources=(), renderer=None): super().__init__(renderer) self._sources = [] self._sources.extend(sources) def sources(self): """Return data objects for every page.""" return self._sources def setSources(self, sources): """Set data objects for every page. Invalidates the document.""" self.clear() self._sources[:] = sources def filenames(self): """Return the list of file names of every page.""" return [f if isinstance(f, str) else "" for f in self._sources] setFilenames = setSources def clear(self): """Delete all cached pages, and clear filenames or source objects.""" self.invalidate() self._sources = [] qpageview-0.6.2/qpageview/export.py000066400000000000000000000322131423465244600174050ustar00rootroot00000000000000# -*- coding: utf-8 -*- # # This file is part of the qpageview package. # # Copyright (c) 2019 - 2019 by Wilbert Berendsen # # This program is free software; you can redistribute it and/or # modify it under the terms of the GNU General Public License # as published by the Free Software Foundation; either version 2 # of the License, or (at your option) any later version. # # This program is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU General Public License for more details. # # You should have received a copy of the GNU General Public License # along with this program; if not, write to the Free Software # Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA # See http://www.gnu.org/licenses/ for more information. """ Export Pages to different file formats. """ import os from PyQt5.QtCore import QBuffer, QIODevice, QMimeData, QPoint, QSizeF, Qt, QUrl from PyQt5.QtGui import QDrag, QGuiApplication, QImage, QPageSize, QPdfWriter from . import poppler from . import util class AbstractExporter: """Base class to export a rectangular area of a Page to a file. Specialized subclasses implement each format. You instantiate a subclass with a Page and a rectangle. The rectangle may be None, to specify the full page. After instantiation, you can set attributes to configure the export. The following attributes are supported:: resolution = 300 autocrop = False oversample = 1 grayscale = False paperColor = None forceVector = True # force the render backend to be Arthur for # exporting PDF pages to vector-based formats After setting the attributes, you call one or more of save(), copyData(), copyFile(), mimeData() or tempFileMimeData(), which will trigger the export because they internally call data(), which caches its return value until setPage() is called again. Not all exporters support all attributes, the supportXXX attributes specify whether an attribute is supported or not. """ # user settings: resolution = 300 antialiasing = True autocrop = False oversample = 1 grayscale = False paperColor = None forceVector = True # force the render backend to be Arthur for PDF pages # properties of exporter: wantsVector = True supportsResolution = True supportsAntialiasing = True supportsAutocrop = True supportsOversample = True supportsGrayscale = True supportsPaperColor = True mimeType = "application/octet-stream" filename = "" defaultBasename = "document" defaultExt = "" def __init__(self, page, rect=None): self.setPage(page, rect) def setPage(self, page, rect=None): self._page = page.copy() if self._page.renderer: self._page.renderer = page.renderer.copy() self._rect = rect self._result = None # where the exported object is stored self._tempFile = None self._autoCropRect = None self._document = None self._pixmap = None def page(self): """Return our page, setting the renderer to our preferences.""" p = self._page.copy() p.paperColor = self.paperColor if self._page.renderer: p.renderer = self._page.renderer.copy() p.renderer.paperColor = self.paperColor p.renderer.antialiasing = self.antialiasing if self.forceVector and self.wantsVector and \ isinstance(p, poppler.PopplerPage) and poppler.popplerqt5: p.renderer.printRenderBackend = \ poppler.popplerqt5.Poppler.Document.ArthurBackend return p def autoCroppedRect(self): """Return the rect, autocropped if desired.""" if not self.autocrop: return self._rect if self._autoCropRect is None: p = self._page dpiX = p.width / p.defaultSize().width() * p.dpi dpiY = p.height / p.defaultSize().height() * p.dpi image = p.image(self._rect, dpiX, dpiY) rect = util.autoCropRect(image) # add one pixel to prevent loosing small joins or curves etc rect = image.rect() & rect.adjusted(-1, -1, 1, 1) if self._rect is not None: rect.translate(self._rect.topLeft()) self._autoCropRect = rect return self._autoCropRect def export(self): """Perform the export, based on the settings, and return the exported data object.""" def successful(self): """Return True when export was successful.""" return self.data() is not None def data(self): """Return the export result, assuming it is binary data of the exported file.""" if self._result is None: self._result = self.export() return self._result def document(self): """Return a one-page Document to display the image to export. Internally calls createDocument(), and caches the result, setting the papercolor to the papercolor attribute if the exporter supports papercolor. """ if self._document is None: doc = self._document = self.createDocument() if self.paperColor and self.paperColor.isValid(): for p in doc.pages(): p.paperColor = self.paperColor return self._document def createDocument(self): """Create and return a one-page Document to display the image to export.""" def renderer(self): """Return a renderer for the document(). By default, None is returned.""" return None def copyData(self): """Copy the QMimeData() to the clipboard.""" QGuiApplication.clipboard().setMimeData(self.mimeData()) def mimeData(self): """Return a QMimeData() object representing the exported data.""" data = QMimeData() data.setData(self.mimeType, self.data()) return data def save(self, filename): """Save the exported image to a file.""" with open(filename, "wb") as f: f.write(self.data()) def suggestedFilename(self): """Return a suggested file name for the file to export. The name is based on the filename (if set) and also contains the directory path. But the name will never be the same as the filename set in the filename attribute. """ if self.filename: base = os.path.splitext(self.filename)[0] name = base + self.defaultExt if name == self.filename: name = base + "-export" + self.defaultExt else: name = self.defaultBasename + self.defaultExt return name def tempFilename(self): """Save data() to a tempfile and returns the filename.""" if self._tempFile is None: if self.filename: basename = os.path.splitext(os.path.basename(self.filename))[0] else: basename = self.defaultBasename d = util.tempdir() fname = self._tempFile = os.path.join(d, basename + self.defaultExt) self.save(fname) return self._tempFile def tempFileMimeData(self): """Save the exported image to a temp file and return a QMimeData object for the url.""" data = QMimeData() data.setUrls([QUrl.fromLocalFile(self.tempFilename())]) return data def copyFile(self): """Save the exported image to a temp file and copy its name to the clipboard.""" QGuiApplication.clipboard().setMimeData(self.tempFileMimeData()) def pixmap(self, size=100): """Return a small pixmap to use for dragging etc.""" if self._pixmap is None: paperColor = self.paperColor if self.supportsPaperColor else None page = self.document().pages()[0] self._pixmap = page.pixmap(paperColor=paperColor) return self._pixmap def drag(self, parent, mimeData): """Called by dragFile and dragData. Execs a QDrag on the mime data.""" d = QDrag(parent) d.setMimeData(mimeData) d.setPixmap(self.pixmap()) d.setHotSpot(QPoint(-10, -10)) return d.exec_(Qt.CopyAction) def dragData(self, parent): """Start dragging the data. Parent can be any QObject.""" return self.drag(parent, self.mimeData()) def dragFile(self, parent): """Start dragging the data. Parent can be any QObject.""" return self.drag(parent, self.tempFileMimeData()) class ImageExporter(AbstractExporter): """Export a rectangular area of a Page (or the whole page) to an image.""" wantsVector = False defaultBasename = "image" defaultExt = ".png" def export(self): """Create the QImage representing the exported image.""" res = self.resolution if self.oversample != 1: res *= self.oversample i = self.page().image(self._rect, res, res, self.paperColor) if self.oversample != 1: i = i.scaled(i.size() / self.oversample, transformMode=Qt.SmoothTransformation) if self.grayscale: i = i.convertToFormat(QImage.Format_Grayscale8) if self.autocrop: i = i.copy(util.autoCropRect(i)) return i def image(self): return self.data() def createDocument(self): from . import image return image.ImageDocument([self.image()], self.renderer()) def copyData(self): QGuiApplication.clipboard().setImage(self.image()) def mimeData(self): data = QMimeData() data.setImageData(self.image()) return data def save(self, filename): if not self.image().save(filename): raise OSError("Could not save image") class SvgExporter(AbstractExporter): """Export a rectangular area of a Page (or the whole page) to a SVG file.""" mimeType = "image/svg" supportsGrayscale = False supportsOversample = False defaultBasename = "image" defaultExt = ".svg" def export(self): rect = self.autoCroppedRect() buf = QBuffer() buf.open(QBuffer.WriteOnly) success = self.page().svg(buf, rect, self.resolution, self.paperColor) buf.close() if success: return buf.data() def createDocument(self): from . import svg return svg.SvgDocument([self.data()], self.renderer()) class PdfExporter(AbstractExporter): """Export a rectangular area of a Page (or the whole page) to a PDF file.""" mimeType = "application/pdf" supportsGrayscale = False supportsOversample = False defaultExt = ".pdf" def export(self): rect = self.autoCroppedRect() buf = QBuffer() buf.open(QBuffer.WriteOnly) success = self.page().pdf(buf, rect, self.resolution, self.paperColor) buf.close() if success: return buf.data() def createDocument(self): from . import poppler return poppler.PopplerDocument(self.data(), self.renderer()) class EpsExporter(AbstractExporter): """Export a rectangular area of a Page (or the whole page) to an EPS file.""" mimeType = "application/postscript" supportsGrayscale = False supportsOversample = False defaultExt = ".eps" def export(self): rect = self.autoCroppedRect() buf = QBuffer() buf.open(QBuffer.WriteOnly) success = self.page().eps(buf, rect, self.resolution, self.paperColor) buf.close() if success: return buf.data() def createDocument(self): from . import poppler rect = self.autoCroppedRect() buf = QBuffer() buf.open(QBuffer.WriteOnly) success = self.page().pdf(buf, rect, self.resolution, self.paperColor) buf.close() return poppler.PopplerDocument(buf.data(), self.renderer()) def pdf(filename, pageList, resolution=72, paperColor=None): """Export the pages in pageList to a PDF document. filename can be a string or any QIODevice. The pageList is a list of the Page objects to export. Normally vector graphics are rendered, but in cases where that is not possible, the resolution will be used to determine the DPI for the generated rendering. The computedRotation attribute of the pages is used to determine the rotation. Make copies of the pages if you run this function in a background thread. """ pdf = QPdfWriter(filename) pdf.setCreator("qpageview") pdf.setResolution(resolution) for n, page in enumerate(pageList): # map to the original page source = page.pageRect() # scale to target size w = source.width() * page.scaleX h = source.height() * page.scaleY if page.computedRotation & 1: w, h = h, w targetSize = QSizeF(w, h) if n: pdf.newPage() layout = pdf.pageLayout() layout.setMode(layout.FullPageMode) layout.setPageSize(QPageSize(targetSize * 72.0 / page.dpi, QPageSize.Point)) pdf.setPageLayout(layout) # TODO handle errors? page.output(pdf, source, paperColor) qpageview-0.6.2/qpageview/highlight.py000066400000000000000000000203051423465244600200320ustar00rootroot00000000000000# -*- coding: utf-8 -*- # # This file is part of the qpageview package. # # Copyright (c) 2010 - 2019 by Wilbert Berendsen # # This program is free software; you can redistribute it and/or # modify it under the terms of the GNU General Public License # as published by the Free Software Foundation; either version 2 # of the License, or (at your option) any later version. # # This program is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU General Public License for more details. # # You should have received a copy of the GNU General Public License # along with this program; if not, write to the Free Software # Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA # See http://www.gnu.org/licenses/ for more information. """ Highlight rectangular areas inside a View. """ import collections import weakref from PyQt5.QtCore import QRect, QRectF, QTimer from PyQt5.QtGui import QPainter, QPen from PyQt5.QtWidgets import QApplication class Highlighter: """A Highlighter can draw rectangles to highlight e.g. links in a View. An instance represents a certain type of highlighting, e.g. of a particular style. The paintRects() method is called with a list of rectangles that need to be drawn. To implement different highlighting behaviour just inherit paintRects(). The default implementation of paintRects() uses the `color` attribute to get the color to use and the `lineWidth` (default: 2) and `radius` (default: 3) attributes. `lineWidth` specifies the thickness in pixels of the border drawn, `radius` specifies the distance in pixels the border is drawn (by default with rounded corners) around the area to be highlighted. `color` is set to None by default, causing the paintRects method to choose the application's palette highlight color. """ lineWidth = 2 radius = 3 color = None def paintRects(self, painter, rects): """Override this method to implement different drawing behaviour.""" color = self.color if self.color is not None else QApplication.palette().highlight().color() pen = QPen(color) pen.setWidth(self.lineWidth) painter.setPen(pen) painter.setRenderHint(QPainter.Antialiasing, True) rad = self.radius for r in rects: r.adjust(-rad, -rad, rad, rad) painter.drawRoundedRect(r, rad, rad) class HighlightViewMixin: """Mixin methods vor view.View for highlighting areas. This mixin allows for highlighting rectangular areas on pages. You can highlight different sets of areas independently, using different Highlighter instances. Highlighting can be set to stay on forever or to disappear after a certain amount of microseconds. If desired, the View can be scrolled to show the highlighted areas. How the highlighting is drawn is determined by the paintRects() method of Highlighter. """ def __init__(self, parent=None, **kwds): self._highlights = weakref.WeakKeyDictionary() self._defaultHighlighter = None super().__init__(parent, **kwds) def defaultHighlighter(self): """Return a default highlighter, creating it if necessary.""" if self._defaultHighlighter is None: self._defaultHighlighter = Highlighter() return self._defaultHighlighter def setDefaultHighlighter(self, highlighter): """Set a Highlighter to use as the default highlighter.""" self._defaultHighlighter = highlighter def highlightRect(self, areas): """Return the bounding rect of the areas.""" boundingRect = QRect() for page, rects in areas.items(): f = page.mapToPage(1, 1).rect pbound = QRect() for r in rects: pbound |= f(r) boundingRect |= pbound.translated(page.pos()) return boundingRect def highlight(self, areas, highlighter=None, msec=0, scroll=False, margins=None, allowKinetic=True): """Highlight the areas dict using the given or default highlighter. The areas dict maps Page objects to lists of rectangles, where the rectangle is a QRectF() inside (0, 0, 1, 1) like the area attribute of a Link. If the highlighter is not specified, the default highlighter will be used. If msec > 0, the highlighting will vanish after that many microseconds. If scroll is True, the View will be scrolled to show the areas to highlight if needed, using View.ensureVisible(highlightRect(areas), margins, allowKinetic). """ if highlighter is None: highlighter = self.defaultHighlighter() if scroll: self.ensureVisible(self.highlightRect(areas), margins, allowKinetic) if msec: msec += self.remainingScrollTime() d = weakref.WeakKeyDictionary(areas) if msec: selfref = weakref.ref(self) def clear(): self = selfref() if self: self.clearHighlight(highlighter) t = QTimer(singleShot = True, timeout = clear) t.start(msec) else: t = None self.clearHighlight(highlighter) self._highlights[highlighter] = (d, t) self.viewport().update() def clearHighlight(self, highlighter=None): """Removes the highlighted areas of the given or default highlighter.""" if highlighter is None: highlighter = self.defaultHighlighter() try: (d, t) = self._highlights[highlighter] except KeyError: return if t is not None: t.stop() del self._highlights[highlighter] self.viewport().update() def isHighlighting(self, highlighter=None): """Return True if the given or default highlighter is active.""" if highlighter is None: highlighter = self.defaultHighlighter() return highlighter in self._highlights def highlightUrls(self, urls, highlighter=None, msec=0, scroll=False, margins=None, allowKinetic=True): """Convenience method highlighting the specified urls in the Document. The urls argument is a list of urls (str); the other arguments are used for calling highlight() on the areas returned by getUrlHighlightAreas(urls). """ areas = self.getUrlHighlightAreas(urls) if areas: self.highlight(areas, highlighter, msec, scroll, margins, allowKinetic) def getUrlHighlightAreas(self, urls): """Return the areas to highlight all occurrences of the specified URLs. The areas are found in the dictionary returned by document().urls(). URLs that are not in that dictionary are silently skipped. If there is no document set this method returns nothing. """ doc = self.document() if doc: u = doc.urls() if u: pages = doc.pages() areas = collections.defaultdict(list) for url in urls: d = u.get(url) if d: for n, linkareas in d.items(): rects = [] for a in linkareas: r = QRectF() r.setCoords(*a) rects.append(r) areas[pages[n]].extend(rects) return areas def paintEvent(self, ev): """Paint the highlighted areas in the viewport.""" super().paintEvent(ev) # first paint the contents painter = QPainter(self.viewport()) for highlighter, (d, t) in self._highlights.items(): for page, rect in self.pagesToPaint(ev.rect(), painter): try: areas = d[page] except KeyError: continue rectarea = page.mapFromPage(1, 1).rect(rect) f = page.mapToPage(1, 1).rect rects = [f(area) for area in areas if area & rectarea] highlighter.paintRects(painter, rects) qpageview-0.6.2/qpageview/image.py000066400000000000000000000170101423465244600171440ustar00rootroot00000000000000# -*- coding: utf-8 -*- # # This file is part of the qpageview package. # # Copyright (c) 2019 - 2019 by Wilbert Berendsen # # This program is free software; you can redistribute it and/or # modify it under the terms of the GNU General Public License # as published by the Free Software Foundation; either version 2 # of the License, or (at your option) any later version. # # This program is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU General Public License for more details. # # You should have received a copy of the GNU General Public License # along with this program; if not, write to the Free Software # Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA # See http://www.gnu.org/licenses/ for more information. """ A page that can display an image, loaded using QImage. ImagePages are instantiated quite fast. The image is only really loaded on first display. """ from PyQt5.QtCore import QPoint, QRect, QSize, Qt from PyQt5.QtGui import QImage, QImageIOHandler, QImageReader, QPainter, QTransform from . import document from . import locking from . import page from . import render class ImageContainer: """Represent an image, is shared among copies of the "same" Page.""" def __init__(self, image): """Init with a QImage.""" self._image = image def size(self): return self._image.size() def image(self, clip=None): if clip is None: return self._image return self._image.copy(clip) class ImageLoader(ImageContainer): """Represent an image loaded from a file or IO device.""" def __init__(self, source, autoTransform=True): """Init with a filename or QIODevice. If autoTransform is True (the default), EXIF rotation is automatically applied when loading the image. """ self._size = None self.source = source self.autoTransform = autoTransform def _reader(self): """Return a QImageReader for the source.""" reader = QImageReader(self.source) reader.setAutoTransform(self.autoTransform) return reader def size(self): """Return the size of the image. If the image can't be loaded, a null size is returned. The resulting value is cached. """ if self._size is None: self._size = QSize() reader = self._reader() if reader.canRead(): size = reader.size() if size: if self.autoTransform and reader.transformation() & 4: size.transpose() self._size = size return QSize(self._size) def image(self, clip=None): """Load and return the image. If clip is given, it should be a QRect describing the area to load. """ with locking.lock(self): reader = self._reader() if clip: if self.autoTransform: size = reader.size() transf = reader.transformation() m = QTransform() m.translate(size.width() / 2, size.height() / 2) if transf & QImageIOHandler.TransformationMirror: # horizontal mirror m.scale(-1, 1) if transf & QImageIOHandler.TransformationFlip: # vertical mirror m.scale(1, -1) if transf & QImageIOHandler.TransformationRotate90: # rotate 90 m.rotate(-90) m.translate(size.height() / -2, size.width() / -2) else: m.translate(size.width() / -2, size.height() / -2) clip = m.mapRect(clip) reader.setClipRect(clip) return reader.read() class ImagePage(page.AbstractRenderedPage): """A Page that displays an image in any file format supported by Qt.""" autoTransform = True # whether to automatically apply exif transformations dpi = 96 # TODO: maybe this can be image dependent. def __init__(self, container, renderer=None): super().__init__(renderer) self.setPageSize(container.size()) self._ic = container @classmethod def load(cls, filename, renderer=None): """Load the image and yield one ImagePage instance if loading was successful.""" loader = ImageLoader(filename, cls.autoTransform) if loader.size(): yield cls(loader, renderer) @classmethod def fromImage(cls, image, renderer=None): """Instantiate one ImagePage from the supplied QImage. As the image is kept in memory, it is not advised to instantiate many Page instances this way. Use load() for images on the filesystem. The image must be valid, and have a size > 0. """ return cls(ImageContainer(image), renderer) def print(self, painter, rect=None, paperColor=None): """Paint a page for printing.""" if rect is None: image = self._ic.image() else: rect = rect.normalized() & self.pageRect() # we copy the image, because QSvgGenerator otherwise includes the # full image in the resulting SVG file! image = self._ic.image(rect.toRect()) painter.drawImage(QPoint(0, 0), image) def image(self, rect=None, dpiX=None, dpiY=None, paperColor=None): """Return a QImage of the specified rectangle.""" if rect is None: rect = self.rect() else: rect = rect & self.rect() if dpiX is None: dpiX = self.dpi if dpiY is None: dpiY = dpiX s = self.defaultSize() m = QTransform() m.scale(s.width() * dpiX / self.dpi, s.height() * dpiY / self.dpi) m.translate(.5, .5) m.rotate(self.computedRotation * 90) m.translate(-.5, -.5) m.scale(1 / self.pageWidth, 1 / self.pageHeight) source = self.transform().inverted()[0].mapRect(rect) return self._ic.image(source).transformed(m, Qt.SmoothTransformation) def group(self): return self._ic def mutex(self): return self._ic class ImageDocument(document.MultiSourceDocument): """A Document representing a group of images. A source may be a filename, a QIODevice or a QImage. """ pageClass = ImagePage def createPages(self): for s in self.sources(): if isinstance(s, QImage): if not s.isNull(): yield self.pageClass.fromImage(s, self.renderer) else: for p in self.pageClass.load(s, self.renderer): yield p class ImageRenderer(render.AbstractRenderer): def draw(self, page, painter, key, tile, paperColor=None): """Draw the specified tile of the page (coordinates in key) on painter.""" # determine the part to draw; convert tile to viewbox source = self.map(key, page.pageRect()).mapRect(QRect(*tile)) target = QRect(0, 0, tile.w, tile.h) if key.rotation & 1: target.setSize(target.size().transposed()) image = page._ic.image(source).scaled( target.size(), Qt.KeepAspectRatio, Qt.SmoothTransformation) painter.drawImage(target, image) # install a default renderer, so SvgPage can be used directly ImagePage.renderer = ImageRenderer() qpageview-0.6.2/qpageview/imageview.py000066400000000000000000000066001423465244600200420ustar00rootroot00000000000000# -*- coding: utf-8 -*- # # This file is part of the qpageview package. # # Copyright (c) 2019 - 2019 by Wilbert Berendsen # # This program is free software; you can redistribute it and/or # modify it under the terms of the GNU General Public License # as published by the Free Software Foundation; either version 2 # of the License, or (at your option) any later version. # # This program is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU General Public License for more details. # # You should have received a copy of the GNU General Public License # along with this program; if not, write to the Free Software # Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA # See http://www.gnu.org/licenses/ for more information. """ ImageView, a View optimized for display of one Page, e.g. one image. Clicking in the view toggles between FitBoth and NaturalSize. """ from PyQt5.QtCore import QMargins, Qt from . import constants from . import util from . import view class ImageViewMixin: """View Mixin with a few customisations for displaying a single page/image. Adds the instance variable: fitNaturalSizeEnabled = True If True, the image will not be scaled larger than its natural size when FitWidth, -Height, or -Both is active. """ fitNaturalSizeEnabled = True def __init__(self, parent=None): super().__init__(parent) self.setViewMode(constants.FitBoth) self.pageLayout().setMargins(QMargins(0, 0, 0, 0)) def setImage(self, image): """Convenience method to display a QImage.""" self.loadImages([image]) def toggleZooming(self): """Toggles between FitBoth and natural size.""" if self.viewMode() == constants.FitBoth: self.setViewMode(constants.FixedScale) self.zoomNaturalSize() else: self.setViewMode(constants.FitBoth) def fitPageLayout(self): """Reimplemented to avoid zooming-to-fit larger than naturalsize.""" layout = self.pageLayout() if self.fitNaturalSizeEnabled and self.viewMode() and layout.count(): zoom_factor = layout.zoomFactor # fit layout but prevent zoomFactorChanged from being emitted with util.signalsBlocked(self): super().fitPageLayout() # what would be the natural size? factor = layout[0].dpi / self.physicalDpiX() # adjust if the image was scaled larger if layout.zoomFactor > factor: layout.zoomFactor = factor if zoom_factor != layout.zoomFactor: self.zoomFactorChanged.emit(layout.zoomFactor) else: super().fitPageLayout() def mouseReleaseEvent(self, ev): """Reimplemented to toggle between FitBoth and ZoomNaturalSize.""" if not self.isDragging() and ev.button() == Qt.LeftButton: self.toggleZooming() super().mouseReleaseEvent(ev) class ImageView(ImageViewMixin, view.View): """A View, optimized for display of one Page, e.g. one image. Append one Page to the layout, use one of the load* methods to load a single page document, or use the setImage() method to display a QImage. """ clickToSetCurrentPageEnabled = False qpageview-0.6.2/qpageview/layout.py000066400000000000000000000534501423465244600174070ustar00rootroot00000000000000# -*- coding: utf-8 -*- # # This file is part of the qpageview package. # # Copyright (c) 2010 - 2019 by Wilbert Berendsen # # This program is free software; you can redistribute it and/or # modify it under the terms of the GNU General Public License # as published by the Free Software Foundation; either version 2 # of the License, or (at your option) any later version. # # This program is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU General Public License for more details. # # You should have received a copy of the GNU General Public License # along with this program; if not, write to the Free Software # Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA # See http://www.gnu.org/licenses/ for more information. """ Manages and positions a group of Page instances. """ import copy import itertools import math from PyQt5.QtCore import QMargins, QPoint, QPointF, QRect, QSize, Qt from . import rectangles from . import util from .constants import ( FixedScale, FitWidth, FitHeight, FitBoth, Rotate_0, Rotate_90, Rotate_180, Rotate_270, Horizontal, Vertical, ) class PageRects(rectangles.Rectangles): def get_coords(self, page): return page.geometry().getCoords() class PageLayout(util.Rectangular, list): """Manages page.Page instances with a list-like api. You can iterate over the layout itself, which yields all Page instances. The following instance attributes are used, with these class-level defaults:: zoomFactor = 1.0 dpiX = 72.0 dpiY = 72.0 rotation = Rotate_0 orientation = Vertical alignment = Qt.AlignCenter The layout has margins around each page, accessible via pageMargins(), and margins around the whole layout, accessible via margins(). Both have class level defaults as a tuple, but they are converted to a QMargins object for the layout instance when first accessed via the margins() and pageMargins() methods:: _margins = (6, 6, 6, 6) _pageMargins = (0, 0, 0, 0) spacing = 8 # pixels between pages x = 0 # x, y, width and height are set by update() y = 0 width = 0 height = 0 continuousMode = True # whether to show all pages The actual layout is done by a LayoutEngine in the engine attribute. After having changed pages, engine or layout attributes, call update() to update the layout. """ _margins = (6, 6, 6, 6) _pageMargins = (0, 0, 0, 0) spacing = 8 zoomFactor = 1.0 dpiX = 72.0 dpiY = 72.0 rotation = Rotate_0 orientation = Vertical alignment = Qt.AlignCenter continuousMode = True currentPageSet = 0 # used in non-continuous mode _rects = None def __bool__(self): """Always return True.""" return True def count(self): """Return the number of Page instances.""" return len(self) def empty(self): """Return True if there are zero pages.""" return len(self) == 0 def setMargins(self, margins): """Sets our margins to a QMargins object.""" self._m = margins def margins(self): """Return our margins as a QMargins object, intialized from _margins""" try: return self._m except AttributeError: self._m = QMargins(*self._margins) return self._m def setPageMargins(self, margins): """Sets our page margins to a QMargins object.""" self._pm = margins def pageMargins(self): """Return our page margins as a QMargins object, intialized from _pageMargins""" try: return self._pm except AttributeError: self._pm = QMargins(*self._pageMargins) return self._pm def _pageRects(self): """(Internal) Return the PageRects object for quickly finding pages.""" if self._rects: return self._rects r = self._rects = PageRects(self.displayPages()) return r def pageAt(self, point): """Return the page that contains the given QPoint. If the point is not on any page, None is returned. """ for page in self._pageRects().at(point.x(), point.y()): return page def pagesAt(self, rect): """Yield the pages touched by the given QRect. The pages are in undefined order. """ for page in self._pageRects().intersecting(*rect.getCoords()): yield page def nearestPageAt(self, point): """Return the page at the shortest distance from the given point. The returned page does not contain the point. (Use pageAt() for that.) If there are no pages outside the point, None is returned. """ return self._pageRects().nearest(point.x(), point.y()) def defaultWidth(self, page): """Return the default width of the page.""" if (page.rotation + self.rotation) & 1: return page.pageHeight * page.scaleY / page.dpi else: return page.pageWidth * page.scaleX / page.dpi def defaultHeight(self, page): """Return the default height of the page.""" if (page.rotation + self.rotation) & 1: return page.pageWidth * page.scaleX / page.dpi else: return page.pageHeight * page.scaleY / page.dpi def widestPage(self): """Return the page with the largest default width, if any.""" if self.count(): return max(self, key=self.defaultWidth) def highestPage(self): """Return the page with the largest default height, if any.""" if self.count(): return max(self, key=self.defaultHeight) def fit(self, size, mode): """Fits the layout in the given size (QSize) and ViewMode.""" self.engine.fit(self, size, mode) def zoomsToFit(self): """Return True if the layout engine changes the zoomFactor to fit.""" return self.engine.zoomToFit def update(self): """Compute the size of all pages and updates their positions. Finally set our own size. You should call this after having added or deleted pages or after having changed the scale, dpi, zoom factor, spacing or margins. This function returns True if the total geometry has changed. """ self._rects = None self.updatePageSizes() if self.count(): self.engine.updatePagePositions(self) geometry = self.computeGeometry() changed = self.geometry() != geometry self.setGeometry(geometry) return changed def updatePageSizes(self): """Compute the correct size of every Page.""" for page in self: page.computedRotation = (page.rotation + self.rotation) & 3 page.updateSize(self.dpiX, self.dpiY, self.zoomFactor) def computeGeometry(self): """Return the total geometry (position and size) of the layout. In most cases the implementation of this method is sufficient: it computes the bounding rectangle of all Pages and adds the margin. """ r = QRect() for page in self.displayPages(): r |= page.geometry() return r + self.margins() + self.pageMargins() def pos2offset(self, pos): """Return a three-tuple (index, x, y). The index refers to a page in the layout, or nowhere if -1. The x and y refer to a spot on the page (or layout if empty) in the range 0..1. You can use it to store a certain position and restore it after changing the zoom e.g. """ page = self.pageAt(pos) or self.nearestPageAt(pos) if page: pos = pos - page.pos() w = page.width h = page.height i = self.index(page) else: w = self.width h = self.height i = -1 x = pos.x() / w y = pos.y() / h return (i, x, y) def offset2pos(self, offset): """Return the pos on the layout for the specified offset. The offset is a three-tuple like returned by pos2offset(). """ i, x, y = offset if i < 0 or i >= len(self): pos = QPoint(0, 0) w = self.width h = self.height else: page = self[i] pos = page.pos() w = page.width h = page.height return pos + QPoint(round(x * w), round(y * h)) def displayPages(self): """Return the pages that are to be displayed.""" return self[self.currentPageSetSlice()] def currentPageSetSlice(self): """Return a slice object describing the current page set.""" if not self.continuousMode: num = self.currentPageSet count = self.pageSetCount() # make sure a valid slice is returned if num and num >= count: num = self.currentPageSet = count - 1 p = 0 s = 0 for count, length in self.pageSets(): if p + count <= num: p += count s += count * length continue count = num - p s += count * length return slice(s, s + length) return slice(0, self.count()) def pageSets(self): """Return a list of (count, length) tuples. Every count is the number of page sets of that length. The list is created by the LayoutEngine.pageSets() method. """ return self.engine.pageSets(self.count()) def pageSetCount(self): """Return the number of page sets.""" return sum(count for count, length in self.pageSets()) def pageSet(self, index): """Return the page set containing page at index.""" s = 0 # the index at the start of the last page set p = 0 # the page set for count, length in self.pageSets(): if s + count * length < index: s += count * length p += count continue return p + (index - s) // length return 0 # happens with empty layout class LayoutEngine: """A LayoutEngine takes care of the actual layout process. A PageLayout has its LayoutEngine in the `engine` attribute. Putting this functionality in a separate object makes it easier to alter the behaviour of a layout without changing all the user-set options and added Pages. The default implementation of LayoutEngine puts pages in a horizontal or vertical row. You can override grid() to implement a different behaviour, and you can override pageSets() to get a different behaviour in non-continuous mode. If there are multiple rows or columns, every row is as high as the highest page it contains, and every column is as wide as its widest page. You can set the attributes evenWidths and/or evenHeights to True if you want all columns to have the same width, and/or respectively, the rows the same height. """ zoomToFit = True # True means: engine changes the zoomFactor to fit orientation = None # None means: use layout orientation evenWidths = False evenHeights = False def grid(self, layout): """Return a three-tuple (ncols, nrows, prepend). ncols is the number of columns the layout will contain, nrows the number of rows; and prepend if the number of empty positions that the layout wants, when the first row has less pages. """ if layout.orientation == Vertical: return 1, layout.count(), 0 else: return layout.count(), 1, 0 def pages(self, layout, ncols, nrows, prepend=0): """Yield the layout's pages in a grid: (page, (x, y)). If prepend > 0, that number of first grid positions will remain unused. This can be used for layouts that have less pages in the first row. """ if (self.orientation or layout.orientation) == Vertical: gen = ((col, row) for col in range(ncols) for row in range(nrows)) else: gen = ((col, row) for row in range(nrows) for col in range(ncols)) if prepend: for i in itertools.islice(gen, prepend): pass # skip unused positions return zip(layout, gen) def dimensions(self, layout, ncols, nrows, prepend=0): """Return two lists: columnwidths and rowheights. The width and height are page dimensions, without page margin. """ colwidths = [0] * ncols rowheights = [0] * nrows for page, (col, row) in self.pages(layout, ncols, nrows, prepend): colwidths[col] = max(colwidths[col], page.width) rowheights[row] = max(rowheights[row], page.height) if self.evenWidths: colwidths = [max(colwidths)] * ncols if self.evenHeights: rowheights = [max(rowheights)] * nrows return colwidths, rowheights def updatePagePositions(self, layout): """Performs the positioning of the pages. Don't call on empty layout.""" ncols, nrows, prepend = self.grid(layout) colwidths, rowheights = self.dimensions(layout, ncols, nrows, prepend) m = layout.margins() pm = layout.pageMargins() pmh = pm.left() + pm.right() # horizontal page margin pmv = pm.top() + pm.bottom() # vertical page margin # accumulate for column and row offsets, adding spacing xoff = [m.left() + pm.left()] + colwidths[:-1] yoff = [m.top() + pm.top()] + rowheights[:-1] for i in range(1, ncols): xoff[i] += xoff[i-1] + layout.spacing + pmh for i in range(1, nrows): yoff[i] += yoff[i-1] + layout.spacing + pmv # and go for positioning! for page, (col, row) in self.pages(layout, ncols, nrows, prepend): x, y = util.align(page.width, page.height, colwidths[col], rowheights[row], layout.alignment) page.x = xoff[col] + x page.y = yoff[row] + y def fit(self, layout, size, mode): """Called by PageLayout.fit().""" if mode and layout.count(): zoomfactors = [] if mode & FitWidth: zoomfactors.append(self.zoomFitWidth(layout, size.width())) if mode & FitHeight: zoomfactors.append(self.zoomFitHeight(layout, size.height())) layout.zoomFactor = min(zoomfactors) def zoomFitWidth(self, layout, width): """Return the zoom factor this layout would need to fit in the width. This method is called by fit(). The default implementation returns a suitable zoom factor for the widest Page. """ m, p = layout.margins(), layout.pageMargins() width -= m.left() + m.right() + p.left() + p.right() return layout.widestPage().zoomForWidth(width, layout.rotation, layout.dpiX) def zoomFitHeight(self, layout, height): """Return the zoom factor this layout would need to fit in the height. This method is called by fit(). The default implementation returns a suitable zoom factor for the highest Page. """ m, p = layout.margins(), layout.pageMargins() height -= m.top() + m.bottom() + p.top() + p.bottom() return layout.highestPage().zoomForHeight(height, layout.rotation, layout.dpiY) def pageSets(self, count): """Return a list of (count, length) tuples. Every count is the number of page sets of that length. When the layout is in non-continuous mode, it displays only a single page set at a time. For most layout engines, a page set is just one Page, but for column- based layouts other values make sense. """ return [(count, 1)] if count else [] class RowLayoutEngine(LayoutEngine): """A layout engine that orders pages in rows. Additional instance attributes: `pagesPerRow` = 2, the number of pages to display in a row `pagesFirstRow` = 1, the number of pages to display in the first row `fitAllColumns` = True, whether "fit width" uses all columns In non-continuous mode, this layout engine displayes a row of pages together. The `orientation` layout attribute is ignored in this layout engine. """ pagesPerRow = 2 pagesFirstRow = 1 fitAllColumns = True orientation = Horizontal # do not change def pageSets(self, count): """Return a list of (count, length) tuples respecting our column settings.""" result = [] left = count if left: if self.pagesFirstRow and self.pagesFirstRow != self.pagesPerRow: length = min(left, self.pagesFirstRow) result.append((1, length)) left -= length if left: count, left = divmod(left, self.pagesPerRow) if count: result.append((count, self.pagesPerRow)) if left: # merge result entries with same length if result and result[-1][1] == left: result[-1] == (result[-1][0] + 1, left) else: result.append((1, left)) return result def grid(self, layout): """Return (ncols, nrows, prepend). Takes into account the pagesPerRow and pagesFirstRow instance variables. If desired, prepends empty positions so the first row contains less pages than the column width. """ ncols = self.pagesPerRow if layout.count() > ncols: prepend = (ncols - self.pagesFirstRow) % ncols else: ncols = layout.count() prepend = 0 nrows = math.ceil((layout.count() + prepend) / ncols) return ncols, nrows, prepend def zoomFitWidth(self, layout, width): """Reimplemented to respect the fitAllColumns setting.""" if not self.fitAllColumns or self.pagesPerRow == 1 or layout.count() < 2: return super().zoomFitWidth(layout, width) ncols, nrows, prepend = self.grid(layout) m, p = layout.margins(), layout.pageMargins() width -= m.left() + m.right() + (p.left() + p.right()) * ncols width -= layout.spacing * (ncols - 1) if self.evenWidths: return super().zoomFitWidth(layout, width // ncols) # find the default width of the columns cols = [[] for n in range(ncols)] for page, (col, row) in self.pages(layout, ncols, nrows, prepend): cols[col].append(page) # widest page of every column widestpages = [max(col, key=layout.defaultWidth) for col in cols] totalDefaultWidth = sum(map(layout.defaultWidth, widestpages)) return min(page.zoomForWidth( width * layout.defaultWidth(page) // totalDefaultWidth, layout.rotation, layout.dpiX) for page in widestpages) class RasterLayoutEngine(LayoutEngine): """A layout engine that aligns the pages in a grid. This layout does not zoom to fit, but changes the number of columns and rows according to the available space. FitBoth is handled like FitWidth. """ zoomToFit = False _h = 0 _w = 0 _mode = FixedScale def fit(self, layout, size, mode): """Reimplemented.""" self._h = size.height() self._w = size.width() self._mode = mode def grid(self, layout): """Return a grid that would fit in the layout.""" m, p = layout.margins(), layout.pageMargins() width = self._w - m.left() - m.right() height = self._h - m.top() - m.bottom() pmh = p.left() + p.right() # horizontal page margin pmv = p.top() + p.bottom() # vertical page margin if self._mode & FitWidth: w = layout.widestPage().width + pmh ncols = (width + layout.spacing) // (w + layout.spacing) if ncols: # this will fit, but try more for tryncols in range(ncols + 1, layout.count() + 1): trynrows = math.ceil(layout.count() / tryncols) cw, rh = self.dimensions(layout, tryncols, trynrows) # compute width: column widths, spacing and page margins w = sum(cw) + layout.spacing * (tryncols - 1) + pmh * tryncols if w >= width: ncols = tryncols - 1 break else: ncols = layout.count() else: ncols = 1 # the minimum nrows = math.ceil(layout.count() / ncols) elif self._mode & FitHeight: h = layout.highestPage().height + pmv nrows = (height + layout.spacing) // (h + layout.spacing) if nrows: # this will fit, but try more for trynrows in range(nrows + 1, layout.count() + 1): tryncols = math.ceil(layout.count() / trynrows) cw, rh = self.dimensions(layout, tryncols, trynrows) # compute height: row heights, spacing and page margins h = sum(rh) + layout.spacing * (trynrows - 1) + pmv * trynrows if h >= height: nrows = trynrows - 1 break else: nrows = layout.count() else: nrows = 1 # the minimum ncols = math.ceil(layout.count() / nrows) else: ncols = math.ceil(math.sqrt(layout.count())) nrows = math.ceil(layout.count() / ncols) return ncols, nrows, 0 # install a default layout engine at the class level PageLayout.engine = LayoutEngine() qpageview-0.6.2/qpageview/link.py000066400000000000000000000165041423465244600170260ustar00rootroot00000000000000# -*- coding: utf-8 -*- # # This file is part of the qpageview package. # # Copyright (c) 2019 - 2019 by Wilbert Berendsen # # This program is free software; you can redistribute it and/or # modify it under the terms of the GNU General Public License # as published by the Free Software Foundation; either version 2 # of the License, or (at your option) any later version. # # This program is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU General Public License for more details. # # You should have received a copy of the GNU General Public License # along with this program; if not, write to the Free Software # Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA # See http://www.gnu.org/licenses/ for more information. """ Generic Link class and handling of links (clickable areas on a Page). The link area is in coordinates between 0.0 and 1.0, like Poppler does it. This way we can easily compute where the link area is on a page in different sizes or rotations. """ import collections from PyQt5.QtCore import pyqtSignal, QEvent, QRectF, Qt from . import page from . import rectangles Area = collections.namedtuple("Area", "left top right bottom") class Link: url = "" tooltip = "" area = Area(0, 0, 0, 0) def __init__(self, left, top, right, bottom, url=None, tooltip=None): self.area = Area(left, top, right, bottom) if url: self.url = url if tooltip: self.tooltip = tooltip def rect(self): """Return the area attribute as a QRectF().""" r = QRectF() r.setCoords(*self.area) return r class Links(rectangles.Rectangles): """Manages a list of Link objects. See the rectangles documentation for how to access the links. """ def get_coords(self, link): return link.area class LinkViewMixin: """Mixin class to enhance view.View with link capabilities.""" #: (page, link) emitted when the user hovers a link linkHovered = pyqtSignal(page.AbstractPage, Link) #: (no args) emitted when the user does not hover a link anymore linkLeft = pyqtSignal() #: (event, page, link) emitted when the user clicks a link linkClicked = pyqtSignal(QEvent, page.AbstractPage, Link) #: (event, page, link) emitted when a What's This or Toolip is requested. #: The event's type determines the type of this help event. linkHelpRequested = pyqtSignal(QEvent, page.AbstractPage, Link) #: whether to actually enable Link handling linksEnabled = True def __init__(self, parent=None, **kwds): self._currentLinkId = None self._linkHighlighter = None super().__init__(parent, **kwds) def setLinkHighlighter(self, highlighter): """Sets a Highlighter (see highlight.py) to highlight a link on hover. Use None to remove an active Highlighter. By default no highlighter is set to highlight links on hover. To be able to actually *use* highlighting, be sure to also mix in the HighlightViewMixin class from the highlight module. """ self._linkHighlighter = highlighter def linkHighlighter(self): """Return the currently set Highlighter, if any. By default no highlighter is set to highlight links on hover, and None is returned in that case. """ return self._linkHighlighter def adjustCursor(self, pos): """Adjust the cursor if pos is on a link (and linksEnabled is True). Also emits signals when the cursor enters or leaves a link. """ if self.linksEnabled: page, link = self.linkAt(pos) if link: lid = id(link) else: lid = None if lid != self._currentLinkId: if self._currentLinkId is not None: self.linkHoverLeave() self._currentLinkId = lid if lid is not None: self.linkHoverEnter(page, link) if link: return # do not call super() if we are on a link super().adjustCursor(pos) def linkAt(self, pos): """If the pos (in the viewport) is over a link, return a (page, link) tuple. Otherwise returns (None, None). """ pos = pos - self.layoutPosition() page = self._pageLayout.pageAt(pos) if page: links = page.linksAt(pos - page.pos()) if links: return page, links[0] return None, None def linkHoverEnter(self, page, link): """Called when the mouse hovers over a link. The default implementation emits the linkHovered(page, link) signal, sets a pointing hand mouse cursor, and, if a Highlighter was set using setLinkHighlighter(), highlights the link. You can reimplement this method to do something different. """ self.setCursor(Qt.PointingHandCursor) self.linkHovered.emit(page, link) if self._linkHighlighter: self.highlight({page: [link.rect()]}, self._linkHighlighter, 3000) def linkHoverLeave(self): """Called when the mouse does not hover a link anymore. The default implementation emits the linkLeft() signal, sets a default mouse cursor, and, if a Highlighter was set using setLinkHighlighter(), removes the highlighting of the current link. You can reimplement this method to do something different. """ self.unsetCursor() self.linkLeft.emit() if self._linkHighlighter: self.clearHighlight(self._linkHighlighter) def linkClickEvent(self, ev, page, link): """Called when a link is clicked. The default implementation emits the linkClicked(event, page, link) signal. The event can be used for things like determining which button was used, and which keyboard modifiers were in effect. """ self.linkClicked.emit(ev, page, link) def linkHelpEvent(self, ev, page, link): """Called when a ToolTip or WhatsThis wants to appear. The default implementation emits the linkHelpRequested(event, page, link) signal. Using the event you can find the position, and the type of the help event. """ self.linkHelpRequested.emit(ev, page, link) def event(self, ev): """Reimplemented to handle HelpEvent for links.""" if self.linksEnabled and ev.type() in (QEvent.ToolTip, QEvent.WhatsThis): page, link = self.linkAt(ev.pos()) if link: self.linkHelpEvent(ev, page, link) return True return super().event(ev) def mousePressEvent(self, ev): """Implemented to detect clicking a link and calling linkClickEvent().""" if self.linksEnabled: page, link = self.linkAt(ev.pos()) if link: self.linkClickEvent(ev, page, link) return super().mousePressEvent(ev) def leaveEvent(self, ev): """Implemented to leave a link, might there still be one hovered.""" if self.linksEnabled and self._currentLinkId is not None: self.linkHoverLeave() self._currentLinkId = None super().leaveEvent(ev) qpageview-0.6.2/qpageview/locking.py000066400000000000000000000026061423465244600175150ustar00rootroot00000000000000# -*- coding: utf-8 -*- # # This file is part of the qpageview package. # # Copyright (c) 2010 - 2019 by Wilbert Berendsen # # This program is free software; you can redistribute it and/or # modify it under the terms of the GNU General Public License # as published by the Free Software Foundation; either version 2 # of the License, or (at your option) any later version. # # This program is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU General Public License for more details. # # You should have received a copy of the GNU General Public License # along with this program; if not, write to the Free Software # Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA # See http://www.gnu.org/licenses/ for more information. """ Manages locking access (across threads) to any object. Use it for example to lock access to Poppler.Document instances. """ import threading import weakref _locks = weakref.WeakKeyDictionary() _lock = threading.RLock() def lock(item): """Return a threading.RLock instance for the given item. Use: with lock(document): do_something """ with _lock: try: return _locks[item] except KeyError: res = _locks[item] = threading.RLock() return res qpageview-0.6.2/qpageview/magnifier.py000066400000000000000000000263361423465244600200360ustar00rootroot00000000000000# -*- coding: utf-8 -*- # # This file is part of the qpageview package. # # Copyright (c) 2010 - 2019 by Wilbert Berendsen # # This program is free software; you can redistribute it and/or # modify it under the terms of the GNU General Public License # as published by the Free Software Foundation; either version 2 # of the License, or (at your option) any later version. # # This program is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU General Public License for more details. # # You should have received a copy of the GNU General Public License # along with this program; if not, write to the Free Software # Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA # See http://www.gnu.org/licenses/ for more information. """ The Magnifier magnifies a part of the displayed document. """ from PyQt5.QtCore import QEvent, QPoint, QRect, Qt from PyQt5.QtGui import ( QColor, QCursor, QPainter, QPalette, QPen, QRegion, QTransform) from PyQt5.QtWidgets import QWidget DRAG_SHORT = 1 # visible only while keeping the mouse button pressed DRAG_LONG = 2 # remain visible and drag when picked up with the mouse class Magnifier(QWidget): """A Magnifier is added to a View with view.setMagnifier(). It is shown when a mouse button is pressed together with a modifier (by default Ctrl). It can then be resized by moving the mouse is with two buttons pressed, or by wheeling with resizemodifier pressed. Its size can be changed with resize() and the scale (defaulting to 3.0) with setScale(). If can also be shown programatically with the show() method. In this case it can be dragged with the left mouse button. Wheel zooming with the modifier (by default Ctrl) zooms the magnifier. Instance attributes: ``showmodifier``: the modifier to popup (Qt.ControlModifier) ``zoommodifier``: the modifier to wheel zoom (Qt.ControlModifier) ``resizemodifier``: the key to press for wheel resizing (Qt.ShiftModifier) ``showbutton``: the mouse button causing the magnifier to popup (by default Qt.LeftButton) ``resizebutton``: the extra mouse button to be pressed when resizing the magnifier (by default Qt.RightButton) ``MAX_EXTRA_ZOOM``: the maximum zoom (relative to the View's maximum zoom level) """ # modifier for show showmodifier = Qt.ControlModifier # modifier for wheel zoom zoommodifier = Qt.ControlModifier # extra modifier for wheel resize resizemodifier = Qt.ShiftModifier # button for show showbutton = Qt.LeftButton # extra button for resizing resizebutton = Qt.RightButton # Maximum extra zoom above the View.MAX_ZOOM MAX_EXTRA_ZOOM = 1.25 # Minimal size MIN_SIZE = 50 # Maximal size MAX_SIZE = 640 def __init__(self): super().__init__() self._dragging = False self._resizepos = None self._resizewidth = 0 self._scale = 3.0 self.setAutoFillBackground(True) self.setBackgroundRole(QPalette.Dark) self.resize(350, 350) self.hide() def moveCenter(self, pos): """Called by the View, centers the widget on the given QPoint.""" r = self.geometry() r.moveCenter(pos) self.setGeometry(r) def setScale(self, scale): """Sets the scale, relative to the dislayed size in the View.""" self._scale = scale self.update() def scale(self): """Returns the scale, defaulting to 3.0 (=300%).""" return self._scale def startShortDrag(self, pos): """Start a short drag (e.g. on ctrl+click).""" viewport = self.parent() self._dragging = DRAG_SHORT self.moveCenter(pos) self.raise_() self.show() viewport.setCursor(Qt.BlankCursor) def endShortDrag(self): """End a short drag.""" viewport = self.parent() view = viewport.parent() viewport.unsetCursor() self.hide() self._resizepos = None self._dragging = False view.stopScrolling() # just if needed def startLongDrag(self, pos): """Start a long drag (when we are already visible and then dragged).""" self._dragging = DRAG_LONG self._dragpos = pos self.setCursor(Qt.ClosedHandCursor) def endLongDrag(self): """End a long drag.""" self._dragging = False self.unsetCursor() view = self.parent().parent() view.stopScrolling() # just if needed def resizeEvent(self, ev): """Called on resize, sets our circular mask.""" self.setMask(QRegion(self.rect(), QRegion.Ellipse)) def moveEvent(self, ev): """Called on move, updates the contents.""" # we also update on paint events, but they are not generated if the # magnifiers fully covers the viewport self.update() def eventFilter(self, viewport, ev): """Handle events on the viewport of the View.""" view = viewport.parent() if not self.isVisible(): if (ev.type() == QEvent.MouseButtonPress and ev.modifiers() == self.showmodifier and ev.button() == self.showbutton): # show and drag while button pressed: DRAG_SHORT self.startShortDrag(ev.pos()) return True elif ev.type() == QEvent.Paint: # if the viewport is painted, also update self.update() elif self._dragging == DRAG_SHORT: if ev.type() == QEvent.MouseButtonPress: if ev.button() == self.resizebutton: return True elif ev.type() == QEvent.MouseMove: if ev.buttons() == self.showbutton | self.resizebutton: # DRAG_SHORT is busy, both buttons are pressed: resize! if self._resizepos == None: self._resizepos = ev.pos() self._resizewidth = self.width() dy = 0 else: dy = (ev.pos() - self._resizepos).y() g = self.geometry() w = min(max(self.MIN_SIZE, self._resizewidth + 2 * dy), self.MAX_SIZE) self.resize(w, w) self.moveCenter(g.center()) else: # just drag our center self.moveCenter(ev.pos()) view.scrollForDragging(ev.pos()) return True elif ev.type() == QEvent.MouseButtonRelease: if ev.button() == self.showbutton: # left button is released, stop dragging and/or resizing, hide self.endShortDrag() elif ev.button() == self.resizebutton: # right button is released, stop resizing, warp cursor to center self._resizepos = None QCursor.setPos(viewport.mapToGlobal(self.geometry().center())) ev.accept() return True elif ev.type() == QEvent.ContextMenu: self.endShortDrag() return False def mousePressEvent(self, ev): """Start dragging the magnifier.""" if self._dragging == DRAG_SHORT: ev.ignore() elif not self._dragging and ev.button() == Qt.LeftButton: self.startLongDrag(ev.pos()) def mouseMoveEvent(self, ev): """Move the magnifier if we were dragging it.""" ev.ignore() if self._dragging == DRAG_LONG: ev.accept() pos = self.mapToParent(ev.pos()) self.move(pos - self._dragpos) view = self.parent().parent() view.scrollForDragging(pos) def mouseReleaseEvent(self, ev): """The button is released, stop moving ourselves.""" ev.ignore() if self._dragging == DRAG_LONG and ev.button() == Qt.LeftButton: self.endLongDrag() def wheelEvent(self, ev): """Implement zooming the magnifying glass.""" if ev.modifiers() & self.zoommodifier: ev.accept() if ev.modifiers() & self.resizemodifier: factor = 1.1 ** (ev.angleDelta().y() / 120) g = self.geometry() c = g.center() g.setWidth(int(min(max(g.width() * factor, self.MIN_SIZE), self.MAX_SIZE))) g.setHeight(int(min(max(g.height() * factor, self.MIN_SIZE), self.MAX_SIZE))) g.moveCenter(c) self.setGeometry(g) else: factor = 1.1 ** (ev.angleDelta().y() / 120) scale = self._scale * factor view = self.parent().parent() layout = view.pageLayout() scale = max(min(scale, view.MAX_ZOOM * self.MAX_EXTRA_ZOOM / layout.zoomFactor), view.MIN_ZOOM / layout.zoomFactor) self.setScale(scale) else: super().wheelEvent(ev) def paintEvent(self, ev): """Called when paint is needed, finds out which page to magnify.""" view = self.parent().parent() layout = view.pageLayout() scale = max(min(self._scale, view.MAX_ZOOM * self.MAX_EXTRA_ZOOM / layout.zoomFactor), view.MIN_ZOOM / layout.zoomFactor) matrix = QTransform().scale(scale, scale) # the position of our center on the layout c = self.geometry().center() - view.layoutPosition() # make a region scaling back to the view scale rect = matrix.inverted()[0].mapRect(self.rect()) rect.moveCenter(c) region = QRegion(rect, QRegion.Ellipse) # touches the Pages we need to draw # our rect on the enlarged pages our_rect = self.rect() our_rect.moveCenter(matrix.map(c)) # the virtual position of the whole scaled-up layout ev_rect = ev.rect().translated(our_rect.topLeft()) # draw shadow border? shadow = False if hasattr(view, "drawDropShadow") and view.dropShadowEnabled: shadow = True shadow_width = layout.spacing * scale // 2 painter = QPainter(self) for p in layout.pagesAt(region.boundingRect()): # get a (reused) the copy of the page page = p.copy(self, matrix) # now paint it rect = (page.geometry() & ev_rect).translated(-page.pos()) painter.save() painter.translate(page.pos() - our_rect.topLeft()) if shadow: view.drawDropShadow(page, painter, shadow_width) page.paint(painter, rect, self.repaintPage) painter.restore() self.drawBorder(painter) def drawBorder(self, painter): """Draw a nice looking glass border.""" painter.setRenderHint(QPainter.Antialiasing, True) painter.setPen(QPen(QColor(192, 192, 192, 128), 6)) painter.drawEllipse(self.rect().adjusted(2, 2, -2, -2)) def repaintPage(self, page): """Called when a Page was rendered in the background.""" ## TODO: smarter determination which part to update self.update() qpageview-0.6.2/qpageview/multipage.py000066400000000000000000000341171423465244600200600ustar00rootroot00000000000000# -*- coding: utf-8 -*- # # This file is part of the qpageview package. # # Copyright (c) 2019 - 2019 by Wilbert Berendsen # # This program is free software; you can redistribute it and/or # modify it under the terms of the GNU General Public License # as published by the Free Software Foundation; either version 2 # of the License, or (at your option) any later version. # # This program is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU General Public License for more details. # # You should have received a copy of the GNU General Public License # along with this program; if not, write to the Free Software # Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA # See http://www.gnu.org/licenses/ for more information. """ A MultiPage has no contents itself (but it has a size!), and renders a list of embedded pages. The MultiPageRenderer has the same interface as an ordinary renderer, but defers rendering to the renderer of the embedded pages. """ import collections import itertools from PyQt5.QtCore import QPoint, QRect, QRectF, Qt from PyQt5.QtGui import QColor, QImage, QPainter, QPixmap, QRegion, QTransform from . import document from . import page from . import render class MultiPage(page.AbstractRenderedPage): """A special Page that has a list of embedded sub pages. The sub pages are in the pages attribute, the first one is on top. The position and size of the embedded pages is set in the updateSize() method, which is inherited from AbstractPage. By default all sub pages are centered in their natural size. Rotation of sub pages is relative to the MultiPage. The `scalePages` instance attribute can be used to multiply the zoomfactor for the sub pages. The `opaquePages` instance attribute optimizes some procedures when set to True (i.e. it prevents rendering sub pages that are hidden below others). By default, only links in the first sub page are handled. Set `linksOnlyFirstSubPage` to False if you want links in all sub pages. """ scalePages = 1.0 opaquePages = True linksOnlyFirstSubPage = True def __init__(self, renderer=None): self.pages = [] if renderer is not None: self.renderer = renderer @classmethod def createPages(cls, pageLists, renderer=None, pad=page.BlankPage): """Yield pages, taking each page from every pageList. If pad is given and is not None, it is a callable that instantiates blank pages, to pad the shorter pageLists with. In that case, the returned list of pages has the same length as the longest pageList given. If pad is None, the returned list of pages has the same length as the shortest pageList given. """ it = itertools.zip_longest(*pageLists) if pad else zip(*pageLists) for pages in it: page = cls(renderer) page.pages[:] = (p if p else pad() for p in pages) yield page def copy(self, owner=None, matrix=None): """Reimplemented to also copy the sub pages.""" page = super().copy(owner, matrix) page.pages = [p.copy(owner, matrix) for p in self.pages] return page def updateSize(self, dpiX, dpiY, zoomFactor): """Reimplemented to also position our sub-pages. The default implementation of this method zooms the sub pages at the zoom level of the page * self.scalePages. """ super().updateSize(dpiX, dpiY, zoomFactor) # zoom the sub pages, using the same zoomFactor for page in self.pages: page.computedRotation = (page.rotation + self.computedRotation) & 3 page.updateSize(dpiX, dpiY, zoomFactor * self.scalePages) self.updatePagePositions() def updatePagePositions(self): """Called by updateSize(), set the page positions. The default implementation of this method centers the pages. """ # center the pages center = self.rect().center() for page in self.pages: r = page.rect() r.moveCenter(center) page.setGeometry(r) def visiblePagesAt(self, rect): """Yield (page, rect) for all subpages. The rect may be invalid when opaquePages is False. If opaquePages is True, pages outside rect or hidden below others are exclued. The yielded rect is always valid in that case. """ if not self.opaquePages: for p in self.pages: yield p, rect & p.geometry() else: covered = QRegion() for p in self.pages: overlayrect = rect & p.geometry() if not overlayrect or not QRegion(overlayrect).subtracted(covered): continue # skip if this part is hidden below the other covered += overlayrect yield p, overlayrect if not QRegion(rect).subtracted(covered): break def printablePagesAt(self, rect): """Yield (page, matrix) for all subpages that are visible in rect. If opaquePages is True, excludes pages outside rect or hidden below others. The matrix (QTransform) describes the transformation from the page to the sub page. Rect is in original coordinates, as with the print() method. """ origmatrix = self.transform().inverted()[0] # map pos to original page origmatrix.scale(self.scaleX, self.scaleY) # undo the scaling done in printing.py for p, r in self.visiblePagesAt(self.mapToPage().rect(rect)): center = origmatrix.map(QRectF(p.geometry()).center()) m = QTransform() # matrix from page to subpage m.translate(center.x(), center.y()) m.rotate(p.rotation * 90) # rotation relative to us m.scale( self.scalePages * p.scaleX * self.dpi / p.dpi, self.scalePages * p.scaleY * self.dpi / p.dpi) m.translate(p.pageWidth / -2, p.pageHeight / -2) yield p, m def print(self, painter, rect=None, paperColor=None): """Prints our sub pages.""" if rect is None: rect = self.pageRect() else: rect = rect & self.pageRect() painter.translate(-rect.topLeft()) # print from bottom to top for p, m in reversed(list(self.printablePagesAt(rect))): # find center of the page corresponding to our center painter.save() painter.setTransform(m, True) # handle rect clipping clip = m.inverted()[0].mapRect(rect) & p.pageRect() painter.fillRect(clip, paperColor or Qt.white) # draw a white background painter.translate(clip.topLeft()) # the page will go back... p.print(painter, clip) painter.restore() def text(self, rect): """Reimplemented to get text from sub pages.""" for p, rect in self.visiblePagesAt(rect): if rect: text = p.text(rect.translated(-p.pos())) if text: return text def _linkPages(self, rect=None): """Internal. Yield the pages allowed for links (and visible in rect if given).""" for p, rect in self.visiblePagesAt(rect or self.rect()): yield p, rect if self.linksOnlyFirstSubPage: break def linksAt(self, point): """Reimplemented to find links in sub pages.""" result = [] for p, rect in self._linkPages(): if point in rect: result.extend(p.linksAt(point - p.pos())) return result def linksIn(self, rect): """Reimplemented to find links in sub pages.""" result = set() for p, rect in self._linkPages(rect): result.update(p.linksIn(rect.translated(-p.pos()))) return result def linkRect(self, link): """Reimplemented to get correct area on the page the link belongs to.""" for p, r in self._linkPages(): if link in p.links(): return p.linkRect(link).translated(p.pos()) return QRect() # just in case class MultiPageDocument(document.MultiSourceDocument): """A Document that combines pages from different documents.""" pageClass = MultiPage def createPages(self): pageLists = [[p.copy() for p in doc.pages()] for doc in self.sources()] return self.pageClass.createPages(pageLists, self.renderer) class MultiPageRenderer(render.AbstractRenderer): """A renderer that interfaces with the renderers of the sub pages of a MultiPage.""" def update(self, page, device, rect, callback=None): """Reimplemented to check/rerender (if needed) all sub pages.""" # make the call back return with the original page, not the overlay page newcallback = CallBack(callback, page) if callback else None ok = True for p, overlayrect in page.visiblePagesAt(rect): if (overlayrect and p.renderer and not p.renderer.update(p, device, overlayrect.translated(-p.pos()), newcallback)): ok = False return ok def paint(self, page, painter, rect, callback=None): """Reimplemented to paint all the sub pages on top of each other.""" # make the call back return with the original page, not the overlay page newcallback = CallBack(callback, page) if callback else None # get the device pixel ratio to paint for try: ratio = painter.device().devicePixelRatioF() except AttributeError: ratio = painter.device().devicePixelRatio() pixmaps = [] covered = QRegion() for p, overlayrect in page.visiblePagesAt(rect): pixmap = QPixmap(overlayrect.size() * ratio) if not pixmap.isNull(): pixmap.setDevicePixelRatio(ratio) pt = QPainter(pixmap) pt.translate(p.pos() - overlayrect.topLeft()) p.paint(pt, overlayrect.translated(-p.pos()), newcallback) pt.end() # even an empty pixmap is appended, otherwise the layer count when # compositing goes awry pos = overlayrect.topLeft() pixmaps.append((pos, pixmap)) covered += overlayrect if QRegion(rect).subtracted(covered): painter.fillRect(rect, page.paperColor or self.paperColor) self.combine(painter, pixmaps) def image(self, page, rect, dpiX, dpiY, paperColor): """Return a QImage of the specified rectangle, of all images combined.""" overlays = [] # find out the scale used for the image, to be able to position the # overlay images correctly (code copied from AbstractRenderer.image()) s = page.defaultSize() hscale = s.width() * dpiX / page.dpi / page.width vscale = s.height() * dpiY / page.dpi / page.height ourscale = s.width() / page.width for p, overlayrect in page.visiblePagesAt(rect): # compute the correct resolution, find out which scale was # used by updateSize() (which may have been reimplemented) overlaywidth = p.pageWidth * p.scaleX * page.dpi / p.dpi if p.computedRotation & 1: overlayscale = overlaywidth / p.height else: overlayscale = overlaywidth / p.width scale = ourscale / overlayscale img = p.image(overlayrect.translated(-p.pos()), dpiX * scale, dpiY * scale, paperColor) pos = overlayrect.topLeft() - rect.topLeft() pos = QPoint(round(pos.x() * hscale), round(pos.y() * vscale)) overlays.append((pos, img)) image = QImage(rect.width() * hscale, rect.height() * vscale, self.imageFormat) image.fill(paperColor or page.paperColor or self.paperColor) self.combine(QPainter(image), overlays) return image def unschedule(self, pages, callback): """Reimplemented to unschedule all sub pages.""" for page in pages: newcallback = CallBack(callback, page) if callback else None for p in page.pages: if p.renderer: p.renderer.unschedule((p,), newcallback) def invalidate(self, pages): """Reimplemented to invalidate the base and overlay pages.""" renderers = collections.defaultdict(list) for page in pages: for p in page.pages: if p.renderer: renderers[p.renderer].append(p) for renderer, pages in renderers.items(): renderer.invalidate(pages) def combine(self, painter, images): """Paints images on the painter. Each image is a tuple(QPoint, QPixmap), describing where to draw. The image on top is first, so drawing should start with the last. """ for pos, image in reversed(images): if isinstance(image, QPixmap): painter.drawPixmap(pos, image) else: painter.drawImage(pos, image) class CallBack: """A wrapper for a callable that is called with the original Page.""" def __new__(cls, origcallable, page): # if the callable is already a CallBack instance, just return it. This # would happen if a MultiPage has a subpage that is also a MultiPage. if cls == type(origcallable): return origcallable cb = object.__new__(cls) cb.origcallable = origcallable cb.page = page return cb def __hash__(self): """Return the hash of the original callable. This way only one callback will be in the Job.callbacks attribute, despite of multiple pages, and unscheduling a job with subpages still works. """ return hash(self.origcallable) def __call__(self, page): """Call the original callback with the original Page.""" self.origcallable(self.page) # install a default renderer, so MultiPage can be used directly MultiPage.renderer = MultiPageRenderer() qpageview-0.6.2/qpageview/page.py000066400000000000000000000533211423465244600170030ustar00rootroot00000000000000# -*- coding: utf-8 -*- # # This file is part of the qpageview package. # # Copyright (c) 2016 - 2019 by Wilbert Berendsen # # This program is free software; you can redistribute it and/or # modify it under the terms of the GNU General Public License # as published by the Free Software Foundation; either version 2 # of the License, or (at your option) any later version. # # This program is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU General Public License for more details. # # You should have received a copy of the GNU General Public License # along with this program; if not, write to the Free Software # Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA # See http://www.gnu.org/licenses/ for more information. """ A Page is responsible for drawing a page inside a PageLayout. """ import weakref from PyQt5.QtCore import QBuffer, QPointF, QRect, QRectF, QSizeF, Qt from PyQt5.QtGui import ( QColor, QImage, QPageSize, QPainter, QPdfWriter, QPixmap, QTransform) from PyQt5.QtSvg import QSvgGenerator from . import util from .constants import Rotate_0 # a cache to store "owned" copies _copycache = weakref.WeakKeyDictionary() class AbstractPage(util.Rectangular): """A Page is a rectangle that is positioned in a PageLayout. A Page represents one page, added to a PageLayout that is displayed in a View. Although there is no mechanism to enforce it, a Page is normally only used in one PageLayout at a time. A Page has instance attributes: * that normally do not change during its lifetime: `pageWidth` the original width (by default in points, `dpi` is 72.0 `pageHeight` the original height but can be changed at class level) * that can be modified by the user (having defaults at the class level): `scaleX` the scale in X-direction of the original page (1.0) `scaleY` the scale in Y-direction of the original page (1.0) `rotation` the rotation (Rotate_0) `z` the z-index (0) (only relevant when pages overlap) `paperColor` the paper color (None). If None, the renderer's paperColor is used. * and that are set by the layout when computing the size and positioning the pages: `x` the position x-coordinate `y` the position y-coordinate `width` the width in pixels `height` the height in pixels `computedRotation` the rotation in which finally to render The class variable `dpi` is 72.0 by default but can be set to a different value depending on the page type. E.g. for Svg pages 90 or 96 makes sense. """ renderer = None dpi = 72.0 pageWidth = 595.28 # default to A4 pageHeight = 841.89 z = 0 rotation = Rotate_0 computedRotation = Rotate_0 scaleX = 1.0 scaleY = 1.0 paperColor = None @classmethod def load(cls, filename, renderer=None): """Implement this to yield one or more pages by reading the file. The renderer may be None, and not all page types use a renderer. The filename may be a string or a QByteArray object containing the data. """ pass @classmethod def loadFiles(cls, filenames, renderer=None): """Load multiple files, yielding Page instances of this type.""" for f in filenames: for page in cls.load(f, renderer): yield page def copy(self, owner=None, matrix=None): """Return a copy of the page with the same instance attributes. If owner is specified, the copy is weakly cached for that owner and returned next time. All instance attribute will be updated each time. If matrix is specified, it should be a QTransform, and it will be used to map the geometry of the original to the (cached) copy before it is returned. """ cls = type(self) if owner: try: page = _copycache[owner][self] except KeyError: page = cls.__new__(cls) _copycache.setdefault(owner, weakref.WeakKeyDictionary())[self] = page else: page.__dict__.clear() else: page = cls.__new__(cls) page.__dict__.update(self.__dict__) if matrix: page.setGeometry(matrix.mapRect(self.geometry())) return page def setPageSize(self, sizef): """Set our natural page size (QSizeF). Normally this is done in the constructor, based on the page we need to render. By default the page size is assumed to be in points, 1/72 of an inch. You can set the `dpi` class variable to use a different unit. """ self.pageWidth = sizef.width() self.pageHeight = sizef.height() def pageSize(self): """Return our natural page size (QSizeF). By default the page size is assumed to be in points, 1/72 of an inch. You can set the `dpi` class variable to use a different unit. """ return QSizeF(self.pageWidth, self.pageHeight) def pageRect(self): """Return QRectF(0, 0, pageWidth, pageHeight).""" return QRectF(0, 0, self.pageWidth, self.pageHeight) def transform(self, width=None, height=None): """Return a QTransform, converting an original area to page coordinates. The `width` and `height` refer to the original (unrotated) width and height of the page's contents, and default to pageWidth and pageHeight. """ if width is None: width = self.pageWidth if height is None: height = self.pageHeight t = QTransform() t.scale(self.width, self.height) t.translate(.5, .5) t.rotate(self.computedRotation * 90) t.translate(-.5, -.5) t.scale(1 / width, 1 / height) return t def defaultSize(self): """Return the pageSize() scaled and rotated (if needed). Based on scaleX, scaleY, and computedRotation attributes. """ s = QSizeF(self.pageWidth * self.scaleX, self.pageHeight * self.scaleY) if self.computedRotation & 1: s.transpose() return s def updateSize(self, dpiX, dpiY, zoomFactor): """Set the width and height attributes of the page. This size is computed based on the page's natural size, dpi, scale and computedRotation attribute; and the supplied dpiX, dpiY, and zoomFactor. """ s = self.defaultSize() # now handle dpi, scale and zoom self.width = round(s.width() * dpiX / self.dpi * zoomFactor) self.height = round(s.height() * dpiY / self.dpi * zoomFactor) def zoomForWidth(self, width, rotation, dpiX): """Return the zoom we need to display ourselves at the given width.""" width = max(width, 1) if (self.rotation + rotation) & 1: w = self.pageHeight / self.scaleY else: w = self.pageWidth / self.scaleX return width * self.dpi / dpiX / w def zoomForHeight(self, height, rotation, dpiY): """Return the zoom we need to display ourselves at the given height.""" height = max(height, 1) if (self.rotation + rotation) & 1: h = self.pageWidth / self.scaleX else: h = self.pageHeight / self.scaleY return height * self.dpi / dpiY / h def paint(self, painter, rect, callback=None): """Implement this to paint our Page. The View calls this method in the paint event. If you can't paint quickly, just return and schedule an image to be rendered in the background. If a callback is specified, it is called when the image is ready with the page as argument. """ pass def print(self, painter, rect=None, paperColor=None): """Implement this to paint a page for printing. The difference with paint() and image() is that the rect (QRectF) supplied to print() is not in the Page coordinates, but in the original pageSize() and unrotated. The painter has been prepared for scale and rotation. If rect is None, the full pageRect() is used. """ pass def output(self, device, rect=None, paperColor=None): """Paint specified rectangle (or the whole page) to the paint device. The page is rotated and scaled, and the resolution of the paint device is used in case pixelbased images need to be generated. But where possible, vector painting is used. This method uses :meth:`print` to do the actual painting to the paint device. If paperColor is not given, no background is printed normally. """ if rect is None: rect = self.pageRect() painter = QPainter(device) painter.scale(device.logicalDpiX() / self.dpi, device.logicalDpiY() / self.dpi) util.rotate(painter, self.computedRotation, rect.width(), rect.height()) painter.scale(self.scaleX, self.scaleY) self.print(painter, rect, paperColor) return painter.end() def image(self, rect=None, dpiX=None, dpiY=None, paperColor=None): """Implement this to return a QImage of the specified rectangle. The rectangle is relative to our top-left position. dpiX defaults to our default dpi and dpiY defaults to dpiX. """ pass def pdf(self, filename, rect=None, resolution=72.0, paperColor=None): """Create a PDF file for the selected rect or the whole page. The filename may be a string or a QIODevice object. The rectangle is relative to our top-left position. Normally vector graphics are rendered, but in cases where that is not possible, the resolution will be used to determine the DPI for the generated rendering. """ # map to the original page source = self.pageRect() if rect is None else self.mapFromPage().rect(rect) # scale to target size w = source.width() * self.scaleX h = source.height() * self.scaleY if self.computedRotation & 1: w, h = h, w targetSize = QSizeF(w, h) pdf = QPdfWriter(filename) pdf.setCreator("qpageview") pdf.setResolution(int(resolution)) layout = pdf.pageLayout() layout.setMode(layout.FullPageMode) layout.setPageSize(QPageSize(targetSize * 72.0 / self.dpi, QPageSize.Point)) pdf.setPageLayout(layout) return self.output(pdf, source, paperColor) def eps(self, filename, rect=None, resolution=72.0, paperColor=None): """Create a EPS (Encapsulated Postscript) file for the selected rect or the whole page. This needs the popplerqt5 module. The filename may be a string or a QIODevice object. The rectangle is relative to our top-left position. Normally vector graphics are rendered, but in cases where that is not possible, the resolution will be used to determine the DPI for the generated rendering. """ buf = QBuffer() buf.open(QBuffer.WriteOnly) success = self.pdf(buf, rect, resolution, paperColor) buf.close() if success: from . import poppler for pdf in poppler.PopplerPage.load(buf.data()): ps = pdf.document.psConverter() ps.setPageList([pdf.pageNumber+1]) if isinstance(filename, str): ps.setOutputFileName(filename) else: ps.setOutputDevice(filename) try: ps.setPSOptions(ps.PSOption(ps.Printing | ps.StrictMargins)) ps.setPSOptions(ps.PSOption(ps.Printing | ps.StrictMargins | ps.PrintToEPS)) except AttributeError: pass ps.setVDPI(resolution) ps.setHDPI(resolution) return ps.convert() return False def svg(self, filename, rect=None, resolution=72.0, paperColor=None): """Create a SVG file for the selected rect or the whole page. The filename may be a string or a QIODevice object. The rectangle is relative to our top-left position. Normally vector graphics are rendered, but in cases where that is not possible, the resolution will be used to determine the DPI for the generated rendering. """ # map to the original page source = self.pageRect() if rect is None else self.mapFromPage().rect(rect) # scale to target size w = source.width() * self.scaleX h = source.height() * self.scaleY if self.computedRotation & 1: w, h = h, w targetSize = QSizeF(w, h) * resolution / self.dpi svg = QSvgGenerator() if isinstance(filename, str): svg.setFileName(filename) else: svg.setOutputDevice(filename) svg.setResolution(int(resolution)) svg.setSize(targetSize.toSize()) svg.setViewBox(QRectF(0, 0, targetSize.width(), targetSize.height())) return self.output(svg, source, paperColor) def pixmap(self, rect=None, size=100, paperColor=None): """Return a QPixmap, scaled so that width or height doesn't exceed size. Uses the :meth:`image` method to get the image, and converts that to a QPixmap. """ s = self.defaultSize() w, h = s.width(), s.height() if rect is not None: w *= rect.width() / self.width h *= rect.height() / self.height l = max(w, h) dpi = size / l * self.dpi return QPixmap.fromImage(self.image(rect, dpi, dpi, paperColor)) def mutex(self): """Return an object that should be locked when rendering the page. Page are guaranteed not to be rendered at the same time when they return the same mutex object. By default, None is returned. """ def group(self): """Return the group the page belongs to. This could be some document structure, so that different Page objects could refer to the same graphical contents, preventing double caching. This object is used together with the value returned by ident() as a key to cache the page. The idea is that the contents of the page are uniquely identified by the objects returned by group() and ident(). This way, when the same document is opened in multiple page instances, only one copy resides in the (global) cache. By default, the page object itself is returned. """ return self def ident(self): """Return a value that identifies the page within the group returned by group(). By default, None is returned. """ return None def mapToPage(self, width=None, height=None): """Return a MapToPage object, that can map original to Page coordinates. The `width` and `height` refer to the original (unrotated) width and height of the page's contents, and default to pageWidth and pageHeight. """ return util.MapToPage(self.transform(width, height)) def mapFromPage(self, width=None, height=None): """Return a MapFromPage object, that can map Page to original coordinates. The `width` and `height` refer to the original (unrotated) width and height of the page's contents, and default to pageWidth and pageHeight. """ return util.MapFromPage(self.transform(width, height).inverted()[0]) def text(self, rect): """Implement this method to get the text at the specified rectangle. The rectangle should be in page coordinates. The default implementation simply returns an empty string. """ return "" def getLinks(self): """Implement this method to load our links.""" from . import link return link.Links() def links(self): """Return the Links object, containing Link objects. Every Link denotes a clickable area on a Page, in coordinates 0.0-1.0. The Links object makes it possible to quickly find a link on a Page. This is cached after the first request, you should implement the getLinks() method to load the links. """ try: return self._links except AttributeError: links = self._links = self.getLinks() return links def linksAt(self, point): """Return a list of zero or more links touched by QPoint point. The point is in page coordinates. The list is sorted with the smallest rectangle first. """ # Link objects have their area ranging # in width and height from 0.0 to 1.0 ... pos = self.mapFromPage(1, 1).point(point) links = self.links() return sorted(links.at(pos.x(), pos.y()), key=links.width) def linksIn(self, rect): """Return an unordered set of links enclosed in rectangle. The rectangle is in page coordinates. """ return self.links().inside(*self.mapFromPage(1, 1).rect(rect).getCoords()) def linkRect(self, link): """Return a QRect encompassing the linkArea of a link in coordinates of our page.""" return self.mapToPage(1, 1).rect(link.rect()) class AbstractRenderedPage(AbstractPage): """A Page that has a renderer that performs caching and painting. The renderer lives in the renderer attribute. """ def __init__(self, renderer=None): if renderer is not None: self.renderer = renderer def paint(self, painter, rect, callback=None): """Reimplement this to paint our Page. The View calls this method in the paint event. If you can't paint quickly, just return and schedule an image to be rendered in the background. If a callback is specified, it is called when the image is ready with the page as argument. By default, this method calls the renderer's :meth:`~.render.AbstractRenderer.paint` method. """ if rect: self.renderer.paint(self, painter, rect, callback) def print(self, painter, rect=None, paperColor=None): """Paint a page for printing. The difference with :meth:`paint` and :meth:`image` is that the rect (QRectF) supplied to print() is not in the Page coordinates, but in the original pageSize() and unrotated. The painter has been prepared for scale and rotation. If rect is None, the full pageRect() is used. This method calls the renderer's draw() method. """ if rect is None: rect = self.pageRect() else: rect = rect & self.pageRect() from . import render k = render.Key(self.group(), self.ident(), 0, self.pageWidth, self.pageHeight) t = render.Tile(*rect.normalized().getRect()) self.renderer.draw(self, painter, k, t, paperColor) def image(self, rect=None, dpiX=None, dpiY=None, paperColor=None): """Returns a QImage of the specified rectangle. The rectangle is relative to our top-left position. dpiX defaults to our default dpi and dpiY defaults to dpiX. This implementation calls the renderer to generate the image. The image is not cached. """ if rect is None: rect = self.rect() if dpiX is None: dpiX = self.dpi if dpiY is None: dpiY = dpiX return self.renderer.image(self, rect, dpiX, dpiY, paperColor) class BlankPage(AbstractPage): """A blank page.""" def paint(self, painter, rect, callback=None): """Paint blank page in the View.""" painter.fillRect(rect, self.paperColor or Qt.white) def print(self, painter, rect=None, paperColor=None): """Paint blank page for printing.""" if rect is None: rect = self.pageRect() else: rect = rect & self.pageRect() painter.fillRect(rect, paperColor or Qt.white) def image(self, rect=None, dpiX=None, dpiY=None, paperColor=None): """Return a blank image.""" if rect is None: rect = self.rect() if dpiX is None: dpiX = self.dpi if dpiY is None: dpiY = dpiX s = self.defaultSize() width = s.width() * dpiX / self.dpi height = s.height() * dpiY / self.dpi image = QImage(width, height, QImage.Format_ARGB32_Premultiplied) image.fill(paperColor or Qt.white) return image class ImagePrintPageMixin: """A Page mixin that implements print() using the image() method. This can be used e.g. for compositing pages, which does not work well when painting to a PDF, a printer or a SVG generator. """ def print(self, painter, rect=None, paperColor=None): """Print using the image() method.""" if rect is None: rect = self.pageRect() else: rect = rect & self.pageRect() # Find the rectangle on the Page in page coordinates target = self.mapToPage().rect(rect) # Make an image exactly in the printer's resolution m = painter.transform() r = m.mapRect(rect) # see where the rect ends up w, h = r.width(), r.height() if m.m11() == 0: w, h = h, w # swap if rotation & 1 :-) # now we know the scale from our dpi to the paintdevice's logicalDpi! hscale = w / rect.width() vscale = h / rect.height() dpiX = self.dpi * hscale dpiY = self.dpi * vscale image = self.image(target, dpiX, dpiY, paperColor) painter.translate(-rect.topLeft()) painter.drawImage(rect, image) qpageview-0.6.2/qpageview/pkginfo.py000066400000000000000000000034241423465244600175230ustar00rootroot00000000000000# -*- coding: utf-8 -*- # # This file is part of the qpageview package. # # Copyright (c) 2016 - 2020 by Wilbert Berendsen # # This program is free software; you can redistribute it and/or # modify it under the terms of the GNU General Public License # as published by the Free Software Foundation; either version 2 # of the License, or (at your option) any later version. # # This program is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU General Public License for more details. # # You should have received a copy of the GNU General Public License # along with this program; if not, write to the Free Software # Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA # See http://www.gnu.org/licenses/ for more information. """ Meta-information about the qpageview package. This information is used by the install script. """ import collections Version = collections.namedtuple("Version", "major minor patch") #: name of the package name = "qpageview" #: the current version version = Version(0, 6, 2) version_suffix = "" #: the current version as a string version_string = "{}.{}.{}".format(*version) + version_suffix #: short description description = "Widget to display page-based documents for Qt5/PyQt5" #: long description long_description = \ "The qpageview package provides a Python library to display page-based " \ "documents, such as PDF and possibly other formats." #: maintainer name maintainer = "Wilbert Berendsen" #: maintainer email maintainer_email = "info@frescobaldi.org" #: homepage url = "https://github.com/frescobaldi/qpageview" #: license license = "GPL v3" #: copyright year copyright_year = "2020-2022" qpageview-0.6.2/qpageview/poppler.py000066400000000000000000000267101423465244600175520ustar00rootroot00000000000000# -*- coding: utf-8 -*- # # This file is part of the qpageview package. # # Copyright (c) 2016 - 2019 by Wilbert Berendsen # # This program is free software; you can redistribute it and/or # modify it under the terms of the GNU General Public License # as published by the Free Software Foundation; either version 2 # of the License, or (at your option) any later version. # # This program is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU General Public License for more details. # # You should have received a copy of the GNU General Public License # along with this program; if not, write to the Free Software # Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA # See http://www.gnu.org/licenses/ for more information. """ Interface with popplerqt5, popplerqt5-specific classes etc. This module depends on popplerqt5, although it can be imported when popplerqt5 is not available. You need this module to display PDF documents. """ import contextlib import weakref from PyQt5.QtCore import Qt, QRectF from PyQt5.QtGui import QRegion, QPainter, QPicture, QTransform try: import popplerqt5 except ImportError: popplerqt5 = None from . import document from . import page from . import link from . import locking from . import render from .constants import ( Rotate_0, Rotate_90, Rotate_180, Rotate_270, ) # store the links in the page of a Poppler document as long as the document exists _linkscache = weakref.WeakKeyDictionary() class Link(link.Link): """A Link that encapsulates a Poppler.Link object.""" def __init__(self, linkobj): self.linkobj = linkobj self.area = link.Area(*linkobj.linkArea().normalized().getCoords()) @property def url(self): """The url the link points to.""" if isinstance(self.linkobj, popplerqt5.Poppler.LinkBrowse): return self.linkobj.url() return "" class PopplerPage(page.AbstractRenderedPage): """A Page capable of displaying one page of a Poppler.Document instance. It has two additional instance attributes: `document`: the Poppler.Document instance `pageNumber`: the page number to render """ def __init__(self, document, pageNumber, renderer=None): super().__init__(renderer) self.document = document self.pageNumber = pageNumber self.setPageSize(document.page(pageNumber).pageSizeF()) @classmethod def loadPopplerDocument(cls, document, renderer=None, pageSlice=None): """Convenience class method yielding instances of this class. The Page instances are created from the document, in page number order. The specified Renderer is used, or else the global poppler renderer. If pageSlice is given, it should be a slice object and only those pages are then loaded. """ it = range(document.numPages()) if pageSlice is not None: it = it[pageSlice] for num in it: yield cls(document, num, renderer) @classmethod def load(cls, filename, renderer=None): """Load a Poppler document, and yield of instances of this class. The filename can also be a QByteArray or a popplerqt5.Poppler.Document instance. The specified Renderer is used, or else the global poppler renderer. """ doc = load(filename) return cls.loadPopplerDocument(doc, renderer) if doc else () def mutex(self): """No two pages of same Poppler document are rendered at the same time.""" return self.document def group(self): """Reimplemented to return the Poppler document our page displays a page from.""" return self.document def ident(self): """Reimplemented to return the page number of this page.""" return self.pageNumber def text(self, rect): """Returns text inside rectangle.""" rect = self.mapFromPage(self.pageWidth, self.pageHeight).rect(rect) with locking.lock(self.document): page = self.document.page(self.pageNumber) return page.text(rect) def links(self): """Reimplemented to use a different caching mechanism.""" document, pageNumber = self.document, self.pageNumber try: return _linkscache[document][pageNumber] except KeyError: with locking.lock(document): links = link.Links(map(Link, document.page(pageNumber).links())) _linkscache.setdefault(document, {})[pageNumber] = links return links class PopplerDocument(document.SingleSourceDocument): """A lazily loaded Poppler (PDF) document.""" pageClass = PopplerPage def __init__(self, source=None, renderer=None): super().__init__(source, renderer) self._document = None def invalidate(self): """Reimplemented to clear the Poppler Document reference.""" super().invalidate() self._document = None def createPages(self): doc = self.document() if doc: return self.pageClass.loadPopplerDocument(doc, self.renderer) return () def document(self): """Return the Poppler Document object. Returns None if no source was yet set, and False if loading failed. """ if self._document is None: source = self.source() if source: self._document = load(source) or False return self._document class PopplerRenderer(render.AbstractRenderer): if popplerqt5: renderBackend = popplerqt5.Poppler.Document.SplashBackend printRenderBackend = popplerqt5.Poppler.Document.SplashBackend else: renderBackend = printRenderBackend = 0 oversampleThreshold = 96 def render(self, page, key, tile, paperColor=None): """Generate an image for the Page referred to by key.""" if paperColor is None: paperColor = page.paperColor or self.paperColor doc = page.document num = page.pageNumber s = page.pageSize() if key.rotation & 1: s.transpose() xres = 72.0 * key.width / s.width() yres = 72.0 * key.height / s.height() multiplier = 2 if xres < self.oversampleThreshold else 1 image = self.render_poppler_image(doc, num, xres * multiplier, yres * multiplier, tile.x * multiplier, tile.y * multiplier, tile.w * multiplier, tile.h * multiplier, key.rotation, paperColor) if multiplier == 2: image = image.scaledToWidth(tile.w, Qt.SmoothTransformation) image.setDotsPerMeterX(int(xres * 39.37)) image.setDotsPerMeterY(int(yres * 39.37)) return image def setRenderHints(self, doc): """Set the poppler render hints we want to set.""" if self.antialiasing: doc.setRenderHint(popplerqt5.Poppler.Document.Antialiasing) doc.setRenderHint(popplerqt5.Poppler.Document.TextAntialiasing) @contextlib.contextmanager def setup(self, doc, backend=None, paperColor=None): """Use the poppler document in context, properly configured and locked.""" with locking.lock(doc): if backend is not None: oldbackend = doc.renderBackend() doc.setRenderBackend(backend) oldhints = int(doc.renderHints()) doc.setRenderHint(oldhints, False) self.setRenderHints(doc) if paperColor is not None: oldcolor = doc.paperColor() doc.setPaperColor(paperColor) try: yield finally: if backend is not None: doc.setRenderBackend(oldbackend) doc.setRenderHint(int(doc.renderHints()), False) doc.setRenderHint(oldhints) if paperColor is not None: doc.setPaperColor(oldcolor) def render_poppler_image(self, doc, pageNum, xres=72.0, yres=72.0, x=-1, y=-1, w=-1, h=-1, rotate=Rotate_0, paperColor=None): """Render an image, almost like calling page.renderToImage(). The document is properly locked during rendering and render options are set. """ with self.setup(doc, self.renderBackend, paperColor): return doc.page(pageNum).renderToImage(xres, yres, x, y, w, h, rotate) def draw(self, page, painter, key, tile, paperColor=None): """Draw a tile on the painter. The painter is already at the right position and rotation. For the Poppler page and renderer, draw() is only used for printing. (See AbstractPage.print().) """ source = self.map(key, page.pageRect()).mapRect(QRectF(*tile)).toRect() # rounded target = QRectF(0, 0, tile.w, tile.h) if key.rotation & 1: target.setSize(target.size().transposed()) doc = page.document p = doc.page(page.pageNumber) with self.setup(doc, self.printRenderBackend, paperColor): if self.printRenderBackend == popplerqt5.Poppler.Document.ArthurBackend: # Poppler's Arthur backend removes the current transform from # the painter (it sets a default CTM, instead of combining it # with the current transform). We let Poppler draw on a QPicture, # and draw that on our painter. pic = QPicture() p.renderToPainter(QPainter(pic), page.dpi, page.dpi, source.x(), source.y(), source.width(), source.height()) # our resolution could be different, scale accordingly painter.save() painter.scale(pic.logicalDpiX() / painter.device().logicalDpiX(), pic.logicalDpiY() / painter.device().logicalDpiY()) pic.play(painter) painter.restore() else: # Make an image exactly in the printer's resolution m = painter.transform() r = m.mapRect(source) # see where the source ends up w, h = r.width(), r.height() if m.m11() == 0: w, h = h, w # swap if rotation & 1 :-) # now we know the scale from our dpi to the paintdevice's logicalDpi! hscale = w / source.width() vscale = h / source.height() s = QTransform().scale(hscale, vscale).mapRect(source) dpiX = page.dpi * hscale dpiY = page.dpi * vscale img = p.renderToImage(dpiX, dpiY, s.x(), s.y(), s.width(), s.height()) painter.drawImage(target, img, QRectF(img.rect())) def load(source): """Load a Poppler document. Source may be: - a Poppler document, which is then simply returned :-) - a filename - q QByteArray instance. Returns None if popplerqt5 is not available or the document could not be loaded. """ if popplerqt5: if isinstance(source, popplerqt5.Poppler.Document): return source elif isinstance(source, str): return popplerqt5.Poppler.Document.load(source) else: return popplerqt5.Poppler.Document.loadFromData(source) # install a default renderer, so PopplerPage can be used directly PopplerPage.renderer = PopplerRenderer() qpageview-0.6.2/qpageview/printing.py000066400000000000000000000112521423465244600177160ustar00rootroot00000000000000# -*- coding: utf-8 -*- # # This file is part of the qpageview package. # # Copyright (c) 2019 - 2019 by Wilbert Berendsen # # This program is free software; you can redistribute it and/or # modify it under the terms of the GNU General Public License # as published by the Free Software Foundation; either version 2 # of the License, or (at your option) any later version. # # This program is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU General Public License for more details. # # You should have received a copy of the GNU General Public License # along with this program; if not, write to the Free Software # Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA # See http://www.gnu.org/licenses/ for more information. """ Printing facilities for qpageview. """ from PyQt5.QtCore import pyqtSignal from PyQt5.QtGui import QPainter, QTransform from PyQt5.QtWidgets import QMessageBox, QProgressDialog from . import backgroundjob class PrintJob(backgroundjob.Job): """Performs a print job in the background. Emits the following signals: ``progress(pageNumber, num, total)`` before each Page ``finished()`` when done """ progress = pyqtSignal(int, int, int) aborted = False def __init__(self, printer, pageList, parent=None): """Initialize with a QPrinter object and a list of pages. pageList may be a list of two-tuples (num, page). Otherwise, the pages are numbered from 1 in the progress message. The pages are copied. """ super().__init__(parent) self.printer = printer self.setPageList(pageList) def setPageList(self, pageList): """Set the pagelist to print. pageList may be a list of two-tuples (num, page). Otherwise, the pages are numbered from 1 in the progress message. The pages are copied. """ self.pageList = [] for n, page in enumerate(pageList, 1): if isinstance(page, tuple): pageNum, page = page else: pageNum = n page = page.copy() # set zoom to 1.0 so computations based on geometry() are # accurate enough page.updateSize(page.dpi, page.dpi, 1.0) self.pageList.append((pageNum, page)) def work(self): """Paint the pages to the printer in the background.""" p = self.printer p.setFullPage(True) painter = QPainter(p) for n, (num, page) in enumerate(self.pageList): if self.isInterruptionRequested(): self.aborted = True return p.abort() self.progress.emit(num, n+1, len(self.pageList)) if n: p.newPage() painter.save() # center on the page and use scale 100% (TEMP) r = p.pageRect() m = QTransform() m.translate(r.center().x(), r.center().y()) m.scale(p.logicalDpiX() / page.dpi, p.logicalDpiY() / page.dpi) m.rotate(page.rotation * 90) m.scale(page.scaleX, page.scaleY) m.translate(page.pageWidth / -2, page.pageHeight / -2) painter.setTransform(m, True) page.print(painter) painter.restore() return painter.end() class PrintProgressDialog(QProgressDialog): """A simple progress dialog displaying the printing progress.""" def __init__(self, job, parent=None): """Initializes ourselves with the print job and optional parent widget.""" super().__init__(parent) self._job = job job.progress.connect(self.showProgress) job.finished.connect(self.jobFinished) self.canceled.connect(job.requestInterruption) self.setMinimumDuration(0) self.setRange(0, len(job.pageList)) self.setLabelText("Preparing to print...") def showProgress(self, page, num, total): """Called by the job when printing a page.""" self.setValue(num) self.setLabelText("Printing page {page} ({num} of {total})...".format( page=page, num=num, total=total)) def jobFinished(self): """Called when the print job has finished.""" if not self._job.result and not self._job.aborted: self.showErrorMessage() del self._job self.deleteLater() def showErrorMessage(self): """Reimplement to show a different or translated error message.""" QMessageBox.warning(self.parent(), "Printing Error", "Could not send the document to the printer.") qpageview-0.6.2/qpageview/rectangles.py000066400000000000000000000246001423465244600202140ustar00rootroot00000000000000# -*- coding: utf-8 -*- # # This file is part of the qpageview package. # # Copyright (c) 2010 - 2019 by Wilbert Berendsen # # This program is free software; you can redistribute it and/or # modify it under the terms of the GNU General Public License # as published by the Free Software Foundation; either version 2 # of the License, or (at your option) any later version. # # This program is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU General Public License for more details. # # You should have received a copy of the GNU General Public License # along with this program; if not, write to the Free Software # Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA # See http://www.gnu.org/licenses/ for more information. """ Manages lists of rectangular objects and quickly finds them. """ import bisect import operator Left = 0 Top = 1 Right = 2 Bottom = 3 class Rectangles: """ Manages a list of rectangular objects and quickly finds objects at some point, in some rectangle or intersecting some rectangle. The implementation uses four lists of the objects sorted on either coordinate, so retrieval is fast. Bulk adding is done in the constructor or via the bulk_add() method (which clears the indexes, that are recreated on first search). Single objects can be added and deleted, keeping the indexes, but that's slower. You should inherit from this class and implement the method get_coords(obj) to get the rectangle of the object (x, y, x2, y2). These are requested only once. x should be < x2 and y should be < y2. """ def __init__(self, objects=None): """Initializes the Rectangles object. objects should, if given, be an iterable of rectangular objects, and bulk_add() is called on those objects. """ self._items = {} # maps object to the result of func(object) self._index = {} # maps side to indices, objects (index=coordinate of that side) if objects: self.bulk_add(objects) def get_coords(self, obj): """You should implement this method. The result should be a four-tuple with the coordinates of the rectangle the object represents (x, y, x2, y2). These are requested only once. x should be < x2 and y should be < y2. """ return (0, 0, 0, 0) def add(self, obj): """Adds an object to our list. Keeps the index intact.""" if obj in self._items: return self._items[obj] = coords = self.get_coords(obj) for side, (indices, objects) in self._index.items(): i = bisect.bisect_left(indices, coords[side]) indices.insert(i, coords[side]) objects.insert(i, obj) def bulk_add(self, objects): """Adds many new items to the index using the function given in the constructor. After this, the index is cleared and recreated on the first search operation. """ self._items.update((obj, self.get_coords(obj)) for obj in objects) self._index.clear() def remove(self, obj): """Removes an object from our list. Keeps the index intact.""" del self._items[obj] for indices, objects in self._index.values(): i = objects.index(obj) del objects[i] del indices[i] def clear(self): """Empties the list of items.""" self._items.clear() self._index.clear() def at(self, x, y): """Returns a set() of objects that are touched by the given point.""" return self._test( (self._smaller, Top, y), (self._larger, Bottom, y), (self._smaller, Left, x), (self._larger, Right, x)) def inside(self, left, top, right, bottom): """Returns a set() of objects that are fully in the given rectangle.""" return self._test( (self._larger, Top, top), (self._smaller, Bottom, bottom), (self._larger, Left, left), (self._smaller, Right, right)) def intersecting(self, left, top, right, bottom): """Returns a set() of objects intersecting the given rectangle.""" return self._test( (self._smaller, Top, bottom), (self._larger, Bottom, top), (self._smaller, Left, right), (self._larger, Right, left)) def width(self, obj): """Return the width of the specified object. This can be used for sorting a set returned by at(), inside() or intersecting(). For example:: for r in sorted(rects.at(10, 20), key=rects.width): # ... """ coords = self._items[obj] return coords[Right] - coords[Left] def height(self, obj): """Return the height of the specified object. See also width().""" coords = self._items[obj] return coords[Bottom] - coords[Top] def closest(self, obj, side): """Returns the object closest to the given one, going to the given side.""" coords = self._items[obj] pos = coords[side^2] lat = (coords[side^1|2] - coords[side^1&2]) / 2.0 direction = -1 if side < Right else 1 indices, objects = self._sorted(side^2) i = objects.index(obj) mindist = indices[-1] result = [] for other in objects[i+direction::direction]: coords = self._items[other] pos1 = coords[side^2] d = abs(pos1 - pos) if d > mindist: break lat1 = (coords[side^1|2] - coords[side^1&2]) / 2.0 dlat = abs(lat1 - lat) if dlat < d: dist = dlat + d # manhattan dist result.append((other, dist)) mindist = min(mindist, dist) if result: result.sort(key=lambda r: r[1]) return result[0][0] def nearest(self, x, y): """Return the object with the shortest distance to the point x, y. The point (x, y) is outside the object. Use at() to get objects that touch the point (x, y). If there are no objects, None is returned. """ i = self._items left = self._larger(Left, x) # closest one is first right = self._smaller(Right, x) # closest one is last top = self._larger(Top, y) # closest one is first bottom = self._smaller(Bottom, y) # closest one is last result = [] # first find adjacent rectangles. For each side, as soon as one is # found, don't look further for that side. Only save rectangles that are # closer but not adjacent, they could be closer on another side. left_over = 0 for o in left: if o not in top and o not in bottom: result.append((i[o][Left] - x, o)) break left_over += 1 top_over = 0 for o in top: if o not in left and o not in right: result.append((i[o][Top] - y, o)) break top_over += 1 right_over = 0 for o in right[::-1]: if o not in top and o not in bottom: result.append((x - i[o][Right], o)) break right_over -= 1 bottom_over = 0 for o in bottom[::-1]: if o not in left and o not in right: result.append((y - i[o][Bottom], o)) break bottom_over -= 1 # at most 4 rectangles are found, the closest one on each edge. # Now look for rectangles that could be closer at the corner. if left_over and top_over: for o in set(left[:left_over]).intersection(top[:top_over]): result.append((i[o][Left] - x + i[o][Top] - y, o)) if top_over and right_over: for o in set(top[:top_over]).intersection(right[right_over:]): result.append((i[o][Top] - y + x - i[o][Right], o)) if left_over and bottom_over: for o in set(left[:left_over]).intersection(bottom[bottom_over:]): result.append((i[o][Left] - x + y - i[o][Bottom], o)) if bottom_over and right_over: for o in set(bottom[bottom_over:]).intersection(right[right_over:]): result.append((y - i[o][Bottom] + x - i[o][Right], o)) if result: return min(result, key=operator.itemgetter(0))[1] def __len__(self): """Return the number of objects.""" return len(self._items) def __contains__(self, obj): """Return True if the object is managed by us.""" return obj in self._items def __bool__(self): """Always return True.""" return True def __iter__(self): """Iterate over the objects in undefined order.""" return iter(self._items) # private helper methods def _test(self, *tests): """Performs tests and returns objects that fulfill all of them. Every test should be a three tuple(method, side, value). Method is either self._smaller or self._larger. Returns a (possibly empty) set. """ meth, side, value = tests[0] result = set(meth(side, value)) if result: for meth, side, value in tests[1:]: result &= set(meth(side, value)) if not result: break return result def _smaller(self, side, value): """Returns objects for side below value.""" indices, objects = self._sorted(side) i = bisect.bisect_right(indices, value) return objects[:i] def _larger(self, side, value): """Returns objects for side above value.""" indices, objects = self._sorted(side) i = bisect.bisect_left(indices, value) return objects[i:] def _sorted(self, side): """Returns a two-tuple (indices, objects) sorted on index for the given side.""" try: return self._index[side] except KeyError: if self._items: objects = [(coords[side], obj) for obj, coords in self._items.items()] objects.sort(key=operator.itemgetter(0)) result = tuple(map(list, zip(*objects))) else: result = [], [] self._index[side] = result return result qpageview-0.6.2/qpageview/render.py000066400000000000000000000432111423465244600173430ustar00rootroot00000000000000# -*- coding: utf-8 -*- # # This file is part of the qpageview package. # # Copyright (c) 2016 - 2019 by Wilbert Berendsen # # This program is free software; you can redistribute it and/or # modify it under the terms of the GNU General Public License # as published by the Free Software Foundation; either version 2 # of the License, or (at your option) any later version. # # This program is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU General Public License for more details. # # You should have received a copy of the GNU General Public License # along with this program; if not, write to the Free Software # Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA # See http://www.gnu.org/licenses/ for more information. """ Infrastructure for rendering and caching Page images. """ import collections import sys import time from PyQt5.QtCore import QRect, QRectF, Qt from PyQt5.QtGui import QColor, QImage, QPainter, QRegion, QTransform from . import backgroundjob from . import cache from . import util #: Describes a tile to render. Most times all coordinates are integers. #: The needed tiles for a page are yielded by :meth:`AbstractRenderer.tiles`. Tile = collections.namedtuple('Tile', 'x y w h') Tile.x.__doc__ = "The x coordinate of the tile" Tile.y.__doc__ = "The y coordinate of the tile" Tile.w.__doc__ = "The width of the tile" Tile.h.__doc__ = "The height of the tile" #: Identifies a render operation for a Page, returned by #: :meth:`AbstractRenderer.key`. Key = collections.namedtuple("Key", "group ident rotation width height") Key.group.__doc__ = "The :meth:`~.page.AbstractPage.group` of the page" Key.ident.__doc__ = "The :meth:`~.page.AbstractPage.ident` of the page" Key.rotation.__doc__ = "The :attr:`~.page.AbstractPage.computedRotation` of the page" Key.width.__doc__ = "The :attr:`~.util.Rectangular.width` of the page" Key.height.__doc__ = "The :attr:`~.util.Rectangular.height` of the page" #: Information about cached or missing rendered tiles to display a rectangular #: part of a Page at a certain size. Returned by :meth:`AbstractRenderer.info`. RenderInfo = collections.namedtuple("RenderInfo", "images missing key target ratio") RenderInfo.images.__doc__ = "a list of tuples (tile, image) that are available in the cache" RenderInfo.missing.__doc__ = "a list of Tile instances that are needed but not available in the cache" RenderInfo.key.__doc__ = "the Key returned by :meth:`~AbstractRenderer.key`, describing width, height, rotation and identity of the page" RenderInfo.target.__doc__ = "the rect multiplied by the ratio" RenderInfo.images.__doc__ = "the devicepixelratio of the specified paint device" # the maximum number of concurrent jobs (at global level) maxjobs = 4 # we use a global dict to keep running jobs in, so a thread is never # deallocated when a renderer dies. _jobs = {} class AbstractRenderer: """Handle rendering and caching of images. A renderer can be assigned to the renderer attribute of a Page and takes care for generating, caching and updating the images needed for display of the Page at different sizes. You can use a renderer for as many Page instances as you like. You can use one global renderer in your application or more, depending on how you use the qpageview package. You must inherit from this class and at least implement the render() or the draw() method. Instance attributes: ``paperColor`` Paper color. If possible this background color is used when rendering the pages, also for temporary drawings when a page has to be rendered. If a Page specifies its own paperColor, that color prevails. ``imageFormat`` QImage format to use (if possible). Default is QImage.Format_ARGB32_Premultiplied ``antialiasing`` True by default. Whether to antialias graphics. (Most Renderers antialias anyway, even if this is False.) """ MAX_TILE_WIDTH = 2400 MAX_TILE_HEIGHT = 1600 # default paper color to use (if possible, and when drawing an empty page) paperColor = QColor(Qt.white) # QImage format to use (if possible) imageFormat = QImage.Format_ARGB32_Premultiplied # antialias True by default (not all renderers may support this) antialiasing = True def __init__(self, cache=None): if cache: self.cache = cache def copy(self): """Return a copy of the renderer, with always a new cache.""" c = self.cache if c: c = type(c)() c.__dict__.update(self.cache.__dict__) r = type(self)(c) r.__dict__.update(self.__dict__) return r @staticmethod def key(page, ratio): """Return a five-tuple Key describing the page. The ratio is a device pixel ratio; width and height are multiplied with this value, to render and cache an image correctly on high- density displays. This is used for rendering and caching. It is never stored as is. The cache can store the group object using a weak reference. The tuple contains the following values: ``group`` the object returned by ``page.group()`` ``ident`` the value returned by ``page.ident()`` ``rotation`` ``page.computedRotation`` ``width`` ``page.width * ratio`` ``height`` ``page.height * ratio`` """ return Key( page.group(), page.ident(), page.computedRotation, int(page.width * ratio), int(page.height * ratio), ) def tiles(self, width, height): """Yield four-tuples Tile(x, y, w, h) describing the tiles to render.""" rowcount = height // self.MAX_TILE_HEIGHT colcount = width // self.MAX_TILE_WIDTH tilewidth, extrawidth = divmod(width, colcount + 1) tileheight, extraheight = divmod(height, rowcount + 1) rows = [tileheight] * rowcount + [tileheight + extraheight] cols = [tilewidth] * colcount + [tilewidth + extrawidth] y = 0 for h in rows: x = 0 for w in cols: yield Tile(x, y, w, h) x += w y += h def map(self, key, box): """Return a QTransform converting from Key coordinates to a box. The box should be a QRectF or QRect, and describes the original area of the page. The returned matrix can be used to convert e.g. tile coordinates to the position on the original page. """ t = QTransform() t.translate(box.x(), box.y()) t.scale(box.width(), box.height()) t.translate(.5, .5) t.rotate(-key.rotation * 90) t.translate(-.5, -.5) t.scale(1 / key.width, 1 / key.height) return t def image(self, page, rect, dpiX, dpiY, paperColor): """Returns a QImage of the specified rectangle on the Page. The rectangle is relative to the top-left position. The image is not cached. """ s = page.defaultSize() hscale = s.width() * dpiX / page.dpi / page.width vscale = s.height() * dpiY / page.dpi / page.height matrix = QTransform().scale(hscale, vscale) tile = Tile(*matrix.mapRect(rect).getRect()) key = Key(page.group(), page.ident(), page.computedRotation, *matrix.map(page.width, page.height)) return self.render(page, key, tile, paperColor) def render(self, page, key, tile, paperColor=None): """Generate a QImage for tile of the Page. The width, height and rotation to render at should be taken from the key, as the page could be resized or rotated in the mean time. The default implementation prepares the image, a painter and then calls draw() to actually draw the contents. If the paperColor is not specified, it will be read from the Page's paperColor attribute (if not None) or else from the renderer's paperColor attribute. """ if paperColor is None: paperColor = page.paperColor or self.paperColor i = QImage(tile.w, tile.h, self.imageFormat) i.fill(paperColor) painter = QPainter(i) # rotate the painter accordingly util.rotate(painter, key.rotation, tile.w, tile.h, True) # draw it on the image self.draw(page, painter, key, tile, paperColor) return i def draw(self, page, painter, key, tile, paperColor=None): """Draw the page contents; implement at least this method. The painter is already at the top-left position and the correct rotation. You should convert the tile to the original area on the page, you can use the map() method for that. You can draw in tile/key coordinates. Don't use width, height and rotation from the Page object, as it could have been resized or rotated in the mean time. The paperColor can be speficied, but it is not needed to paint it: by default the render() method already fills the image, and when drawing on a printer, painting the background is normally not desired. """ pass def info(self, page, device, rect): """Return a namedtuple RenderInfo(images, missing, key, target, ratio). images is a list of tuples (tile, image) that are available in the cache; missing is a list of Tile instances that are not available in the cache; key is the Key returned by key(), describing width, height, rotation and identity of the page; target is the rect multiplied by the ratio; which is the devicepixelratio of the specified paint device. """ try: ratio = device.devicePixelRatioF() except AttributeError: ratio = device.devicePixelRatio() key = self.key(page, ratio) # paint rect in tile coordinates target = QRect(int(rect.x() * ratio), int(rect.y() * ratio), int(rect.width() * ratio), int(rect.height() * ratio)) # tiles to paint tiles = [t for t in self.tiles(key.width, key.height) if QRect(*t) & target] # look in cache, get a dict with tiles and their images tileset = self.cache.tileset(key) images = [] missing = [] for t in tiles: entry = tileset.get(t) if entry: entry.time = time.time() # prevent aging ;-) images.append((t, entry.image)) else: missing.append(t) return RenderInfo(images, missing, key, target, ratio) def update(self, page, device, rect, callback=None): """Check if a page can be painted on the device without waiting. Return True if that is the case. Otherwise schedules missing tiles for rendering and calls the callback each time one tile if finished. """ info = self.info(page, device, rect) if info.missing: self.schedule(page, info.key, info.missing, callback) return False return True def paint(self, page, painter, rect, callback=None): """Paint a page, using images from the cache. ``page``: the Page to draw ``painter``: the QPainter to use to draw ``rect``: the region to draw, relative to the topleft of the page. ``callback``: if specified, a callable accepting the `page` argument. Typically this should be used to trigger a repaint of the view. The Page calls this method by default in its :meth:`~.page.AbstractPage.paint` method. This method tries to fetch an image from the cache and paint that. If no image is available, render() is called in the background to generate one. If it is ready, the callback is called with the Page as argument. An interim image may be painted in the meantime (e.g. scaled from another size). """ images = [] # list of images to draw at end of this method region = QRegion() # painted region in tile coordinates info = self.info(page, painter.device(), rect) for t, image in info.images: r = QRect(*t) & info.target # part of the tile that needs to be drawn images.append((r, image, QRectF(r.translated(-t.x, -t.y)))) region += r if info.missing: self.schedule(page, info.key, info.missing, callback) # find other images from cache for missing tiles for width, height, tileset in self.cache.closest(info.key): # we have a dict of tiles for an image of size width x height hscale = info.key.width / width vscale = info.key.height / height for t in tileset: # scale to our image size r = QRect(int(t.x * hscale), int(t.y * vscale), int(t.w * hscale), int(t.h * vscale)) & info.target if r and QRegion(r).subtracted(region): # we have an image that can be drawn in rect r source = QRectF(r.x() / hscale - t.x, r.y() / vscale - t.y, r.width() / hscale, r.height() / vscale) images.append((r, tileset[t].image, source)) region += r # stop if we have covered the whole drawing area if not QRegion(info.target).subtracted(region): break else: continue break else: if QRegion(info.target).subtracted(region): # paint background, still partly uncovered painter.fillRect(rect, page.paperColor or self.paperColor) # draw lowest quality images first for (r, image, source) in reversed(images): # scale the target rect back to the paint device target = QRectF(r.x() / info.ratio, r.y() / info.ratio, r.width() / info.ratio, r.height() / info.ratio) painter.drawImage(target, image, source) def schedule(self, page, key, tiles, callback): """Schedule a new rendering job for the specified tiles of the page. If this page has already a job pending, the callback is added to the pending job. """ for tile in tiles: try: job = _jobs[(key, tile)] except KeyError: # make a new Job for this tile job = _jobs[(key, tile)] = self.job(page, key, tile) job.time = time.time() job.callbacks.add(callback) self.checkstart() def job(self, page, key, tile): """Return a new :class:`~.backgroundjob.Job` tailored for this tile.""" job = backgroundjob.Job() job.callbacks = callbacks = set() job.mutex = page.mutex() exception = [] def work(): try: return self.render(page, key, tile) except Exception: exception.extend(sys.exc_info()) return QImage() def finalize(image): self.cache.addtile(key, tile, image) for cb in callbacks: cb(page) del _jobs[(key, tile)] self.checkstart() if exception: self.exception(*exception) job.work = work job.finalize = finalize return job def unschedule(self, pages, callback): """Unschedule a possible pending rendering job for the given pages. If the pending job has no other callbacks left, it is removed, unless it is running. """ pages = set((p.group(), p.ident()) for p in pages) unschedule = [] for (key, tile), job in _jobs.items(): if key[:2] in pages: job.callbacks.discard(callback) if not job.callbacks and not job.running: unschedule.append((key, tile)) for jobkey in unschedule: job = _jobs.pop(jobkey) job.finalize = job.work = None def invalidate(self, pages): """Delete the cached images for the given pages.""" for p in pages: self.cache.invalidate(p) def checkstart(self): """Check whether there are jobs that need to be started. This method is called by the schedule() method, and by the finish() method when a job finishes, so that the number of running jobs never exceeds `maxjobs`. """ runningjobs = [job for job in _jobs.values() if job.running] waitingjobs = sorted((job for job in _jobs.values() if not job.running), key=lambda j: j.time, reverse=True) # newest first jobcount = maxjobs - len(runningjobs) if jobcount > 0: mutexes = set(j.mutex for j in runningjobs) mutexes.discard(None) for job in waitingjobs: m = job.mutex if m is None or m not in mutexes: mutexes.add(m) job.start() jobcount -= 1 if jobcount == 0: break def exception(self, exctype, excvalue, exctb): """Called when an exception has occurred in a background rendering job. The default implementation prints a traceback to stderr. """ import traceback traceback.print_exception(exctype, excvalue, exctb) # install a global cache to use by default AbstractRenderer.cache = cache.ImageCache() qpageview-0.6.2/qpageview/rubberband.py000066400000000000000000000367641423465244600202110ustar00rootroot00000000000000# -*- coding: utf-8 -*- # # This file is part of the qpageview package. # # Copyright (c) 2010 - 2019 by Wilbert Berendsen # # This program is free software; you can redistribute it and/or # modify it under the terms of the GNU General Public License # as published by the Free Software Foundation; either version 2 # of the License, or (at your option) any later version. # # This program is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU General Public License for more details. # # You should have received a copy of the GNU General Public License # along with this program; if not, write to the Free Software # Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA # See http://www.gnu.org/licenses/ for more information. """ Rubberband selection in a View. """ from PyQt5.QtCore import QEvent, QPoint, QRect, QSize, Qt, pyqtSignal from PyQt5.QtGui import QColor, QContextMenuEvent, QCursor, QPainter, QPalette, QPen, QRegion from PyQt5.QtWidgets import QApplication, QWidget # dragging/moving selection: _OUTSIDE = 0 _LEFT = 1 _TOP = 2 _RIGHT = 4 _BOTTOM = 8 _INSIDE = 15 class Rubberband(QWidget): """A Rubberband to select a rectangular region. A Rubberband is added to a View with view.setRubberband(). The Rubberband lets the user select a rectangular region. When the selection is changed, the `selectionChanged` signal is emitted, having the selection rectangle in layout coordinates as argument. Instance variables: ``showbutton`` (Qt.RightButton) the button used to drag a new rectangle ``dragbutton`` (Qt.LeftButton) the button to alter an existing rectangle ``trackSelection`` (False) whether to continuously emit selectionChanged(). When True, ``selectionChanged()`` is emitted on every change, when False, the signal is only emitted when the mouse button is released. """ selectionChanged = pyqtSignal(QRect) # the button used to drag a new rectangle showbutton = Qt.RightButton # the button to alter an existing rectangle dragbutton = Qt.LeftButton # whether to continuously track the selection trackSelection = False def __init__(self): super().__init__() self._dragging = False self._dragedge = 0 self._dragpos = None self._selection = QRect() self._layoutOffset = None # used to keep on spot during resize/zoom self.setMouseTracking(True) self.setContextMenuPolicy(Qt.PreventContextMenu) def paintEvent(self, ev): ### Paint code contributed by Richard Cognot Jun 2012 color = self.palette().color(QPalette.Highlight) painter = QPainter(self) # Filled rectangle. painter.setClipRect(self.rect()) color.setAlpha(50) painter.fillRect(self.rect().adjusted(2,2,-2,-2), color) # Thin rectangle outside. color.setAlpha(150) painter.setPen(color) # XXX can this adjustment be done smarter? adjust = int(-1 / self.devicePixelRatio()) painter.drawRect(self.rect().adjusted(0, 0, adjust, adjust)) # Pseudo-handles at the corners and sides color.setAlpha(100) pen = QPen(color) pen.setWidth(8) painter.setPen(pen) painter.setBackgroundMode(Qt.OpaqueMode) # Clip at 4 corners region = QRegion(QRect(0,0,20,20)) region += QRect(self.rect().width()-20, 0, 20, 20) region += QRect(self.rect().width()-20, self.rect().height()-20, 20, 20) region += QRect(0, self.rect().height()-20, 20, 20) # Clip middles region += QRect(0, self.rect().height() // 2 - 10, self.rect().width(), 20) region += QRect(self.rect().width() // 2 - 10, 0, 20, self.rect().height()) # Draw thicker rectangles, clipped at corners and sides. painter.setClipRegion(region) painter.drawRect(self.rect()) def edge(self, point): """Return the edge where the point touches our geometry.""" rect = self.geometry() if point not in rect: return _OUTSIDE edge = 0 if point.x() <= rect.left() + 8: edge |= _LEFT elif point.x() >= rect.right() - 8: edge |= _RIGHT if point.y() <= rect.top() + 8: edge |= _TOP elif point.y() >= rect.bottom() - 8: edge |= _BOTTOM return edge or _INSIDE def adjustCursor(self, edge): """Sets the cursor shape when we are at edge.""" cursor = None if edge in (_TOP, _BOTTOM): cursor = Qt.SizeVerCursor elif edge in (_LEFT, _RIGHT): cursor = Qt.SizeHorCursor elif edge in (_LEFT | _TOP, _RIGHT | _BOTTOM): cursor = Qt.SizeFDiagCursor elif edge in (_TOP | _RIGHT, _BOTTOM | _LEFT): cursor = Qt.SizeBDiagCursor elif edge is _INSIDE: cursor = Qt.SizeAllCursor if cursor: self.setCursor(cursor) else: self.unsetCursor() def hasSelection(self): """Return True when there is a selection.""" return bool(self._selection) def selection(self): """Return our selection rectangle, relative to the view's layout position.""" return self._selection def selectedPages(self): """Yield tuples (page, rect) describing the selection. Every rect is intersected with the page rect and translated to the page's position. """ rect = self.selection() if rect: view = self.parent().parent() layout = view.pageLayout() for page in layout.pagesAt(rect): yield page, rect.intersected(page.geometry()).translated(-page.pos()) def selectedPage(self): """Returns (page, rect) if there is a selection. If the selection contains more pages, the largest intersection is chosen. If no meaningful area is selected, (None, None) is returned. """ selection = sorted(self.selectedPages(), key=lambda pr: pr[1].height() + pr[1].width()) if selection: return selection[-1] else: return None, None def selectedImage(self, resolution=None, paperColor=None): """Returns an image of the selected part on a Page. If resolution is None, the displayed size is chosen. Otherwise, the resolution is an integer, interpreted as DPI (dots per inch). """ page, rect = self.selectedPage() if page and rect: if resolution is None: view = self.parent().parent() try: ratio = view.devicePixelRatioF() except AttributeError: ratio = view.devicePixelRatio() resolution = view.physicalDpiX() * view.zoomFactor() * ratio return page.image(rect, resolution, resolution, paperColor) def selectedText(self): """Return the text found in the selection, as far as the pages support it.""" result = [] for page, rect in self.selectedPages(): result.append(page.text(rect)) return '\n'.join(result) def selectedLinks(self): """Yield tuples (page, links) for every page in the selection. links is a non-empty set() of Link instances on that page that intersect with the selection. """ for page, rect in self.selectedPages(): links = page.linksIn(rect) if links: yield page, links def setSelection(self, rect): """Sets the selection, the rectangle should be relative to the view's layout position.""" if rect: view = self.parent().parent() geom = rect.translated(view.layoutPosition()) self.setGeometry(geom) self._setLayoutOffset(geom.topLeft()) self._oldZoom = view.zoomFactor() self.show() self._setSelectionFromGeometry(geom) else: self.hide() self._setSelectionFromGeometry(QRect()) def clearSelection(self): """Hide ourselves and clear the selection.""" self.hide() self._dragging = False self._setSelectionFromGeometry(QRect()) def _setSelectionFromGeometry(self, rect): """(Internal) Called to emit the selectionChanged signal. Only emits the signal when the selection really changed. The rect should be our geometry or an empty QRect(). """ if rect: view = self.parent().parent() rect = rect.translated(-view.layoutPosition()) old, self._selection = self._selection, rect if rect != old: self.selectionChanged.emit(rect) def _setLayoutOffset(self, pos): """Store the position as offset from the layout, and also from the page at that position. Used for keeping the same spot on zoom change. """ view = self.parent().parent() pos = pos - view.layoutPosition() self._layoutOffset = view.pageLayout().pos2offset(pos) def _getLayoutOffset(self): """Get the stored layout offset position back, after zoom or move.""" view = self.parent().parent() pos = view.pageLayout().offset2pos(self._layoutOffset) return pos + view.layoutPosition() def scrollBy(self, diff): """Called by the View when scrolling.""" if not self._dragging: self.move(self.pos() + diff) # adjust the cursor self.adjustCursor(self.edge(self.parent().mapFromGlobal(QCursor.pos()))) elif self._dragedge != _INSIDE: self._draggeom.moveTo(self._draggeom.topLeft() + diff) self.dragBy(-diff) elif self.isVisible() and self.trackSelection: self._setSelectionFromGeometry(self.geometry()) def startDrag(self, pos, button): """Start dragging the rubberband.""" self._dragging = True self._dragpos = pos self._dragedge = self.edge(pos) self._draggeom = self.geometry() self._dragbutton = button def drag(self, pos): """Continue dragging the rubberband, scrolling the View if necessary.""" diff = pos - self._dragpos self._dragpos = pos self.dragBy(diff) # check if we are dragging close to the edge of the view, scroll if needed view = self.parent().parent() view.scrollForDragging(pos) def dragBy(self, diff): """Drag by diff (QPoint).""" edge = self._dragedge self._draggeom.adjust( diff.x() if edge & _LEFT else 0, diff.y() if edge & _TOP else 0, diff.x() if edge & _RIGHT else 0, diff.y() if edge & _BOTTOM else 0) geom = self._draggeom.normalized() if geom.isValid(): self.setGeometry(geom) if self.trackSelection: self._setSelectionFromGeometry(geom) if self.cursor().shape() in (Qt.SizeBDiagCursor, Qt.SizeFDiagCursor): # we're dragging a corner, use correct diagonal cursor bdiag = (edge in (3, 12)) ^ (self._draggeom.width() * self._draggeom.height() >= 0) self.setCursor(Qt.SizeBDiagCursor if bdiag else Qt.SizeFDiagCursor) def stopDrag(self): """Stop dragging the rubberband.""" self._dragging = False # TODO: use the kinetic scroller if implemented view = self.parent().parent() view.stopScrolling() if self.width() < 8 and self.height() < 8: self.unsetCursor() self._setSelectionFromGeometry(QRect()) else: self._setSelectionFromGeometry(self.geometry()) self._setLayoutOffset(self.pos()) def slotZoomChanged(self, zoom): """Called when the zooming in the view changes, resizes ourselves.""" if self.hasSelection(): view = self.parent().parent() factor = zoom / self._oldZoom self._oldZoom = zoom geom = QRect(self._getLayoutOffset(), self.size() * factor) self.setGeometry(geom) self._setSelectionFromGeometry(geom) def eventFilter(self, viewport, ev): """Act on events in the viewport: * keep on the same place when the viewport resizes * start dragging the selection if showbutton clicked (preventing the contextmenu if the showbutton is the right button) * end a drag on mousebutton release, if that button would have shown the context menu, show it on button release. """ if ev.type() == QEvent.Resize and self.isVisible(): view = self.parent().parent() if not view.viewMode(): # fixed scale, try to keep ourselves in the same position on resize self.move(self._getLayoutOffset()) elif (self.showbutton == Qt.RightButton and isinstance(ev, QContextMenuEvent) and ev.reason() == QContextMenuEvent.Mouse): # suppress context menu event if that would coincide with start selection if not self._dragging or (self.geometry() and self.edge(ev.pos()) == _INSIDE): return False return True elif not self._dragging: if ev.type() == QEvent.MouseButtonPress and ev.button() == self.showbutton: if self.isVisible(): # this cancels a previous selection if we were visible self._setSelectionFromGeometry(QRect()) self.setGeometry(QRect(ev.pos(), QSize(0, 0))) self._setLayoutOffset(ev.pos()) self._oldZoom = viewport.parent().zoomFactor() self.startDrag(ev.pos(), ev.button()) self._dragedge = _RIGHT | _BOTTOM self.adjustCursor(self._dragedge) self.show() return True elif self._dragging: if ev.type() == QEvent.MouseMove: self.drag(ev.pos()) return True elif ev.type() == QEvent.MouseButtonRelease and ev.button() == self._dragbutton: self.stopDrag() if ev.button() == Qt.RightButton: QApplication.postEvent(viewport, QContextMenuEvent(QContextMenuEvent.Mouse, ev.pos())) return True return False def mousePressEvent(self, ev): """Can start a new drag when we are clicked ourselves.""" pos = self.mapToParent(ev.pos()) if not self._dragging: if ev.button() == self.dragbutton: self.startDrag(pos, ev.button()) elif ev.button() == self.showbutton: if self.showbutton != Qt.RightButton or self.edge(pos) != _INSIDE: self.startDrag(pos, ev.button()) def mouseMoveEvent(self, ev): """Move if we are dragging; show the correct cursor shape on the edges.""" pos = self.mapToParent(ev.pos()) if self._dragging: self.drag(pos) else: edge = self.edge(pos) self.adjustCursor(edge) def mouseReleaseEvent(self, ev): """End a self-initiated drag; if the right button was used; send a context menu event.""" if self._dragging and ev.button() == self._dragbutton: self.stopDrag() if ev.button() == Qt.RightButton: QApplication.postEvent(self.parent(), QContextMenuEvent(QContextMenuEvent.Mouse, ev.pos() + self.pos())) qpageview-0.6.2/qpageview/scrollarea.py000066400000000000000000000424171423465244600202220ustar00rootroot00000000000000# -*- coding: utf-8 -*- # # This file is part of the qpageview package. # # Copyright (c) 2016 - 2019 by Wilbert Berendsen # # This program is free software; you can redistribute it and/or # modify it under the terms of the GNU General Public License # as published by the Free Software Foundation; either version 2 # of the License, or (at your option) any later version. # # This program is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU General Public License for more details. # # You should have received a copy of the GNU General Public License # along with this program; if not, write to the Free Software # Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA # See http://www.gnu.org/licenses/ for more information. """ ScrollArea, that supports kinetic scrolling and other features. """ import math from PyQt5.QtCore import QPoint, QRect, QSize, Qt from PyQt5.QtWidgets import QAbstractScrollArea from . import util class ScrollArea(QAbstractScrollArea): """A scroll area that supports kinetic scrolling and other features.""" #: how to align the scrolled area if smaller than the viewport (Qt.AlignCenter) alignment = Qt.AlignCenter #: how many scroll updates to draw per second (50, 50 is recommended). scrollupdatespersec = 50 #: whether the mouse wheel and PgUp/PgDn keys etc use kinetic scrolling (True) kineticScrollingEnabled = True #: If enabled, the user can drag the contents of the scrollarea to #: move it with the mouse. draggingEnabled = True def __init__(self, parent=None, **kwds): super().__init__(parent, **kwds) self._areaSize = 0, 0 self._dragPos = None self._dragSpeed = None self._dragTime = None self._scroller = None self._scrollTimer = None def setAreaSize(self, size): """Updates the scrollbars to be able to display an area of this size.""" self._areaSize = (size.width(), size.height()) self._updateScrollBars() def areaSize(self): """Return the size of the area as set by setAreaSize().""" return QSize(*self._areaSize) def areaPos(self): """Return the position of the area relative to the viewport. The alignment attribute is taken into account when the area is smaller than the viewport (horizontally and/or vertically). """ w, h = self._areaSize vw = self.viewport().width() vh = self.viewport().height() left, top = util.align(w, h, vw, vh, self.alignment) if left < 0: left = -self.horizontalScrollBar().value() if top < 0: top = -self.verticalScrollBar().value() return QPoint(left, top) def visibleArea(self): """Return a rectangle describing the part of the area that is visible.""" pos = self.areaPos() r = self.viewport().rect() & QRect(pos, self.areaSize()) return r.translated(-pos) def offsetToEnsureVisible(self, rect): """Return an offset QPoint with the minimal scroll to make rect visible. If the rect is too large, it is positioned top-left. """ area = self.visibleArea() # vertical dy = 0 if rect.bottom() > area.bottom(): dy = rect.bottom() - area.bottom() if rect.top() < area.top() + dy: dy = rect.top() - area.top() # horizontal dx = 0 if rect.right() > area.right(): dx = rect.right() - area.right() if rect.left() < area.left() + dx: dx = rect.left() - area.left() return QPoint(dx, dy) def ensureVisible(self, rect, margins=None, allowKinetic=True): """Performs the minimal scroll to make rect visible. If the rect is not completely visible it is scrolled into view, adding the margins if given (a QMargins instance). If allowKinetic is False, immediately jumps to the position, otherwise scrolls smoothly (if kinetic scrolling is enabled). """ if rect not in self.visibleArea(): if margins is not None: rect = rect + margins diff = self.offsetToEnsureVisible(rect) if allowKinetic and self.kineticScrollingEnabled: self.kineticScrollBy(diff) else: self.scrollBy(diff) def _updateScrollBars(self): """Internal. Adjust the range of the scrollbars to the area size. Called in setAreaSize() and resizeEvent(). """ w, h = self._areaSize maxsize = self.maximumViewportSize() vbar = self.verticalScrollBar() hbar = self.horizontalScrollBar() if w <= maxsize.width() and h <= maxsize.height(): vbar.setRange(0, 0) hbar.setRange(0, 0) else: viewport = self.viewport() vbar.setRange(0, h - viewport.height()) vbar.setPageStep(int(viewport.height() * .9)) hbar.setRange(0, w - viewport.width()) hbar.setPageStep(int(viewport.width() * .9)) def scrollOffset(self): """Return the current scroll offset.""" x = self.horizontalScrollBar().value() y = self.verticalScrollBar().value() return QPoint(x, y) def canScrollBy(self, diff): """Does not scroll, but return the actual distance the View would scroll. diff is a QPoint instance. """ hbar = self.horizontalScrollBar() vbar = self.verticalScrollBar() x = min(max(0, hbar.value() + diff.x()), hbar.maximum()) y = min(max(0, vbar.value() + diff.y()), vbar.maximum()) return QPoint(x - hbar.value(), y - vbar.value()) def scrollForDragging(self, pos): """Slowly scroll the View if pos is close to the edge of the viewport. Can be used while dragging things. """ viewport = self.viewport().rect() dx = pos.x() - viewport.left() - 12 if dx >= 0: dx = max(0, pos.x() - viewport.right() + 12) dy = pos.y() - viewport.top() - 12 if dy >= 0: dy = max(0, pos.y() - viewport.bottom() + 12) self.steadyScroll(QPoint(dx*10, dy*10)) def scrollTo(self, pos): """Scroll the View to get pos (QPoint) in the top left corner (if possible). Returns the actual distance moved. """ return self.scrollBy(pos - self.scrollOffset()) def scrollBy(self, diff): """Scroll the View diff pixels (QPoint) in x and y direction. Returns the actual distance moved. """ hbar = self.horizontalScrollBar() vbar = self.verticalScrollBar() x = hbar.value() hbar.setValue(hbar.value() + diff.x()) x = hbar.value() - x y = vbar.value() vbar.setValue(vbar.value() + diff.y()) y = vbar.value() - y return QPoint(x, y) def kineticScrollTo(self, pos): """Scroll the View to get pos (QPoint) in the top left corner (if possible). Returns the actual distance the scroll area will move. """ return self.kineticScrollBy(pos - self.scrollOffset()) def kineticScrollBy(self, diff): """Scroll the View diff pixels (QPoint) in x and y direction. Returns the actual distance the scroll area will move. """ ret = self.canScrollBy(diff) if diff: scroller = KineticScroller() scroller.scrollBy(diff) self.startScrolling(scroller) return ret def kineticAddDelta(self, diff): """Add diff (QPoint) to an existing kinetic scroll. If no scroll is active, a new one is started (like kineticScrollBy). """ if isinstance(self._scroller, KineticScroller): self._scroller.scrollBy(self._scroller.remainingDistance() + diff) else: self.kineticScrollBy(diff) def steadyScroll(self, diff): """Start steadily scrolling diff (QPoint) pixels per second. Stops automatically when the end is reached. """ if diff: self.startScrolling(SteadyScroller(diff, self.scrollupdatespersec)) else: self.stopScrolling() def startScrolling(self, scroller): """Begin a scrolling operation using the specified scroller.""" self._scroller = scroller if self._scrollTimer is None: self._scrollTimer = self.startTimer(1000 // self.scrollupdatespersec) def stopScrolling(self): """Stop scrolling.""" if self._scroller: self.killTimer(self._scrollTimer) self._scroller = None self._scrollTimer = None def isScrolling(self): """Return True if a scrolling movement is active.""" return self._scroller is not None def remainingScrollTime(self): """If a kinetic scroll is active, return how many msecs the scroll wil last. Otherwise, return 0. """ if isinstance(self._scroller, KineticScroller): return 1000 // self.scrollupdatespersec * self._scroller.remainingTicks() return 0 def isDragging(self): """Return True if the user is dragging the background.""" return self._dragPos is not None def timerEvent(self, ev): """Implemented to handle the scroll timer.""" if ev.timerId() == self._scrollTimer: diff = self._scroller.step() # when scrolling slowly, it might be that no redraw is needed if diff: # change the scrollbars, but check how far they really moved. if not self.scrollBy(diff) or self._scroller.finished(): self.stopScrolling() def resizeEvent(self, ev): """Implemented to update the scrollbars to the aera size.""" self._updateScrollBars() def mousePressEvent(self, ev): """Implemented to handle dragging the document with the left button.""" self.stopScrolling() super().mousePressEvent(ev) def mouseMoveEvent(self, ev): """Implemented to handle dragging the document with the left button.""" if self.draggingEnabled and ev.buttons() & Qt.LeftButton: if self._dragPos is None: self.setCursor(Qt.SizeAllCursor) else: diff = self._dragPos - ev.pos() self._dragSpeed = (ev.timestamp() - self._dragTime, diff) self.scrollBy(diff) self._dragPos = ev.pos() self._dragTime = ev.timestamp() super().mouseMoveEvent(ev) def mouseReleaseEvent(self, ev): """Implemented to handle dragging the document with the left button.""" if self.draggingEnabled and ev.button() == Qt.LeftButton and self._dragPos is not None: self.unsetCursor() if self.kineticScrollingEnabled and self._dragSpeed is not None: # compute speed of last movement time, speed = self._dragSpeed time += ev.timestamp() - self._dragTime # add time between last mvt and release speed = speed * 1000 / self.scrollupdatespersec / time # compute diff to scroll sx = abs(speed.x()) diffx = int(sx * (sx + 1) / 2) sy = abs(speed.y()) diffy = int(sy * (sy + 1) / 2) if speed.x() < 0: diffx = -diffx if speed.y() < 0: diffy = -diffy self.kineticScrollBy(QPoint(diffx, diffy)) self._dragPos = None self._dragTime = None self._dragSpeed = None super().mouseReleaseEvent(ev) def wheelEvent(self, ev): """Reimplemented to use kinetic mouse wheel scrolling if enabled.""" if self.kineticScrollingEnabled: self.kineticAddDelta(-ev.angleDelta()) else: super().wheelEvent(ev) def keyPressEvent(self, ev): """Reimplemented to use kinetic cursor movements.""" hbar = self.horizontalScrollBar() vbar = self.verticalScrollBar() # add Home and End, even in non-kinetic mode scroll = self.kineticScrollBy if self.kineticScrollingEnabled else self.scrollBy if ev.key() == Qt.Key_Home: scroll(QPoint(0, -vbar.value())) elif ev.key() == Qt.Key_End: scroll(QPoint(0, vbar.maximum() - vbar.value())) elif self.kineticScrollingEnabled: # make arrow keys and PgUp and PgDn kinetic if ev.key() == Qt.Key_PageDown: self.kineticAddDelta(QPoint(0, vbar.pageStep())) elif ev.key() == Qt.Key_PageUp: self.kineticAddDelta(QPoint(0, -vbar.pageStep())) elif ev.key() == Qt.Key_Down: self.kineticAddDelta(QPoint(0, vbar.singleStep())) elif ev.key() == Qt.Key_Up: self.kineticAddDelta(QPoint(0, -vbar.singleStep())) elif ev.key() == Qt.Key_Left: self.kineticAddDelta(QPoint(-hbar.singleStep(), 0)) elif ev.key() == Qt.Key_Right: self.kineticAddDelta(QPoint(hbar.singleStep(), 0)) else: super().keyPressEvent(ev) else: super().keyPressEvent(ev) class Scroller: """Abstract base class, encapsulates scrolling behaviour. A Scroller subclass must implement the step() and finished() methods and may define additional methods. """ def step(self): """Implement this method to return a QPoint for the current scrolling step.""" def finished(self): """Implement this method to return True if scrolling is finished.""" class SteadyScroller(Scroller): """Scrolls the area steadily n pixels per second.""" def __init__(self, speed, updates_per_second): """Initializes with speed (QPoint) pixels per second.""" self._x = speed.x() self._y = speed.y() self._restx = 0 self._resty = 0 self._ups = updates_per_second def step(self): """Return a QPoint indicating the diff to scroll in this step. If this is a QPoint(0, 0) it does not indicate that scrolling has finished. Use finished() for that. """ # the amount of pixels to scroll per second x = self._x y = self._y # how many updates per second, compute the number of pixes to scroll now ups = self._ups dx, rx = divmod(abs(x), ups) dy, ry = divmod(abs(y), ups) dx1, self._restx = divmod(self._restx + rx, ups) dy1, self._resty = divmod(self._resty + ry, ups) dx += dx1 dy += dy1 # scroll in the right direction diff = QPoint(-dx if x < 0 else dx, -dy if y < 0 else dy) return diff def finished(self): """As this scroller has a constant speed, it never stops.""" return False class KineticScroller(Scroller): """Scrolls the area with a decreasing speed.""" def __init__(self): self._x = 0 self._y = 0 self._offset = None def scrollBy(self, diff): """Start a new kinetic scroll of the specified amount.""" ### logic by Richard Cognot, May 2012, simplified by WB dx = diff.x() dy = diff.y() # solve speed*(speed+1)/2 = delta to ensure 1+2+3+...+speed is as close as possible under delta.. sx = int(math.sqrt(1 + 8 * abs(dx)) - 1) // 2 sy = int(math.sqrt(1 + 8 * abs(dy)) - 1) // 2 # compute the amount of displacement still needed because we're dealing with integer values. # Since this function is called for exact moves (not free scrolling) # limit the kinetic time to 2 seconds, which means 100 ticks, 5050 pixels. # (TODO: adapt for other ticker speeds? WB) if sy > 100: sy = 100 offy = abs(dy) - sy * (sy + 1) // 2 # Although it is less likely to go beyond that limit for horizontal scrolling, # do it for x as well. if sx > 100: sx = 100 offx = abs(dx) - sx * (sx + 1) // 2 # adjust directions if dx < 0: sx = -sx offx = -offx if dy < 0: sy = -sy offy = -offy self._x = sx self._y = sy # the offset is accounted for in the first step self._offset = QPoint(offx, offy) def remainingDistance(self): """Return the remaining distance.""" sx = abs(self._x) dx = sx * (sx + 1) // 2 if self._x < 0: dx = -dx sy = abs(self._y) dy = sy * (sy + 1) // 2 if self._y < 0: dy = -dy return QPoint(dx, dy) def remainingTicks(self): """Return the remaining ticks of this scroll.""" return max(abs(self._x), abs(self._y)) def step(self): """Return a QPoint indicating the diff to scroll in this step.""" ret = QPoint(self._x, self._y) if self._offset: ret += self._offset self._offset = None if self._x > 0: self._x -= 1 elif self._x < 0: self._x += 1 if self._y > 0: self._y -= 1 elif self._y < 0: self._y += 1 return ret def finished(self): """Return True if scrolling is done.""" return self._x == 0 and self._y == 0 qpageview-0.6.2/qpageview/selector.py000066400000000000000000000172321423465244600177100ustar00rootroot00000000000000# -*- coding: utf-8 -*- # # This file is part of the qpageview package. # # Copyright (c) 2019 - 2019 by Wilbert Berendsen # # This program is free software; you can redistribute it and/or # modify it under the terms of the GNU General Public License # as published by the Free Software Foundation; either version 2 # of the License, or (at your option) any later version. # # This program is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU General Public License for more details. # # You should have received a copy of the GNU General Public License # along with this program; if not, write to the Free Software # Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA # See http://www.gnu.org/licenses/ for more information. """ SelectorViewMixin class, to mixin with View. Adds the capability to select or unselect Pages. """ import contextlib from PyQt5.QtCore import pyqtSignal, QRect, Qt from PyQt5.QtGui import QPainter, QKeySequence from PyQt5.QtWidgets import QStyle, QStyleOptionButton class SelectorViewMixin: """SelectorViewMixin class, to mixin with View. Adds the capability to select or unselect Pages. Pages are numbered from 1. Instance variables: ``userChangeSelectionModeEnabled`` = True whether the user can change the selectionMode (by longpressing a page to enable selectionMode, and pressing ESC to leave selectionMode. (Be sure to mix in the :class:`qpageview.util.LongMousePressMixin` class when you want to use the long mouse press event.) """ selectionChanged = pyqtSignal() selectionModeChanged = pyqtSignal(bool) userChangeSelectionModeEnabled = True def __init__(self, parent=None, **kwds): self._selection = set() self._selectionMode = False super().__init__(parent, **kwds) def selection(self): """Return the current list of selected page numbers.""" return sorted(self._selection) @contextlib.contextmanager def modifySelection(self): """Context manager that allows changing the selection. Yields a set, and on exit of the context, stores the modifications and emits the selectionChanged() signal. Used internally by all other methods. """ old = set(self._selection) yield self._selection self._checkSelection() diff = self._selection ^ old if diff: self.selectionChanged.emit() # repaint if needed visible = self.visibleRect() if any(self.page(n).geometry() & visible for n in diff): self.viewport().update() def updatePageLayout(self, lazy=False): """Reimplemented to also check the selection.""" super().updatePageLayout(lazy) self._checkSelection() def _checkSelection(self): """Internal; silently remove page numbers from the selection that do not exist (anymore).""" count = self.pageCount() nums = [n for n in self._selection if n < 1 or n > count] self._selection.difference_update(nums) def clearSelection(self): """Convenience method to clear the selection.""" with self.modifySelection() as s: s.clear() def selectAll(self): """Convenience method to select all pages.""" with self.modifySelection() as s: s.update(range(1, self.pageCount() + 1)) def toggleSelection(self, pageNumber): """Toggles the selected state of page number pageNumber.""" with self.modifySelection() as s: count = len(s) s.add(pageNumber) if count == len(s): s.remove(pageNumber) def selectionMode(self): """Return the current selectionMode (True is enabled, False is disabled).""" return self._selectionMode def setSelectionMode(self, mode): """Switch selection mode on or off (True is enabled, False is disabled).""" if self._selectionMode != mode: self._selectionMode = mode self.selectionModeChanged.emit(mode) self.viewport().update() # repaint def paintEvent(self, ev): super().paintEvent(ev) # first draw the contents if self._selectionMode: painter = QPainter(self.viewport()) for page, rect in self.pagesToPaint(ev.rect(), painter): self.drawSelection(page, painter) def drawSelection(self, page, painter): """Draws the state (selected or not) for the page.""" option = QStyleOptionButton() option.initFrom(self) option.rect = QRect(0, 0, QStyle.PM_IndicatorWidth, QStyle.PM_IndicatorHeight) pageNum = self.pageLayout().index(page) + 1 option.state |= QStyle.State_On if pageNum in self._selection else QStyle.State_Off scale = None # in the unlikely case the checkboxes are larger than the page, scale them down if option.rect not in page.rect(): scale = min(page.width / option.rect.width(), page.height / option.rect.height()) painter.save() painter.scale(scale, scale) self.style().drawPrimitive(QStyle.PE_IndicatorCheckBox, option, painter, self) if scale is not None: painter.restore() def mousePressEvent(self, ev): """Reimplemented to check if a checkbox was clicked.""" if self._selectionMode and ev.buttons() == Qt.LeftButton: pos = ev.pos() - self.layoutPosition() page = self._pageLayout.pageAt(pos) if page: pageNum = self._pageLayout.index(page) + 1 pos -= page.pos() if pos in QRect(0, 0, QStyle.PM_IndicatorWidth, QStyle.PM_IndicatorHeight): # the indicator has been clicked if ev.modifiers() & Qt.ControlModifier: # CTRL toggles selection of page self.toggleSelection(pageNum) elif self._selection and ev.modifiers() & Qt.ShiftModifier: # Shift extends the selection with self.modifySelection() as s: s.add(pageNum) first, last = min(self._selection), max(self._selection) s.update(range(first, last+1)) else: # toggle this one and clear all the others with self.modifySelection() as s: select = pageNum not in s s.clear() if select: s.add(pageNum) return super().mousePressEvent(ev) def keyPressEvent(self, ev): """Clear the selection and switch off selectionmode with ESC.""" if self._selectionMode: if self.userChangeSelectionModeEnabled and ev.key() == Qt.Key_Escape and not ev.modifiers(): self.clearSelection() self.setSelectionMode(False) return elif ev.matches(QKeySequence.SelectAll): self.selectAll() return super().keyPressEvent(ev) def longMousePressEvent(self, ev): """Called on long mouse button press, set selectionMode on if enabled.""" if self.userChangeSelectionModeEnabled: if not self._selectionMode: self.setSelectionMode(True) return elif not self._selection: self.setSelectionMode(False) return super().longMousePressEvent(ev) qpageview-0.6.2/qpageview/shadow.py000066400000000000000000000046501423465244600173550ustar00rootroot00000000000000# -*- coding: utf-8 -*- # # This file is part of the qpageview package. # # Copyright (c) 2019 - 2019 by Wilbert Berendsen # # This program is free software; you can redistribute it and/or # modify it under the terms of the GNU General Public License # as published by the Free Software Foundation; either version 2 # of the License, or (at your option) any later version. # # This program is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU General Public License for more details. # # You should have received a copy of the GNU General Public License # along with this program; if not, write to the Free Software # Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA # See http://www.gnu.org/licenses/ for more information. """ A View mixin class that draws a nice drop shadow around all pages. """ from PyQt5.QtCore import QPoint, Qt from PyQt5.QtGui import QColor, QPainter, QPen class ShadowViewMixin: """Mixin class that draws a drop shadow around every Page. Drawing the drop shadow can be turned off by setting dropShadowEnabled to False. """ dropShadowEnabled = True def paintEvent(self, ev): if self.dropShadowEnabled: width = round(self._pageLayout.spacing / 2.0) # make the rect slightly larger, so we "see" shadow of pages that # would be outside view normally. rect = ev.rect().adjusted(-width, -width, width // 2, width // 2) painter = QPainter(self.viewport()) for page, rect in self.pagesToPaint(rect, painter): self.drawDropShadow(page, painter, width) super().paintEvent(ev) # then draw the contents def drawDropShadow(self, page, painter, width): """Draw a drop shadow of width pixels around the Page. The painter is already translated to the topleft corner of the Page. """ width = round(width) rect = page.rect().adjusted(width // 2, width // 2, 0, 0) color = QColor(Qt.black) pen = QPen() pen.setWidth(1) pen.setJoinStyle(Qt.MiterJoin) for i in range(width): f = (width-i)/width color.setAlpha(int(200**f + 55*f)) pen.setColor(color) painter.setPen(pen) painter.drawRect(rect.adjusted(-i, -i, i, i)) qpageview-0.6.2/qpageview/sidebarview.py000066400000000000000000000146401423465244600203740ustar00rootroot00000000000000# -*- coding: utf-8 -*- # # This file is part of the qpageview package. # # Copyright (c) 2019 - 2019 by Wilbert Berendsen # # This program is free software; you can redistribute it and/or # modify it under the terms of the GNU General Public License # as published by the Free Software Foundation; either version 2 # of the License, or (at your option) any later version. # # This program is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU General Public License for more details. # # You should have received a copy of the GNU General Public License # along with this program; if not, write to the Free Software # Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA # See http://www.gnu.org/licenses/ for more information. """ SidebarView, a special View with miniatures to use as a sidebar for a View. Automatically displays all pages in a view in small size, and makes it easier to browse large documents. """ from PyQt5.QtCore import QEvent, QMargins, QRect, Qt from PyQt5.QtGui import QPainter from . import constants from . import layout from . import selector from . import view from . import util class SidebarView(selector.SelectorViewMixin, util.LongMousePressMixin, view.View): """A special View with miniatures to use as a sidebar for a View. Automatically displays all pages in a view in small size, and makes it easier to browse large documents. Use setView() to connect a View, and it automatically shows the pages, also when the view is changed. """ MAX_ZOOM = 1.0 pagingOnScrollEnabled = False wheelZoomingEnabled = False firstPageNumber = 1 scrollupdatespersec = 100 autoOrientationEnabled = True def __init__(self, parent=None, **kwds): super().__init__(parent, **kwds) self._view = None self.setOrientation(constants.Vertical) self.pageLayout().spacing = 1 self.pageLayout().setMargins(QMargins(0, 0, 0, 0)) self.pageLayout().setPageMargins(QMargins(4, 4, 4, 20)) self.setLayoutFontHeight() self.currentPageNumberChanged.connect(self.viewport().update) def setOrientation(self, orientation): """Reimplemented to also set the corresponding view mode.""" super().setOrientation(orientation) if orientation == constants.Vertical: self.setViewMode(constants.FitWidth) else: self.setViewMode(constants.FitHeight) def setLayoutFontHeight(self): """Reads the current font height and reserves enough space in the layout.""" self.pageLayout().pageMargins().setBottom(self.fontMetrics().height()) self.updatePageLayout() def setView(self, view): """Connects to a View, or disconnects the current view if view is None.""" if view is not self._view: if self._view: self.currentPageNumberChanged.disconnect(self._view.setCurrentPageNumber) self._view.currentPageNumberChanged.disconnect(self.slotCurrentPageNumberChanged) self._view.pageLayoutUpdated.disconnect(self.slotLayoutUpdated) self.clear() self._view = view if view: self.slotLayoutUpdated() self.setCurrentPageNumber(view.currentPageNumber()) self.currentPageNumberChanged.connect(view.setCurrentPageNumber) view.currentPageNumberChanged.connect(self.slotCurrentPageNumberChanged) view.pageLayoutUpdated.connect(self.slotLayoutUpdated) def slotLayoutUpdated(self): """Called when the layout of the connected view is updated.""" self.pageLayout()[:] = (p.copy(self) for p in self._view.pageLayout()) self.pageLayout().rotation = self._view.pageLayout().rotation self.updatePageLayout() def slotCurrentPageNumberChanged(self, num): """Called when the page number in the connected view changes. Does not scroll but updates the current page mark in our View. """ self._currentPageNumber = num self.viewport().update() def paintEvent(self, ev): """Reimplemented to print page numbers and a selection box.""" painter = QPainter(self.viewport()) layout = self.pageLayout() for p, rect in self.pagesToPaint(ev.rect(), painter): ## draw selection background on current page if p is self.currentPage(): bg = rect + layout.pageMargins() painter.fillRect(bg, self.palette().highlight()) painter.setPen(self.palette().highlightedText().color()) else: painter.setPen(self.palette().text().color()) # draw text textr = QRect(rect.x(), rect.bottom(), rect.width(), layout.pageMargins().bottom()) painter.drawText(textr, Qt.AlignCenter, str(layout.index(p) + self.firstPageNumber)) super().paintEvent(ev) def wheelEvent(self, ev): """Reimplemented to page instead of scroll.""" if ev.angleDelta().y() > 0: self.gotoPreviousPage() elif ev.angleDelta().y() < 0: self.gotoNextPage() def keyPressEvent(self, ev): """Reimplemented to page instead of scroll.""" if ev.key() in (Qt.Key_PageDown, Qt.Key_Down): self.gotoNextPage() elif ev.key() in (Qt.Key_PageUp, Qt.Key_Up): self.gotoPreviousPage() elif ev.key() == Qt.Key_End: self.setCurrentPageNumber(self.pageCount()) elif ev.key() == Qt.Key_Home: self.setCurrentPageNumber(1) else: super().keyPressEvent(ev) def resizeEvent(self, ev): """Reimplemented to auto-change the orientation if desired.""" super().resizeEvent(ev) if self.autoOrientationEnabled: s = ev.size() if s.width() > s.height() and self.orientation() == constants.Vertical: self.setOrientation(constants.Horizontal) elif s.width() < s.height() and self.orientation() == constants.Horizontal: self.setOrientation(constants.Vertical) def changeEvent(self, ev): """Reimplemented to set the correct font height for the page numbers.""" super().changeEvent(ev) if ev.type() in (QEvent.ApplicationFontChange, QEvent.FontChange): self.setLayoutFontHeight() qpageview-0.6.2/qpageview/svg.py000066400000000000000000000072231423465244600166660ustar00rootroot00000000000000# -*- coding: utf-8 -*- # # This file is part of the qpageview package. # # Copyright (c) 2016 - 2019 by Wilbert Berendsen # # This program is free software; you can redistribute it and/or # modify it under the terms of the GNU General Public License # as published by the Free Software Foundation; either version 2 # of the License, or (at your option) any later version. # # This program is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU General Public License for more details. # # You should have received a copy of the GNU General Public License # along with this program; if not, write to the Free Software # Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA # See http://www.gnu.org/licenses/ for more information. """ A page that can display a SVG document. """ from PyQt5.QtCore import QRect, QRectF, Qt from PyQt5.QtGui import QColor, QPainter from PyQt5.QtSvg import QSvgRenderer from .constants import ( Rotate_0, Rotate_90, Rotate_180, Rotate_270, ) from . import document from . import locking from . import page from . import render class SvgPage(page.AbstractRenderedPage): """A page that can display a SVG document.""" dpi = 90.0 def __init__(self, svgrenderer, renderer=None): super().__init__(renderer) self._svg = svgrenderer self.pageWidth = svgrenderer.defaultSize().width() self.pageHeight = svgrenderer.defaultSize().height() self._viewBox = svgrenderer.viewBoxF() @classmethod def load(cls, filename, renderer=None): """Load a SVG document from filename, which may also be a QByteArray. Yields only one Page instance, as SVG currently supports one page per file. If the file can't be loaded by the underlying QSvgRenderer, no Page is yielded. """ r = QSvgRenderer() if r.load(filename): yield cls(r, renderer) def mutex(self): return self._svg def group(self): return self._svg class SvgDocument(document.MultiSourceDocument): """A Document representing a group of SVG files.""" pageClass = SvgPage def createPages(self): return self.pageClass.loadFiles(self.sources(), self.renderer) class SvgRenderer(render.AbstractRenderer): """Render SVG pages.""" def setRenderHints(self, painter): """Sets the renderhints for the painter we want to use.""" painter.setRenderHint(QPainter.Antialiasing, self.antialiasing) painter.setRenderHint(QPainter.TextAntialiasing, self.antialiasing) def draw(self, page, painter, key, tile, paperColor=None): """Draw the specified tile of the page (coordinates in key) on painter.""" # determine the part to draw; convert tile to viewbox viewbox = self.map(key, page._viewBox).mapRect(QRectF(*tile)) target = QRectF(0, 0, tile.w, tile.h) if key.rotation & 1: target.setSize(target.size().transposed()) with locking.lock(page._svg): page._svg.setViewBox(viewbox) # we must specify the target otherwise QSvgRenderer scales to the # unrotated image painter.save() painter.setClipRect(target, Qt.IntersectClip) # QSvgRenderer seems to set antialiasing always on anyway... :-) self.setRenderHints(painter) page._svg.render(painter, target) painter.restore() page._svg.setViewBox(page._viewBox) # install a default renderer, so SvgPage can be used directly SvgPage.renderer = SvgRenderer() qpageview-0.6.2/qpageview/util.py000066400000000000000000000231021423465244600170360ustar00rootroot00000000000000# -*- coding: utf-8 -*- # # This file is part of the qpageview package. # # Copyright (c) 2019 - 2019 by Wilbert Berendsen # # This program is free software; you can redistribute it and/or # modify it under the terms of the GNU General Public License # as published by the Free Software Foundation; either version 2 # of the License, or (at your option) any later version. # # This program is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU General Public License for more details. # # You should have received a copy of the GNU General Public License # along with this program; if not, write to the Free Software # Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA # See http://www.gnu.org/licenses/ for more information. """ Small utilities and simple base classes for the qpageview module. """ import collections import contextlib from PyQt5.QtCore import QPoint, QPointF, QRect, QRectF, QSize, Qt from PyQt5.QtGui import QBitmap, QMouseEvent, QRegion from PyQt5.QtWidgets import QApplication class Rectangular: """Defines a Qt-inspired and -based interface for rectangular objects. The attributes x, y, width and height default to 0 at the class level and can be set and read directly. For convenience, Qt-styled methods are available to access and modify these attributes. """ x = 0 y = 0 width = 0 height = 0 def setPos(self, point): """Set the x and y coordinates from the given QPoint point.""" self.x = point.x() self.y = point.y() def pos(self): """Return our x and y coordinates as a QPoint(x, y).""" return QPoint(self.x, self.y) def setSize(self, size): """Set the height and width attributes from the given QSize size.""" self.width = size.width() self.height = size.height() def size(self): """Return the height and width attributes as a QSize(width, height).""" return QSize(self.width, self.height) def setGeometry(self, rect): """Set our x, y, width and height directly from the given QRect.""" self.x, self.y, self.width, self.height = rect.getRect() def geometry(self): """Return our x, y, width and height as a QRect.""" return QRect(self.x, self.y, self.width, self.height) def rect(self): """Return QRect(0, 0, width, height).""" return QRect(0, 0, self.width, self.height) class MapToPage: """Simple class wrapping a QTransform to map rect and point to page coordinates.""" def __init__(self, transform): self.t = transform def rect(self, rect): """Convert QRect or QRectF to a QRect in page coordinates.""" return self.t.mapRect(QRectF(rect)).toRect() def point(self, point): """Convert QPointF or QPoint to a QPoint in page coordinates.""" return self.t.map(QPointF(point)).toPoint() class MapFromPage(MapToPage): """Simple class wrapping a QTransform to map rect and point from page to original coordinates.""" def rect(self, rect): """Convert QRect or QRectF to a QRectF in original coordinates.""" return self.t.mapRect(QRectF(rect)) def point(self, point): """Convert QPointF or QPoint to a QPointF in original coordinates.""" return self.t.map(QPointF(point)) class LongMousePressMixin: """Mixin class to add support for long mouse press to a QWidget. To handle a long mouse press event, implement longMousePressEvent(). """ #: Whether to enable handling of long mouse presses; set to False to disable longMousePressEnabled = True #: Allow moving some pixels before a long mouse press is considered a drag longMousePressTolerance = 3 #: How long to presse a mouse button (in msec) for a long press longMousePressTime = 800 def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) self._longPressTimer = None self._longPressAttrs = None self._longPressPos = None def _startLongMousePressEvent(self, ev): """Start the timer for a QMouseEvent mouse press event.""" self._cancelLongMousePressEvent() self._longPressTimer = self.startTimer(self.longMousePressTime) # copy the event's attributes because Qt might reuse the event self._longPressAttrs = (ev.type(), ev.localPos(), ev.windowPos(), ev.screenPos(), ev.button(), ev.buttons(), ev.modifiers()) self._longPressPos = ev.pos() def _checkLongMousePressEvent(self, ev): """Cancel the press event if the current event has moved more than 3 pixels.""" if self._longPressTimer is not None: dist = (self._longPressPos - ev.pos()).manhattanLength() if dist > self.longMousePressTolerance: self._cancelLongMousePressEvent() def _cancelLongMousePressEvent(self): """Stop the timer for a long mouse press event.""" if self._longPressTimer is not None: self.killTimer(self._longPressTimer) self._longPressTimer = None self._longPressAttrs = None self._longPressPos = None def longMousePressEvent(self, ev): """Implement this to handle a long mouse press event.""" pass def timerEvent(self, ev): """Implemented to check for a long mouse button press.""" if ev.timerId() == self._longPressTimer: event = QMouseEvent(*self._longPressAttrs) self._cancelLongMousePressEvent() self.longMousePressEvent(event) super().timerEvent(ev) def mousePressEvent(self, ev): """Reimplemented to check for a long mouse button press.""" if self.longMousePressEnabled: self._startLongMousePressEvent(ev) super().mousePressEvent(ev) def mouseMoveEvent(self, ev): """Reimplemented to check for moves during a long press.""" self._checkLongMousePressEvent(ev) super().mouseMoveEvent(ev) def mouseReleaseEvent(self, ev): """Reimplemented to cancel a long press.""" self._cancelLongMousePressEvent() super().mouseReleaseEvent(ev) def rotate(matrix, rotation, width, height, dest=False): """Rotate matrix inside a rectangular area of width x height. The ``matrix`` can be a either a QPainter or a QTransform. The ``rotation`` is 0, 1, 2 or 3, etc. (``Rotate_0``, ``Rotate_90``, etc...). If ``dest`` is True, ``width`` and ``height`` refer to the destination, otherwise to the source. """ if rotation & 3: if dest or not rotation & 1: matrix.translate(width / 2, height / 2) else: matrix.translate(height / 2, width / 2) matrix.rotate(rotation * 90) if not dest or not rotation & 1: matrix.translate(width / -2, height / -2) else: matrix.translate(height / -2, width / -2) def align(w, h, ow, oh, alignment=Qt.AlignCenter): """Return (x, y) to align a rect w x h in an outer rectangle ow x oh. The alignment can be a combination of the Qt.Alignment flags. If w > ow, x = -1; and if h > oh, y = -1. """ if w > ow: x = -1 elif alignment & Qt.AlignHCenter: x = (ow - w) // 2 elif alignment & Qt.AlignRight: x = ow - w else: x = 0 if h > oh: y = -1 elif alignment & Qt.AlignVCenter: y = (oh - h) // 2 elif alignment & Qt.AlignBottom: y = oh - h else: y = 0 return x, y def alignrect(rect, point, alignment=Qt.AlignCenter): """Align rect with point according to the alignment. The alignment can be a combination of the Qt.Alignment flags. """ rect.moveCenter(point) if alignment & Qt.AlignLeft: rect.moveLeft(point.x()) elif alignment & Qt.AlignRight: rect.moveRight(point.x()) if alignment & Qt.AlignTop: rect.moveTop(point.y()) elif alignment & Qt.AlignBottom: rect.moveBottom(point.y()) # Found at: https://stackoverflow.com/questions/1986152/why-doesnt-python-have-a-sign-function def sign(x): """Return the sign of x: -1 if x < 0, 0 if x == 0, or 1 if x > 0.""" return bool(x > 0) - bool(x < 0) @contextlib.contextmanager def signalsBlocked(*objs): """Block the pyqtSignals of the given QObjects during the context.""" blocks = [obj.blockSignals(True) for obj in objs] try: yield finally: for obj, block in zip(objs, blocks): obj.blockSignals(block) def autoCropRect(image): """Return a QRect specifying the contents of the QImage. Edges of the image are trimmed if they have the same color. """ # pick the color at most of the corners colors = collections.defaultdict(int) w, h = image.width(), image.height() for x, y in (0, 0), (w - 1, 0), (w - 1, h - 1), (0, h - 1): colors[image.pixel(x, y)] += 1 most = max(colors, key=colors.get) # let Qt do the masking work mask = image.createMaskFromColor(most) return QRegion(QBitmap.fromImage(mask)).boundingRect() def tempdir(): """Return a temporary directory that is erased on app quit.""" import tempfile global _tempdir try: _tempdir except NameError: name = QApplication.applicationName().translate({ord('/'): None}) or 'qpageview' _tempdir = tempfile.mkdtemp(prefix=name + '-') import atexit import shutil @atexit.register def remove(): shutil.rmtree(_tempdir, ignore_errors=True) return tempfile.mkdtemp(dir=_tempdir) qpageview-0.6.2/qpageview/view.py000066400000000000000000001440041423465244600170400ustar00rootroot00000000000000# -*- coding: utf-8 -*- # # This file is part of the qpageview package. # # Copyright (c) 2016 - 2019 by Wilbert Berendsen # # This program is free software; you can redistribute it and/or # modify it under the terms of the GNU General Public License # as published by the Free Software Foundation; either version 2 # of the License, or (at your option) any later version. # # This program is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU General Public License for more details. # # You should have received a copy of the GNU General Public License # along with this program; if not, write to the Free Software # Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA # See http://www.gnu.org/licenses/ for more information. """ The View, deriving from QAbstractScrollArea. """ import collections import contextlib import weakref from PyQt5.QtCore import pyqtSignal, QEvent, QPoint, QRect, QSize, Qt from PyQt5.QtGui import QCursor, QPainter, QPalette, QRegion from PyQt5.QtWidgets import QGestureEvent, QPinchGesture, QStyle from PyQt5.QtPrintSupport import QPrinter, QPrintDialog from . import layout from . import page from . import scrollarea from . import util from .constants import ( # rotation: Rotate_0, Rotate_90, Rotate_180, Rotate_270, # viewModes: FixedScale, FitWidth, FitHeight, FitBoth, # orientation: Horizontal, Vertical, ) Position = collections.namedtuple("Position", "pageNumber x y") class View(scrollarea.ScrollArea): """View is a generic scrollable widget to display Pages in a layout. Using setPageLayout() you can set a PageLayout to the View, and you can add Pages to the layout using a list-like api. (PageLayout derives from list). A simple PageLayout is set by default. Call updatePageLayout() after every change to the layout (like adding or removing pages). You can also add a Magnifier to magnify parts of a Page, and a Rubberband to enable selecting a rectangular region. View emits the following signals: :attr:`pageCountChanged` (int) emitted when the total amount of pages has changed :attr:`currentPageNumberChanged` (int) emitted when the current page number has changed (starting with 1) :attr:`viewModeChanged` (int) emitted when the ``viewMode`` has changed :attr:`rotationChanged` (int) emitted when the ``rotation`` has changed :attr:`orientationChanged` (int) emitted when the ``orientation`` has changed :attr:`zoomFactorChanged` (float) emitted when the ``zoomFactor`` has changed :attr:`continuousModeChanged` (bool) emitted when the ``continuousMode`` has changed :attr:`pageLayoutModeChanged` (str) emitted when the ``pageLayoutMode`` has changed :attr:`pageLayoutUpdated` () emitted whenever the page layout has been updated (redraw/resize) """ MIN_ZOOM = 0.05 MAX_ZOOM = 64.0 #: whether to enable mouse wheel zooming wheelZoomingEnabled = True #: whether to enable kinetic scrolling while paging (setCurrentPageNumber) kineticPagingEnabled = True #: whether to keep track of current page while scrolling pagingOnScrollEnabled = True #: whether a mouse click in a page makes it the current page clickToSetCurrentPageEnabled = True #: whether PageUp and PageDown call setCurrentPageNumber instead of scroll strictPagingEnabled = False #: can be set to a DocumentPropertyStore object. If set, the object is #: used to store certain View settings on a per-document basis. #: (This happens in the :meth:`clear` and :meth:`setDocument` methods.) documentPropertyStore = None #: (int) emitted when the total amount of pages has changed pageCountChanged = pyqtSignal(int) #: (int) emitted when the current page number has changed (starting with 1) currentPageNumberChanged = pyqtSignal(int) #: (int) emitted when the ``viewMode`` has changed viewModeChanged = pyqtSignal(int) #: (int) emitted when the ``rotation`` has changed rotationChanged = pyqtSignal(int) #: (int) emitted when the ``orientation`` has changed orientationChanged = pyqtSignal(int) #: (float) emitted when the ``zoomFactor`` has changed zoomFactorChanged = pyqtSignal(float) #: (bool) emitted when the ``continuousMode`` has changed continuousModeChanged = pyqtSignal(bool) #: (str) emitted when the ``pageLayoutMode`` has changed pageLayoutModeChanged = pyqtSignal(str) #: emitted whenever the page layout has been updated (redraw/resize) pageLayoutUpdated = pyqtSignal() def __init__(self, parent=None, **kwds): super().__init__(parent, **kwds) self._document = None self._currentPageNumber = 0 self._pageCount = 0 self._scrollingToPage = 0 self._prev_pages_to_paint = set() self._viewMode = FixedScale self._pageLayout = None self._magnifier = None self._rubberband = None self._pinchStartFactor = None self.grabGesture(Qt.PinchGesture) self.viewport().setBackgroundRole(QPalette.Dark) self.verticalScrollBar().setSingleStep(20) self.horizontalScrollBar().setSingleStep(20) self.setMouseTracking(True) self.setMinimumSize(QSize(60, 60)) self.setPageLayout(layout.PageLayout()) props = self.properties().setdefaults() self._viewMode = props.viewMode self._pageLayout.continuousMode = props.continuousMode self._pageLayout.orientation = props.orientation self._pageLayoutMode = props.pageLayoutMode self.pageLayout().engine = self.pageLayoutModes()[props.pageLayoutMode]() def pageCount(self): """Return the number of pages in the view.""" return self._pageCount def currentPageNumber(self): """Return the current page number in view (starting with 1).""" return self._currentPageNumber def setCurrentPageNumber(self, num): """Scrolls to the specified page number (starting with 1). If the page is already in view, the view is not scrolled, otherwise the view is scrolled to center the page. (If the page is larger than the view, the top-left corner is positioned top-left in the view.) """ self.updateCurrentPageNumber(num) page = self.currentPage() if page: margins = self._pageLayout.margins() + self._pageLayout.pageMargins() with self.pagingOnScrollDisabled(): self.ensureVisible(page.geometry(), margins, self.kineticPagingEnabled) if self.isScrolling(): self._scrollingToPage = True def updateCurrentPageNumber(self, num): """Set the current page number without scrolling the view.""" count = self.pageCount() n = max(min(count, num), 1 if count else 0) if n == num and n != self._currentPageNumber: self._currentPageNumber = num self.currentPageNumberChanged.emit(num) def gotoNextPage(self): """Convenience method to go to the next page.""" num = self.currentPageNumber() if num < self.pageCount(): self.setCurrentPageNumber(num + 1) def gotoPreviousPage(self): """Convenience method to go to the previous page.""" num = self.currentPageNumber() if num > 1: self.setCurrentPageNumber(num - 1) def currentPage(self): """Return the page pointed to by currentPageNumber().""" if self._pageCount: return self._pageLayout[self._currentPageNumber-1] def page(self, num): """Return the page at the specified number (starting at 1).""" if 0 < num <= self._pageCount: return self._pageLayout[num-1] def pages(self): """Return a list of all Pages in the page layout.""" return list(self._pageLayout) def position(self): """Return a three-tuple Position(pageNumber, x, y). The Position describes where the center of the viewport is on the layout. The page is the page number (starting with 1) and x and y the position on the page, in a 0..1 range. This way a position can be remembered even if the zoom or orientation of the layout changes. """ pos = self.viewport().rect().center() i, x, y = self._pageLayout.pos2offset(pos - self.layoutPosition()) return Position(i + 1, x, y) def setPosition(self, position, allowKinetic=True): """Centers the view on the spot stored in the specified Position. If allowKinetic is False, immediately jumps to the position, otherwise scrolls smoothly (if kinetic scrolling is enabled). """ i, x, y = position rect = self.viewport().rect() rect.moveCenter(self._pageLayout.offset2pos((i - 1, x, y))) self.ensureVisible(rect, allowKinetic=allowKinetic) def setPageLayout(self, layout): """Set our current PageLayout instance. The dpiX and dpiY attributes of the layout are set to the physical resolution of the widget, which should result in a natural size of 100% at zoom factor 1.0. """ if self._pageLayout: self._unschedulePages(self._pageLayout) layout.dpiX = self.physicalDpiX() layout.dpiY = self.physicalDpiY() self._pageLayout = layout self.updatePageLayout() def pageLayout(self): """Return our current PageLayout instance.""" return self._pageLayout def pageLayoutModes(self): """Return a dictionary mapping names to callables. The callable returns a configured LayoutEngine that is set to the page layout. You can reimplement this method to returns more layout modes, but it is required that the name "single" exists. """ def single(): return layout.LayoutEngine() def raster(): return layout.RasterLayoutEngine() def double_left(): engine = layout.RowLayoutEngine() engine.pagesPerRow = 2 engine.pagesFirstRow = 0 return engine def double_right(): engine = double_left() engine.pagesFirstRow = 1 return engine return locals() def pageLayoutMode(self): """Return the currently set page layout mode.""" return self._pageLayoutMode def setPageLayoutMode(self, mode): """Set the page layout mode. The mode is one of the names returned by pageLayoutModes(). The mode name "single" is guaranteed to exist. """ if mode != self._pageLayoutMode: # get a suitable LayoutEngine try: engine = self.pageLayoutModes()[mode]() except KeyError: return self._pageLayout.engine = engine # keep the current page in view page = self.currentPage() self.updatePageLayout() if page: margins = self._pageLayout.margins() + self._pageLayout.pageMargins() with self.pagingOnScrollDisabled(): self.ensureVisible(page.geometry(), margins, False) self._pageLayoutMode = mode self.pageLayoutModeChanged.emit(mode) if self.viewMode(): with self.keepCentered(): self.fitPageLayout() def updatePageLayout(self, lazy=False): """Update layout, adjust scrollbars, keep track of page count. If lazy is set to True, calls lazyUpdate() to update the view. """ self._pageLayout.update() # keep track of page count count = self._pageLayout.count() if count != self._pageCount: self._pageCount = count self.pageCountChanged.emit(count) n = max(min(count, self._currentPageNumber), 1 if count else 0) self.updateCurrentPageNumber(n) self.setAreaSize(self._pageLayout.size()) self.pageLayoutUpdated.emit() self.lazyUpdate() if lazy else self.viewport().update() @contextlib.contextmanager def modifyPages(self): """Return the list of pages and enter a context to make modifications. Note that the first page is at index 0. On exit of the context the page layout is updated. """ pages = list(self._pageLayout) if self.rubberband(): selectedpages = set(p for p, r in self.rubberband().selectedPages()) else: selectedpages = set() lazy = bool(pages) try: yield pages finally: lazy &= bool(pages) removedpages = set(self._pageLayout) - set(pages) if selectedpages & removedpages: self.rubberband().clearSelection() # rubberband'll always be there self._unschedulePages(removedpages) self._pageLayout[:] = pages if self._viewMode: zoomFactor = self._pageLayout.zoomFactor self.fitPageLayout() if zoomFactor != self._pageLayout.zoomFactor: lazy = False self.updatePageLayout(lazy) @contextlib.contextmanager def modifyPage(self, num): """Return the page (numbers start with 1) and enter a context. On exit of the context, the page layout is updated. """ page = self.page(num) yield page if page: self._unschedulePages((page,)) self.updatePageLayout(True) def clear(self): """Convenience method to clear the current layout.""" self.setPages([]) def setPages(self, pages): """Load the iterable of pages into the View. Existing pages are removed, and the document is set to None. """ if self.documentPropertyStore and self._document: self.documentPropertyStore.set(self._document, self.properties().get(self)) self._document = None with self.modifyPages() as pgs: pgs[:] = pages def setDocument(self, document): """Set the Document to display (see document.Document).""" store = self._document is not document and self.documentPropertyStore if store and self._document: store.set(self._document, self.properties().get(self)) self._document = document with self.modifyPages() as pages: pages[:] = document.pages() if store: (store.get(document) or store.default or self.properties()).set(self) def document(self): """Return the Document currently displayed (see document.Document).""" return self._document def reload(self): """If a Document was set, invalidate()s it and then reloads it.""" if self._document: self._document.invalidate() with self.modifyPages() as pages: pages[:] = self._document.pages() def loadPdf(self, filename, renderer=None): """Convenience method to load the specified PDF file. The filename can also be a QByteArray or an already loaded popplerqt5.Poppler.Document instance. """ from . import poppler self.setDocument(poppler.PopplerDocument(filename, renderer)) def loadSvgs(self, filenames, renderer=None): """Convenience method to load the specified list of SVG files. Each SVG file is loaded in one Page. A filename can also be a QByteArray. """ from . import svg self.setDocument(svg.SvgDocument(filenames, renderer)) def loadImages(self, filenames, renderer=None): """Convenience method to load images from the specified list of files. Each image is loaded in one Page. A filename can also be a QByteArray or a QImage. """ from . import image self.setDocument(image.ImageDocument(filenames, renderer)) def print(self, printer=None, pageNumbers=None, showDialog=True): """Print all, or speficied pages to QPrinter printer. If given the pageNumbers should be a list containing page numbers starting with 1. If showDialog is True, a print dialog is shown, and printing is canceled when the user cancels the dialog. If the QPrinter to use is not specified, a default one is created. The print job is started and returned (a printing.PrintJob instance), so signals for monitoring the progress could be connected to. (If the user cancels the dialog, no print job is returned.) """ if printer is None: printer = QPrinter() printer.setResolution(300) if showDialog: dlg = QPrintDialog(printer, self) dlg.setMinMax(1, self.pageCount()) if not dlg.exec_(): return # cancelled if not pageNumbers: if printer.printRange() == QPrinter.CurrentPage: pageNumbers = [self.currentPageNumber()] else: if printer.printRange() == QPrinter.PageRange: first = printer.toPage() or 1 last = printer.fromPage() or self.pageCount() else: first, last = 1, self.pageCount() pageNumbers = list(range(first, last + 1)) if printer.pageOrder() == QPrinter.LastPageFirst: pageNumbers.reverse() # add the page objects pageList = [(n, self.page(n)) for n in pageNumbers] from . import printing job = printing.PrintJob(printer, pageList) job.start() return job @staticmethod def properties(): """Return an uninitialized ViewProperties object.""" return ViewProperties() def readProperties(self, settings): """Read View settings from the QSettings object. If a documentPropertyStore is set, the settings are also set as default for the DocumentPropertyStore. """ props = self.properties().load(settings) props.position = None # storing the position makes no sense props.set(self) if self.documentPropertyStore: self.documentPropertyStore.default = props def writeProperties(self, settings): """Write the current View settings to the QSettings object. If a documentPropertyStore is set, the settings are also set as default for the DocumentPropertyStore. """ props = self.properties().get(self) props.position = None # storing the position makes no sense props.save(settings) if self.documentPropertyStore: self.documentPropertyStore.default = props def setViewMode(self, mode): """Sets the current ViewMode.""" if mode == self._viewMode: return self._viewMode = mode if mode: with self.keepCentered(): self.fitPageLayout() else: # call layout once to tell FixedScale is active self.pageLayout().fit(QSize(), mode) self.viewModeChanged.emit(mode) def viewMode(self): """Returns the current ViewMode.""" return self._viewMode def setRotation(self, rotation): """Set the current rotation.""" layout = self._pageLayout if rotation != layout.rotation: with self.keepCentered(): layout.rotation = rotation self.fitPageLayout() self.rotationChanged.emit(rotation) def rotation(self): """Return the current rotation.""" return self._pageLayout.rotation def rotateLeft(self): """Rotate the pages 270 degrees.""" self.setRotation((self.rotation() - 1) & 3) def rotateRight(self): """Rotate the pages 90 degrees.""" self.setRotation((self.rotation() + 1) & 3) def setOrientation(self, orientation): """Set the orientation (Horizontal or Vertical).""" layout = self._pageLayout if orientation != layout.orientation: with self.keepCentered(): layout.orientation = orientation self.fitPageLayout() self.orientationChanged.emit(orientation) def orientation(self): """Return the current orientation (Horizontal or Vertical).""" return self._pageLayout.orientation def setContinuousMode(self, continuous): """Sets whether the layout should display all pages. If True, the layout shows all pages. If False, only the page set containing the current page is displayed. If the pageLayout() does not support the PageSetLayoutMixin methods, this method does nothing. """ layout = self._pageLayout oldcontinuous = layout.continuousMode if continuous: if not oldcontinuous: with self.pagingOnScrollDisabled(), self.keepCentered(): layout.continuousMode = True self.fitPageLayout() self.continuousModeChanged.emit(True) elif oldcontinuous: p = self.currentPage() index = layout.index(p) if p else 0 with self.pagingOnScrollDisabled(), self.keepCentered(): layout.continuousMode = False layout.currentPageSet = layout.pageSet(index) self.fitPageLayout() self.continuousModeChanged.emit(False) def continuousMode(self): """Return True if the layout displays all pages.""" return self._pageLayout.continuousMode def displayPageSet(self, what): """Try to display a page set (if the layout is not in continuous mode). `what` can be: "next": go to the next page set "previous": go to the previous page set "first": go to the first page set "last": go to the last page set integer: go to the specified page set """ layout = self._pageLayout if layout.continuousMode: return sb = None # where to move the scrollbar after fitlayout if what == "first": what = 0 sb = "up" # move to the start elif what == "last": what = layout.pageSetCount() - 1 sb = "down" # move to the end elif what == "previous": what = layout.currentPageSet - 1 if what < 0: return sb = "down" elif what == "next": what = layout.currentPageSet + 1 if what >= layout.pageSetCount(): return sb = "up" elif not 0 <= what < layout.pageSetCount(): return layout.currentPageSet = what self.fitPageLayout() self.updatePageLayout() if sb: self.verticalScrollBar().setValue(0 if sb == "up" else self.verticalScrollBar().maximum()) if self.pagingOnScrollEnabled and not self._scrollingToPage: s = layout.currentPageSetSlice() num = s.stop - 1 if sb == "down" else s.start self.updateCurrentPageNumber(num + 1) def setMagnifier(self, magnifier): """Sets the Magnifier to use (or None to disable the magnifier). The viewport takes ownership of the Magnifier. """ if self._magnifier: self.viewport().removeEventFilter(self._magnifier) self._magnifier.setParent(None) self._magnifier = magnifier if magnifier: magnifier.setParent(self.viewport()) self.viewport().installEventFilter(magnifier) def magnifier(self): """Returns the currently set magnifier.""" return self._magnifier def setRubberband(self, rubberband): """Sets the Rubberband to use for selections (or None to not use one).""" if self._rubberband: self.viewport().removeEventFilter(self._rubberband) self.zoomFactorChanged.disconnect(self._rubberband.slotZoomChanged) self.rotationChanged.disconnect(self._rubberband.clearSelection) self._rubberband.setParent(None) self._rubberband = rubberband if rubberband: rubberband.setParent(self.viewport()) rubberband.clearSelection() self.viewport().installEventFilter(rubberband) self.zoomFactorChanged.connect(rubberband.slotZoomChanged) self.rotationChanged.connect(rubberband.clearSelection) def rubberband(self): """Return the currently set rubberband.""" return self._rubberband @contextlib.contextmanager def pagingOnScrollDisabled(self): """During this context a scroll is not tracked to update the current page number.""" old, self._scrollingToPage = self._scrollingToPage, True try: yield finally: self._scrollingToPage = old def scrollContentsBy(self, dx, dy): """Reimplemented to move the rubberband and adjust the mouse cursor.""" if self._rubberband: self._rubberband.scrollBy(QPoint(dx, dy)) if not self.isScrolling() and not self.isDragging(): # don't adjust the cursor during a kinetic scroll pos = self.viewport().mapFromGlobal(QCursor.pos()) if pos in self.viewport().rect() and not self.viewport().childAt(pos): self.adjustCursor(pos) self.viewport().update() # keep track of current page. If the scroll wasn't initiated by the # setCurrentPage() call, check # whether the current page number needs # to be updated if self.pagingOnScrollEnabled and not self._scrollingToPage and self.pageCount() > 0: # do nothing if current page is still fully in view if self.currentPage().geometry() not in self.visibleRect(): # find the page in the center of the view layout = self._pageLayout pos = self.visibleRect().center() p = layout.pageAt(pos) or layout.nearestPageAt(pos) if p: num = layout.index(p) + 1 self.updateCurrentPageNumber(num) def stopScrolling(self): """Reimplemented to adjust the mouse cursor on scroll stop.""" super().stopScrolling() self._scrollingToPage = False pos = self.viewport().mapFromGlobal(QCursor.pos()) if pos in self.viewport().rect() and not self.viewport().childAt(pos): self.adjustCursor(pos) def fitPageLayout(self): """Fit the layout according to the view mode. Does nothing in FixedScale mode. Prevents scrollbar/resize loops by precalculating which scrollbars will appear. """ mode = self.viewMode() if mode == FixedScale: return maxsize = self.maximumViewportSize() # can vertical or horizontal scrollbars appear? vcan = self.verticalScrollBarPolicy() == Qt.ScrollBarAsNeeded hcan = self.horizontalScrollBarPolicy() == Qt.ScrollBarAsNeeded # width a scrollbar takes off the viewport size framewidth = 0 if self.style().styleHint(QStyle.SH_ScrollView_FrameOnlyAroundContents, None, self): framewidth = self.style().pixelMetric(QStyle.PM_DefaultFrameWidth) * 2 scrollbarextent = self.style().pixelMetric(QStyle.PM_ScrollBarExtent, None, self) + framewidth # remember old factor zoom_factor = self.zoomFactor() # first try to fit full size layout = self._pageLayout layout.fit(maxsize, mode) layout.update() # minimal values minwidth = maxsize.width() minheight = maxsize.height() if vcan: minwidth -= scrollbarextent if hcan: minheight -= scrollbarextent # do width and/or height fit? fitw = layout.width <= maxsize.width() fith = layout.height <= maxsize.height() if not fitw and not fith: if vcan or hcan: layout.fit(QSize(minwidth, minheight), mode) elif mode & FitWidth and fitw and not fith and vcan: # a vertical scrollbar will appear w = minwidth layout.fit(QSize(w, maxsize.height()), mode) layout.update() if layout.height <= maxsize.height(): # now the vert. scrollbar would disappear! # enlarge it as long as the vertical scrollbar would not be needed while True: w += 1 layout.fit(QSize(w, maxsize.height()), mode) layout.update() if layout.height > maxsize.height(): layout.fit(QSize(w - 1, maxsize.height()), mode) break elif mode & FitHeight and fith and not fitw and hcan: # a horizontal scrollbar will appear h = minheight layout.fit(QSize(maxsize.width(), h), mode) layout.update() if layout.width <= maxsize.width(): # now the horizontal scrollbar would disappear! # enlarge it as long as the horizontal scrollbar would not be needed while True: h += 1 layout.fit(QSize(maxsize.width(), h), mode) layout.update() if layout.width > maxsize.width(): layout.fit(QSize(maxsize.width(), h - 1), mode) break if zoom_factor != self.zoomFactor(): self.zoomFactorChanged.emit(self.zoomFactor()) self._unschedulePages(layout) @contextlib.contextmanager def keepCentered(self, pos=None): """Context manager to keep the same spot centered while changing the layout. If pos is not given, the viewport's center is used. After yielding, updatePageLayout() is called. """ if pos is None: pos = self.viewport().rect().center() # find the spot on the page layout = self._pageLayout layout_pos = self.layoutPosition() pos_on_layout = pos - layout_pos offset = layout.pos2offset(pos_on_layout) pos_on_layout -= layout.pos() # pos() of the layout might change yield self.updatePageLayout() new_pos_on_layout = layout.offset2pos(offset) - layout.pos() diff = new_pos_on_layout - pos self.verticalScrollBar().setValue(diff.y()) self.horizontalScrollBar().setValue(diff.x()) def setZoomFactor(self, factor, pos=None): """Set the zoom factor (1.0 by default). If pos is given, that position (in viewport coordinates) is kept in the center if possible. If None, zooming centers around the viewport center. """ factor = max(self.MIN_ZOOM, min(self.MAX_ZOOM, factor)) if factor != self._pageLayout.zoomFactor: with self.keepCentered(pos): self._pageLayout.zoomFactor = factor if self._pageLayout.zoomsToFit(): self.setViewMode(FixedScale) self.zoomFactorChanged.emit(factor) self._unschedulePages(self._pageLayout) def zoomFactor(self): """Return the page layout's zoom factor.""" return self._pageLayout.zoomFactor def zoomIn(self, pos=None, factor=1.1): """Zoom in. If pos is given, it is the position in the viewport to keep centered. Otherwise zooming centers around the viewport center. """ self.setZoomFactor(self.zoomFactor() * factor, pos) def zoomOut(self, pos=None, factor=1.1): """Zoom out. If pos is given, it is the position in the viewport to keep centered. Otherwise zooming centers around the viewport center. """ self.setZoomFactor(self.zoomFactor() / factor, pos) def zoomNaturalSize(self, pos=None): """Zoom to the natural pixel size of the current page. The natural pixel size zoom factor can be different than 1.0, if the screen's DPI differs from the current page's DPI. """ p = self.currentPage() factor = p.dpi / self.physicalDpiX() if p else 1.0 self.setZoomFactor(factor, pos) def layoutPosition(self): """Return the position of the PageLayout relative to the viewport. This is the top-left position of the layout, relative to the top-left position of the viewport. If the layout is smaller than the viewport it is centered by default. (See ScrollArea.alignment.) """ return self.areaPos() - self._pageLayout.pos() def visibleRect(self): """Return the QRect of the page layout that is currently visible in the viewport.""" return self.visibleArea().translated(self._pageLayout.pos()) def visiblePages(self, rect=None): """Yield the Page instances that are currently visible. If rect is not given, the visibleRect() is used. The pages are sorted so that the pages with the largest visible part come first. """ if rect is None: rect = self.visibleRect() def key(page): overlayrect = rect & page.geometry() return overlayrect.width() * overlayrect.height() return sorted(self._pageLayout.pagesAt(rect), key=key, reverse=True) def ensureVisible(self, rect, margins=None, allowKinetic=True): """Ensure rect is visible, switching page set if necessary.""" if not any(self.pageLayout().pagesAt(rect)): if self.continuousMode(): return # we might need to switch page set # find the rect for p in layout.PageRects(self.pageLayout()).intersecting(*rect.getCoords()): num = self.pageLayout().index(p) self.displayPageSet(self.pageLayout().pageSet(num)) break else: return rect = rect.translated(-self._pageLayout.pos()) super().ensureVisible(rect, margins, allowKinetic) def adjustCursor(self, pos): """Sets the correct mouse cursor for the position on the page.""" pass def repaintPage(self, page): """Call this when you want to redraw the specified page.""" rect = page.geometry().translated(self.layoutPosition()) self.viewport().update(rect) def lazyUpdate(self, page=None): """Lazily repaint page (if visible) or all visible pages. Defers updating the viewport for a page until all rendering tasks for that page have finished. This reduces flicker. """ viewport = self.viewport() full = True updates = [] for p in self.visiblePages(): rect = self.visibleRect() & p.geometry() if rect and p.renderer: info = p.renderer.info(p, viewport, rect.translated(-p.pos())) if info.missing: full = False if page is p or page is None: p.renderer.schedule(p, info.key, info.missing, self.lazyUpdate) elif page is p or page is None: updates.append(rect.translated(self.layoutPosition())) if full: viewport.update() elif updates: viewport.update(sum(updates, QRegion())) def rerender(self, page=None): """Schedule the specified page or all pages for rerendering. Call this when you have changed render options or page contents. Repaints the page or visible pages lazily, reducing flicker. """ renderers = collections.defaultdict(list) pages = (page,) if page else self._pageLayout for p in pages: if p.renderer: renderers[p.renderer].append(p) for renderer, pages in renderers.items(): renderer.invalidate(pages) self.lazyUpdate(page) def _unschedulePages(self, pages): """(Internal.) Unschedule rendering of pages that are pending but not needed anymore. Called inside paintEvent, on zoomFactor change and some other places. This prevents rendering jobs hogging the cpu for pages that are deleted or out of view. """ unschedule = collections.defaultdict(set) for page in pages: if page.renderer: unschedule[page.renderer].add(page) for renderer, pages in unschedule.items(): renderer.unschedule(pages, self.repaintPage) def pagesToPaint(self, rect, painter): """Yield (page, rect) to paint in the specified rectangle. The specified rect is in viewport coordinates, as in the paint event. The returned rect describes the part of the page actually to draw, in page coordinates. (The full rect can be found in page.rect().) Translates the painter to the top left of each page. The pages are sorted with largest area last. """ layout_pos = self.layoutPosition() ev_rect = rect.translated(-layout_pos) for p in self.visiblePages(ev_rect): r = (p.geometry() & ev_rect).translated(-p.pos()) painter.save() painter.translate(layout_pos + p.pos()) yield p, r painter.restore() def event(self, ev): """Reimplemented to get Gesture events.""" if isinstance(ev, QGestureEvent) and self.handleGestureEvent(ev): ev.accept() # Accepts all gestures in the event return True return super().event(ev) def handleGestureEvent(self, event): """Gesture event handler. Return False if event is not accepted. Currently only cares about PinchGesture. Could also handle Swipe and Pan gestures. """ ## originally contributed by David Rydh, 2017 pinch = event.gesture(Qt.PinchGesture) if pinch: return self.pinchGesture(pinch) return False def pinchGesture(self, gesture): """Pinch gesture event handler. Return False if event is not accepted. Currently only cares about ScaleFactorChanged and not RotationAngleChanged. """ ## originally contributed by David Rydh, 2017 # Gesture start? Reset _pinchStartFactor in case we didn't # catch the finish event if gesture.state() == Qt.GestureStarted: self._pinchStartFactor = None changeFlags = gesture.changeFlags() if changeFlags & QPinchGesture.ScaleFactorChanged: factor = gesture.property("totalScaleFactor") if not self._pinchStartFactor: # Gesture start? self._pinchStartFactor = self.zoomFactor() self.setZoomFactor(self._pinchStartFactor * factor, self.mapFromGlobal(gesture.hotSpot().toPoint())) # Gesture finished? if gesture.state() in (Qt.GestureFinished, Qt.GestureCanceled): self._pinchStartFactor = None return True def paintEvent(self, ev): """Paint the contents of the viewport.""" painter = QPainter(self.viewport()) pages_to_paint = set() for p, r in self.pagesToPaint(ev.rect(), painter): p.paint(painter, r, self.repaintPage) pages_to_paint.add(p) # remove pending render jobs for pages that were visible, but are not # visible now rect = self.visibleRect() pages = set(page for page in self._prev_pages_to_paint - pages_to_paint if not rect.intersects(page.geometry())) self._unschedulePages(pages) self._prev_pages_to_paint = pages_to_paint def resizeEvent(self, ev): """Reimplemented to scale the view if needed and update the scrollbars.""" if self._viewMode and not self._pageLayout.empty(): with self.pagingOnScrollDisabled(): # sensible repositioning vbar = self.verticalScrollBar() hbar = self.horizontalScrollBar() x, xm = hbar.value(), hbar.maximum() y, ym = vbar.value(), vbar.maximum() self.fitPageLayout() self.updatePageLayout() if xm: hbar.setValue(round(x * hbar.maximum() / xm)) if ym: vbar.setValue(round(y * vbar.maximum() / ym)) super().resizeEvent(ev) def wheelEvent(self, ev): """Reimplemented to support wheel zooming and paging through page sets.""" if self.wheelZoomingEnabled and ev.angleDelta().y() and ev.modifiers() & Qt.CTRL: factor = 1.1 ** util.sign(ev.angleDelta().y()) self.setZoomFactor(self.zoomFactor() * factor, ev.pos()) elif not ev.modifiers(): # if scrolling is not possible, try going to next or previous pageset. sb = self.verticalScrollBar() sp = self.strictPagingEnabled if ev.angleDelta().y() > 0 and sb.value() == 0: self.gotoPreviousPage() if sp else self.displayPageSet("previous") elif ev.angleDelta().y() < 0 and sb.value() == sb.maximum(): self.gotoNextPage() if sp else self.displayPageSet("next") else: super().wheelEvent(ev) else: super().wheelEvent(ev) def mousePressEvent(self, ev): """Implemented to set the clicked page as current, without moving it.""" if self.clickToSetCurrentPageEnabled: page = self._pageLayout.pageAt(ev.pos() - self.layoutPosition()) if page: num = self._pageLayout.index(page) + 1 self.updateCurrentPageNumber(num) super().mousePressEvent(ev) def mouseMoveEvent(self, ev): """Implemented to adjust the mouse cursor depending on the page contents.""" # no cursor updates when dragging the background is busy, see scrollarea.py. if not self.isDragging(): self.adjustCursor(ev.pos()) super().mouseMoveEvent(ev) def keyPressEvent(self, ev): """Reimplemented to go to next or previous page set if possible.""" # ESC clears the selection, if any. if (ev.key() == Qt.Key_Escape and not ev.modifiers() and self.rubberband() and self.rubberband().hasSelection()): self.rubberband().clearSelection() return # Paging through page sets? sb = self.verticalScrollBar() sp = self.strictPagingEnabled if ev.key() == Qt.Key_PageUp: if sp: self.gotoPreviousPage() elif sb.value() == 0: self.displayPageSet("previous") else: super().keyPressEvent(ev) elif ev.key() == Qt.Key_PageDown: if sp: self.gotoNextPage() elif sb.value() == sb.maximum(): self.displayPageSet("next") else: super().keyPressEvent(ev) elif ev.key() == Qt.Key_Home and ev.modifiers() == Qt.ControlModifier: self.setCurrentPageNumber(1) if sp else self.displayPageSet("first") elif ev.key() == Qt.Key_End and ev.modifiers() == Qt.ControlModifier: self.setCurrentPageNumber(self.pageCount()) if sp else self.displayPageSet("last") else: super().keyPressEvent(ev) class ViewProperties: """Simple helper class encapsulating certain settings of a View. The settings can be set to and got from a View, and saved to or loaded from a QSettings group. Class attributes serve as default values, None means: no change. All methods return self, so operations can easily be chained. If you inherit from a View and add more settings, you can also add properties to this class by inheriting from it. Reimplement View.properties() to return an instance of your new ViewProperties subclass. """ position = None rotation = Rotate_0 zoomFactor = 1.0 viewMode = FixedScale orientation = None continuousMode = None pageLayoutMode = None def setdefaults(self): """Set all properties to default values. Also used by View on init.""" self.orientation = Vertical self.continuousMode = True self.pageLayoutMode = "single" return self def copy(self): """Return a copy or ourselves.""" cls = type(self) props = cls.__new__(cls) props.__dict__.update(self.__dict__) return props def names(self): """Return a tuple with all the property names we support.""" return ( 'position', 'rotation', 'zoomFactor', 'viewMode', 'orientation', 'continuousMode', 'pageLayoutMode', ) def mask(self, names): """Set properties not listed in names to None.""" for name in self.names(): if name not in names and getattr(self, name) is not None: setattr(self, name, None) return self def get(self, view): """Get the properties of a View.""" self.position = view.position() self.rotation = view.rotation() self.orientation = view.orientation() self.viewMode = view.viewMode() self.zoomFactor = view.zoomFactor() self.continuousMode = view.continuousMode() self.pageLayoutMode = view.pageLayoutMode() return self def set(self, view): """Set all our properties that are not None to a View.""" if self.pageLayoutMode is not None: view.setPageLayoutMode(self.pageLayoutMode) if self.rotation is not None: view.setRotation(self.rotation) if self.orientation is not None: view.setOrientation(self.orientation) if self.continuousMode is not None: view.setContinuousMode(self.continuousMode) if self.viewMode is not None: view.setViewMode(self.viewMode) if self.zoomFactor is not None: if self.viewMode is FixedScale or not view.pageLayout().zoomsToFit(): view.setZoomFactor(self.zoomFactor) if self.position is not None: view.setPosition(self.position, False) return self def save(self, settings): """Save the properties that are not None to a QSettings group.""" if self.pageLayoutMode is not None: settings.setValue("pageLayoutMode", self.pageLayoutMode) else: settings.remove("pageLayoutMode") if self.rotation is not None: settings.setValue("rotation", self.rotation) else: settings.remove("rotation") if self.orientation is not None: settings.setValue("orientation", self.orientation) else: settings.remove("orientation") if self.continuousMode is not None: settings.setValue("continuousMode", self.continuousMode) else: settings.remove("continuousMode") if self.viewMode is not None: settings.setValue("viewMode", self.viewMode) else: settings.remove("viewMode") if self.zoomFactor is not None: settings.setValue("zoomFactor", self.zoomFactor) else: settings.remove("zoomFactor") if self.position is not None: settings.setValue("position/pageNumber", self.position.pageNumber) settings.setValue("position/x", self.position.x) settings.setValue("position/y", self.position.y) else: settings.remove("position") return self def load(self, settings): """Load the properties from a QSettings group.""" if settings.contains("pageLayoutMode"): v = settings.value("pageLayoutMode", "", str) if v: self.pageLayoutMode = v if settings.contains("rotation"): v = settings.value("rotation", -1, int) if v in (Rotate_0, Rotate_90, Rotate_180, Rotate_270): self.rotation = v if settings.contains("orientation"): v = settings.value("orientation", 0, int) if v in (Horizontal, Vertical): self.orientation = v if settings.contains("continuousMode"): v = settings.value("continuousMode", True, bool) self.continuousMode = v if settings.contains("viewMode"): v = settings.value("viewMode", -1, int) if v in (FixedScale, FitHeight, FitWidth, FitBoth): self.viewMode = v if settings.contains("zoomFactor"): v = settings.value("zoomFactor", 0, float) if v: self.zoomFactor = v if settings.contains("position/pageNumber"): pageNumber = settings.value("position/pageNumber", -1, int) if pageNumber != -1: x = settings.value("position/x", 0.0, float) y = settings.value("position/y", 0.0, float) self.position = Position(pageNumber, x, y) return self class DocumentPropertyStore: """Store ViewProperties (settings) on a per-Document basis. If you create a DocumentPropertyStore and install it in the documentPropertyStore attribute of a View, the View will automatically remember its settings for earlier displayed Document instances. """ default = None mask = None def __init__(self): self._properties = weakref.WeakKeyDictionary() def get(self, document): """Get the View properties stored for the document, if available. If a ViewProperties instance is stored in the `default` attribute, it is returned when no properties were available. Otherwise, None is returned. """ props = self._properties.get(document) if props is None: if self.default: props = self.default if self.mask: props = props.copy().mask(self.mask) return props def set(self, document, properties): """Store the View properties for the document. If the `mask` attribute is set to a list or tuple of names, only the listed properties are remembered. """ if self.mask: properties.mask(self.mask) self._properties[document] = properties qpageview-0.6.2/qpageview/viewactions.py000066400000000000000000000602051423465244600204210ustar00rootroot00000000000000# -*- coding: utf-8 -*- # # This file is part of the qpageview package. # # Copyright (c) 2019 - 2019 by Wilbert Berendsen # # This program is free software; you can redistribute it and/or # modify it under the terms of the GNU General Public License # as published by the Free Software Foundation; either version 2 # of the License, or (at your option) any later version. # # This program is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU General Public License for more details. # # You should have received a copy of the GNU General Public License # along with this program; if not, write to the Free Software # Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA # See http://www.gnu.org/licenses/ for more information. """ ViewActions provides QActions to control a View. """ import weakref from PyQt5.QtCore import pyqtSignal, QObject, Qt from PyQt5.QtGui import QKeySequence from PyQt5.QtWidgets import ( QAction, QActionGroup, QApplication, QComboBox, QLabel, QSpinBox, QWidgetAction) from . import util from .constants import * class ViewActions(QObject): """ViewActions provides QActions to control a View. Use setView() to connect the actions with a View. If no View is connected, and an action is used; the viewRequested signal is emitted. You can connect this signal and call setView() in the called slot; the action is then performed on the View. The attribute `smartLayoutOrientationEnabled` (defaulting to True) enables some intuitive behaviour: if set to True, for layout modes that do not make sense in horizontal mode the orientation is automatically set to Vertical; and when the user chooses Horizontal orientation in such modes, the layout mode is set to "single". """ smartLayoutOrientationEnabled = True viewRequested = pyqtSignal() def __init__(self, *args, **kwargs): """Create the actions. Does not yet connect anything, use setView() for that. """ super().__init__(*args, **kwargs) self._view = lambda: None self.createActions() self.connectActions() self.setActionTexts() self.setActionIcons() self.setActionShortcuts() def setView(self, view): """Connects all the actions to the View. Use None to set no view. If a view was previously set, all connections are removed from that View. """ old = self._view() if old == view: return if old: old.viewModeChanged.disconnect(self.updateViewModeActions) old.zoomFactorChanged.disconnect(self.updateZoomActions) old.pageLayoutModeChanged.disconnect(self.updatePageLayoutModeActions) old.orientationChanged.disconnect(self.updateActions) old.continuousModeChanged.disconnect(self.updateActions) old.currentPageNumberChanged.disconnect(self.updatePagerActions) old.pageCountChanged.disconnect(self.updatePagerActions) if view: view.viewModeChanged.connect(self.updateViewModeActions) view.zoomFactorChanged.connect(self.updateZoomActions) view.pageLayoutModeChanged.connect(self.updatePageLayoutModeActions) view.orientationChanged.connect(self.updateActions) view.continuousModeChanged.connect(self.updateActions) view.currentPageNumberChanged.connect(self.updatePagerActions) view.pageCountChanged.connect(self.updatePagerActions) self._view = weakref.ref(view) self.updateActions() self.updateViewModeActions(view.viewMode()) self.updateZoomActions(view.zoomFactor()) self.updatePagerActions() else: self._view = lambda: None def view(self): """Return the View. If no View is set, viewRequested is emitted. You can connect to this signal to create a View, and call setView() to use it to perform the requested action. """ view = self._view() if not view: self.viewRequested.emit() return self._view() @staticmethod def names(): """Return a tuple of all the names of the actions we support.""" return ( 'print', 'fit_width', 'fit_height', 'fit_both', 'zoom_natural', 'zoom_original' 'zoom_in', 'zoom_out', 'zoomer', 'rotate_left', 'rotate_right', 'layout_single', 'layout_double_right', 'layout_double_left', 'layout_raster', 'vertical', 'horizontal', 'continuous', 'reload', 'previous_page', 'next_page', 'pager', 'magnifier', ) def createActions(self): """Creates the actions; called by __init__().""" self.print = QAction(self) self._viewMode = QActionGroup(self) self.fit_width = QAction(self._viewMode, checkable=True) self.fit_height = QAction(self._viewMode, checkable=True) self.fit_both = QAction(self._viewMode, checkable=True) self.zoom_natural = QAction(self) self.zoom_original = QAction(self) self.zoom_in = QAction(self) self.zoom_out = QAction(self) self.zoomer = ZoomerAction(self) self.rotate_left = QAction(self) self.rotate_right = QAction(self) self._pageLayoutMode = QActionGroup(self) self.layout_single = QAction(self._pageLayoutMode, checkable=True) self.layout_double_right = QAction(self._pageLayoutMode, checkable=True) self.layout_double_left = QAction(self._pageLayoutMode, checkable=True) self.layout_raster = QAction(self._pageLayoutMode, checkable=True) self._orientation = QActionGroup(self) self.vertical = QAction(self._orientation, checkable=True) self.horizontal = QAction(self._orientation, checkable=True) self.continuous = QAction(self, checkable=True) self.reload = QAction(self) self.previous_page = QAction(self) self.next_page = QAction(self) self.pager = PagerAction(self) self.magnifier = QAction(self, checkable=True) def updateFromProperties(self, properties): """Set the actions to the state stored in the given ViewProperties.""" if properties.pageLayoutMode is not None: self.updatePageLayoutModeActions(properties.pageLayoutMode) if properties.orientation is not None: self.vertical.setChecked(properties.orientation == Vertical) self.horizontal.setChecked(properties.orientation == Horizontal) if properties.continuousMode is not None: self.continuous.setChecked(properties.continuousMode) if properties.zoomFactor is not None: self.updateZoomActions(properties.zoomFactor) if properties.viewMode is not None: self.updateViewModeActions(properties.viewMode) def connectActions(self): """Connect our actions with our methods. Called by __init__().""" self.print.triggered.connect(self.slotPrint) self._viewMode.triggered.connect(self.slotViewMode) self.zoom_natural.triggered.connect(self.slotZoomNatural) self.zoom_original.triggered.connect(self.slotZoomOriginal) self.zoom_in.triggered.connect(self.slotZoomIn) self.zoom_out.triggered.connect(self.slotZoomOut) self.zoomer.zoomFactorChanged.connect(self.slotZoomFactor) self.zoomer.viewModeChanged.connect(self.slotZoomViewMode) self.rotate_left.triggered.connect(self.slotRotateLeft) self.rotate_right.triggered.connect(self.slotRotateRight) self._pageLayoutMode.triggered.connect(self.slotPageLayoutMode) self._orientation.triggered.connect(self.slotOrientation) self.continuous.triggered.connect(self.slotContinuousMode) self.reload.triggered.connect(self.slotReload) self.previous_page.triggered.connect(self.slotPreviousPage) self.next_page.triggered.connect(self.slotNextPage) self.pager.currentPageNumberChanged.connect(self.slotSetPageNumber) self.magnifier.triggered.connect(self.slotMagnifier) def updateActions(self): """Update the state of the actions not handled in the other update methods.""" view = self.view() if not view: return self.print.setEnabled(view.pageCount() > 0) self.vertical.setChecked(view.orientation() == Vertical) self.horizontal.setChecked(view.orientation() == Horizontal) self.continuous.setChecked(view.continuousMode()) self.magnifier.setEnabled(bool(view.magnifier())) self.magnifier.setChecked(bool(view.magnifier() and view.magnifier().isVisible())) def updatePageLayoutModeActions(self, mode): """Update the state of the layout mode actions.""" self.layout_single.setChecked(mode == "single") self.layout_double_left.setChecked(mode == "double_left") self.layout_double_right.setChecked(mode == "double_right") self.layout_raster.setChecked(mode == "raster") def updateViewModeActions(self, mode): """Update the state of view mode related actions.""" self.fit_width.setChecked(mode == FitWidth) self.fit_height.setChecked(mode == FitHeight) self.fit_both.setChecked(mode == FitBoth) self.zoomer.setViewMode(mode) def updateZoomActions(self, factor): """Update the state of zoom related actions.""" self.zoomer.setZoomFactor(factor) def updatePagerActions(self): """Update the state of paging-related actions.""" view = self.view() if not view: return self.pager.setPageCount(view.pageCount()) self.pager.updateCurrentPageNumber(view.currentPageNumber()) self.pager.setEnabled(view.pageCount() > 0) self.previous_page.setEnabled(view.currentPageNumber() > 1) self.next_page.setEnabled(view.currentPageNumber() < view.pageCount()) def setActionTexts(self, _=None): """Set a default text to all the actions, you may override or translate them. You may also set tooltip or whatsthis text in this method. """ if _ is None: _ = lambda t: t self.print.setText(_("&Print...")) self.fit_width.setText(_("Fit &Width")) self.fit_height.setText(_("Fit &Height")) self.fit_both.setText(_("Fit &Page")) self.zoom_natural.setText(_("&Natural Size")) self.zoom_original.setText(_("Original &Size")) self.zoom_in.setText(_("Zoom &In")) self.zoom_out.setText(_("Zoom &Out")) self.zoomer.setViewModes(( # L10N: "Width" as in "Fit Width" (display in zoom menu) (FitWidth, _("Width")), # L10N: "Height" as in "Fit Height" (display in zoom menu) (FitHeight, _("Height")), # L10N: "Page" as in "Fit Page" (display in zoom menu) (FitBoth, _("Page")), )) self.rotate_left.setText(_("Rotate &Left")) self.rotate_right.setText(_("Rotate &Right")) self.layout_single.setText(_("Single Pages")) self.layout_double_right.setText(_("Two Pages (first page right)")) self.layout_double_left.setText(_("Two Pages (first page left)")) self.layout_raster.setText(_("Raster")) self.vertical.setText(_("Vertical")) self.horizontal.setText(_("Horizontal")) self.continuous.setText(_("&Continuous")) self.reload.setText(_("Re&load View")) self.previous_page.setText(_("Previous Page")) self.previous_page.setIconText(_("Previous")) self.next_page.setText(_("Next Page")) self.next_page.setIconText(_("Next")) self.magnifier.setText(_("Magnifier")) def setActionIcons(self): """Implement this method to set icons to the actions.""" pass def setActionShortcuts(self): """Implement this method to set keyboard shortcuts to the actions.""" self.print.setShortcuts(QKeySequence.Print) self.zoom_in.setShortcuts(QKeySequence.ZoomIn) self.zoom_out.setShortcuts(QKeySequence.ZoomOut) self.reload.setShortcut(QKeySequence(Qt.Key_F5)) def slotPrint(self): view = self.view() if view: view.print() def slotViewMode(self, action): view = self.view() if view: viewMode = FitWidth if action == self.fit_width else \ FitHeight if action == self.fit_height else \ FitBoth view.setViewMode(viewMode) def slotZoomNatural(self): view = self.view() if view: view.zoomNaturalSize() def slotZoomOriginal(self): view = self.view() if view: view.setZoomFactor(1.0) def slotZoomIn(self): view = self.view() if view: view.zoomIn() def slotZoomOut(self): view = self.view() if view: view.zoomOut() def slotZoomViewMode(self, mode): view = self.view() if view: view.setViewMode(mode) def slotZoomFactor(self, factor): view = self.view() if view: view.setZoomFactor(factor) def slotRotateLeft(self): view = self.view() if view: view.rotateLeft() def slotRotateRight(self): view = self.view() if view: view.rotateRight() def slotPageLayoutMode(self, action): view = self.view() if view: mode = "single" if action == self.layout_single else \ "double_left" if action == self.layout_double_left else \ "double_right" if action == self.layout_double_right else \ "raster" view.setPageLayoutMode(mode) if self.smartLayoutOrientationEnabled: if mode in ("double_left", "double_right"): view.setOrientation(Vertical) def slotOrientation(self, action): view = self.view() if view: orientation = Vertical if action == self.vertical else Horizontal view.setOrientation(orientation) if self.smartLayoutOrientationEnabled: if orientation == Horizontal and \ view.pageLayoutMode() in ("double_left", "double_right"): view.setPageLayoutMode("single") def slotContinuousMode(self): view = self.view() if view: view.setContinuousMode(self.continuous.isChecked()) def slotReload(self): view = self.view() if view: view.reload() def slotPreviousPage(self): view = self.view() if view: view.gotoPreviousPage() def slotNextPage(self): view = self.view() if view: view.gotoNextPage() def slotSetPageNumber(self, num): view = self.view() if view: view.setCurrentPageNumber(num) def slotMagnifier(self): view = self._view() # do not trigger creation if view and view.magnifier(): view.magnifier().setVisible(self.magnifier.isChecked()) class PagerAction(QWidgetAction): """PagerAction shows a spinbox widget with the current page number. When the current page number is changed (by the user or by calling setCurrentPageNumber()) the signal currentPageNumberChanged() is emitted with the new current page number. You can use the instance or class attributes buttonSymbols, focusPolicy and the displayFormat() method to influence behaviour and appearance of the spinbox widget(s) that is/are created when this action is added to a toolbar. The displayFormat string should contain the text "{num}". You can also include the string "{total}", so the page count is displayed as well. """ currentPageNumberChanged = pyqtSignal(int) buttonSymbols = QSpinBox.NoButtons focusPolicy = Qt.ClickFocus def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) self._currentPage = 0 self._pageCount = 0 self._displayFormat = "{num} of {total}" def createWidget(self, parent): w = QSpinBox(parent, buttonSymbols=self.buttonSymbols) w.setFocusPolicy(self.focusPolicy) self._adjustSpinBox(w) if self._currentPage: w.setValue(self._currentPage) w.valueChanged[int].connect(self.setCurrentPageNumber) return w def setButtonSymbols(self, buttonSymbols): """Set the ``buttonSymbols`` property, and update already existing widgets.""" self.buttonSymbols = buttonSymbols for w in self.createdWidgets(): w.setButtonSymbols(buttonSymbols) def displayFormat(self): """Return the currently active display format string.""" return self._displayFormat def setDisplayFormat(self, displayFormat): """Set the display format string to use. The default is "{num} of {total}". """ assert "{num}" in displayFormat if displayFormat != self._displayFormat: self._displayFormat = displayFormat self._updateDisplay() def pageCount(self): """Return the currently set page count.""" return self._pageCount def setPageCount(self, pageCount): """Set the page count.""" if pageCount != self._pageCount: self._pageCount = pageCount if pageCount: self._currentPage = max(1, min(self._currentPage, pageCount)) else: self._currentPage = 0 self._updateDisplay() def currentPageNumber(self): """Return the current page number.""" return self._currentPage def setCurrentPageNumber(self, num): """Set our current page number.""" if num and num != self._currentPage: self.updateCurrentPageNumber(num) self.currentPageNumberChanged.emit(num) def updateCurrentPageNumber(self, num): """Set our current page number, but without emitting the signal.""" if num and num != self._currentPage: self._currentPage = num for w in self.createdWidgets(): w.setValue(num) w.lineEdit().deselect() def _adjustSpinBox(self, widget): """Update the display of the individual spinbox.""" if self._pageCount: if "{num}" in self._displayFormat: prefix, suffix = self._displayFormat.split('{num}', 1) else: prefix, suffix = "", "" widget.setSpecialValueText("") widget.setRange(1, self._pageCount) widget.setSuffix(suffix.format(total=self._pageCount)) widget.setPrefix(prefix.format(total=self._pageCount)) else: widget.setSpecialValueText(" ") widget.setRange(0, 0) widget.setSuffix("") widget.setPrefix("") widget.clear() def _updateDisplay(self): """Update the display in the pager. This is called when the page count or the display format string is changed. """ for w in self.createdWidgets(): self._adjustSpinBox(w) class ZoomerAction(QWidgetAction): """ZoomerAction provides a combobox with view modes and zoom factors.""" zoomFactorChanged = pyqtSignal(float) viewModeChanged = pyqtSignal(int) def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) self._zoomFactor = 1.0 self._viewMode = FixedScale self._viewModes = ( (FitWidth, "Width"), (FitHeight, "Height"), (FitBoth, "Page"), ) self._zoomFactors = ( 0.5, 0.75, 1.0, 1.25, 1.5, 2.0, 3.0, 8.0, 24.0, 64.0, ) self._zoomFormat = "{0:.0%}" def viewModes(self): """Return the view modes that are displayed in the combobox. See setViewModes() for explanation. """ return self._viewModes def setViewModes(self, modes): """Set the view modes to display on top of the zoom values in the box. An iterable of tuples (mode, name) is expected; every mode is a viewMode, the name is displayed. By default modes 1, 2 and 3 are displayed with the names "Width", "Height", "Page". """ self._viewModes = tuple(modes) self._setupComboBoxes() def zoomFactors(self): """Return the zoom factors that are displayed in the combobox. A zoom factor of 100% is represented by a floating point value of 1.0. """ return self._zoomFactors def setZoomFactors(self, factors): """Set the zoom factors to display in the combobox. A zoom factor of 100% is represented by a floating point value of 1.0. """ self._zoomFactors = tuple(factors) self._setupComboBoxes() def zoomFormat(self): """Return the format string used to display zoom factors.""" return self._zoomFormat def setZoomFormat(self, zoomFormat): """Set the format string used to display zoom factors.""" self._zoomFormat = zoomFormat self._setupComboBoxes() def createWidget(self, parent): w = QComboBox(parent) w.setSizeAdjustPolicy(QComboBox.AdjustToContents) w.setEditable(True) w.lineEdit().setReadOnly(True) w.setFocusPolicy(Qt.NoFocus) self._setupComboBox(w) self._adjustComboBox(w) w.activated[int].connect(self.setCurrentIndex) return w def viewMode(self): """Return the current view mode.""" return self._viewMode def setViewMode(self, mode): """Set the current view mode.""" if mode != self._viewMode: self._viewMode = mode self.viewModeChanged.emit(mode) self._adjustComboBoxes() def zoomFactor(self): """Return the current zoom factor.""" return self._zoomFactor def setZoomFactor(self, factor): """Set the current zoom factor.""" if factor != self._zoomFactor: self._zoomFactor = factor self.zoomFactorChanged.emit(factor) self._adjustComboBoxes() def setCurrentIndex(self, index): """Called when the user chooses an entry in a combobox.""" viewModeCount = len(self._viewModes) if index < viewModeCount: self.setViewMode(self._viewModes[index][0]) else: self.setZoomFactor(self._zoomFactors[index - viewModeCount]) def _setupComboBoxes(self): """Update the contents and current setting of all comboboxes. Called after setting view modes and zoom values. """ for w in self.createdWidgets(): with util.signalsBlocked(w): self._setupComboBox(w) self._adjustComboBox(w) def _adjustComboBoxes(self): """Adjust the current setting (zoom/viewmode) of all comboboxes. Called when current zoom or view mode changes. """ for w in self.createdWidgets(): with util.signalsBlocked(w): self._adjustComboBox(w) def _setupComboBox(self, w): """Put the entries in the (new) QComboBox widget.""" w.clear() for mode, name in self._viewModes: w.addItem(name) for v in self._zoomFactors: w.addItem(self._zoomFormat.format(v)) def _adjustComboBox(self, w): """Select the current index based on our zoomFactor and view mode.""" for i, (mode, name) in enumerate(self._viewModes): if mode == self._viewMode: w.setCurrentIndex(i) break else: if self._zoomFactor in self._zoomFactors: i = self._zoomFactors.index(self._zoomFactor) + len(self._viewModes) w.setCurrentIndex(i) else: w.setEditText(self._zoomFormat.format(self._zoomFactor)) qpageview-0.6.2/qpageview/widgetoverlay.py000066400000000000000000000140421423465244600207510ustar00rootroot00000000000000# -*- coding: utf-8 -*- # # This file is part of the qpageview package. # # Copyright (c) 2019 - 2019 by Wilbert Berendsen # # This program is free software; you can redistribute it and/or # modify it under the terms of the GNU General Public License # as published by the Free Software Foundation; either version 2 # of the License, or (at your option) any later version. # # This program is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU General Public License for more details. # # You should have received a copy of the GNU General Public License # along with this program; if not, write to the Free Software # Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA # See http://www.gnu.org/licenses/ for more information. """ View mixin class to display QWidgets on top of a Page. """ import collections from PyQt5.QtCore import QPoint, QRect, Qt from . import constants OverlayData = collections.namedtuple("OverlayData", "page point rect alignment") class WidgetOverlayViewMixin: """Mixin class to add widgets to be displayed on top of pages. Widgets are added using addWidget(), and become children of the viewport. This class adds the following instance attribute: deleteUnusedOverlayWidgets = True If True, unused widgets are deleted using QObject.deleteLater(). Otherwise, only the parent is set to None. A widget becomes unused if the Page it was added to disappears from the page layout. """ deleteUnusedOverlayWidgets = True def __init__(self, parent=None): self._widgets = {} super().__init__(parent) def addWidget(self, widget, page, where=None, alignment=None): """Add widget to be displayed on top of page. The widget becomes a child of the viewport. The `where` argument can be a QPoint or a QRect. If a rect is given, the widget is resized to occupy that rectangle. The rect should be in page coordinates. When the zoom factor is changed, the widget will be resized. If a point is given, the widget is not resized and aligned on the point using the specified alignment (top-left if None). If where is None, the widget occupies the whole page. You can also use this method to change the page or rect for a widget that already has been added. """ if not alignment: alignment = Qt.AlignTop | Qt.AlignLeft # translate rect to original coordinates rect = None point = None if where is not None: if isinstance(where, QPoint): point = page.mapFromPage().point(where) else: rect = page.mapFromPage().rect(where) else: rect = page.pageRect() widget.setParent(self.viewport()) self._widgets[widget] = OverlayData(page, point, rect, alignment) self._updateWidget(widget) widget.setVisible(page in set(self.visiblePages())) def removeWidget(self, widget): """Remove the widget. The widget is not deleted, but its parent is set to None. """ try: del self._widgets[widget] except KeyError: pass else: widget.setParent(None) def widgets(self, page=None): """Yield all widgets (for the Page if given).""" if page: for widget, d in self._widgets.items(): if d.page is page: yield widget else: for widget in self._widgets: yield widget def removeWidgets(self, page=None): """Remove all widgets (for the Page if given). The widget are not deleted, but their parent is set to None. """ if page: for widget in list(self.widgets(page)): widget.setParent(None) del self._widgets[widget] else: for widget in self._widgets: widget.setParent(None) self._widgets.clear() def _updateWidget(self, widget): """Internal. Updates size and position of the specified widget.""" d = self._widgets[widget] pos = self.layoutPosition() + d.page.pos() if d.point: point = pos + d.page.mapToPage().point(d.point) geom = util.alignrect(widget.geometry(), point, d.alignment) else: # d.rect: rect = d.page.mapToPage().rect(d.rect) geom = rect.translated(pos) widget.setGeometry(geom) def _updateWidgets(self): """Internal. Updates size and position of the widgets.""" pages = set(self.visiblePages()) remove = [] for widget, d in self._widgets.items(): if d.page in self.pageLayout(): self._updateWidget(widget) widget.setVisible(d.page in pages) else: remove.append(widget) # remove widgets that are not used anymore for w in remove: w.setParent(None) del self._widgets[w] if self.deleteUnusedOverlayWidgets: for w in remove: w.deleteLater() def updatePageLayout(self, lazy=False): """Reimplemented to update the size and position of the widgets.""" super().updatePageLayout(lazy) self._updateWidgets() def scrollContentsBy(self, dx, dy): """Reimplemented to scroll the page widgets along with the layout.""" super().scrollContentsBy(dx, dy) d = QPoint(dx, dy) for widget in self._widgets.keys(): widget.move(widget.pos() + d) def resizeEvent(self, ev): """Reimplemented to keep page widgets in the right position.""" super().resizeEvent(ev) # in fixed scale mode, call _updateWidgets(). In other view modes, # updatePageLayout() is called which calls _updateWidgets() anyway. if self.viewMode() == constants.FixedScale: self._updateWidgets() qpageview-0.6.2/setup.py000066400000000000000000000043101423465244600152310ustar00rootroot00000000000000# -*- coding: utf-8 -*- # # This file is part of the qpageview package. # # Copyright © 2019-2020 by Wilbert Berendsen # # This module is free software: you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by # the Free Software Foundation, either version 3 of the License, or # (at your option) any later version. # # This module is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU General Public License for more details. # # You should have received a copy of the GNU General Public License # along with this program. If not, see . """ Setup script. """ import os try: from setuptools import setup except ImportError: from distutils.core import setup from qpageview import pkginfo def packagelist(directory): """Return a sorted list with package names for all packages under the given directory.""" folder, basename = os.path.split(directory) return list(sorted(root[len(folder)+1:].replace(os.sep, '.') for root, dirs, files in os.walk(directory) if '__init__.py' in files)) scripts = [] packages = packagelist('./qpageview') py_modules = [] with open('README.rst', encoding="utf-8") as f: long_description = f.read() package_data = { } classifiers = [ 'Development Status :: 5 - Production/Stable', 'Intended Audience :: Developers', 'Topic :: Multimedia :: Graphics', 'License :: OSI Approved :: GNU General Public License v3 or later (GPLv3+)', 'Operating System :: MacOS :: MacOS X', 'Operating System :: Microsoft :: Windows', 'Operating System :: POSIX', 'Programming Language :: Python :: 3.6', ] setup( name = pkginfo.name, version = pkginfo.version_string, description = pkginfo.description, long_description = long_description, maintainer = pkginfo.maintainer, maintainer_email = pkginfo.maintainer_email, url = pkginfo.url, license = pkginfo.license, scripts = scripts, packages = packages, package_data = package_data, py_modules = py_modules, classifiers = classifiers, )