Compare commits

15 Commits

Author SHA1 Message Date
ee650f2b71 version: knoxmakers-inkscape-20260122
All checks were successful
bundle / bundle (push) Successful in 13s
2026-01-22 15:00:59 +00:00
a5ddc33a27 bundle: update (2026-01-22)
All checks were successful
version / update-version (release) Successful in 13s
2026-01-22 15:00:45 +00:00
a9c87f2adf create version file for future use 2026-01-22 09:58:10 -05:00
5692f4bb08 force new release 2026-01-22 09:55:57 -05:00
9af24d49ee Merge branch 'main' of ssh://git.knoxmakers.org:2222/KnoxMakers/knoxmakers-inkscape 2026-01-22 09:53:29 -05:00
7211fd44ef release changes 2026-01-22 09:53:22 -05:00
b72b468ad4 bundle: update (2026-01-19)
All checks were successful
bundle / bundle (push) Successful in 18s
2026-01-19 09:23:04 +00:00
8a126fec7d bundle: update (2026-01-18)
All checks were successful
bundle / bundle (push) Successful in 16s
2026-01-18 09:23:06 +00:00
e0dc82c22d Merge branch 'main' of ssh://git.knoxmakers.org:2222/KnoxMakers/knoxmakers-inkscape
All checks were successful
bundle / bundle (push) Successful in 15s
2026-01-17 23:53:24 -05:00
c2fa54d3b4 readme 2026-01-17 23:53:16 -05:00
7597f2f156 bundle: update (2026-01-18) 2026-01-18 04:47:51 +00:00
822898e4a3 add km-hershey 2026-01-17 23:45:12 -05:00
22a55924e3 friendlier no-update 2026-01-17 22:04:59 -05:00
cf789ba0b8 Merge branch 'main' of ssh://git.knoxmakers.org:2222/KnoxMakers/knoxmakers-inkscape 2026-01-17 22:02:51 -05:00
74011e30ba rename releases/tags 2026-01-17 22:02:44 -05:00
232 changed files with 31254 additions and 5 deletions

View File

@@ -45,14 +45,15 @@ jobs:
git push origin HEAD:main git push origin HEAD:main
# Create a unique tag for this bundle run # Create a unique tag for this bundle run
TAG="bundle-$(date -u +%Y%m%d-%H%M%S)" DATETIME="$(date -u +%Y%m%d)"
TAG="knoxmakers-inkscape-${DATETIME}"
SHA="$(git rev-parse HEAD)" SHA="$(git rev-parse HEAD)"
git tag -a "$TAG" -m "Automated bundle: $TAG ($SHA)" git tag -a "$TAG" -m "Automated bundle: $TAG ($SHA)"
git push origin "$TAG" git push origin "$TAG"
# Build a release artifact: include everything except scripts/ and .gitea/ and .git/ # Build a release artifact: include everything except scripts/ and .gitea/ and .git/
ART="knoxmakers-inkscape-${TAG}.zip" ART="${TAG}.zip"
rm -f "$ART" rm -f "$ART"
zip -r "$ART" . \ zip -r "$ART" . \
@@ -66,7 +67,7 @@ jobs:
CREATE_JSON="$(jq -n \ CREATE_JSON="$(jq -n \
--arg tag "$TAG" \ --arg tag "$TAG" \
--arg name "$TAG" \ --arg name "$TAG" \
--arg body "Automated bundle release.\n\nExcludes: scripts/, .gitea/\nCommit: $SHA\nUTC: $(date -u +'%Y-%m-%d %H:%M:%S')" \ --arg body "Automated bundle release." \
'{tag_name:$tag, name:$name, body:$body, draft:false, prerelease:false}')" '{tag_name:$tag, name:$name, body:$body, draft:false, prerelease:false}')"
RELEASE_RESP="$(curl -fsS -X POST "$API_CREATE" \ RELEASE_RESP="$(curl -fsS -X POST "$API_CREATE" \

View File

@@ -0,0 +1,45 @@
name: version
on:
release:
types: [published]
jobs:
update-version:
runs-on: [ubuntu-24.04]
steps:
- name: Checkout repository
run: |
set -euo pipefail
rm -rf .git || true
rm -rf ./* ./.??* || true
git clone --depth 1 https://git.knoxmakers.org/KnoxMakers/knoxmakers-inkscape.git .
git checkout main
- name: Configure git identity
run: |
git config user.name "haxbot"
git config user.email "haxbot@knoxmakers.sh"
- name: Write version file
run: |
echo "${{ gitea.event.release.name }}" > version
- name: Commit and push version file
env:
HAXBOT_TOKEN: ${{ secrets.HAXBOT_TOKEN }}
run: |
set -euo pipefail
git add version
if git diff --cached --quiet; then
echo "No changes to version file"
exit 0
fi
git commit -m "version: ${{ gitea.event.release.name }}"
REPO_URL="https://x-access-token:${HAXBOT_TOKEN}@git.knoxmakers.org/KnoxMakers/knoxmakers-inkscape.git"
git remote set-url origin "$REPO_URL"
git push origin HEAD:main

View File

@@ -1,2 +1,27 @@
# knoxmakers-inkscape # knoxmakers-inkscape
A bundled collection of Inkscape extensions for Knox Makers makerspace.
## Installation
Download the latest release zip and extract into to your Inkscape extensions directory:
- **Linux**: `~/.config/inkscape/extensions/`
- **Linux (Flatpak)**: `~/.var/app/org.inkscape.Inkscape/config/inkscape/extensions/`
- **Linux (Snap)**: `~/snap/inkscape/current/.config/inkscape/extensions/`
- **macOS**: `~/Library/Application Support/org.inkscape.Inkscape/config/inkscape/extensions/`
- **Windows**: `%APPDATA%\inkscape\extensions\`
## Included Extensions
- **botbox3000** - Box generator for laser cutting
- **km-living-hinge** - Living hinge pattern generator
- **km-plot** - Detect and send designs to serial HPGL plotters/vinyl cutters
- **km-hatch** - Hatching/fill patterns
- **km-hershey** - Single Line text for plotting/engraving
Extensions appear under **Extensions > Knox Makers** in Inkscape.
## Releases
Automated bundles are created daily when upstream repositories have changes.

View File

@@ -182,7 +182,7 @@ class Boxbot(inkex.EffectExtension):
self.top_hole_inset = PathElement() self.top_hole_inset = PathElement()
self.top_hole_inset.set_id(self.svg.get_unique_id("top_hole_inset")) self.top_hole_inset.set_id(self.svg.get_unique_id("top_hole_inset"))
self.top_hole_inset.set('d', top_hole_inset_d) self.top_hole_inset.set('d', top_hole_inset_d)
self.top_hole_inset.style = self.CUT_OUTER_STYLE self.top_hole_inset.style = self.CUT_INNER_STYLE
top_tabs_group.append(self.top_hole_inset) top_tabs_group.append(self.top_hole_inset)
except ValueError: except ValueError:
pass pass

View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 148 KiB

View File

@@ -0,0 +1 @@
main

View File

@@ -0,0 +1 @@
b9b0c988e4078c87c7b78820f92fad58dcddaf00

View File

@@ -0,0 +1 @@
https://git.knoxmakers.org/KnoxMakers/km-hershey.git

View File

@@ -0,0 +1,668 @@
GNU GENERAL PUBLIC LICENSE
Version 3, 29 June 2007
Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>
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.
<one line to give the program's name and a brief idea of what it does.>
Copyright (C) <year> <name of author>
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 <https://www.gnu.org/licenses/>.
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:
<program> Copyright (C) <year> <name of author>
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
<https://www.gnu.org/licenses/>.

View File

@@ -0,0 +1,31 @@
# KM Hershey Text
An Inkscape extension for rendering text using single-stroke (engraving) fonts. Designed for pen plotters, laser engravers, and CNC machines where stroke-based fonts produce cleaner results than filled outline fonts. Basically like the built-in hershey text extension but with a generate text option and a few more fonts.
## Manual Installation
1. Create the subdirectory `km-hershey/` in your Inkscape extensions folder:
- **Linux:** `~/.config/inkscape/extensions/`
- **Linux (Flatpak):** `~/.var/app/org.inkscape.Inkscape/config/inkscape/extensions/`
- **Linux (Snap):** `~/snap/inkscape/current/.config/inkscape/extensions/`
- **macOS:** `~/Library/Application Support/org.inkscape.Inkscape/config/inkscape/extensions/`
- **Windows:** `%APPDATA%\inkscape\extensions\`
2. Copy all files from this repository into your `km-hershey/` directory.
3. Restart Inkscape. The extension appears under **Extensions > Knox Makers > Laser > Hershey Text**.
## Acknowledgements
Inspiration, examples, and code came from:
The now Inkscape built-in hershey text extension
https://gitlab.com/inkscape/extensions/-/blob/master/hershey.py
Which originally came from from Evil Mad Scientist Laboratories
https://github.com/evil-mad/
Based on Hershey fonts created for plotters
https://en.wikipedia.org/wiki/Hershey_fonts
Cutlings
https://cutlings.datafil.no/

View File

@@ -0,0 +1,33 @@
# coding=utf-8
"""
This describes the core API for the inkex core modules.
This provides the basis from which you can develop your inkscape extension.
"""
# pylint: disable=wildcard-import
import sys
from .extensions import *
from .utils import AbortExtension, DependencyError, Boolean, errormsg
from .styles import *
from .paths import Path, CubicSuperPath # Path commands are not exported
from .colors import Color, ColorError, ColorIdError, is_color
from .colors.spaces import *
from .transforms import *
from .elements import *
# legacy proxies
from .deprecated import Effect
from .deprecated import localize
from .deprecated import debug
# legacy functions
from .deprecated import are_near_relative
from .deprecated import unittouu
MIN_VERSION = (3, 7)
if sys.version_info < MIN_VERSION:
sys.exit("Inkscape extensions require Python 3.7 or greater.")
__version__ = "1.4.0" # Version number for inkex; may differ from Inkscape version.

View File

@@ -0,0 +1,567 @@
# coding=utf-8
#
# Copyright (c) 2018 - Martin Owens <doctormo@gmail.com>
#
# 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 Street, Fifth Floor, Boston, MA 02110-1301, USA.
#
"""
The ultimate base functionality for every Inkscape extension.
"""
import io
import os
import re
import sys
import copy
from typing import (
Dict,
List,
Tuple,
Type,
Optional,
Callable,
Any,
Union,
IO,
TYPE_CHECKING,
cast,
)
from argparse import ArgumentParser, Namespace
from lxml import etree
from .utils import filename_arg, AbortExtension, ABORT_STATUS, errormsg, do_nothing
from .elements._parser import load_svg
from .elements._utils import NSS
from .localization import localize
if TYPE_CHECKING:
from .elements._svg import SvgDocumentElement
from .elements._base import BaseElement
class InkscapeExtension:
"""
The base class extension, provides argument parsing and basic
variable handling features.
"""
multi_inx = False # Set to true if this class is used by multiple inx files.
extra_nss = {} # type: Dict[str, str]
# Provide a unique value to allow detection of no argument specified
# for `output` parameter of `run()`, not even `None`; this has to be an io
# type for type checking purposes:
output_unspecified = io.StringIO("")
def __init__(self):
# type: () -> None
NSS.update(self.extra_nss)
self.file_io = None # type: Optional[IO]
self.options = Namespace()
self.document = None # type: Union[None, bytes, str, etree.element]
self.arg_parser = ArgumentParser(description=self.__doc__)
self.arg_parser.add_argument(
"input_file",
nargs="?",
metavar="INPUT_FILE",
type=filename_arg,
help="Filename of the input file (default is stdin)",
default=None,
)
self.arg_parser.add_argument(
"--output",
type=str,
default=None,
help="Optional output filename for saving the result (default is stdout).",
)
self.add_arguments(self.arg_parser)
localize()
def add_arguments(self, pars):
# type: (ArgumentParser) -> None
"""Add any extra arguments to your extension handle, use:
def add_arguments(self, pars):
pars.add_argument("--num-cool-things", type=int, default=3)
pars.add_argument("--pos-in-doc", type=str, default="doobry")
"""
# No extra arguments by default so super is not required
def parse_arguments(self, args):
# type: (List[str]) -> None
"""Parse the given arguments and set 'self.options'"""
self.options = self.arg_parser.parse_args(args)
def arg_method(self, prefix="method"):
# type: (str) -> Callable[[str], Callable[[Any], Any]]
"""Used by add_argument to match a tab selection with an object method
pars.add_argument("--tab", type=self.arg_method(), default="foo")
...
self.options.tab(arguments)
...
.. code-block:: python
.. def method_foo(self, arguments):
.. # do something
"""
def _inner(value):
name = f"""{prefix}_{value.strip('"').lower()}""".replace("-", "_")
try:
return getattr(self, name)
except AttributeError as error:
if name.startswith("_"):
return do_nothing
raise AbortExtension(f"Can not find method {name}") from error
return _inner
@staticmethod
def arg_number_ranges():
"""Parses a number descriptor. e.g:
``1,2,4-5,7,9-`` is parsed to ``1, 2, 4, 5, 7, 9, 10, ..., lastvalue``
.. versionadded:: 1.2
Usage:
.. code-block:: python
# in add_arguments()
pars.add_argument("--pages", type=self.arg_number_ranges(), default=1-)
# later on, pages is then a list of ints
pages = self.options.pages(lastvalue)
"""
def _inner(value):
def method(pages, lastvalue, startvalue=1):
# replace ranges, such as -3, 10- with startvalue,2,3,10..lastvalue
pages = re.sub(
r"(\d+|)\s?-\s?(\d+|)",
lambda m: (
",".join(
map(
str,
range(
int(m.group(1) or startvalue),
int(m.group(2) or lastvalue) + 1,
),
)
)
if not (m.group(1) or m.group(2)) == ""
else ""
),
pages,
)
pages = map(int, re.findall(r"(\d+)", pages))
pages = tuple({i for i in pages if i <= lastvalue})
return pages
return lambda lastvalue, startvalue=1: method(
value, lastvalue, startvalue=startvalue
)
return _inner
@staticmethod
def arg_class(options: List[Type]) -> Callable[[str], Any]:
"""Used by add_argument to match an option with a class
Types to choose from are given by the options list
.. versionadded:: 1.2
Usage:
.. code-block:: python
pars.add_argument("--class", type=self.arg_class([ClassA, ClassB]),
default="ClassA")
"""
def _inner(value: str):
name = value.strip('"')
for i in options:
if name == i.__name__:
return i
raise AbortExtension(f"Can not find class {name}")
return _inner
def debug(self, msg):
# type: (str) -> None
"""Write a debug message"""
errormsg(f"DEBUG<{type(self).__name__}> {msg}\n")
@staticmethod
def msg(msg):
# type: (str) -> None
"""Write a non-error message"""
errormsg(msg)
def run(self, args=None, output=output_unspecified):
# type: (Optional[List[str]], Union[str, IO]) -> None
"""Main entrypoint for any Inkscape Extension"""
try:
if args is None:
args = sys.argv[1:]
self.parse_arguments(args)
if self.options.input_file is None:
self.options.input_file = sys.stdin
elif "DOCUMENT_PATH" not in os.environ:
os.environ["DOCUMENT_PATH"] = self.options.input_file
self.bin_stdout = None
if self.options.output is None:
# If no output was specified, attempt to extract a binary
# output from stdout, and if that doesn't seem possible,
# punt and try whatever stream stdout is:
if output is InkscapeExtension.output_unspecified:
output = sys.stdout
if "b" not in getattr(output, "mode", "") and not isinstance(
output, (io.RawIOBase, io.BufferedIOBase)
):
if hasattr(output, "buffer"):
output = output.buffer # type: ignore
elif hasattr(output, "fileno"):
self.bin_stdout = os.fdopen(
output.fileno(), "wb", closefd=False
)
output = self.bin_stdout
self.options.output = output
self.load_raw()
self.save_raw(self.effect())
except AbortExtension as err:
errormsg(str(err))
sys.exit(ABORT_STATUS)
finally:
self.clean_up()
def load_raw(self):
# type: () -> None
"""Load the input stream or filename, save everything to self"""
if isinstance(self.options.input_file, str):
# pylint: disable=consider-using-with
self.file_io = open(self.options.input_file, "rb")
document = self.load(self.file_io)
else:
document = self.load(self.options.input_file)
self.document = document
def save_raw(self, ret):
# type: (Any) -> None
"""Save to the output stream, use everything from self"""
if self.has_changed(ret):
if isinstance(self.options.output, str):
with open(self.options.output, "wb") as stream:
self.save(stream)
else:
if sys.platform == "win32" and not "PYTEST_CURRENT_TEST" in os.environ:
# When calling an extension from within Inkscape on Windows,
# Python thinks that the output stream is seekable
# (https://gitlab.com/inkscape/inkscape/-/issues/3273)
self.options.output.seekable = lambda self: False
def seek_replacement(offset: int, whence: int = 0):
raise AttributeError(
"We can't seek in the stream passed by Inkscape on Windows"
)
def tell_replacement():
raise AttributeError(
"We can't tell in the stream passed by Inkscape on Windows"
)
# Some libraries (e.g. ZipFile) don't query seekable, but check for an error
# on seek
self.options.output.seek = seek_replacement
self.options.output.tell = tell_replacement
self.save(self.options.output)
def load(self, stream):
# type: (IO) -> str
"""Takes the input stream and creates a document for parsing"""
raise NotImplementedError(f"No input handle for {self.name}")
def save(self, stream):
# type: (IO) -> None
"""Save the given document to the output file"""
raise NotImplementedError(f"No output handle for {self.name}")
def effect(self):
# type: () -> Any
"""Apply some effects on the document or local context"""
raise NotImplementedError(f"No effect handle for {self.name}")
def has_changed(self, ret): # pylint: disable=no-self-use
# type: (Any) -> bool
"""Return true if the output should be saved"""
return ret is not False
def clean_up(self):
# type: () -> None
"""Clean up any open handles and other items"""
if hasattr(self, "bin_stdout"):
if self.bin_stdout is not None:
self.bin_stdout.close()
if self.file_io is not None:
self.file_io.close()
@classmethod
def svg_path(cls, default=None):
# type: (Optional[str]) -> Optional[str]
"""
Return the folder the svg is contained in.
Returns None if there is no file.
.. versionchanged:: 1.1
A default path can be given which is returned in case no path to the
SVG file can be determined.
"""
path = cls.document_path()
if path:
return os.path.dirname(path)
if default:
return default
return path # Return None or '' for context
@classmethod
def ext_path(cls):
# type: () -> str
"""Return the folder the extension script is in"""
return os.path.dirname(sys.modules[cls.__module__].__file__ or "")
@classmethod
def get_resource(cls, name, abort_on_fail=True):
# type: (str, bool) -> str
"""Return the full filename of the resource in the extension's dir
.. versionadded:: 1.1"""
filename = cls.absolute_href(name, cwd=cls.ext_path())
if abort_on_fail and not os.path.isfile(filename):
raise AbortExtension(f"Could not find resource file: {filename}")
return filename
@classmethod
def document_path(cls):
# type: () -> Optional[str]
"""Returns the saved location of the document
* Normal return is a string containing the saved location
* Empty string means the document was never saved
* 'None' means this version of Inkscape doesn't support DOCUMENT_PATH
DO NOT READ OR WRITE TO THE DOCUMENT FILENAME!
* Inkscape may have not written the latest changes, leaving you reading old
data.
* Inkscape will not respect anything you write to the file, causing data loss.
.. versionadded:: 1.1
"""
return os.environ.get("DOCUMENT_PATH", None)
@classmethod
def absolute_href(cls, filename, default="~/", cwd=None):
# type: (str, str, Optional[str]) -> str
"""
Process the filename such that it's turned into an absolute filename
with the working directory being the directory of the loaded svg.
User's home folder is also resolved. So '~/a.png` will be `/home/bob/a.png`
Default is a fallback working directory to use if the svg's filename is not
available.
.. versionchanged:: 1.1
If you set default to None, then the user will be given errors if
there's no working directory available from Inkscape.
"""
filename = os.path.expanduser(filename)
if not os.path.isabs(filename):
filename = os.path.expanduser(filename)
if not os.path.isabs(filename):
if cwd is None:
cwd = cls.svg_path(default)
if cwd is None:
raise AbortExtension(
"Can not use relative path, Inkscape isn't telling us the "
"current working directory."
)
if cwd == "":
raise AbortExtension(
"The SVG must be saved before you can use relative paths."
)
filename = os.path.join(cwd, filename)
return os.path.realpath(os.path.expanduser(filename))
@property
def name(self):
# type: () -> str
"""Return a fixed name for this extension"""
return type(self).__name__
if TYPE_CHECKING:
_Base = InkscapeExtension
else:
_Base = object
class TempDirMixin(_Base): # pylint: disable=abstract-method
"""
Provide a temporary directory for extensions to stash files.
"""
dir_suffix = ""
dir_prefix = "inktmp"
def __init__(self, *args, **kwargs):
self.tempdir = None
self._tempdir = None
super().__init__(*args, **kwargs)
def load_raw(self):
# type: () -> None
"""Create the temporary directory"""
# pylint: disable=import-outside-toplevel
from tempfile import TemporaryDirectory
# Need to hold a reference to the Directory object or else it might get GC'd
self._tempdir = TemporaryDirectory( # pylint: disable=consider-using-with
prefix=self.dir_prefix, suffix=self.dir_suffix
)
self.tempdir = os.path.realpath(self._tempdir.name)
super().load_raw()
def clean_up(self):
# type: () -> None
"""Delete the temporary directory"""
self.tempdir = None
# if the file does not exist, _tempdir is never set.
if self._tempdir is not None:
self._tempdir.cleanup()
super().clean_up()
class SvgInputMixin(_Base): # pylint: disable=too-few-public-methods, abstract-method
"""
Expects the file input to be an svg document and will parse it.
"""
# Select all objects if none are selected
select_all: Tuple[Type["BaseElement"], ...] = ()
def __init__(self):
super().__init__()
self.arg_parser.add_argument(
"--id",
action="append",
type=str,
dest="ids",
default=[],
help="id attribute of object to manipulate",
)
self.arg_parser.add_argument(
"--selected-nodes",
action="append",
type=str,
dest="selected_nodes",
default=[],
help="id:subpath:position of selected nodes, if any",
)
def load(self, stream):
# type: (IO) -> etree
"""Load the stream as an svg xml etree and make a backup"""
document = load_svg(stream)
self.original_document = copy.deepcopy(document)
self.svg: SvgDocumentElement = document.getroot()
self.svg.selection.set(*self.options.ids)
if not self.svg.selection and self.select_all:
self.svg.selection = self.svg.descendants().filter(*self.select_all)
return document
class SvgOutputMixin(_Base): # pylint: disable=too-few-public-methods, abstract-method
"""
Expects the output document to be an svg document and will write an etree xml.
A template can be specified to kick off the svg document building process.
"""
template = """<svg viewBox="0 0 {width} {height}"
width="{width}{unit}" height="{height}{unit}"
xmlns="http://www.w3.org/2000/svg" xmlns:svg="http://www.w3.org/2000/svg"
xmlns:xlink="http://www.w3.org/1999/xlink"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape">
</svg>"""
@classmethod
def get_template(cls, **kwargs):
"""
Opens a template svg document for building, the kwargs
MUST include all the replacement values in the template, the
default template has 'width' and 'height' of the document.
"""
kwargs.setdefault("unit", "")
return load_svg(str(cls.template.format(**kwargs)))
def save(self, stream):
# type: (IO) -> None
"""Save the svg document to the given stream"""
if isinstance(self.document, (bytes, str)):
document = self.document
elif "Element" in type(self.document).__name__:
# isinstance can't be used here because etree is broken
doc = cast(etree, self.document)
document = doc.getroot().tostring()
else:
raise ValueError(
f"Unknown type of document: {type(self.document).__name__} can not"
+ "save."
)
try:
stream.write(document)
except TypeError:
# we hope that this happens only when document needs to be encoded
stream.write(document.encode("utf-8")) # type: ignore
class SvgThroughMixin(SvgInputMixin, SvgOutputMixin): # pylint: disable=abstract-method
"""
Combine the input and output svg document handling (usually for effects).
"""
def has_changed(self, ret): # pylint: disable=unused-argument
# type: (Any) -> bool
"""Return true if the svg document has changed"""
original = etree.tostring(self.original_document)
result = etree.tostring(self.document)
return original != result

View File

@@ -0,0 +1,582 @@
# coding=utf-8
#
# Copyright (C) 2010 Nick Drobchenko, nick@cnc-club.ru
# Copyright (C) 2005 Aaron Spike, aaron@ekips.org
#
# 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 Street, Fifth Floor, Boston, MA 02110-1301, USA.
#
# pylint: disable=invalid-name,too-many-locals
#
"""
Bezier calculations
"""
import cmath
import math
import numpy
from .transforms import DirectedLineSegment
from .localization import inkex_gettext as _
# bez = ((bx0,by0),(bx1,by1),(bx2,by2),(bx3,by3))
def pointdistance(point_a, point_b):
"""The straight line distance between two points"""
return math.sqrt(
((point_b[0] - point_a[0]) ** 2) + ((point_b[1] - point_a[1]) ** 2)
)
def between_point(point_a, point_b, time=0.5):
"""Returns the point between point a and point b"""
return point_a[0] + time * (point_b[0] - point_a[0]), point_a[1] + time * (
point_b[1] - point_a[1]
)
def percent_point(point_a, point_b, percent=50.0):
"""Returns between_point but takes percent instead of 0.0-1.0"""
return between_point(point_a, point_b, percent / 100.0)
def root_wrapper(root_a, root_b, root_c, root_d):
"""Get the Cubic function, moic formular of roots, simple root"""
if root_a:
# Monics formula, see
# http://en.wikipedia.org/wiki/Cubic_function#Monic_formula_of_roots
mono_a, mono_b, mono_c = (root_b / root_a, root_c / root_a, root_d / root_a)
m = 2.0 * mono_a**3 - 9.0 * mono_a * mono_b + 27.0 * mono_c
k = mono_a**2 - 3.0 * mono_b
n = m**2 - 4.0 * k**3
w1 = -0.5 + 0.5 * cmath.sqrt(-3.0)
w2 = -0.5 - 0.5 * cmath.sqrt(-3.0)
if n < 0:
m1 = pow(complex((m + cmath.sqrt(n)) / 2), 1.0 / 3)
n1 = pow(complex((m - cmath.sqrt(n)) / 2), 1.0 / 3)
else:
if m + math.sqrt(n) < 0:
m1 = -pow(-(m + math.sqrt(n)) / 2, 1.0 / 3)
else:
m1 = pow((m + math.sqrt(n)) / 2, 1.0 / 3)
if m - math.sqrt(n) < 0:
n1 = -pow(-(m - math.sqrt(n)) / 2, 1.0 / 3)
else:
n1 = pow((m - math.sqrt(n)) / 2, 1.0 / 3)
return (
-1.0 / 3 * (mono_a + m1 + n1),
-1.0 / 3 * (mono_a + w1 * m1 + w2 * n1),
-1.0 / 3 * (mono_a + w2 * m1 + w1 * n1),
)
if root_b:
det = root_c**2.0 - 4.0 * root_b * root_d
if det:
return (
(-root_c + cmath.sqrt(det)) / (2.0 * root_b),
(-root_c - cmath.sqrt(det)) / (2.0 * root_b),
)
return (-root_c / (2.0 * root_b),)
if root_c:
return (1.0 * (-root_d / root_c),)
return ()
def bezlenapprx(sp1, sp2):
"""Return the aproximate length between two beziers"""
return (
pointdistance(sp1[1], sp1[2])
+ pointdistance(sp1[2], sp2[0])
+ pointdistance(sp2[0], sp2[1])
)
def cspbezsplit(sp1, sp2, time=0.5):
"""Split a cubic bezier at the time period"""
m1 = tpoint(sp1[1], sp1[2], time)
m2 = tpoint(sp1[2], sp2[0], time)
m3 = tpoint(sp2[0], sp2[1], time)
m4 = tpoint(m1, m2, time)
m5 = tpoint(m2, m3, time)
m = tpoint(m4, m5, time)
return [[sp1[0][:], sp1[1][:], m1], [m4, m, m5], [m3, sp2[1][:], sp2[2][:]]]
def cspbezsplitatlength(sp1, sp2, length=0.5, tolerance=0.001):
"""Split a cubic bezier at length"""
bez = (sp1[1][:], sp1[2][:], sp2[0][:], sp2[1][:])
time = beziertatlength(bez, length, tolerance)
return cspbezsplit(sp1, sp2, time)
def cspseglength(sp1, sp2, tolerance=0.001):
"""Get cubic bezier segment length"""
bez = (sp1[1][:], sp1[2][:], sp2[0][:], sp2[1][:])
return bezierlength(bez, tolerance)
def csplength(csp):
"""Get cubic bezier length"""
total = 0
lengths = []
for sp in csp:
lengths.append([])
for i in range(1, len(sp)):
l = cspseglength(sp[i - 1], sp[i])
lengths[-1].append(l)
total += l
return lengths, total
def bezierparameterize(bez):
"""Return the bezier parameter size
Converts the bezier parametrisation from the default form
P(t) = (1-t)³ P_1 + 3(1-t)²t P_2 + 3(1-t)t² P_3 + t³ x_4
to the a form which can be differentiated more easily
P(t) = a t³ + b t² + c t + P0
Args:
bez (List[Tuple[float, float]]): the Bezier curve. The elements of the list the
coordinates of the points (in this order): Start point, Start control point,
End control point, End point.
Returns:
Tuple[float, float, float, float, float, float, float, float]:
the values ax, ay, bx, by, cx, cy, x0, y0
"""
((bx0, by0), (bx1, by1), (bx2, by2), (bx3, by3)) = bez
# parametric bezier
x0 = bx0
y0 = by0
cx = 3 * (bx1 - x0)
bx = 3 * (bx2 - bx1) - cx
ax = bx3 - x0 - cx - bx
cy = 3 * (by1 - y0)
by = 3 * (by2 - by1) - cy
ay = by3 - y0 - cy - by
return ax, ay, bx, by, cx, cy, x0, y0
def linebezierintersect(arg_a, bez):
"""Where a line and bezier intersect"""
((lx1, ly1), (lx2, ly2)) = arg_a
# parametric line
dd = lx1
cc = lx2 - lx1
bb = ly1
aa = ly2 - ly1
if aa:
coef1 = cc / aa
coef2 = 1
else:
coef1 = 1
coef2 = aa / cc
ax, ay, bx, by, cx, cy, x0, y0 = bezierparameterize(bez)
# cubic intersection coefficients
a = coef1 * ay - coef2 * ax
b = coef1 * by - coef2 * bx
c = coef1 * cy - coef2 * cx
d = coef1 * (y0 - bb) - coef2 * (x0 - dd)
roots = root_wrapper(a, b, c, d)
retval = []
for i in roots:
if isinstance(i, complex) and i.imag == 0:
i = i.real
if not isinstance(i, complex) and 0 <= i <= 1:
retval.append(bezierpointatt(bez, i))
return retval
def bezierpointatt(bez, t):
"""Get coords at the given time point along a bezier curve"""
ax, ay, bx, by, cx, cy, x0, y0 = bezierparameterize(bez)
x = ax * (t**3) + bx * (t**2) + cx * t + x0
y = ay * (t**3) + by * (t**2) + cy * t + y0
return x, y
def bezierslopeatt(bez, t):
"""Get slope at the given time point along a bezier curve
The slope is computed as (dx, dy) where dx = df_x(t)/dt and dy = df_y(t)/dt.
Note that for lines P1=P2 and P3=P4, so the slope at the end points is dx=dy=0
(slope not defined).
Args:
bez (List[Tuple[float, float]]): the Bezier curve. The elements of the list the
coordinates of the points (in this order): Start point, Start control point,
End control point, End point.
t (float): time in the interval [0, 1]
Returns:
Tuple[float, float]: x and y increment
"""
ax, ay, bx, by, cx, cy, _, _ = bezierparameterize(bez)
dx = 3 * ax * (t**2) + 2 * bx * t + cx
dy = 3 * ay * (t**2) + 2 * by * t + cy
return dx, dy
def beziertatslope(bez, d):
"""Reverse; get time from slope along a bezier curve"""
ax, ay, bx, by, cx, cy, _, _ = bezierparameterize(bez)
(dy, dx) = d
# quadratic coefficients of slope formula
if dx:
slope = 1.0 * (dy / dx)
a = 3 * ay - 3 * ax * slope
b = 2 * by - 2 * bx * slope
c = cy - cx * slope
elif dy:
slope = 1.0 * (dx / dy)
a = 3 * ax - 3 * ay * slope
b = 2 * bx - 2 * by * slope
c = cx - cy * slope
else:
return []
roots = root_wrapper(0, a, b, c)
retval = []
for i in roots:
if isinstance(i, complex) and i.imag == 0:
i = i.real
if not isinstance(i, complex) and 0 <= i <= 1:
retval.append(i)
return retval
def tpoint(p1, p2, t):
"""Linearly interpolate between p1 and p2.
t = 0.0 returns p1, t = 1.0 returns p2.
:return: Interpolated point
:rtype: tuple
:param p1: First point as sequence of two floats
:param p2: Second point as sequence of two floats
:param t: Number between 0.0 and 1.0
:type t: float
"""
x1, y1 = p1
x2, y2 = p2
return x1 + t * (x2 - x1), y1 + t * (y2 - y1)
def beziersplitatt(bez, t):
"""Split bezier at given time"""
((bx0, by0), (bx1, by1), (bx2, by2), (bx3, by3)) = bez
m1 = tpoint((bx0, by0), (bx1, by1), t)
m2 = tpoint((bx1, by1), (bx2, by2), t)
m3 = tpoint((bx2, by2), (bx3, by3), t)
m4 = tpoint(m1, m2, t)
m5 = tpoint(m2, m3, t)
m = tpoint(m4, m5, t)
return ((bx0, by0), m1, m4, m), (m, m5, m3, (bx3, by3))
def addifclose(bez, l, error=0.001):
"""Gravesen, Add if the line is closed, in-place addition to array l"""
box = 0
for i in range(1, 4):
box += pointdistance(bez[i - 1], bez[i])
chord = pointdistance(bez[0], bez[3])
if (box - chord) > error:
first, second = beziersplitatt(bez, 0.5)
addifclose(first, l, error)
addifclose(second, l, error)
else:
l[0] += (box / 2.0) + (chord / 2.0)
# balfax, balfbx, balfcx, balfay, balfby, balfcy = 0, 0, 0, 0, 0, 0
def balf(t, args):
"""Bezier Arc Length Function"""
ax, bx, cx, ay, by, cy = args
retval = (ax * (t**2) + bx * t + cx) ** 2 + (ay * (t**2) + by * t + cy) ** 2
return math.sqrt(retval)
def simpson(start, end, maxiter, tolerance, bezier_args):
"""Calculate the length of a bezier curve using Simpson's algorithm:
http://steve.hollasch.net/cgindex/curves/cbezarclen.html
Args:
start (int): Start time (between 0 and 1)
end (int): End time (between start time and 1)
maxiter (int): Maximum number of iterations. If not a power of 2, the algorithm
will behave like the value is set to the next power of 2.
tolerance (float): maximum error ratio
bezier_args (list): arguments as computed by bezierparametrize()
Returns:
float: the appoximate length of the bezier curve
"""
n = 2
multiplier = (end - start) / 6.0
endsum = balf(start, bezier_args) + balf(end, bezier_args)
interval = (end - start) / 2.0
asum = 0.0
bsum = balf(start + interval, bezier_args)
est1 = multiplier * (endsum + (2.0 * asum) + (4.0 * bsum))
est0 = 2.0 * est1
# print(multiplier, endsum, interval, asum, bsum, est1, est0)
while n < maxiter and abs(est1 - est0) > tolerance:
n *= 2
multiplier /= 2.0
interval /= 2.0
asum += bsum
bsum = 0.0
est0 = est1
for i in range(1, n, 2):
bsum += balf(start + (i * interval), bezier_args)
est1 = multiplier * (endsum + (2.0 * asum) + (4.0 * bsum))
# print(multiplier, endsum, interval, asum, bsum, est1, est0)
return est1
def bezierlength(bez, tolerance=0.001, time=1.0):
"""Get length of bezier curve"""
ax, ay, bx, by, cx, cy, _, _ = bezierparameterize(bez)
return simpson(0.0, time, 4096, tolerance, [3 * ax, 2 * bx, cx, 3 * ay, 2 * by, cy])
def beziertatlength(bez, l=0.5, tolerance=0.001):
"""Get bezier curve time at the length specified"""
curlen = bezierlength(bez, tolerance, 1.0)
time = 1.0
tdiv = time
targetlen = l * curlen
diff = curlen - targetlen
while abs(diff) > tolerance:
tdiv /= 2.0
if diff < 0:
time += tdiv
else:
time -= tdiv
curlen = bezierlength(bez, tolerance, time)
diff = curlen - targetlen
return time
def maxdist(bez):
"""Get maximum distance within bezier curve"""
seg = DirectedLineSegment(bez[0], bez[3])
return max(seg.distance_to_point(*bez[1]), seg.distance_to_point(*bez[2]))
def cspsubdiv(csp, flat):
"""Sub-divide cubic sub-paths"""
for sp in csp:
subdiv(sp, flat)
def subdiv(sp, flat, i=1):
"""sub divide bezier curve"""
while i < len(sp):
p0 = sp[i - 1][1]
p1 = sp[i - 1][2]
p2 = sp[i][0]
p3 = sp[i][1]
bez = (p0, p1, p2, p3)
mdist = maxdist(bez)
if mdist <= flat:
i += 1
else:
one, two = beziersplitatt(bez, 0.5)
sp[i - 1][2] = one[1]
sp[i][0] = two[2]
p = [one[2], one[3], two[1]]
sp[i:1] = [p]
def csparea(csp):
r"""Get total area of cubic superpath.
.. hint::
The results may be slightly inaccurate for paths containing arcs due
to the loss of accuracy during arc -> cubic bezier conversion.
The function works as follows: For each subpath,
#. compute the area of the polygon created by the path's vertices:
For a line with coordinates :math:`(x_0, y_0)` and :math:`(x_1, y_1)`, the area
of the trapezoid of its projection on the x axis is given by
.. math::
\frac{1}{2} (y_1 + y_0) (x_1 - x_0)
Summing the contribution of all lines of the polygon yields the polygon's area
(lines from left to right have a positive contribution, while those right-to
left have a negative area contribution, canceling out the computed area not
inside the polygon), so we find (setting :math:`x_{0} = x_N` etc.):
.. math::
A = \frac{1}{2} * \sum_{i=1}^N (x_i y_i - x_{i-1} y_{i-1} + x_i y_{i-1}
- x_{i-1} y_{i})
The first two terms cancel out in the summation over all points, and the second
two terms can be regrouped as
.. math::
A = \frac{1}{2} * \sum_{i=1}^N x_i (y_{i+1} -y_{i-1})
#. The contribution by the bezier curve is considered: We compute
the integral :math:`\int_{x(t=0)}^{x(t=1)} y dx`, i.e. the area between the x
axis and the curve, where :math:`y = y(t)` (the Bezier curve). By substitution
:math:`dx = x'(t) dt`, performing the integration and
subtracting the trapezoid we already considered above, we find (with control
points :math:`(x_{c1}, y_{c1})` and :math:`(x_{c2}, y_{c2})`)
.. math::
\Delta A &= \int_0^1 y(t) x'(t) dt - \frac{1}{2} (y_1 + y_0) (x_1 - x_0) \\
&= \frac{3}{20} \cdot \begin{pmatrix}
& y_0(& & 2x_{c1} & + x_{c2} & -3x_1&) \\
+ & y_{c1}(& -2x_0 & & + x_{c2} &+ x_1&) \\
+ & y_{c2}(& -x_0 & -x_{c1} & & + 2x_1&) \\
+ & y_1(& 3x_0 & - x_{c1} & -2 x_{c2} &&)
\end{pmatrix}
This is computed for every bezier and added to the area. Again, this is a signed
area: convex beziers have a positive area and concave ones a negative area
contribution.
"""
MAT_AREA = numpy.array(
[[0, 2, 1, -3], [-2, 0, 1, 1], [-1, -1, 0, 2], [3, -1, -2, 0]]
)
area = 0.0
for sp in csp:
if len(sp) < 2:
continue
for x, coord in enumerate(sp): # calculate polygon area
area += 0.5 * sp[x - 1][1][0] * (coord[1][1] - sp[x - 2][1][1])
for i in range(1, len(sp)): # add contribution from cubic Bezier
# EXPLANATION: https://github.com/Pomax/BezierInfo-2/issues/238#issue-554619801
vec_x = numpy.array(
[sp[i - 1][1][0], sp[i - 1][2][0], sp[i][0][0], sp[i][1][0]]
)
vec_y = numpy.array(
[sp[i - 1][1][1], sp[i - 1][2][1], sp[i][0][1], sp[i][1][1]]
)
vex = numpy.matmul(vec_x, MAT_AREA)
area += 0.15 * numpy.matmul(vex, vec_y.T)
return -area
def cspcofm(csp):
r"""Get center of area / gravity for a cubic superpath.
.. hint::
The results may be slightly inaccurate for paths containing arcs due
to the loss of accuracy during arc -> cubic bezier conversion.
The function works similar to :func:`csparea`, only the computations are a bit more
difficult. Again all subpaths are considered. The total center of mass is given by
.. math::
C_y = \frac{1}{A} \int_A y dA
The integral can be expressed as a weighted sum; first, the contributions
of the polygon created by the path's nodes is computed. Second, we compute the
contribution of the Bezier curve; this is again done by an integral from which
the weighted CofM of the trapezoid between end points and horizontal axis is
removed. For the integrals, we have
.. math::
A * C_{y,bez} &= \int_A y dA = \int_{x(t=0)}^{y(t=1)} \int_{0}^{y(x)} y dy dx \\
&= \int_{x(t=0)}^{y(t=1)} \frac 12 y(x)^2 dx
= \int_0^1 \frac 12 y(t)^2 x'(t) dt \\
A * C_{x,bez} &= \int_A x dA = \int_{x(t=0)}^{y(t=1)} x \int_{0}^{y(x)} dy dx \\
&= \int_{x(t=0)}^{y(t=1)} x y(x) dx = \int_0^1 x(t) y(t) x'(t) dt
from which the trapezoids are removed, in case of the y-CofM this amounts to
.. math::
\frac{y_0}{2} (x_1-x_0)y_0 + \left(y_0 + \frac 13 (y_1 - y_0)\right)
\cdot \frac 12 (y_1 - y_0) (x_1 - x_0)
"""
MAT_COFM_0 = numpy.array(
[[0, 35, 10, -45], [-35, 0, 12, 23], [-10, -12, 0, 22], [45, -23, -22, 0]]
)
MAT_COFM_1 = numpy.array(
[[0, 15, 3, -18], [-15, 0, 9, 6], [-3, -9, 0, 12], [18, -6, -12, 0]]
)
MAT_COFM_2 = numpy.array(
[[0, 12, 6, -18], [-12, 0, 9, 3], [-6, -9, 0, 15], [18, -3, -15, 0]]
)
MAT_COFM_3 = numpy.array(
[[0, 22, 23, -45], [-22, 0, 12, 10], [-23, -12, 0, 35], [45, -10, -35, 0]]
)
area = csparea(csp)
xc = 0.0
yc = 0.0
if abs(area) < 1.0e-8:
raise ValueError(_("Area is zero, cannot calculate Center of Mass"))
for sp in csp:
for x, coord in enumerate(sp): # calculate polygon moment
xc += (
sp[x - 1][1][1]
* (sp[x - 2][1][0] - coord[1][0])
* (sp[x - 2][1][0] + sp[x - 1][1][0] + coord[1][0])
/ 6
)
yc += (
sp[x - 1][1][0]
* (coord[1][1] - sp[x - 2][1][1])
* (sp[x - 2][1][1] + sp[x - 1][1][1] + coord[1][1])
/ 6
)
for i in range(1, len(sp)): # add contribution from cubic Bezier
vec_x = numpy.array(
[sp[i - 1][1][0], sp[i - 1][2][0], sp[i][0][0], sp[i][1][0]]
)
vec_y = numpy.array(
[sp[i - 1][1][1], sp[i - 1][2][1], sp[i][0][1], sp[i][1][1]]
)
def _mul(MAT, vec_x=vec_x, vec_y=vec_y):
return numpy.matmul(numpy.matmul(vec_x, MAT), vec_y.T)
vec_t = numpy.array(
[_mul(MAT_COFM_0), _mul(MAT_COFM_1), _mul(MAT_COFM_2), _mul(MAT_COFM_3)]
)
xc += numpy.matmul(vec_x, vec_t.T) / 280
yc += numpy.matmul(vec_y, vec_t.T) / 280
return -xc / area, -yc / area

View File

@@ -0,0 +1,49 @@
# coding=utf-8
"""
The color module allows for the parsing and printing of CSS colors in an SVG document.
Support formats are currently:
1. #RGB #RRGGBB #RGBA #RRGGBBAA formats
2. Named colors such as 'red'
3. icc-color(...) which is specific to SVG 1.1
4. rgb(...) and rgba(...) from CSS Color Module 3
5. hsl(...) and hsla(...) from CSS Color Module 3
6. hwb(...) from CSS Color Module 4, but encoded internally as hsv
7. device-cmyk(...) from CSS Color Module 4
Each color space has it's own class, such as ColorRGB. Each space will parse multiple
formats, for example ColorRGB supports hex and rgb CSS module formats.
Each color object is a list of numbers, each number is a channel in that color space
with alpha channel being held in it's own property which may be a unit number or None.
The numbers a color stores are typically in the range defined in the CSS module
specification so for example RGB, all the numbers are between 0-255 while for hsl
the hue channel is between 0-360 and the saturation and lightness are between 0-100.
To get normalised numbers you can use to the `to_units` function to get everything 0-1
Each Color space type has a name value which can be used to identify the color space,
if this is more useful than checking the class type. Either can be used when converting
the color values between spaces.
A color object may be converted into a different space by using the
`color.to(other_space)` function, which will return a new color object in the requested
space.
There are three special cases.
1. ColorNamed is a type of ColorRGB which will preferentially print the name instead
of the hex value if one is available.
2. ColorNone is a special value which indicates the keyword `none` and does not
allow any values or alpha.
3. ColorCMS can not be converted to other color spaces and contains a `fallback_color`
to access the RGB fallback if it was provided.
"""
from .color import Color, ColorError, ColorIdError
from .utils import is_color
from .spaces import *

View File

@@ -0,0 +1,295 @@
# coding=utf-8
#
# Copyright (C) 2020 Martin Owens
# 2021 Jonathan Neuhauser
#
# 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 Street, Fifth Floor, Boston, MA 02110-1301, USA.
#
"""
Basic color controls
"""
from typing import Dict, Optional, Tuple, Union
from .converters import Converters
Number = Union[int, float]
def round_by_type(kind, number):
"""Round a number to zero or five decimal places depending on it's type"""
return kind(round(number, kind == float and 5 or 0))
class ColorError(KeyError):
"""Specific color parsing error"""
class ColorIdError(ColorError):
"""Special color error for gradient and color stop ids"""
class Color(list):
"""A parsed color object which could be in many color spaces, the default is sRGB
Can be constructed from valid CSS color attributes, as well as
tuple/list + color space. Percentage values are supported.
"""
_spaces: Dict[str, type] = {}
name: Optional[str] = None
# A list of known channels
channels: Tuple[str, ...] = ()
# A list of scales for converting css color values to known qantities
scales: Tuple[
Union[Tuple[Number, Number, bool], Tuple[Number, Number]], ...
] = () # Min (int/float), Max (int/float), [wrap around (bool:False)]
# If alpha is not specified, this is the default for most color types.
default_alpha = 1.0
def __init_subclass__(cls):
if not cls.name:
return # It is a base class
# Add space to a dictionary of available color spaces
cls._spaces[cls.name] = cls
Converters.add_space(cls)
def __new__(cls, value=None, alpha=None, arg=None):
if not cls.name:
if value is None:
return super().__new__(cls._spaces["none"])
if isinstance(value, int):
return super().__new__(cls._spaces["rgb"])
if isinstance(value, str):
# String from xml or css attributes
for space in cls._spaces.values():
if space.can_parse(value.lower()):
return super().__new__(space, value)
if isinstance(value, Color):
return super().__new__(type(value), value)
if isinstance(value, (list, tuple)):
from ..deprecated.main import _deprecated
_deprecated(
"Anonymous lists of numbers for colors no longer default to rgb"
)
return super().__new__(cls._spaces["rgb"], value)
return super().__new__(cls, value, alpha=alpha, arg=arg)
def __init__(self, values, alpha=None, arg=None):
super().__init__()
if not self.name:
raise ColorError(f"Not a known color value: '{values}' {arg}")
if not isinstance(values, (list, tuple)):
raise ColorError(
f"Colors must be constructed with a list of values: '{values}'"
)
if alpha is not None and not isinstance(alpha, float):
raise ColorError("Color alpha property must be a float number")
if alpha is None and self.channels and len(values) == len(self.channels) + 1:
alpha = values.pop()
if isinstance(values, Color):
alpha = values.alpha
if self.channels and len(values) != len(self.channels):
raise ColorError(
f"You must have {len(self.channels)} channels for a {self.name} color"
)
self[:] = values
self.alpha = alpha
def __hash__(self):
"""Allow colors to be hashable"""
return tuple(self + [self.alpha, self.name]).__hash__()
def __str__(self):
raise NotImplementedError(
f"Color space {self.name} can not be printed to a string."
)
def __int__(self):
raise NotImplementedError(
f"Color space {self.name} can not be converted to a number."
)
def __getitem__(self, index):
"""Get the color value"""
space = self.name
if (
isinstance(index, slice)
and index.start is not None
and not isinstance(index.start, int)
):
# We support the format `value = color["space_name":index]` here
space = self._spaces[index.start]
index = int(index.stop)
# Allow regular slicing to fall through more freely than setitem
if space == self.name:
return super().__getitem__(index)
if not isinstance(index, int):
raise ColorError(f"Unknown color getter definition: '{index}'")
return self.to(space)[
index
] # Note: this calls Color.__getitem__ function again
def __setitem__(self, index, value):
"""Set the color value in place, limits setter to specific color space"""
space = self.name
if isinstance(index, slice):
# Support the format color[:] = [list of numbers] here
if index.start is None and index.stop is None:
super().__setitem__(
index, (self.constrain(ind, val) for ind, val in enumerate(value))
)
return
# We support the format `color["space_name":index] = value` here
space = self._spaces[index.start]
index = int(index.stop)
if not isinstance(index, int):
raise ColorError(f"Unknown color setter definition: '{index}'")
# Setting a channel in the existing space
if space == self.name:
super().__setitem__(index, self.constrain(index, value))
else:
# Set channel is another space, convert back and forth
values = self.to(space)
values[index] = value # Note: this calls Color.__setitem__ function again
self[:] = values.to(self.name)
def to(self, space): # pylint: disable=invalid-name
"""Get this color but in a specific color space"""
if space in self._spaces.values():
space = space.name
if space not in self._spaces:
raise AttributeError(
f"Unknown color space {space} when converting from {self.name}"
)
if not hasattr(type(self), f"to_{space}"):
setattr(
type(self),
f"to_{space}",
Converters.find_converter(type(self), self._spaces[space]),
)
return getattr(self, f"to_{space}")()
def __getattr__(self, name):
if name.startswith("to_") and name.count("_") == 1:
return lambda: self.to(name.split("_")[-1])
raise AttributeError(f"Can not find attribute {type(self).__name__}.{name}")
@property
def effective_alpha(self):
"""Get the alpha as set, or tell me what it would be by default"""
if self.alpha is None:
return self.default_alpha
return self.alpha
def get_values(self, alpha=True):
"""Returns all values, including alpha as a list"""
if alpha:
return list(self + [self.effective_alpha])
return list(self)
@classmethod
def to_units(cls, *values):
"""Convert the color values into floats scales from 0.0 to 1.0"""
return [cls.scale_down(ind, val) for ind, val in enumerate(values)]
@classmethod
def from_units(cls, *values):
"""Convert float values to the scales expected and return a new instance"""
return [cls.scale_up(ind, val) for ind, val in enumerate(values)]
@classmethod
def can_parse(cls, string): # pylint: disable=unused-argument
"""Returns true if this string can be parsed for this color type"""
return False
@classmethod
def scale_up(cls, index, value):
"""Convert from float 0.0 to 1.0 to an int used in css"""
(min_value, max_value) = cls.scales[index][:2]
return cls.constrain(
index, (value * (max_value - min_value)) + min_value
) # See inkscape/src/colors/spaces/base.h:SCALE_UP
@classmethod
def scale_down(cls, index, value):
"""Convert from int, often 0 to 255 to a float 0.0 to 1.0"""
(min_value, max_value) = cls.scales[index][:2]
return (cls.constrain(index, value) - min_value) / (
max_value - min_value
) # See inkscape/src/colors/spaces/base.h:SCALE_DOWN
@classmethod
def constrain(cls, index, value):
"""Constrains the value to the css scale"""
scale = cls.scales[index]
if len(scale) == 3 and scale[2] is True:
if value == scale[1]:
return value
return round_by_type(
type(scale[0]), value % scale[1]
) # Wrap around value (i.e. hue)
return min(max(round_by_type(type(scale[0]), value), scale[0]), scale[1])
def interpolate(self, other, fraction):
"""Interpolate two colours by the given fraction
.. versionadded:: 1.1"""
from ..tween import ColorInterpolator # pylint: disable=import-outside-toplevel
try:
other = other.to(type(self))
except ColorError:
raise ColorError("Can not convert color in interpolation.")
return ColorInterpolator(self, other).interpolate(fraction)
class AlphaNotAllowed:
"""Mixin class to indicate that alpha values are not permitted on this color space"""
alpha = property(
lambda self: None,
lambda self, value: None,
)
def get_values(self, alpha=False):
return super().get_values(False)

View File

@@ -0,0 +1,122 @@
# coding=utf-8
#
# Copyright (C) 2018-2024 Martin Owens
#
# 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 Street, Fifth Floor, Boston, MA 02110-1301, USA.
#
"""
Basic color errors and common functions
"""
from collections import defaultdict
from typing import Dict, List, Callable
ConverterFunc = Callable[[float], List[float]]
class Converters:
"""
Record how colors can be converted between different spaces and provides
a way to path-find between multiple step conversions.
"""
links: Dict[str, Dict[str, ConverterFunc]] = defaultdict(dict)
chains: Dict[str, List[List[str]]] = {}
@classmethod
def add_space(cls, color_cls):
"""
Records the stated links between this class and other color spaces
"""
for name, func in color_cls.__dict__.items():
if not name.startswith("convert_"):
continue
_, direction, space = name.split("_", 2)
from_name = color_cls.name if direction == "to" else space
to_name = color_cls.name if direction == "from" else space
if from_name != to_name:
if not isinstance(func, staticmethod):
raise TypeError(f"Method '{name}' must be a static method.")
cls.links[from_name][to_name] = func.__func__
@classmethod
def get_chain(cls, source, target):
"""
Get a chain of conversions between two color spaces, if possible.
"""
def build_chains(chains, space):
new_chains = []
for chain in chains:
for hop in cls.links[space]:
if hop not in chain:
new_chains += build_chains([chain + [hop]], hop)
return chains + new_chains
if source not in cls.chains:
cls.chains[source] = build_chains([[source]], source)
chosen = None
for chain in cls.chains[source] or ():
if chain[-1] == target and (not chosen or len(chain) < len(chosen)):
chosen = chain
return chosen
@classmethod
def find_converter(cls, source, target):
"""
Find a way to convert from source to target using any conversion functions.
Will hop from one space to another if needed.
"""
func = None
# Passthough
if source == target:
return lambda self: self
if func is None:
chain = cls.get_chain(source.name, target.name)
if chain:
return cls.generate_converter(chain, source, target)
# Returning a function means we only run this function once, even when not found
def _error(self):
raise NotImplementedError(
f"Color space {source} can not be converted to {target}."
)
return _error
@classmethod
def generate_converter(cls, chain, source_cls, target_cls):
"""
Put together a function that can do every step of the chain of conversions
"""
# Build a list of functions to run
funcs = [cls.links[a][b] for a, b in zip(chain, chain[1:])]
funcs.insert(0, source_cls.to_units)
funcs.append(target_cls.from_units)
def _inner(values):
if hasattr(values, "alpha") and values.alpha is not None:
values = list(values) + [values.alpha]
for func in funcs:
values = func(*values)
return target_cls(values)
return _inner

View File

@@ -0,0 +1,11 @@
"""
Each color space that this module supports such have one file in this module.
"""
from .cmyk import ColorDeviceCMYK
from .cms import ColorCMS
from .hsl import ColorHSL
from .hsv import ColorHSV
from .named import ColorNamed
from .none import ColorNone
from .rgb import ColorRGB

View File

@@ -0,0 +1,95 @@
# coding=utf-8
#
# Copyright (C) 2024 Martin Owens
#
# 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 Street, Fifth Floor, Boston, MA 02110-1301, USA.
#
"""
SVG icc-color parser
"""
from ..color import Color, AlphaNotAllowed, ColorError, round_by_type
from .css import CssColor
from .rgb import ColorRGB
class ColorCMS(CssColor, AlphaNotAllowed):
"""
Parse and print SVG icc-color objects into their values and the fallback RGB
"""
name = "cms"
css_func = "icc-color"
channels = ()
scales = ()
def __init__(self, values, icc_profile=None, fallback=None):
if isinstance(values, str):
if values.strip().startswith("#") and " " in values:
fallback, values = values.split(" ", 1)
fallback = Color(fallback)
icc_profile, values = self.parse_css_color(values)
if icc_profile is None:
raise ColorError("CMS Color requires an icc color profile name.")
self.icc_profile = icc_profile
self.fallback_rgb = fallback
super().__init__(values)
def __str__(self) -> str:
values = self.css_join.join([f"{v:g}" for v in self.get_css_values()])
fallback = str(ColorRGB(self.fallback_rgb)) + " " if self.fallback_rgb else ""
return f"{fallback}{self.css_func}({self.icc_profile}, {values})"
@classmethod
def can_parse(cls, string: str) -> bool:
# Custom detection because of RGB fallback prefix
return "icc-color" in string.replace("(", " ").split()
@classmethod
def constrain(cls, index, value):
return min(max(round_by_type(float, value), 0.0), 1.0)
@classmethod
def scale_up(cls, index, value):
return value # All cms values are already 0.0 to 1.0
@classmethod
def scale_down(cls, index, value):
return value # All cms values are already 0.0 to 1.0
@staticmethod
def convert_to_rgb(*data):
"""Catch attempted conversions to rgb"""
raise NotImplementedError("Can not convert to RGB from icc color")
@staticmethod
def convert_from_rgb(*data):
"""Catch attempted conversions from rgb"""
raise NotImplementedError("Can not convert from RGB to icc color")
# This is research code for a future developer to use. We already use PIL and this will
# allow icc colors to be converted in python. This isn't needed right now, so this work
# will be left undone.
# @staticmethod
# def convert_to_rgb():
# from PIL import Image, ImageCms
# pixel = Image.fromarray([[int(r * 255), int(g * 255), int(b * 255)]], 'RGB')
# transform = ImageCms.buildTransform(sRGB_profile, self.this_profile, "RGB",
# self.this_profile_mode, self.this_rendering_intent, 0)
# transform.apply_in_place(pixel)
# return [p / 255 for p in pixel[0]]

View File

@@ -0,0 +1,81 @@
# coding=utf-8
#
# Copyright (C) 2024 Martin Owens
#
# 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 Street, Fifth Floor, Boston, MA 02110-1301, USA.
#
# pylint: disable=W0223
"""
DeviceCMYK Color Space
"""
from .css import CssColorModule4
class ColorDeviceCMYK(CssColorModule4):
"""
Parse the device-cmyk CSS Color Module 4 format.
Note that this format is NOT true CMYK as you might expect in a printer and
is instead is an aproximation of the intended ink levels if this was converted
into a real CMYK color profile using a color management system.
"""
name = "cmyk"
channels = ("cyan", "magenta", "yellow", "black")
scales = ((0, 100), (0, 100), (0, 100), (0, 100), (0.0, 1.0))
css_either_prefix = "device-cmyk"
cyan = property(
lambda self: self[0], lambda self, value: self.__setitem__(0, value)
)
magenta = property(
lambda self: self[1], lambda self, value: self.__setitem__(1, value)
)
yellow = property(
lambda self: self[2], lambda self, value: self.__setitem__(2, value)
)
black = property(
lambda self: self[3], lambda self, value: self.__setitem__(3, value)
)
@staticmethod
def convert_to_rgb(cyan, magenta, yellow, black, *alpha):
"""
Convert a set of Device-CMYK identities into RGB
"""
white = 1.0 - black
return [
1.0 - min((1.0, cyan * white + black)),
1.0 - min((1.0, magenta * white + black)),
1.0 - min((1.0, yellow * white + black)),
] + list(alpha)
@staticmethod
def convert_from_rgb(red, green, blue, *alpha):
"""
Convert RGB into Device-CMYK
"""
white = max((red, green, blue))
black = 1.0 - white
return [
# Each channel is it's color chart oposite (cyan->red)
# with a bit of white removed.
(white and (1.0 - red - black) / white or 0.0),
(white and (1.0 - green - black) / white or 0.0),
(white and (1.0 - blue - black) / white or 0.0),
black,
] + list(alpha)

View File

@@ -0,0 +1,139 @@
# coding=utf-8
#
# Copyright (C) 2018-2024 Martin Owens
#
# 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 Street, Fifth Floor, Boston, MA 02110-1301, USA.
#
# pylint: disable=W0223
"""
Parsing CSS elements from colors
"""
from typing import Optional, Union
from ..color import Color, ColorError, ColorIdError
class CssColor(Color):
"""
A Color which is always parsed and printed from a css format.
"""
# A list of css prefixes which ar valid for this space
css_noalpha_prefix: Optional[str] = None
css_alpha_prefix: Optional[str] = None
css_either_prefix: Optional[str] = None
# Some CSS formats require commas, others do not
css_join: str = ", "
css_join_alpha: str = ", "
css_func = "color"
def __str__(self):
values = self.css_join.join([f"{v:g}" for v in self.get_css_values()])
prefix = self.css_noalpha_prefix or self.css_either_prefix
if self.alpha is not None:
# Alpha is stored as a percent for clarity
alpha = int(self.alpha * 100)
values += self.css_join_alpha + f"{alpha}%"
if not self.css_either_prefix:
prefix = self.css_alpha_prefix
if prefix is None:
raise ColorError(f"Can't encode color {self.name} into CSS color format.")
return f"{prefix}({values})"
@classmethod
def can_parse(cls, string: str):
string = string.replace(" ", "")
if "(" not in string or ")" not in string:
return False
for prefix in (
cls.css_noalpha_prefix,
cls.css_alpha_prefix,
cls.css_either_prefix,
):
if prefix and (prefix + "(" in string or "color(" + prefix in string):
return True
return False
def __init__(self, value, alpha=None):
if isinstance(value, str):
prefix, values = self.parse_css_color(value)
has_alpha = (
self.channels is not None and len(values) == len(self.channels) + 1
)
if prefix == self.css_noalpha_prefix or (
prefix == self.css_either_prefix and not has_alpha
):
super().__init__(values)
elif prefix == self.css_alpha_prefix or (
prefix == self.css_either_prefix and has_alpha
):
super().__init__(values, values.pop())
else:
raise ColorError(f"Could not parse {self.name} css color: '{value}'")
else:
super().__init__(value, alpha=alpha)
@classmethod
def parse_css_color(cls, value):
"""Parse a css string into a list of values and it's color space prefix"""
prefix, values = value.lower().strip().strip(")").split("(")
# Some css formats use commas, others do not
if "," in cls.css_join:
values = values.replace(",", " ")
if "/" in cls.css_join_alpha:
values = values.replace("/", " ")
# Split values by spaces
values = values.split()
prefix = prefix.strip()
if prefix == cls.css_func:
prefix = values.pop(0)
if prefix == "url":
raise ColorIdError("Can not parse url as if it was a color.")
return prefix, [cls.parse_css_value(i, v) for i, v in enumerate(values)]
def get_css_values(self):
"""Return a list of values used for css string output"""
return self
@classmethod
def parse_css_value(cls, index, value) -> Union[int, float]:
"""Parse a CSS value such as 100%, 360 or 0.4"""
if cls.scales and index >= len(cls.scales):
raise ValueError("Can't add any more values to color.")
if isinstance(value, str):
value = value.strip()
if value.endswith("%"):
value = float(value.strip("%")) / 100
elif "." in value:
value = float(value)
else:
value = int(value)
if isinstance(value, float) and value <= 1.0:
value = cls.scale_up(index, value)
return cls.constrain(index, value)
class CssColorModule4(CssColor):
"""Tweak the css parser for CSS Module Four formating"""
css_join = " "
css_join_alpha = " / "

View File

@@ -0,0 +1,107 @@
# coding=utf-8
#
# Copyright (C) 2024 Martin Owens
#
# 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 Street, Fifth Floor, Boston, MA 02110-1301, USA.
#
# pylint: disable=W0223
"""
HSL Color Space
"""
from .css import CssColor
class ColorHSL(CssColor):
"""
Parse the HSL CSS Module Module 3 format.
"""
name = "hsl"
channels = ("hue", "saturation", "lightness")
scales = ((0, 360, True), (0, 100), (0, 100), (0.0, 1.0))
css_noalpha_prefix = "hsl"
css_alpha_prefix = "hsla"
hue = property(lambda self: self[0], lambda self, value: self.__setitem__(0, value))
saturation = property(
lambda self: self[1], lambda self, value: self.__setitem__(1, value)
)
lightness = property(
lambda self: self[2], lambda self, value: self.__setitem__(2, value)
)
@staticmethod
def convert_from_rgb(red, green, blue, alpha=None):
"""RGB to HSL colour conversion"""
rgb_max = max(red, green, blue)
rgb_min = min(red, green, blue)
delta = rgb_max - rgb_min
hsl = [0.0, 0.0, (rgb_max + rgb_min) / 2.0]
if delta != 0:
if hsl[2] <= 0.5:
hsl[1] = delta / (rgb_max + rgb_min)
else:
hsl[1] = delta / (2 - rgb_max - rgb_min)
if red == rgb_max:
hsl[0] = (green - blue) / delta
elif green == rgb_max:
hsl[0] = 2.0 + (blue - red) / delta
elif blue == rgb_max:
hsl[0] = 4.0 + (red - green) / delta
hsl[0] /= 6.0
if hsl[0] < 0:
hsl[0] += 1
if hsl[0] > 1:
hsl[0] -= 1
if alpha is not None:
hsl.append(alpha)
return hsl
@staticmethod
def convert_to_rgb(hue, sat, light, *alpha):
"""HSL to RGB Color Conversion"""
if sat == 0:
return [light, light, light] # Gray
if light < 0.5:
val2 = light * (1 + sat)
else:
val2 = light + sat - light * sat
val1 = 2 * light - val2
ret = [
_hue_to_rgb(val1, val2, hue * 6 + 2.0),
_hue_to_rgb(val1, val2, hue * 6),
_hue_to_rgb(val1, val2, hue * 6 - 2.0),
]
return ret + list(alpha)
def _hue_to_rgb(val1, val2, hue):
if hue < 0:
hue += 6.0
if hue > 6:
hue -= 6.0
if hue < 1:
return val1 + (val2 - val1) * hue
if hue < 3:
return val2
if hue < 4:
return val1 + (val2 - val1) * (4 - hue)
return val1

View File

@@ -0,0 +1,88 @@
# coding=utf-8
#
# Copyright (C) 2024 Jonathan Neuhauser
# 2024 Martin Owens
#
# 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 Street, Fifth Floor, Boston, MA 02110-1301, USA.
#
# pylint: disable=W0223
"""
HSV Color Space
"""
from .css import CssColorModule4
class ColorHSV(CssColorModule4):
"""
Parse the HWB CSS Color Module 4 format and retain as HSV values.
"""
name = "hsv"
channels = ("hue", "saturation", "value")
scales = ((0, 360, True), (0, 100), (0, 100), (0.0, 1.0))
# We use HWB to store HSV as this makes the most sense to Inkscape
css_either_prefix = "hwb"
hue = property(lambda self: self[0], lambda self, value: self.__setitem__(0, value))
saturation = property(
lambda self: self[1], lambda self, value: self.__setitem__(1, value)
)
value = property(
lambda self: self[2], lambda self, value: self.__setitem__(2, value)
)
@classmethod
def parse_css_color(cls, value):
"""Parsing HWB as if it was HSV for css input"""
prefix, values = super().parse_css_color(value)
# See https://en.wikipedia.org/wiki/HWB_color_model#Converting_to_and_from_HSV
values[1] /= 100
values[2] /= 100
scale = values[1] + values[2]
if scale > 1.0:
values[1] /= scale
values[2] /= scale
values[1] = int(
(values[2] == 1.0 and 0.0 or (1.0 - (values[1] / (1.0 - values[2])))) * 100
)
values[2] = int((1.0 - values[2]) * 100)
return prefix, values
def get_css_values(self):
"""Convert our HSV values into HWB for css output"""
values = list(self)
values[1] = (100 - values[1]) * (values[2] / 100)
values[2] = 100 - values[2]
return values
@staticmethod
def convert_to_hsl(hue, saturation, value, *alpha):
"""Conversion according to
https://en.wikipedia.org/wiki/HSL_and_HSV#HSV_to_HSL
.. versionadded:: 1.5"""
lum = value * (1 - saturation / 2)
sat = 0 if lum in (0, 1) else (value - lum) / min(lum, 1 - lum)
return [hue, sat, lum] + list(alpha)
@staticmethod
def convert_from_hsl(hue, saturation, lightness, *alpha):
"""Convertion according to Inkscape C++ codebase
.. versionadded:: 1.5"""
val = lightness + saturation * min(lightness, 1 - lightness)
sat = 0 if val == 0 else 2 * (1 - lightness / val)
return [hue, sat, val] + list(alpha)

View File

@@ -0,0 +1,236 @@
# coding=utf-8
#
# Copyright (C) 2024, Martin Owens
#
# 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 Street, Fifth Floor, Boston, MA 02110-1301, USA.
#
"""
CSS Named colors
"""
from typing import Dict
from ..color import Color
from .rgb import ColorRGB
_COLORS = {
"aliceblue": "#f0f8ff",
"antiquewhite": "#faebd7",
"aqua": "#00ffff",
"aquamarine": "#7fffd4",
"azure": "#f0ffff",
"beige": "#f5f5dc",
"bisque": "#ffe4c4",
"black": "#000000",
"blanchedalmond": "#ffebcd",
"blue": "#0000ff",
"blueviolet": "#8a2be2",
"brown": "#a52a2a",
"burlywood": "#deb887",
"cadetblue": "#5f9ea0",
"chartreuse": "#7fff00",
"chocolate": "#d2691e",
"coral": "#ff7f50",
"cornflowerblue": "#6495ed",
"cornsilk": "#fff8dc",
"crimson": "#dc143c",
"cyan": "#00ffff",
"darkblue": "#00008b",
"darkcyan": "#008b8b",
"darkgoldenrod": "#b8860b",
"darkgray": "#a9a9a9",
"darkgreen": "#006400",
"darkgrey": "#a9a9a9",
"darkkhaki": "#bdb76b",
"darkmagenta": "#8b008b",
"darkolivegreen": "#556b2f",
"darkorange": "#ff8c00",
"darkorchid": "#9932cc",
"darkred": "#8b0000",
"darksalmon": "#e9967a",
"darkseagreen": "#8fbc8f",
"darkslateblue": "#483d8b",
"darkslategray": "#2f4f4f",
"darkslategrey": "#2f4f4f",
"darkturquoise": "#00ced1",
"darkviolet": "#9400d3",
"deeppink": "#ff1493",
"deepskyblue": "#00bfff",
"dimgray": "#696969",
"dimgrey": "#696969",
"dodgerblue": "#1e90ff",
"firebrick": "#b22222",
"floralwhite": "#fffaf0",
"forestgreen": "#228b22",
"fuchsia": "#ff00ff",
"gainsboro": "#dcdcdc",
"ghostwhite": "#f8f8ff",
"gold": "#ffd700",
"goldenrod": "#daa520",
"gray": "#808080",
"grey": "#808080",
"green": "#008000",
"greenyellow": "#adff2f",
"honeydew": "#f0fff0",
"hotpink": "#ff69b4",
"indianred": "#cd5c5c",
"indigo": "#4b0082",
"ivory": "#fffff0",
"khaki": "#f0e68c",
"lavender": "#e6e6fa",
"lavenderblush": "#fff0f5",
"lawngreen": "#7cfc00",
"lemonchiffon": "#fffacd",
"lightblue": "#add8e6",
"lightcoral": "#f08080",
"lightcyan": "#e0ffff",
"lightgoldenrodyellow": "#fafad2",
"lightgray": "#d3d3d3",
"lightgreen": "#90ee90",
"lightgrey": "#d3d3d3",
"lightpink": "#ffb6c1",
"lightsalmon": "#ffa07a",
"lightseagreen": "#20b2aa",
"lightskyblue": "#87cefa",
"lightslategray": "#778899",
"lightslategrey": "#778899",
"lightsteelblue": "#b0c4de",
"lightyellow": "#ffffe0",
"lime": "#00ff00",
"limegreen": "#32cd32",
"linen": "#faf0e6",
"magenta": "#ff00ff",
"maroon": "#800000",
"mediumaquamarine": "#66cdaa",
"mediumblue": "#0000cd",
"mediumorchid": "#ba55d3",
"mediumpurple": "#9370db",
"mediumseagreen": "#3cb371",
"mediumslateblue": "#7b68ee",
"mediumspringgreen": "#00fa9a",
"mediumturquoise": "#48d1cc",
"mediumvioletred": "#c71585",
"midnightblue": "#191970",
"mintcream": "#f5fffa",
"mistyrose": "#ffe4e1",
"moccasin": "#ffe4b5",
"navajowhite": "#ffdead",
"navy": "#000080",
"oldlace": "#fdf5e6",
"olive": "#808000",
"olivedrab": "#6b8e23",
"orange": "#ffa500",
"orangered": "#ff4500",
"orchid": "#da70d6",
"palegoldenrod": "#eee8aa",
"palegreen": "#98fb98",
"paleturquoise": "#afeeee",
"palevioletred": "#db7093",
"papayawhip": "#ffefd5",
"peachpuff": "#ffdab9",
"peru": "#cd853f",
"pink": "#ffc0cb",
"plum": "#dda0dd",
"powderblue": "#b0e0e6",
"purple": "#800080",
"rebeccapurple": "#663399",
"red": "#ff0000",
"rosybrown": "#bc8f8f",
"royalblue": "#4169e1",
"saddlebrown": "#8b4513",
"salmon": "#fa8072",
"sandybrown": "#f4a460",
"seagreen": "#2e8b57",
"seashell": "#fff5ee",
"sienna": "#a0522d",
"silver": "#c0c0c0",
"skyblue": "#87ceeb",
"slateblue": "#6a5acd",
"slategray": "#708090",
"slategrey": "#708090",
"snow": "#fffafa",
"springgreen": "#00ff7f",
"steelblue": "#4682b4",
"tan": "#d2b48c",
"teal": "#008080",
"thistle": "#d8bfd8",
"tomato": "#ff6347",
"turquoise": "#40e0d0",
"violet": "#ee82ee",
"wheat": "#f5deb3",
"white": "#ffffff",
"whitesmoke": "#f5f5f5",
"yellow": "#ffff00",
"yellowgreen": "#9acd32",
}
class ColorNamed(ColorRGB):
"""
Parse specific named colors, fall back to RGB parsing if it fails.
"""
_color_names: Dict[ColorRGB, str] = {}
_name_colors: Dict[str, ColorRGB] = {}
name = "named"
def __init__(self, name, alpha=None):
if isinstance(name, str):
super().__init__(self.name_colors()[name.lower().strip()])
else:
super().__init__(name, alpha=alpha)
@classmethod
def color_names(cls):
"""Cache a list of color names"""
if not cls._color_names:
cls._color_names = {
value: name for name, value in cls.name_colors().items()
}
return cls._color_names
@classmethod
def name_colors(cls):
"""Cache a list of color objects"""
if not cls._name_colors:
cls._name_colors = {name: Color(value) for name, value in _COLORS.items()}
return cls._name_colors
def __str__(self):
return self.color_names().get(self, super().__str__())
def __hash__(self):
"""Allow named colors to match rgb colors"""
return tuple(self + [self.alpha, super().name]).__hash__()
@classmethod
def can_parse(cls, string: str):
"""If the string is one of the color names, we can parse it"""
return string in cls.name_colors()
@staticmethod
def convert_to_rgb(*data):
"""Converting to RGB is transparent, already in RGB"""
return data
@staticmethod
def convert_from_rgb(*data):
"""Converting from RGB is transparent, the store is RGB"""
return data
def to_rgb(self):
"""Prevent masking by ColorRGB of to_rgb method"""
return ColorRGB(list(self), alpha=self.alpha)

View File

@@ -0,0 +1,55 @@
# coding=utf-8
#
# Copyright (C) 2021 Jonathan Neuhauser
# 2020 Martin Owens
#
# 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 Street, Fifth Floor, Boston, MA 02110-1301, USA.
#
# pylint: disable=W0223
"""
An empty color for 'none'
"""
from ..color import Color, AlphaNotAllowed
class ColorNone(Color, AlphaNotAllowed):
"""A special color for 'none' colors"""
name = "none"
# Override opacity since none can not have opacity
default_alpha = 0.0
def __init__(self, value=None):
pass
def __str__(self) -> str:
return "none"
@classmethod
def can_parse(cls, string: str) -> bool:
"""Returns true if this is the word 'none'"""
return string == "none"
@staticmethod
def convert_to_rgb(*_):
"""Converting to RGB means transparent black"""
return [0, 0, 0, 0]
@staticmethod
def convert_from_rgb(*_):
"""Converting from RGB means throwing out all data"""
return []

View File

@@ -0,0 +1,105 @@
# coding=utf-8
#
# Copyright (C) 2024, Martin Owens
#
# 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 Street, Fifth Floor, Boston, MA 02110-1301, USA.
#
"""
RGB Colors
"""
from ..color import ColorError
from .css import CssColor
class ColorRGB(CssColor):
"""
Parse multiple versions of RGB from CSS module and standard hex formats.
"""
name = "rgb"
channels = ("red", "green", "blue")
scales = ((0, 255), (0, 255), (0, 255), (0.0, 1.0))
css_noalpha_prefix = "rgb"
css_alpha_prefix = "rgba"
red = property(lambda self: self[0], lambda self, value: self.__setitem__(0, value))
green = property(
lambda self: self[1], lambda self, value: self.__setitem__(1, value)
)
blue = property(
lambda self: self[2], lambda self, value: self.__setitem__(2, value)
)
@classmethod
def can_parse(cls, string: str) -> bool:
return "icc" not in string and (
string.startswith("#")
or string.lstrip("-").isdigit()
or super().can_parse(string)
)
def __init__(self, value, alpha=None):
# Not CSS, but inkscape, some old color values stores as 32bit int strings
if isinstance(value, str) and value.lstrip("-").isdigit():
value = int(value)
if isinstance(value, int):
super().__init__(
[
((value >> 24) & 255), # red
((value >> 16) & 255), # green
((value >> 8) & 255), # blue
((value & 255) / 255.0),
]
) # opacity
elif isinstance(value, str) and value.startswith("#") and " " not in value:
if len(value) == 4: # (css: #rgb -> #rrggbb)
# pylint: disable=consider-using-f-string
value = "#{1}{1}{2}{2}{3}{3}".format(*value)
elif len(value) == 5: # (css: #rgba -> #rrggbbaa)
# pylint: disable=consider-using-f-string
value = "#{1}{1}{2}{2}{3}{3}{4}{4}".format(*value)
# Convert hex to integers
try:
values = [int(value[i : i + 2], 16) for i in range(1, len(value), 2)]
if len(values) == 4:
values[3] /= 255
super().__init__(values)
except ValueError as error:
raise ColorError(f"Bad RGB hex color value '{value}'") from error
else:
super().__init__(value, alpha=alpha)
def __str__(self) -> str:
if self.alpha is not None:
return super().__str__()
if len(self) < len(self.channels):
raise ColorError(
f"Incorrect number of channels for Color Space {self.name}"
)
# Always hex values when outputting color
return "#{0:02x}{1:02x}{2:02x}".format(*(int(v) for v in self)) # pylint: disable=consider-using-f-string
def __int__(self) -> int:
return (
(self[0] << 24)
+ (self[1] << 16)
+ (self[2] << 8)
+ int((self.alpha or 1.0) * 255)
)

View File

@@ -0,0 +1,31 @@
# coding=utf-8
#
# Copyright (C) 2018-2024 Martin Owens
#
# 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 Street, Fifth Floor, Boston, MA 02110-1301, USA.
#
"""
Utilities for color support
"""
from .color import Color, ColorError
def is_color(color):
"""Determine if it is a color that we can use. If not, leave it unchanged."""
try:
return bool(Color(color))
except ColorError:
return False

View File

@@ -0,0 +1,347 @@
# coding=utf-8
#
# Copyright (C) 2019 Martin Owens
#
# 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 Street, Fifth Floor, Boston, MA 02110, USA.
#
"""
This API provides methods for calling Inkscape to execute a given
Inkscape command. This may be needed for various compiling options
(e.g., png), running other extensions or performing other options only
available via the shell API.
Best practice is to avoid using this API except when absolutely necessary,
since it is resource-intensive to invoke a new Inkscape instance.
However, in any circumstance when it is necessary to call Inkscape, it
is strongly recommended that you do so through this API, rather than calling
it yourself, to take advantage of the security settings and testing functions.
"""
import os
import re
import sys
from shutil import which as warlock
from subprocess import Popen, PIPE
from tempfile import TemporaryDirectory
from typing import List
from lxml.etree import ElementTree
from .elements import SvgDocumentElement
INKSCAPE_EXECUTABLE_NAME = os.environ.get("INKSCAPE_COMMAND")
if INKSCAPE_EXECUTABLE_NAME is None:
if sys.platform == "win32":
# prefer inkscape.exe over inkscape.com which spawns a command window
INKSCAPE_EXECUTABLE_NAME = "inkscape.exe"
else:
INKSCAPE_EXECUTABLE_NAME = "inkscape"
class CommandNotFound(IOError):
"""Command is not found"""
class ProgramRunError(ValueError):
"""A specialized ValueError that is raised when a call to an external command fails.
It stores additional information about a failed call to an external program.
If only the ``program`` parameter is given, it is interpreted as the error message.
Otherwise, the error message is compiled from all constructor parameters."""
program: str
"""The absolute path to the called executable"""
returncode: int
"""Return code of the program call"""
stderr: str
"""stderr stream output of the call"""
stdout: str
"""stdout stream output of the call"""
arguments: List
"""Arguments of the call"""
def __init__(self, program, returncode=None, stderr=None, stdout=None, args=None):
self.program = program
self.returncode = returncode
self.stderr = stderr
self.stdout = stdout
self.arguments = args
super().__init__(str(self))
def __str__(self):
if self.returncode is None:
return self.program
return (
f"Return Code: {self.returncode}: {self.stderr}\n{self.stdout}"
f"\nargs: {self.args}"
)
def which(program):
"""
Attempt different methods of trying to find if the program exists.
"""
if os.path.isabs(program) and os.path.isfile(program):
return program
# On Windows, shutil.which may give preference to .py files in the current directory
# (such as pdflatex.py), e.g. if .PY is in pathext, because the current directory is
# prepended to PATH. This can be suppressed by explicitly appending the current
# directory.
try:
if sys.platform == "win32":
prog = warlock(program, path=os.environ["PATH"] + ";" + os.curdir)
if prog:
return prog
except ImportError:
pass
try:
# Python3 only version of which
prog = warlock(program)
if prog:
return prog
except ImportError:
pass # python2
# There may be other methods for doing a `which` command for other
# operating systems; These should go here as they are discovered.
raise CommandNotFound(f"Can not find the command: '{program}'")
def write_svg(svg, *filename):
"""Writes an svg to the given filename"""
filename = os.path.join(*filename)
if os.path.isfile(filename):
return filename
with open(filename, "wb") as fhl:
if isinstance(svg, SvgDocumentElement):
svg = ElementTree(svg)
if hasattr(svg, "write"):
# XML document
svg.write(fhl)
elif isinstance(svg, bytes):
fhl.write(svg)
else:
raise ValueError("Not sure what type of SVG data this is.")
return filename
def to_arg(arg, oldie=False):
"""Convert a python argument to a command line argument"""
if isinstance(arg, (tuple, list)):
(arg, val) = arg
arg = "-" + arg
if len(arg) > 2 and not oldie:
arg = "-" + arg
if val is True:
return arg
if val is False:
return None
return f"{arg}={str(val)}"
return str(arg)
def to_args(prog, *positionals, **arguments):
"""Compile arguments and keyword arguments into a list of strings which Popen will
understand.
:param prog:
Program executable prepended to the output.
:type first: ``str``
:Arguments:
* (``str``) -- String added as given
* (``tuple``) -- Ordered version of Keyword Arguments, see below
:Keyword Arguments:
* *name* (``str``) --
Becomes ``--name="val"``
* *name* (``bool``) --
Becomes ``--name``
* *name* (``list``) --
Becomes ``--name="val1"`` ...
* *n* (``str``) --
Becomes ``-n=val``
* *n* (``bool``) --
Becomes ``-n``
:return: Returns a list of compiled arguments ready for Popen.
:rtype: ``list[str]``
"""
args = [prog]
oldie = arguments.pop("oldie", False)
for arg, value in arguments.items():
arg = arg.replace("_", "-").strip()
if isinstance(value, tuple):
value = list(value)
elif not isinstance(value, list):
value = [value]
for val in value:
args.append(to_arg((arg, val), oldie))
args += [to_arg(pos, oldie) for pos in positionals if pos is not None]
# Filter out empty non-arguments
return [arg for arg in args if arg is not None]
def to_args_sorted(prog, *positionals, **arguments):
"""same as :func:`to_args`, but keyword arguments are sorted beforehand
.. versionadded:: 1.2"""
return to_args(prog, *positionals, **dict(sorted(arguments.items())))
def _call(program, *args, **kwargs):
stdin = kwargs.pop("stdin", None)
if isinstance(stdin, str):
stdin = stdin.encode("utf-8")
inpipe = PIPE if stdin else None
args = to_args(which(program), *args, **kwargs)
kwargs = {}
if sys.platform == "win32":
kwargs["creationflags"] = 0x08000000 # create no console window
with Popen(
args,
shell=False, # Never have shell=True
stdin=inpipe, # StdIn not used (yet)
stdout=PIPE, # Grab any output (return it)
stderr=PIPE, # Take all errors, just incase
**kwargs,
) as process:
(stdout, stderr) = process.communicate(input=stdin)
if process.returncode == 0:
return stdout
raise ProgramRunError(program, process.returncode, stderr, stdout, args)
def call(program, *args, **kwargs):
"""
Generic caller to open any program and return its stdout::
stdout = call('executable', arg1, arg2, dash_dash_arg='foo', d=True, ...)
Will raise :class:`ProgramRunError` if return code is not 0.
Keyword arguments:
return_binary: Should stdout return raw bytes (default: False)
.. versionadded:: 1.1
stdin: The string or bytes containing the stdin (default: None)
All other arguments converted using :func:`to_args` function.
"""
# We use this long input because it's less likely to conflict with --binary=
binary = kwargs.pop("return_binary", False)
stdout = _call(program, *args, **kwargs)
# Convert binary to string when we wish to have strings we do this here
# so the mock tests will also run the conversion (always returns bytes)
if not binary and isinstance(stdout, bytes):
return stdout.decode(sys.stdout.encoding or "utf-8")
return stdout
def inkscape(svg_file, *args, **kwargs):
"""
Call Inkscape with the given svg_file and the given arguments, see call().
Returns the stdout of the call.
.. versionchanged:: 1.3
If the "actions" kwargs parameter is passed, it is checked whether the length of
the action string might lead to issues with the Windows CLI call character
limit. In this case, Inkscape is called in `--shell`
mode and the actions are fed in via stdin. This avoids violating the character
limit for command line arguments on Windows, which results in errors like this:
`[WinError 206] The filename or extension is too long`.
This workaround is also possible when calling Inkscape with long arguments
to `--export-id` and `--query-id`, by converting the call to the appropriate
action sequence. The stdout is cleaned to resemble non-interactive mode.
"""
os.environ["SELF_CALL"] = "true"
actions = kwargs.get("actions", None)
strip_stdout = False
# Keep some safe margin to the 8191 character limit.
if actions is not None and len(actions) > 7000:
args = args + ("--shell",)
kwargs["stdin"] = actions
kwargs.pop("actions")
strip_stdout = True
stdout = call(INKSCAPE_EXECUTABLE_NAME, svg_file, *args, **kwargs)
if strip_stdout:
split = re.split(r"\n> ", stdout)
if len(split) > 1:
if "\n" in split[1]:
stdout = "\n".join(split[1].split("\n")[1:])
else:
stdout = ""
return stdout
def inkscape_command(svg, select=None, actions=None, *args, **kwargs):
"""
Executes Inkscape batch actions with the given <svg> input and returns a new <svg>.
inkscape_command('<svg...>', [select=...], [actions=...], [...])
"""
with TemporaryDirectory(prefix="inkscape-command") as tmpdir:
svg_file = write_svg(svg, tmpdir, "input.svg")
select = ("select", select) if select else None
inkscape(
svg_file,
select,
batch_process=True,
export_overwrite=True,
actions=actions,
*args,
**kwargs,
)
with open(svg_file, "rb") as fhl:
return fhl.read()
def take_snapshot(svg, dirname, name="snapshot", ext="png", dpi=96, **kwargs):
"""
Take a snapshot of the given svg file.
Resulting filename is yielded back, after generator finishes, the
file is deleted so you must deal with the file inside the for loop.
"""
svg_file = write_svg(svg, dirname, name + ".svg")
ext_file = os.path.join(dirname, name + "." + str(ext).lower())
inkscape(
svg_file, export_dpi=dpi, export_filename=ext_file, export_type=ext, **kwargs
)
return ext_file
def is_inkscape_available():
"""Return true if the Inkscape executable is available."""
try:
return bool(which(INKSCAPE_EXECUTABLE_NAME))
except CommandNotFound:
return False

View File

@@ -0,0 +1,3 @@
"""CSS Processing module"""
from .compiler import CSSCompiler

View File

@@ -0,0 +1,483 @@
# coding=utf-8
#
# Copyright (C) 2023 - Jonathan Neuhauser <jonathan.neuhauser@outlook.com>
#
# 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 Street, Fifth Floor, Boston, MA 02110-1301, USA.
#
"""CSS evaluation logic, forked from cssselect2 (rewritten without eval, targeted to
our data structure). CSS selectors are compiled into boolean evaluator functions.
All HTML-specific code has been removed, and we don't duplicate the tree data structure
but work on the normal tree."""
import re
from lxml import etree
from typing import Union, List
from tinycss2.nth import parse_nth
from . import parser
from .parser import SelectorError
# http://dev.w3.org/csswg/selectors/#whitespace
split_whitespace = re.compile("[^ \t\r\n\f]+").findall
def ascii_lower(string): # from webencodings
r"""Transform (only) ASCII letters to lower case: A-Z is mapped to a-z."""
return string.encode("utf8").lower().decode("utf8")
# pylint: disable=protected-access,comparison-with-callable,invalid-name,bad-super-call
# pylint: disable=unnecessary-lambda-assignment
## Iterators without comments.
def iterancestors(element):
"""Iterate over ancestors but ignore comments."""
for e in element.iterancestors():
if isinstance(e, etree._Comment):
continue
yield e
def iterdescendants(element):
"""Iterate over descendants but ignore comments"""
for e in element.iterdescendants():
if isinstance(e, etree._Comment):
continue
yield e
def itersiblings(element, preceding=False):
"""Iterate over descendants but ignore comments"""
for e in element.itersiblings(preceding=preceding):
if isinstance(e, etree._Comment):
continue
yield e
def iterchildren(element):
"""Iterate over children but ignore comments"""
for e in element.iterchildren():
if isinstance(e, etree._Comment):
continue
yield e
def getprevious(element):
"""Get the previous non-comment element"""
for e in itersiblings(element, preceding=True):
return e
return None
def getnext(element):
"""Get the next non-comment element"""
for e in itersiblings(element, preceding=False):
return e
return None
def FALSE(_el):
"""Always returns 0"""
return 0
def TRUE(_el):
"""Always returns 1"""
return 1
class BooleanCompiler:
def __init__(self) -> None:
self._func_map = {
parser.CombinedSelector: self._compile_combined,
parser.CompoundSelector: self._compile_compound,
parser.NegationSelector: self._compile_negation,
parser.RelationalSelector: self._compile_relational,
parser.MatchesAnySelector: self._compile_any,
parser.SpecificityAdjustmentSelector: self._compile_any,
parser.LocalNameSelector: self._compile_local_name,
parser.NamespaceSelector: self._compile_namespace,
parser.ClassSelector: self._compile_class,
parser.IDSelector: self._compile_id,
parser.AttributeSelector: self._compile_attribute,
parser.PseudoClassSelector: self._compile_pseudoclass,
parser.FunctionalPseudoClassSelector: self._compile_functional_pseudoclass,
}
def _compile_combined(self, selector: parser.CombinedSelector):
left_inside = self.compile_node(selector.left)
if left_inside == FALSE:
return FALSE # 0 and x == 0
if left_inside == TRUE:
# 1 and x == x, but the element matching 1 still needs to exist.
if selector.combinator in (" ", ">"):
left = lambda el: el.getparent() is not None
elif selector.combinator in ("~", "+"):
left = lambda el: getprevious(el) is not None
else:
raise SelectorError("Unknown combinator", selector.combinator)
elif selector.combinator == " ":
left = lambda el: any((left_inside(e)) for e in el.ancestors())
elif selector.combinator == ">":
left = lambda el: el.getparent() is not None and left_inside(el.getparent())
elif selector.combinator == "+":
left = lambda el: getprevious(el) is not None and left_inside(
getprevious(el)
)
elif selector.combinator == "~":
left = lambda el: any(
(left_inside(e)) for e in itersiblings(el, preceding=True)
)
else:
raise SelectorError("Unknown combinator", selector.combinator)
right = self.compile_node(selector.right)
if right == FALSE:
return FALSE # 0 and x == 0
if right == TRUE:
return left # 1 and x == x
# Evaluate combinators right to left
return lambda el: right(el) and left(el)
def _compile_compound(self, selector: parser.CompoundSelector):
sub_expressions = [
expr
for expr in map(self.compile_node, selector.simple_selectors)
if expr != TRUE
]
if len(sub_expressions) == 1:
return sub_expressions[0]
if FALSE in sub_expressions:
return FALSE
if sub_expressions:
return lambda e: all(expr(e) for expr in sub_expressions)
return TRUE # all([]) == True
def _compile_negation(self, selector: parser.NegationSelector):
sub_expressions = [
expr
for expr in [
self.compile_node(selector.parsed_tree)
for selector in selector.selector_list
]
if expr != TRUE
]
if not sub_expressions:
return FALSE
return lambda el: not any(expr(el) for expr in sub_expressions)
@staticmethod
def _get_subexpr(expression, relative_selector):
"""Helper function for RelationalSelector"""
if relative_selector.combinator == " ":
return lambda el: any(expression(e) for e in iterdescendants(el))
if relative_selector.combinator == ">":
return lambda el: any(expression(e) for e in iterchildren(el))
if relative_selector.combinator == "+":
return lambda el: expression(next(itersiblings(el)))
if relative_selector.combinator == "~":
return lambda el: any(expression(e) for e in itersiblings(el))
raise SelectorError(
f"Unknown relational selector '{relative_selector.combinator}'"
)
def _compile_relational(self, selector: parser.RelationalSelector):
sub_expr = []
for relative_selector in selector.selector_list:
expression = self.compile_node(relative_selector.selector.parsed_tree)
if expression == FALSE:
continue
sub_expr.append(self._get_subexpr(expression, relative_selector))
return lambda el: any(expr(el) for expr in sub_expr)
def _compile_any(
self,
selector: Union[
parser.MatchesAnySelector, parser.SpecificityAdjustmentSelector
],
):
sub_expressions = [
expr
for expr in [
self.compile_node(selector.parsed_tree)
for selector in selector.selector_list
]
if expr != FALSE
]
if not sub_expressions:
return FALSE
return lambda el: any(expr(el) for expr in sub_expressions)
def _compile_local_name(self, selector: parser.LocalNameSelector):
return lambda el: el.TAG == selector.local_name
def _compile_namespace(self, selector: parser.NamespaceSelector):
return lambda el: el.NAMESPACE == selector.namespace
def _compile_class(self, selector: parser.ClassSelector):
return lambda el: selector.class_name in el.classes
def _compile_id(self, selector: parser.IDSelector):
return lambda el: super(etree.ElementBase, el).get("id", None) == selector.ident # type: ignore
def _compile_attribute(self, selector: parser.AttributeSelector):
if selector.namespace is not None:
if selector.namespace:
key_func = lambda el: (
f"{{{selector.namespace}}}{selector.name}"
if el.NAMESPACE != selector.namespace
else selector.name
)
else:
key_func = lambda el: selector.name
value = selector.value
if selector.case_sensitive is False:
value = value.lower()
attribute_value = (
lambda el: super(etree.ElementBase, el)
.get(key_func(el), "") # type: ignore
.lower()
)
else:
attribute_value = lambda el: super(etree.ElementBase, el).get( # type: ignore
key_func(el), ""
)
if selector.operator is None:
return lambda el: key_func(el) in el.attrib
if selector.operator == "=":
return lambda el: (
key_func(el) in el.attrib and attribute_value(el) == value
)
if selector.operator == "~=":
return (
FALSE
if len(value.split()) != 1 or value.strip() != value
else lambda el: value in split_whitespace(attribute_value(el))
)
if selector.operator == "|=":
return lambda el: (
key_func(el) in el.attrib
and (
attribute_value(el) == value
or attribute_value(el).startswith(value + "-")
)
)
if selector.operator == "^=":
if value:
return lambda el: attribute_value(el).startswith(value)
return FALSE
if selector.operator == "$=":
return (
(lambda el: attribute_value(el).endswith(value)) if value else FALSE
)
if selector.operator == "*=":
return (lambda el: value in attribute_value(el)) if value else FALSE
raise SelectorError("Unknown attribute operator", selector.operator)
# In any namespace
raise NotImplementedError # TODO
def _compile_pseudoclass(self, selector: parser.PseudoClassSelector):
if selector.name in ("link", "any-link", "local-link"):
def ancestors_or_self(el):
yield el
yield from iterancestors(el)
return lambda el: any(
e.TAG == "a" and super(etree.ElementBase, e).get("href", "") != "" # type: ignore
for e in ancestors_or_self(el)
)
if selector.name in (
"visited",
"hover",
"active",
"focus",
"focus-within",
"focus-visible",
"target",
"target-within",
"current",
"past",
"future",
"playing",
"paused",
"seeking",
"buffering",
"stalled",
"muted",
"volume-locked",
"user-valid",
"user-invalid",
):
# Not applicable in a static context: never match.
return FALSE
if selector.name in ("enabled", "disabled", "checked"):
# Not applicable to SVG
return FALSE
if selector.name in ("root", "scope"):
return lambda el: el.getparent() is None
if selector.name == "first-child":
return lambda el: getprevious(el) is None
if selector.name == "last-child":
return lambda el: getnext(el) is None
if selector.name == "first-of-type":
return lambda el: all(
s.tag != el.tag for s in itersiblings(el, preceding=True)
)
if selector.name == "last-of-type":
return lambda el: all(s.tag != el.tag for s in itersiblings(el))
if selector.name == "only-child":
return lambda el: getnext(el) is None and getprevious(el) is None
if selector.name == "only-of-type":
return lambda el: all(s.tag != el.tag for s in itersiblings(el)) and all(
s.tag != el.tag for s in itersiblings(el, preceding=True)
)
if selector.name == "empty":
return lambda el: not list(el) and el.text is None
raise SelectorError("Unknown pseudo-class", selector.name)
def _compile_lang(self, selector: parser.FunctionalPseudoClassSelector):
langs = []
tokens = [
token
for token in selector.arguments
if token.type not in ("whitespace", "comment")
]
while tokens:
token = tokens.pop(0)
if token.type == "ident":
langs.append(token.lower_value)
elif token.type == "string":
langs.append(ascii_lower(token.value))
else:
raise SelectorError("Invalid arguments for :lang()")
if tokens:
token = tokens.pop(0)
if token.type != "ident" and token.value != ",":
raise SelectorError("Invalid arguments for :lang()")
def haslang(el, lang):
print(
el.get("lang"),
lang,
el.get("lang", "") == lang or el.get("lang", "").startswith(lang + "-"),
)
return el.get("lang", "").lower() == lang or el.get(
"lang", ""
).lower().startswith(lang + "-")
return lambda el: any(
haslang(el, lang) or any(haslang(el2, lang) for el2 in iterancestors(el))
for lang in langs
)
def _compile_functional_pseudoclass(
self, selector: parser.FunctionalPseudoClassSelector
):
if selector.name == "lang":
return self._compile_lang(selector)
nth: List[str] = []
selector_list: List[str] = []
current_list = nth
for argument in selector.arguments:
if argument.type == "ident" and argument.value == "of":
if current_list is nth:
current_list = selector_list
continue
current_list.append(argument)
if selector_list:
compiled = tuple(
self.compile_node(selector.parsed_tree)
for selector in parser.parse(selector_list)
)
test = lambda el: all(expr(el) for expr in compiled)
else:
test = TRUE
if selector.name == "nth-child":
count = lambda el: sum(
1 for e in itersiblings(el, preceding=True) if test(e)
)
elif selector.name == "nth-last-child":
count = lambda el: sum(1 for e in itersiblings(el) if test(e))
elif selector.name == "nth-of-type":
count = lambda el: sum(
1
for s in (e for e in itersiblings(el, preceding=True) if test(e))
if s.tag == el.tag
)
elif selector.name == "nth-last-of-type":
count = lambda el: sum(
1 for s in (e for e in itersiblings(el) if test(e)) if s.tag == el.tag
)
else:
raise SelectorError("Unknown pseudo-class", selector.name)
count_func = lambda el: count(el) if test(el) else float("nan")
result = parse_nth(nth)
if result is None:
raise SelectorError(f"Invalid arguments for :{selector.name}()")
a, b = result
# x is the number of siblings before/after the element
# Matches if a positive or zero integer n exists so that:
# x = a*n + b-1
# x = a*n + B
B = b - 1
if a == 0:
# x = B
return lambda el: count_func(el) == B
# n = (x - B) / a
def evaluator(el):
n, r = divmod(count_func(el) - B, a)
return r == 0 and n >= 0
return evaluator
def compile_node(self, selector):
"""Return a boolean expression, as a callable.
When evaluated in a context where the `el` variable is an
:class:`cssselect2.tree.Element` object, tells whether the element is a
subject of `selector`.
"""
try:
return self._func_map[selector.__class__](selector)
except KeyError as e:
raise TypeError(type(selector), selector) from e
CSSCompiler = BooleanCompiler()

View File

@@ -0,0 +1,548 @@
# Forked from cssselect2, 1.2.1, BSD License
"""Parse CSS declarations."""
from tinycss2 import parse_component_value_list
__all__ = ["parse"]
SUPPORTED_PSEUDO_ELEMENTS = {
# As per CSS Pseudo-Elements Module Level 4
"first-line",
"first-letter",
"prefix",
"postfix",
"selection",
"target-text",
"spelling-error",
"grammar-error",
"before",
"after",
"marker",
"placeholder",
"file-selector-button",
# As per CSS Generated Content for Paged Media Module
"footnote-call",
"footnote-marker",
# As per CSS Scoping Module Level 1
"content",
"shadow",
}
def parse(input, namespaces=None, forgiving=False, relative=False):
"""Yield tinycss2 selectors found in given ``input``.
:param input:
A string, or an iterable of tinycss2 component values.
"""
if isinstance(input, str):
input = parse_component_value_list(input)
tokens = TokenStream(input)
namespaces = namespaces or {}
try:
yield parse_selector(tokens, namespaces, relative)
except SelectorError as exception:
if forgiving:
return
raise exception
while 1:
next = tokens.next()
if next is None:
return
elif next == ",":
try:
yield parse_selector(tokens, namespaces, relative)
except SelectorError as exception:
if not forgiving:
raise exception
else:
if not forgiving:
raise SelectorError(next, f"unexpected {next.type} token.")
def parse_selector(tokens, namespaces, relative=False):
tokens.skip_whitespace_and_comment()
if relative:
peek = tokens.peek()
if peek in (">", "+", "~"):
initial_combinator = peek.value
tokens.next()
else:
initial_combinator = " "
tokens.skip_whitespace_and_comment()
result, pseudo_element = parse_compound_selector(tokens, namespaces)
while 1:
has_whitespace = tokens.skip_whitespace()
while tokens.skip_comment():
has_whitespace = tokens.skip_whitespace() or has_whitespace
selector = Selector(result, pseudo_element)
if relative:
selector = RelativeSelector(initial_combinator, selector)
if pseudo_element is not None:
return selector
peek = tokens.peek()
if peek is None or peek == ",":
return selector
elif peek in (">", "+", "~"):
combinator = peek.value
tokens.next()
elif has_whitespace:
combinator = " "
else:
return selector
compound, pseudo_element = parse_compound_selector(tokens, namespaces)
result = CombinedSelector(result, combinator, compound)
def parse_compound_selector(tokens, namespaces):
type_selectors = parse_type_selector(tokens, namespaces)
simple_selectors = type_selectors if type_selectors is not None else []
while 1:
simple_selector, pseudo_element = parse_simple_selector(tokens, namespaces)
if pseudo_element is not None or simple_selector is None:
break
simple_selectors.append(simple_selector)
if simple_selectors or (type_selectors, pseudo_element) != (None, None):
return CompoundSelector(simple_selectors), pseudo_element
peek = tokens.peek()
peek_type = peek.type if peek else "EOF"
raise SelectorError(peek, f"expected a compound selector, got {peek_type}")
def parse_type_selector(tokens, namespaces):
tokens.skip_whitespace()
qualified_name = parse_qualified_name(tokens, namespaces)
if qualified_name is None:
return None
simple_selectors = []
namespace, local_name = qualified_name
if local_name is not None:
simple_selectors.append(LocalNameSelector(local_name))
if namespace is not None:
simple_selectors.append(NamespaceSelector(namespace))
return simple_selectors
def parse_simple_selector(tokens, namespaces):
peek = tokens.peek()
if peek is None:
return None, None
if peek.type == "hash" and peek.is_identifier:
tokens.next()
return IDSelector(peek.value), None
elif peek == ".":
tokens.next()
next = tokens.next()
if next is None or next.type != "ident":
raise SelectorError(next, f"Expected a class name, got {next}")
return ClassSelector(next.value), None
elif peek.type == "[] block":
tokens.next()
attr = parse_attribute_selector(TokenStream(peek.content), namespaces)
return attr, None
elif peek == ":":
tokens.next()
next = tokens.next()
if next == ":":
next = tokens.next()
if next is None or next.type != "ident":
raise SelectorError(next, f"Expected a pseudo-element name, got {next}")
value = next.lower_value
if value not in SUPPORTED_PSEUDO_ELEMENTS:
raise SelectorError(
next, f"Expected a supported pseudo-element, got {value}"
)
return None, value
elif next is not None and next.type == "ident":
name = next.lower_value
if name in ("before", "after", "first-line", "first-letter"):
return None, name
else:
return PseudoClassSelector(name), None
elif next is not None and next.type == "function":
name = next.lower_name
if name in ("is", "where", "not", "has"):
return parse_logical_combination(next, namespaces, name), None
else:
return (FunctionalPseudoClassSelector(name, next.arguments), None)
else:
raise SelectorError(next, f"unexpected {next} token.")
else:
return None, None
def parse_logical_combination(matches_any_token, namespaces, name):
forgiving = True
relative = False
if name == "is":
selector_class = MatchesAnySelector
elif name == "where":
selector_class = SpecificityAdjustmentSelector
elif name == "not":
forgiving = False
selector_class = NegationSelector
elif name == "has":
relative = True
selector_class = RelationalSelector
selectors = [
selector
for selector in parse(
matches_any_token.arguments, namespaces, forgiving, relative
)
if selector.pseudo_element is None
]
return selector_class(selectors)
def parse_attribute_selector(tokens, namespaces):
tokens.skip_whitespace()
qualified_name = parse_qualified_name(tokens, namespaces, is_attribute=True)
if qualified_name is None:
next = tokens.next()
raise SelectorError(next, f"expected attribute name, got {next}")
namespace, local_name = qualified_name
tokens.skip_whitespace()
peek = tokens.peek()
if peek is None:
operator = None
value = None
elif peek in ("=", "~=", "|=", "^=", "$=", "*="):
operator = peek.value
tokens.next()
tokens.skip_whitespace()
next = tokens.next()
if next is None or next.type not in ("ident", "string"):
next_type = "None" if next is None else next.type
raise SelectorError(next, f"expected attribute value, got {next_type}")
value = next.value
else:
raise SelectorError(peek, f"expected attribute selector operator, got {peek}")
tokens.skip_whitespace()
next = tokens.next()
case_sensitive = None
if next is not None:
if next.type == "ident" and next.value.lower() == "i":
case_sensitive = False
elif next.type == "ident" and next.value.lower() == "s":
case_sensitive = True
else:
raise SelectorError(next, f"expected ], got {next.type}")
return AttributeSelector(namespace, local_name, operator, value, case_sensitive)
def parse_qualified_name(tokens, namespaces, is_attribute=False):
"""Return ``(namespace, local)`` for given tokens.
Can also return ``None`` for a wildcard.
The empty string for ``namespace`` means "no namespace".
"""
peek = tokens.peek()
if peek is None:
return None
if peek.type == "ident":
first_ident = tokens.next()
peek = tokens.peek()
if peek != "|":
namespace = "" if is_attribute else namespaces.get(None, None)
return namespace, (first_ident.value, first_ident.lower_value)
tokens.next()
namespace = namespaces.get(first_ident.value)
if namespace is None:
raise SelectorError(
first_ident, f"undefined namespace prefix: {first_ident.value}"
)
elif peek == "*":
next = tokens.next()
peek = tokens.peek()
if peek != "|":
if is_attribute:
raise SelectorError(next, f"expected local name, got {next.type}")
return namespaces.get(None, None), None
tokens.next()
namespace = None
elif peek == "|":
tokens.next()
namespace = ""
else:
return None
# If we get here, we just consumed '|' and set ``namespace``
next = tokens.next()
if next.type == "ident":
return namespace, (next.value, next.lower_value)
elif next == "*" and not is_attribute:
return namespace, None
else:
raise SelectorError(next, f"expected local name, got {next.type}")
class SelectorError(ValueError):
"""A specialized ``ValueError`` for invalid selectors."""
class TokenStream:
def __init__(self, tokens):
self.tokens = iter(tokens)
self.peeked = [] # In reversed order
def next(self):
if self.peeked:
return self.peeked.pop()
else:
return next(self.tokens, None)
def peek(self):
if not self.peeked:
self.peeked.append(next(self.tokens, None))
return self.peeked[-1]
def skip(self, skip_types):
found = False
while 1:
peek = self.peek()
if peek is None or peek.type not in skip_types:
break
self.next()
found = True
return found
def skip_whitespace(self):
return self.skip(["whitespace"])
def skip_comment(self):
return self.skip(["comment"])
def skip_whitespace_and_comment(self):
return self.skip(["comment", "whitespace"])
class Selector:
def __init__(self, tree, pseudo_element=None):
self.parsed_tree = tree
self.pseudo_element = pseudo_element
if pseudo_element is None:
#: Tuple of 3 integers: http://www.w3.org/TR/selectors/#specificity
self.specificity = tree.specificity
else:
a, b, c = tree.specificity
self.specificity = a, b, c + 1
def __repr__(self):
pseudo = f"::{self.pseudo_element}" if self.pseudo_element else ""
return f"{self.parsed_tree!r}{pseudo}"
class RelativeSelector:
def __init__(self, combinator, selector):
self.combinator = combinator
self.selector = selector
@property
def specificity(self):
return self.selector.specificity
@property
def pseudo_element(self):
return self.selector.pseudo_element
def __repr__(self):
return (
f"{self.selector!r}"
if self.combinator == " "
else f"{self.combinator} {self.selector!r}"
)
class CombinedSelector:
def __init__(self, left, combinator, right):
#: Combined or compound selector
self.left = left
# One of `` `` (a single space), ``>``, ``+`` or ``~``.
self.combinator = combinator
#: compound selector
self.right = right
@property
def specificity(self):
a1, b1, c1 = self.left.specificity
a2, b2, c2 = self.right.specificity
return a1 + a2, b1 + b2, c1 + c2
def __repr__(self):
return f"{self.left!r}{self.combinator}{self.right!r}"
class CompoundSelector:
def __init__(self, simple_selectors):
self.simple_selectors = simple_selectors
@property
def specificity(self):
if self.simple_selectors:
# zip(*foo) turns [(a1, b1, c1), (a2, b2, c2), ...]
# into [(a1, a2, ...), (b1, b2, ...), (c1, c2, ...)]
return tuple(
map(sum, zip(*(sel.specificity for sel in self.simple_selectors)))
)
else:
return 0, 0, 0
def __repr__(self):
return "".join(map(repr, self.simple_selectors))
class LocalNameSelector:
specificity = 0, 0, 1
def __init__(self, local_name):
self.local_name, self.lower_local_name = local_name
def __repr__(self):
return self.local_name
class NamespaceSelector:
specificity = 0, 0, 0
def __init__(self, namespace):
#: The namespace URL as a string,
#: or the empty string for elements not in any namespace.
self.namespace = namespace
def __repr__(self):
if self.namespace == "":
return "|"
else:
return f"{{{self.namespace}}}|"
class IDSelector:
specificity = 1, 0, 0
def __init__(self, ident):
self.ident = ident
def __repr__(self):
return f"#{self.ident}"
class ClassSelector:
specificity = 0, 1, 0
def __init__(self, class_name):
self.class_name = class_name
def __repr__(self):
return f".{self.class_name}"
class AttributeSelector:
specificity = 0, 1, 0
def __init__(self, namespace, name, operator, value, case_sensitive):
self.namespace = namespace
self.name, self.lower_name = name
#: A string like ``=`` or ``~=``, or None for ``[attr]`` selectors
self.operator = operator
#: A string, or None for ``[attr]`` selectors
self.value = value
#: ``True`` if case-sensitive, ``False`` if case-insensitive, ``None``
#: if depends on the document language
self.case_sensitive = case_sensitive
def __repr__(self):
namespace = "*|" if self.namespace is None else f"{{{self.namespace}}}"
case_sensitive = (
""
if self.case_sensitive is None
else f" {'s' if self.case_sensitive else 'i'}"
)
return f"[{namespace}{self.name}{self.operator}{self.value!r}{case_sensitive}]"
class PseudoClassSelector:
specificity = 0, 1, 0
def __init__(self, name):
self.name = name
def __repr__(self):
return ":" + self.name
class FunctionalPseudoClassSelector:
specificity = 0, 1, 0
def __init__(self, name, arguments):
self.name = name
self.arguments = arguments
def __repr__(self):
return f":{self.name}{tuple(self.arguments)!r}"
class NegationSelector:
def __init__(self, selector_list):
self.selector_list = selector_list
@property
def specificity(self):
if self.selector_list:
return max(selector.specificity for selector in self.selector_list)
else:
return (0, 0, 0)
def __repr__(self):
return f":not({', '.join(repr(sel) for sel in self.selector_list)})"
class RelationalSelector:
def __init__(self, selector_list):
self.selector_list = selector_list
@property
def specificity(self):
if self.selector_list:
return max(selector.specificity for selector in self.selector_list)
else:
return (0, 0, 0)
def __repr__(self):
return f":has({', '.join(repr(sel) for sel in self.selector_list)})"
class MatchesAnySelector:
def __init__(self, selector_list):
self.selector_list = selector_list
@property
def specificity(self):
if self.selector_list:
return max(selector.specificity for selector in self.selector_list)
else:
return (0, 0, 0)
def __repr__(self):
return f":is({', '.join(repr(sel) for sel in self.selector_list)})"
class SpecificityAdjustmentSelector:
def __init__(self, selector_list):
self.selector_list = selector_list
@property
def specificity(self):
return (0, 0, 0)
def __repr__(self):
return f":where({', '.join(repr(sel) for sel in self.selector_list)})"

View File

@@ -0,0 +1,4 @@
# coding=utf-8This directory contains compatibility layers for all the `simple` modules, such as `simplepath` and `simplestyle`
This directory IS NOT a module path, to denote this we are using a dash in the name and there is no '__init__.py'

View File

@@ -0,0 +1,46 @@
# coding=utf-8
#
# 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 Street, Fifth Floor, Boston, MA 02110-1301, USA.
#
# pylint: disable=invalid-name,unused-argument
"""Deprecated bezmisc API"""
from inkex.deprecated import deprecate
from inkex import bezier
bezierparameterize = deprecate(bezier.bezierparameterize)
linebezierintersect = deprecate(bezier.linebezierintersect)
bezierpointatt = deprecate(bezier.bezierpointatt)
bezierslopeatt = deprecate(bezier.bezierslopeatt)
beziertatslope = deprecate(bezier.beziertatslope)
tpoint = deprecate(bezier.tpoint)
beziersplitatt = deprecate(bezier.beziersplitatt)
pointdistance = deprecate(bezier.pointdistance)
Gravesen_addifclose = deprecate(bezier.addifclose)
balf = deprecate(bezier.balf)
bezierlengthSimpson = deprecate(bezier.bezierlength)
beziertatlength = deprecate(bezier.beziertatlength)
bezierlength = bezierlengthSimpson
@deprecate
def Simpson(func, a, b, n_limit, tolerance):
"""bezier.simpson(a, b, n_limit, tolerance, balf_arguments)"""
raise AttributeError(
"""Because bezmisc.Simpson used global variables, it's not possible to
call the replacement code automatically. In fact it's unlikely you were
using the code or functionality you think you were since it's a highly
broken way of writing python."""
)

View File

@@ -0,0 +1,25 @@
# coding=utf-8
#
# 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 Street, Fifth Floor, Boston, MA 02110-1301, USA.
#
# pylint: disable=invalid-name
"""Deprecated cspsubdiv API"""
from inkex.deprecated import deprecate
from inkex import bezier
maxdist = deprecate(bezier.maxdist)
cspsubdiv = deprecate(bezier.cspsubdiv)
subdiv = deprecate(bezier.subdiv)

View File

@@ -0,0 +1,52 @@
# coding=utf-8
#
# 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 Street, Fifth Floor, Boston, MA 02110-1301, USA.
#
# pylint: disable=invalid-name
"""Deprecated cubic super path API"""
from inkex.deprecated import deprecate
from inkex import paths
@deprecate
def ArcToPath(p1, params):
return paths.arc_to_path(p1, params)
@deprecate
def CubicSuperPath(simplepath):
return paths.Path(simplepath).to_superpath()
@deprecate
def unCubicSuperPath(csp):
return paths.CubicSuperPath(csp).to_path().to_arrays()
@deprecate
def parsePath(d):
return paths.CubicSuperPath(paths.Path(d))
@deprecate
def formatPath(p):
return str(paths.Path(unCubicSuperPath(p)))
matprod = deprecate(paths.matprod)
rotmat = deprecate(paths.rotmat)
applymat = deprecate(paths.applymat)
norm = deprecate(paths.norm)

View File

@@ -0,0 +1,92 @@
# coding=utf-8
#
# 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 Street, Fifth Floor, Boston, MA 02110-1301, USA.
#
# pylint: disable=invalid-name,missing-docstring
"""Deprecated ffgeom API"""
from collections import namedtuple
from inkex.deprecated import deprecate
from inkex.transforms import DirectedLineSegment as NewSeg
try:
NaN = float("NaN")
except ValueError:
PosInf = 1e300000
NaN = PosInf / PosInf
class Point(namedtuple("Point", "x y")):
__slots__ = ()
def __getitem__(self, key):
if isinstance(key, str):
key = "xy".index(key)
return super(Point, self).__getitem__(key)
class Segment(NewSeg):
@deprecate
def __init__(self, e0, e1):
"""inkex.transforms.DirectedLineSegment((x1, y1), (x2, y2))"""
if isinstance(e0, dict):
e0 = (e0["x"], e0["y"])
if isinstance(e1, dict):
e1 = (e1["x"], e1["y"])
super(Segment, self).__init__(e0, e1)
def __getitem__(self, key):
if key:
return {"x": self.x.maximum, "y": self.y.maximum}
return {"x": self.x.minimum, "y": self.y.minimum}
delta_x = lambda self: self.width
delta_y = lambda self: self.height
run = delta_x
rise = delta_y
def distanceToPoint(self, p):
return self.distance_to_point(p["x"], p["y"])
def perpDistanceToPoint(self, p):
return self.perp_distance(p["x"], p["y"])
def angle(self):
return super(Segment, self).angle
def length(self):
return super(Segment, self).length
def pointAtLength(self, length):
return self.point_at_length(length)
def pointAtRatio(self, ratio):
return self.point_at_ratio(ratio)
def createParallel(self, p):
self.parallel(p["x"], p["y"])
@deprecate
def intersectSegments(s1, s2):
"""transforms.Segment(s1).intersect(s2)"""
return Point(*s1.intersect(s2))
@deprecate
def dot(s1, s2):
"""transforms.Segment(s1).dot(s2)"""
return s1.dot(s2)

View File

@@ -0,0 +1,80 @@
# coding=utf-8
#
# Copyright (C) 2008 Stephen Silver
#
# 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 Street, Fifth Floor, Boston, MA 02110-1301, USA
#
"""
Deprecated module for running SVG-generating commands in Inkscape extensions
"""
import os
import sys
import tempfile
from subprocess import Popen, PIPE
from inkex.deprecated import deprecate
def run(command_format, prog_name):
"""inkex.commands.call(...)"""
svgfile = tempfile.mktemp(".svg")
command = command_format % svgfile
msg = None
# ps2pdf may attempt to write to the current directory, which may not
# be writeable, so we switch to the temp directory first.
try:
os.chdir(tempfile.gettempdir())
except IOError:
pass
try:
proc = Popen(command, shell=True, stdout=PIPE, stderr=PIPE)
return_code = proc.wait()
out = proc.stdout.read()
err = proc.stderr.read()
if msg is None:
if return_code:
msg = "{} failed:\n{}\n{}\n".format(prog_name, out, err)
elif err:
sys.stderr.write(
"{} executed but logged the following error:\n{}\n{}\n".format(
prog_name, out, err
)
)
except Exception as inst:
msg = "Error attempting to run {}: {}".format(prog_name, str(inst))
# If successful, copy the output file to stdout.
if msg is None:
if os.name == "nt": # make stdout work in binary on Windows
import msvcrt
msvcrt.setmode(sys.stdout.fileno(), os.O_BINARY)
try:
with open(svgfile, "rb") as fhl:
sys.stdout.write(fhl.read().decode(sys.stdout.encoding))
except IOError as inst:
msg = "Error reading temporary file: {}".format(str(inst))
try:
# Clean up.
os.remove(svgfile)
except (IOError, OSError):
pass
# Output error message (if any) and exit.
return msg

View File

@@ -0,0 +1,68 @@
# coding=utf-8
# COPYRIGHT
#
# pylint: disable=invalid-name
#
"""
Depreicated simplepath replacements with documentation
"""
import math
from inkex.deprecated import deprecate, DeprecatedDict
from inkex.transforms import Transform
from inkex.paths import Path
pathdefs = DeprecatedDict(
{
"M": ["L", 2, [float, float], ["x", "y"]],
"L": ["L", 2, [float, float], ["x", "y"]],
"H": ["H", 1, [float], ["x"]],
"V": ["V", 1, [float], ["y"]],
"C": [
"C",
6,
[float, float, float, float, float, float],
["x", "y", "x", "y", "x", "y"],
],
"S": ["S", 4, [float, float, float, float], ["x", "y", "x", "y"]],
"Q": ["Q", 4, [float, float, float, float], ["x", "y", "x", "y"]],
"T": ["T", 2, [float, float], ["x", "y"]],
"A": [
"A",
7,
[float, float, float, int, int, float, float],
["r", "r", "a", 0, "s", "x", "y"],
],
"Z": ["L", 0, [], []],
}
)
@deprecate
def parsePath(d):
"""element.path.to_arrays()"""
return Path(d).to_arrays()
@deprecate
def formatPath(a):
"""str(element.path) or str(Path(array))"""
return str(Path(a))
@deprecate
def translatePath(p, x, y):
"""Path(array).translate(x, y)"""
p[:] = Path(p).translate(x, y).to_arrays()
@deprecate
def scalePath(p, x, y):
"""Path(array).scale(x, y)"""
p[:] = Path(p).scale(x, y).to_arrays()
@deprecate
def rotatePath(p, a, cx=0, cy=0):
"""Path(array).rotate(angle_degrees, (center_x, center_y))"""
p[:] = Path(p).rotate(math.degrees(a), (cx, cy)).to_arrays()

View File

@@ -0,0 +1,55 @@
# coding=utf-8
# COPYRIGHT
"""DOCSTRING"""
import inkex
from inkex.colors.spaces.named import _COLORS as svgcolors
from inkex.deprecated import deprecate
@deprecate
def parseStyle(s):
"""dict(inkex.Style.parse_str(s))"""
return dict(inkex.Style.parse_str(s))
@deprecate
def formatStyle(a):
"""str(inkex.Style(a))"""
return str(inkex.Style(a))
@deprecate
def isColor(c):
"""inkex.colors.is_color(c)"""
return inkex.colors.is_color(c)
@deprecate
def parseColor(c):
"""inkex.Color(c).to_rgb()"""
return tuple(inkex.Color(c).to_rgb())
@deprecate
def formatColoria(a):
"""str(inkex.Color(a))"""
return str(inkex.ColorRGB(a))
@deprecate
def formatColorfa(a):
"""str(inkex.Color(a))"""
return str(inkex.ColorRGB([b * 255 for b in a]))
@deprecate
def formatColor3i(r, g, b):
"""str(inkex.Color((r, g, b)))"""
return str(inkex.ColorRGB((r, g, b)))
@deprecate
def formatColor3f(r, g, b):
"""str(inkex.Color((r, g, b)))"""
return str(inkex.ColorRGB((r * 255, g * 255, b * 255)))

View File

@@ -0,0 +1,122 @@
# coding=utf-8
#
# pylint: disable=invalid-name
#
"""
Depreicated simpletransform replacements with documentation
"""
import warnings
from inkex.deprecated import deprecate
from inkex.transforms import Transform, BoundingBox, cubic_extrema
from inkex.paths import Path
import inkex, cubicsuperpath
def _lists(mat):
return [list(row) for row in mat]
@deprecate
def parseTransform(transf, mat=None):
"""Transform(str).matrix"""
t = Transform(transf)
if mat is not None:
t = Transform(mat) @ t
return _lists(t.matrix)
@deprecate
def formatTransform(mat):
"""str(Transform(mat))"""
if len(mat) == 3:
warnings.warn("3x3 matrices not suported")
mat = mat[:2]
return str(Transform(mat))
@deprecate
def invertTransform(mat):
"""-Transform(mat)"""
return _lists((-Transform(mat)).matrix)
@deprecate
def composeTransform(mat1, mat2):
"""Transform(M1) * Transform(M2)"""
return _lists((Transform(mat1) @ Transform(mat2)).matrix)
@deprecate
def composeParents(node, mat):
"""elem.composed_transform() or elem.transform * Transform(mat)"""
return (node.transform @ Transform(mat)).matrix
@deprecate
def applyTransformToNode(mat, node):
"""elem.transform = Transform(mat) * elem.transform"""
node.transform = Transform(mat) @ node.transform
@deprecate
def applyTransformToPoint(mat, pt):
"""Transform(mat).apply_to_point(pt)"""
pt2 = Transform(mat).apply_to_point(pt)
# Apply in place as original method was modifying arrays in place.
# but don't do this in your code! This is not good code design.
pt[0] = pt2[0]
pt[1] = pt2[1]
@deprecate
def applyTransformToPath(mat, path):
"""Path(path).transform(mat)"""
return Path(path).transform(Transform(mat)).to_arrays()
@deprecate
def fuseTransform(node):
"""node.apply_transform()"""
return node.apply_transform()
@deprecate
def boxunion(b1, b2):
"""list(BoundingBox(b1) + BoundingBox(b2))"""
bbox = BoundingBox(b1[:2], b1[2:]) + BoundingBox(b2[:2], b2[2:])
return bbox.x.minimum, bbox.x.maximum, bbox.y.minimum, bbox.y.maximum
@deprecate
def roughBBox(path):
"""list(Path(path)).bounding_box())"""
bbox = Path(path).bounding_box()
return bbox.x.minimum, bbox.x.maximum, bbox.y.minimum, bbox.y.maximum
@deprecate
def refinedBBox(path):
"""list(Path(path)).bounding_box())"""
bbox = Path(path).bounding_box()
return bbox.x.minimum, bbox.x.maximum, bbox.y.minimum, bbox.y.maximum
@deprecate
def cubicExtrema(y0, y1, y2, y3):
"""from inkex.transforms import cubic_extrema"""
return cubic_extrema(y0, y1, y2, y3)
@deprecate
def computeBBox(aList, mat=[[1, 0, 0], [0, 1, 0]]):
"""sum([node.bounding_box() for node in aList])"""
return sum([node.bounding_box() for node in aList], None)
@deprecate
def computePointInNode(pt, node, mat=[[1.0, 0.0, 0.0], [0.0, 1.0, 0.0]]):
"""(-Transform(node.transform * mat)).apply_to_point(pt)"""
return (-Transform(node.transform * mat)).apply_to_point(pt)

View File

@@ -0,0 +1,3 @@
from .main import *
from .meta import deprecate, _deprecated
from .deprecatedeffect import DeprecatedEffect, Effect

Some files were not shown because too many files have changed in this diff Show More