feat: introduce local-first SVG workbench

This commit is contained in:
2026-08-02 16:31:49 +02:00
commit 73dc04d042
97 changed files with 20702 additions and 0 deletions
+19
View File
@@ -0,0 +1,19 @@
node_modules/
dist/
release/
coverage/
playwright-report/
test-results/
*.tsbuildinfo
.DS_Store
.env
.env.*
!.env.example
public/CHANGELOG.md
public/LICENSE
public/LICENSES/
public/README.md
public/SOURCE.md
public/THIRD_PARTY_LICENSES.txt
public/THIRD_PARTY_NOTICES.md
public/docs/
+1
View File
@@ -0,0 +1 @@
@add-ideas:registry=https://git.add-ideas.de/api/packages/lotobo/npm/
+8
View File
@@ -0,0 +1,8 @@
dist
release
coverage
playwright-report
test-results
package-lock.json
public/toolbox-app.json
public/THIRD_PARTY_LICENSES.txt
+22
View File
@@ -0,0 +1,22 @@
# Changelog
All notable changes follow Keep a Changelog. Versions follow Semantic
Versioning.
## [0.1.0] - 2026-08-02
### Added
- Initial static, local-first SVG Tools workbench using Toolbox AppShell 0.2.3.
- Source/tree/canvas/inspector synchronization with exact source patches,
last-valid invalid-source handling and application undo/redo.
- Opaque-origin sanitized preview and project-authored hostile SVG tests.
- Standard path parsing and first-class anchor/control/arc handles.
- Transform analysis, preview and supported primitive/path baking.
- Reference, geometry, security and accessibility diagnostics.
- Worker-based SVGO profiles with cancellation and non-destructive diff.
- SVG, SVGZ, derived SVG, raster and deterministic project exports.
- Deterministic standalone release packaging and Toolbox Portal assembly smoke
test.
[0.1.0]: https://git.add-ideas.de/lotobo/svg-tools/releases/tag/v0.1.0
+674
View File
@@ -0,0 +1,674 @@
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/>.
The GNU General Public License does not permit incorporating your program
into proprietary programs. If your program is a subroutine library, you
may consider it more useful to permit linking proprietary applications with
the library. If this is what you want to do, use the GNU Lesser General
Public License instead of this License. But first, please read
<https://www.gnu.org/licenses/why-not-lgpl.html>.
+14
View File
@@ -0,0 +1,14 @@
# Licence inventory
SVG Tools is `GPL-3.0-or-later`; the full project licence is at `../LICENSE`.
`THIRD_PARTY_NOTICES.md` records every direct and transitive runtime package,
its exact installed version, licence, source and role. The release preparation
script additionally collates the project-specific licence/notice files from the
exact locked `node_modules` tree into
`LICENSES/npm-runtime-licenses.txt` inside the static artifact. That generated
file retains each package name/version and its original text.
Development-only packages are recorded separately in the notices and are not
shipped in the browser artifact. Reference repositories were inspected but no
reference source or assets were copied or adapted.
+177
View File
@@ -0,0 +1,177 @@
# SVG Tools
SVG Tools is a static, local-first SVG workbench. It keeps the original SVG
source as the persistent document, then derives a semantic tree, a sanitized
editing projection, a canvas overlay and inspectors from that source. Files are
processed in the browser; the app has no backend, upload endpoint, telemetry or
runtime CDN dependency.
Version 0.1.0 delivers the structured viewer/editor milestone (MVP 1) and
tested vertical slices of the path, transform, reference, optimization,
security, accessibility, animation and export milestones. The later milestone
lists below remain a roadmap, not a claim of complete vector-editor coverage.
## Working model
- The CodeMirror XML document is canonical. Visual changes create exact source
patches and one application-level undo transaction.
- The semantic model records element and attribute source ranges, stable
in-memory keys, source preferences, metrics and diagnostics.
- Tree, source cursor and canvas selection are synchronized.
- Invalid edits remain visible in the source editor while the tree and canvas
retain the last valid revision. Visual editing is disabled until parsing
succeeds again.
- Comments, whitespace and unrelated source remain unchanged for targeted
edits. Prettify, sanitize and optimize are explicit whole-document previews.
- No editor namespace or persistent editor attribute is added to normal SVG
exports.
## Implemented in 0.1.0
- open, drop and paste SVG; magic-byte SVGZ import; new-document templates;
- XML editor, ranged diagnostics, structure tree, filtering and synchronized
selection;
- safe canvas, zoom, pan, grid, selected bounds and last-valid state;
- document dimensions, `viewBox`, `preserveAspectRatio`, arbitrary attributes,
fill, stroke, opacity, rotate, flip, translate, scale and skew;
- exact undo/redo and explicit source-diff previews;
- parsing of `M L H V C S Q T A Z`, absolute/relative and repeated groups;
- anchors, cubic and quadratic controls, derived smooth controls, arc controls,
nested-transform coordinate mapping, keyboard nudging, 50% segment splitting
and path reversal;
- transform-list parsing, ancestor matrices, invertibility diagnostics, preview
overlay, and atomic baking for paths, lines, polylines, polygons, rectangles,
circles and ellipses with disclosed conversion/stroke warnings;
- ID/reference indexing, broken/ambiguous/cyclic reference diagnostics and
atomic ID rename for local URL, href and ARIA references;
- SVGO 4.0.2 in a cancelable worker with conservative, balanced and aggressive
profiles, explicit optional plugins, sizes and source diff;
- non-destructive sanitization, geometry diagnostics, accessible-name audit and
undoable root title/description fixes;
- app-owned, validated CSS opacity animation preview in the isolated canvas and
explicit source application;
- exact SVG, deterministic SVGZ, sanitized SVG, optimized SVG, selected-object
SVG, symbol sprite, PNG, WebP, JPEG and deterministic project export.
Well-formed SVG/XML content that the semantic model does not understand is
preserved in canonical source. The editing projection deliberately excludes
active or externally loading content. Standard rendering still follows the
browser's SVG implementation, so font, filter and text layout can differ across
browsers.
## Path controls
Explicit cubic controls are solid; controls reflected by `S` are dashed and
labelled derived. Quadratic commands show their single control, while `T`
derives the reflection of the preceding quadratic control. Arcs expose endpoint,
center and radius controls plus their flags in the command table. Moving a
derived control intentionally expands shorthand to explicit normalized geometry.
Visual path edits currently serialize the selected `d` attribute in normalized
absolute form; surrounding source is untouched.
Not yet implemented: multi-node modes, delete/convert/join/break/combine,
subpath-start changes, a pen tool, snapping, simplification, boolean operations,
round corners and stroke-to-path.
## Transform model and limits
Transform drafts never change source. SVG Tools shows the local/ancestor chain,
candidate matrix, diagnostics, before/after overlay and source diff before one
atomic Apply. General rectangle and ellipse cases may convert to a path. Baking
is refused for unsupported elements rather than partially rewriting them.
Stroke widths are not silently rewritten for non-conformal transforms; the
preview reports the consequence. Paint servers, markers, filters, clips, masks,
text and shared definitions are not flattened or cloned. Group flattening,
text-to-path and complete paint-server coordinate remapping remain future work.
## Security and privacy
Imported source is never inserted into the application DOM. DOMPurify plus an
application policy produces a separate editing projection. Scripts,
`foreignObject`, event handlers, native SVG animation, navigation and unsafe
namespaces are removed; external/executable URLs and unsafe CSS resource forms
are neutralized. Only unique local fragments and bounded raster data URLs are
eligible for rendering.
The projection runs in an opaque-origin iframe (`sandbox="allow-scripts"`) with
a restrictive child CSP. Its small self-hosted controller accepts a random
channel and messages from the parent window only, then removes its own script
element. Production servers must return the two documented cross-origin headers
for `canvas-frame-controller.js`; see [docs/SECURITY.md](docs/SECURITY.md).
Original source always remains available. Sanitization and optimization require
an explicit preview and Apply. These controls reduce the documented threat
surface; they are not a universal SVG sanitizer for every embedding context.
Resource limits cover source bytes, decompressed SVGZ output, elements, depth,
attributes, text, path commands, CSS, references, animation/filter counts,
embedded data, history, optimization time and raster pixels.
## Accessibility
The current evidence-based audit checks the root accessible name (`title`,
`aria-label` or `aria-labelledby`), missing description, invalid/ambiguous ARIA
references, focusable elements without names and selected contrast evidence.
Safe root title/description fixes are undoable. This is not a complete browser
accessible-name computation and does not establish WCAG conformance.
## Projects and persistence
Normal work is memory-only. SVG Tools does not write source or recent-file data
to browser storage. A `.svgtools.json` project is explicit, deterministic JSON
containing canonical source, selection/expanded state, active panel, zoom, pan,
grid and app-owned animation definitions. Import validates schema, sizes,
numbers, arrays and every animation field before use.
## Development
Node.js 22 or newer and npm are required.
```sh
npm ci
npm run dev
npm run typecheck
npm run lint
npm run format:check
npm test
npm run test:security
npm run test:browser
npm run build
npm run toolbox:check
```
`vite.config.ts` uses `base: './'`; the same build works standalone, in Toolbox
context and below a nested path. `npm run release:artifact` performs the complete
quality gate, creates a deterministic ZIP plus SHA-256 sidecar, and exercises a
temporary Toolbox Portal assembly. Portal consumes that immutable ZIP; it does
not build this repository.
For the opaque canvas controller, a production server must return:
```text
Access-Control-Allow-Origin: *
Cross-Origin-Resource-Policy: cross-origin
```
only for the app-relative `canvas-frame-controller.js`. All other app resources
should keep `Cross-Origin-Resource-Policy: same-origin`. The Toolbox Portal
0.10.0 Nginx policy contains this URI-scoped exception.
## Source and licence
Corresponding source and inspected revisions are recorded in [SOURCE.md](SOURCE.md).
Third-party packages and adoption decisions are recorded in
[THIRD_PARTY_NOTICES.md](THIRD_PARTY_NOTICES.md) and [LICENSES](LICENSES/README.md).
Project-authored fixtures contain no third-party artwork or text.
SVG Tools is licensed under `GPL-3.0-or-later`. See [LICENSE](LICENSE).
## Roadmap
The smallest next slice is multi-node path editing: node selection and modes,
shape-preserving deletion/insertion, segment conversion and open/join/break
operations under nested transforms. Later slices add drawing tools, grouping,
alignment/snapping, gradients/markers/filters, symbol management, advanced
geometry and broader isolated SMIL/CSS animation inspection.
Detailed models and limitations live in [docs](docs/ARCHITECTURE.md).
+37
View File
@@ -0,0 +1,37 @@
# Corresponding source and provenance
The corresponding source for SVG Tools 0.1.0 is:
https://git.add-ideas.de/lotobo/svg-tools/src/tag/v0.1.0
Build from that tag with Node.js 22 and the exact `package-lock.json`:
```sh
npm ci
npm run release:artifact
```
No generated bundle is the preferred source form. No runtime code is loaded
from a CDN. The release ZIP contains the licence, corresponding-source pointer,
dependency inventory and notices.
## Revisions inspected
| Project | Revision/version inspected | Decision |
| ------------------------------------- | ------------------------------------------------------------------------- | ------------------------------------------------------------------------------- |
| SVG Tools | empty public `lotobo/svg-tools` repository before this initial commit | implemented here |
| Toolbox SDK/contract/AppShell/testkit | `ef2dab4b46c61812c9a877d8a19fe497b4a4630a`, packages 0.2.3 | adopted |
| Toolbox Portal | `55b2b12434465511586eaa2d0a43e81770868738`, tag v0.9.3 | release/UX contract reference; Portal 0.10.0 integrates the artifact |
| SVG-Edit | `244a26c88e1ab1c32911c5b3637e214d7a7d8b25` | interaction/implementation reference only; no source copied |
| SVG Path Editor | `937d75a83b6be2bdda11d02b9b3594841315223a` | path interaction reference only; no source copied |
| SVGPathCommander | `9aa91dd2119ee6a65b1807d55d33e86b1ccb27fc` | inspected, not adopted; project-authored path core is smaller and source-ranged |
| SVGO | npm 4.0.2, published `gitHead` `b2309cf541aee11634eb653157b0ff86ab326e98` | adopted in a worker; GitHub clone was not required for shipped bytes |
| DOMPurify | `9365501773d6665aaf334d8afa55081b9930a684`, npm 3.4.12 | adopted as one layer of the projection policy |
| css-tree | npm 3.2.1 | adopted for parsed CSS policy/value checks |
| Boolean/geometry engine | none | not adopted; boolean operations are not claimed |
| Raster engine | browser SVG image decoder and Canvas 2D | no third-party raster engine shipped |
| Compression fallback | fflate 0.8.3 | adopted for bounded SVGZ read/write |
Boxy SVG and SVGViewer were consulted only as public product references. Their
source, assets, branding, layout and text were not copied. All test SVG strings
and visual assets in this repository are project-authored.
+99
View File
@@ -0,0 +1,99 @@
# Third-party notices
The versions below are the exact installed versions in `package-lock.json` for
SVG Tools 0.1.0. “Shipped” means code or data can be present in the browser
bundle. No listed source was copied into project-authored files; packages are
consumed through their public APIs and normal bundling.
The deterministic release contains a generated
`LICENSES/npm-runtime-licenses.txt` with each shipped package's original licence
or notice file. Repository links are the upstream source locations declared by
the packages.
## Direct runtime dependencies
| Project | Version | Licence | Role | Source |
| ----------------------- | ------- | --------------------- | -------------------------------------------------------- | ------------------------------------------------- |
| Toolbox contract | 0.2.3 | Apache-2.0 | manifest/context types | https://git.add-ideas.de/lotobo/toolbox-sdk |
| Toolbox shell React | 0.2.3 | Apache-2.0 | shared app shell/theme/help/source actions | https://git.add-ideas.de/lotobo/toolbox-sdk |
| CodeMirror commands | 6.10.4 | MIT | editor commands/keymap | https://code.haverbeke.berlin/codemirror/commands |
| CodeMirror XML language | 6.1.0 | MIT | XML language support | https://github.com/codemirror/lang-xml |
| CodeMirror language | 6.12.4 | MIT | highlighting/folding | https://code.haverbeke.berlin/codemirror/language |
| CodeMirror search | 6.7.1 | MIT | editor search | https://code.haverbeke.berlin/codemirror/search |
| CodeMirror state | 6.7.1 | MIT | editor state | https://code.haverbeke.berlin/codemirror/state |
| CodeMirror view | 6.43.6 | MIT | source editor UI | https://code.haverbeke.berlin/codemirror/view |
| Lezer XML | 1.0.6 | MIT | ranged XML syntax diagnostics | https://github.com/lezer-parser/xml |
| css-tree | 3.2.1 | MIT | parsed CSS security and animation-value checks | https://github.com/csstree/csstree |
| DOMPurify | 3.4.12 | MPL-2.0 OR Apache-2.0 | first sanitization layer | https://github.com/cure53/DOMPurify |
| fflate | 0.8.3 | MIT | bounded SVGZ decompression and deterministic compression | https://github.com/101arrowz/fflate |
| React | 19.2.6 | MIT | application UI | https://github.com/facebook/react |
| React DOM | 19.2.6 | MIT | browser rendering | https://github.com/facebook/react |
| SVGO | 4.0.2 | MIT | worker-based optional optimization | https://github.com/svg/svgo |
## Transitive runtime dependencies
| Package | Version | Licence | Source |
| ---------------------------- | ------- | ------------- | ------------------------------------------------------- |
| `@codemirror/autocomplete` | 6.20.3 | MIT | https://code.haverbeke.berlin/codemirror/autocomplete |
| `@lezer/common` | 1.5.2 | MIT | https://github.com/lezer-parser/common |
| `@lezer/highlight` | 1.2.3 | MIT | https://github.com/lezer-parser/highlight |
| `@lezer/lr` | 1.4.10 | MIT | https://code.haverbeke.berlin/lezer/lr |
| `@marijn/find-cluster-break` | 1.0.3 | MIT | https://code.haverbeke.berlin/marijn/find-cluster-break |
| `@types/trusted-types` | 2.0.7 | MIT | https://github.com/DefinitelyTyped/DefinitelyTyped |
| `boolbase` | 1.0.0 | ISC | https://github.com/fb55/boolbase |
| `commander` | 11.1.0 | MIT | https://github.com/tj/commander.js |
| `crelt` | 1.0.7 | MIT | https://code.haverbeke.berlin/marijn/crelt |
| `css-select` | 5.2.2 | BSD-2-Clause | https://github.com/fb55/css-select |
| `css-what` | 6.2.2 | BSD-2-Clause | https://github.com/fb55/css-what |
| `csso` | 5.0.5 | MIT | https://github.com/css/csso |
| `dom-serializer` | 2.0.0 | MIT | https://github.com/cheeriojs/dom-serializer |
| `domelementtype` | 2.3.0 | BSD-2-Clause | https://github.com/fb55/domelementtype |
| `domhandler` | 5.0.3 | BSD-2-Clause | https://github.com/fb55/domhandler |
| `domutils` | 3.2.2 | BSD-2-Clause | https://github.com/fb55/domutils |
| `entities` | 4.5.0 | BSD-2-Clause | https://github.com/fb55/entities |
| `mdn-data` | 2.27.1 | CC0-1.0 | https://github.com/mdn/data |
| `nth-check` | 2.1.1 | BSD-2-Clause | https://github.com/fb55/nth-check |
| `picocolors` | 1.1.1 | ISC | https://github.com/alexeyraspopov/picocolors |
| `sax` | 1.6.1 | BlueOak-1.0.0 | https://github.com/isaacs/sax-js |
| `scheduler` | 0.27.0 | MIT | https://github.com/facebook/react |
| `source-map-js` | 1.2.1 | BSD-3-Clause | https://github.com/7rulnik/source-map-js |
| `style-mod` | 4.1.3 | MIT | https://github.com/marijnh/style-mod |
| `w3c-keyname` | 2.2.8 | MIT | https://github.com/marijnh/w3c-keyname |
## Development-only dependencies
These packages are required to build, check or test source and are not shipped
as runtime modules in the release ZIP.
| Package | Version | Licence |
| ----------------------------- | ------- | ---------- |
| `@add-ideas/toolbox-testkit` | 0.2.3 | Apache-2.0 |
| `@eslint/js` | 10.0.1 | MIT |
| `@playwright/test` | 1.61.1 | Apache-2.0 |
| `@testing-library/jest-dom` | 6.9.1 | MIT |
| `@testing-library/react` | 16.3.2 | MIT |
| `@testing-library/user-event` | 14.6.1 | MIT |
| `@types/css-tree` | 2.3.11 | MIT |
| `@types/node` | 25.8.0 | MIT |
| `@types/react` | 19.2.14 | MIT |
| `@types/react-dom` | 19.2.3 | MIT |
| `@vitejs/plugin-react` | 6.0.2 | MIT |
| `eslint` | 10.4.0 | MIT |
| `eslint-plugin-react-hooks` | 7.1.1 | MIT |
| `eslint-plugin-react-refresh` | 0.5.2 | MIT |
| `fast-check` | 4.9.0 | MIT |
| `globals` | 17.6.0 | MIT |
| `jsdom` | 29.1.1 | MIT |
| `prettier` | 3.8.3 | MIT |
| `typescript` | 6.0.3 | Apache-2.0 |
| `typescript-eslint` | 8.59.3 | MIT |
| `vite` | 8.2.0 | MIT |
| `vitest` | 4.1.6 | MIT |
## Reviewed but not shipped
SVG-Edit, SVG Path Editor and SVGPathCommander were inspected at the revisions
in `SOURCE.md`; no code, fixture, asset or explanatory text was copied or
adapted. Boxy SVG and SVGViewer were product references only. No boolean
geometry library, licensed parser, external raster engine, font or optional
Wasm module is included. All hostile and semantic fixtures are project-authored.
+26
View File
@@ -0,0 +1,26 @@
# Accessibility audit
The audit is evidence-based and intentionally narrower than a browser or
assistive-technology accessible-name computation.
Implemented checks include:
- root accessible name evidence from direct `title`, `aria-label` or a uniquely
resolved `aria-labelledby` target;
- missing root description (`desc`/`aria-describedby` evidence);
- missing, duplicate or ambiguous ARIA ID references;
- focusable graphical/link elements without local naming evidence;
- selected fill/stroke contrast evidence where simple literal colours can be
evaluated;
- reference diagnostics that can invalidate accessible relationships.
Findings identify severity, rule, evidence, limitation and a suggested fix.
Safe automatic fixes insert a root `title` or `desc` through an exact source
patch, preserve surrounding source and are undoable.
Limitations: the audit does not compute CSS cascade, rendered visibility,
language, reading order, browser/AT mappings, text contrast over arbitrary
backgrounds, keyboard interaction quality or author intent. It does not certify
WCAG conformance. Generated placeholder title/description text must be replaced
with an author-appropriate description and tested with target browsers and
assistive technology.
+28
View File
@@ -0,0 +1,28 @@
# Animation
Native SVG animation elements are discovered/countable in source but removed
from every rendered editing projection, so imported SMIL and event-triggered
animation cannot execute automatically. Imported scripts are never supported.
Version 0.1.0 implements a deliberately small app-owned animation slice:
- add an opacity animation to the selected element;
- enable/disable/remove definitions;
- preview generated CSS only in the opaque-origin canvas;
- persist definitions in an explicit `.svgtools.json` project;
- apply validated CSS plus stable target IDs to source in one transaction.
One shared validator is used for project import, preview and Apply. It bounds
definition/keyframe counts and strings, requires finite ordered offsets and
bounded timing/iteration values, allowlists property/kind/direction/fill mode,
accepts only safe easing grammar and parses every CSS value with css-tree. URLs,
resource functions, structural CSS/XML characters and parser raw nodes are
rejected even for disabled definitions. Selectors and keyframe identifiers are
escaped/generated by the app.
The preview has play/pause as an enable toggle but no timeline, seek or native
SMIL editing. Duration/keyframes are not yet exposed as a general UI. Transform,
colour, motion-path and compatible path-morph authoring, animation discovery
panels and conversion between CSS/SMIL remain future milestones. Applying CSS
changes the SVG source intentionally and may affect downstream consumers that
do not support the same CSS animation features.
+53
View File
@@ -0,0 +1,53 @@
# Architecture
## Boundaries
SVG Tools is a static Vite application. React owns controls and projections,
not the document. Canonical SVG source is the only persistent document
representation. Normal sessions stay in memory; explicit downloads are the
only persistence boundary.
```text
canonical source
├─ ranged XML scan + DOM parse → semantic document → tree/inspectors
├─ security policy + DOMPurify → editing projection → opaque iframe
└─ source patches/transactions ← visual commands and explicit workflows
```
The last valid semantic document and projection remain available while current
source is invalid. Revision checks reject stale visual, worker or dialog results.
## Representations
1. **Source document:** exact user text, including comments, quotes and
whitespace.
2. **Semantic document:** SVG elements, attributes, source ranges, in-memory
keys, relationships, preferences, metrics and diagnostics.
3. **Editing projection:** cloned, sanitized SVG with temporary mapping
attributes. It is never exported as current SVG.
4. **Preview:** the projection in a sandboxed opaque-origin iframe, plus a
parent-owned overlay for bounds and path controls.
Imported source is never rendered through React or inserted into the main DOM.
The iframe controller is a fixed packaged asset, accepts only its parent and a
random per-instance channel, and removes its script element after startup.
## Commands and workers
Every accepted edit is a transaction containing exact before/after source,
patches, selection and revision. Undo validates the current source before
restoring exact text. Fast consecutive source/key nudge edits may merge under a
bounded merge key.
SVGO executes in a dedicated module worker. Jobs have IDs, timeout,
supersession, AbortSignal cancellation and immediate deterministic rejection.
Parsing currently uses bounded synchronous browser XML facilities because its
`XMLDocument` projection is not transferable; large inputs are throttled and
hard-limited. Rasterization uses a sanitized Blob URL and Canvas 2D.
## Release boundary
`base: './'` keeps every asset relocatable. The independent ZIP is built and
checked here. Toolbox Portal consumes the immutable ZIP and checksum through a
release lock; it never compiles SVG Tools source. AppShell reads optional
Toolbox context but standalone operation is the default fallback.
+45
View File
@@ -0,0 +1,45 @@
# Document model
## Source ranges
The lexical scan records exact half-open offsets for each opening/full element,
name and quoted attribute. CodeMirror's Lezer XML parser supplies ranged syntax
errors; a separate stack scan rejects structures browser DOM parsers might
repair. The DOM parse supplies namespace and decoded semantic values. Element
order must match the lexical tokens or parsing fails with a source-map mismatch.
DOCTYPE text remains in canonical source but is removed before DOM parsing.
Entity declarations are rejected for the semantic model. External entities are
never resolved.
## Stable keys
A unique explicit SVG `id` becomes `id:<id>`. Otherwise a key hashes structural
path, source offset, local name and optional ID; collision suffixes are
in-memory. Reparsing retains explicit-ID identity and best-effort structural
identity. Keys appear only as temporary `data-svg-tools-node` attributes in the
editing projection and are stripped from exports.
## Semantic nodes
Nodes retain namespace/name, parent and children, depth, ID/classes, decoded
attributes, exact attribute ranges, render status and leaf text. The document
also records indentation/newline/quote preference, ordered keys, diagnostics
and bounded metrics.
Unknown well-formed source remains canonical even when there is no inspector.
A targeted command patches only its attribute/tag range. Whole-source
serialization occurs only for explicit format, sanitize or optimize workflows,
each with a source diff.
## Invalid state
Every source update gets a monotonically increasing revision. If current source
is invalid, diagnostics refer to that text while the canvas/tree use the last
valid snapshot and all visual controls are disabled. Once the same current
revision parses and sanitizes successfully it atomically replaces the snapshot.
Resource limits can make otherwise well-formed source invalid for this app.
Limits cover bytes, structure, path/CSS/reference/animation/filter counts,
embedded data and total text. This prevents unbounded model construction but is
not a promise that every file below a limit is inexpensive on every device.
+34
View File
@@ -0,0 +1,34 @@
# Optimization
SVG Tools ships SVGO 4.0.2 (`gitHead`
`b2309cf541aee11634eb653157b0ff86ab326e98`) inside a module worker. The exact
version and active plugin list are displayed with every result.
## Profiles
- **Conservative:** cleanup and safe numeric/style normalization while retaining
IDs, metadata, viewBox, dimensions and accessibility content.
- **Balanced:** additional structural cleanup with protected identifiers and
accessibility-sensitive content.
- **Aggressive:** explicitly lossy/structural choices for users who accept the
disclosed risk.
Optional plugins are selected explicitly in the dialog; plugin names are mapped
to an application allowlist rather than passed through from imported data. See
`src/optimization/profiles.ts` for the authoritative ordered lists.
The worker returns source, exact before/after UTF-8 byte counts, version and
plugins. The main thread rejects stale job IDs. Closing/canceling, a superseding
request or AbortSignal immediately rejects the old promise, removes listeners,
clears its timer and terminates its worker. A 30-second safety timeout is the
final boundary.
Optimization never mutates source automatically. A result remains a candidate
with size summary, source diff and side-by-side isolated visual comparison until
Apply. Applying checks the source revision and produces one undoable transaction.
Even conservative SVGO passes can change insignificant lexical form and browser
edge behavior. Aggressive plugins can change IDs, metadata, editorability or
rendering. Visual comparison cannot prove semantic equivalence, especially for
fonts, filters, animation and external consumer behavior. Keep original source
and use project/domain fixtures for important artwork.
+42
View File
@@ -0,0 +1,42 @@
# Path editor
The project-authored path core accepts every SVG path command: `M/m`, `L/l`,
`H/h`, `V/v`, `C/c`, `S/s`, `Q/q`, `T/t`, `A/a` and `Z/z`, including repeated
parameter groups, implicit lines after move, packed arc flags, exponents,
relative forms and multiple subpaths. It rejects non-finite values, malformed
arity/flags and paths beyond the command limit.
Segments resolve to absolute geometry while retaining source command/form and
fragment offsets for the command table. Serialization intentionally normalizes
edited geometry; no editor metadata is emitted.
## Controls
- Every segment endpoint is an anchor.
- Cubics show both controls. `S` reflects the previous cubic control and marks
the resulting handle as derived/dashed until edited.
- Quadratics show their one control. `T` reflects the previous quadratic
control and marks it derived.
- Arcs expose endpoint, radii and derived center controls and show rotation,
large-arc and sweep flags in the command panel.
- Handle radii and strokes use screen-oriented overlay styling so they remain
usable across zoom levels.
The full selected-element/ancestor transform chain maps local path geometry into
root space. Pointer coordinates use its inverse, including an active preview
matrix; non-invertible chains disable handles. Dragging commits one transaction
on release; arrow keys use 1, Shift+10 or Alt+0.1 units and merge safely.
Segment splitting uses the mathematical midpoint: linear interpolation for
lines, de Casteljau for quadratic/cubic curves, and arc subdivision preserving
the ellipse. Path reversal reverses every supported segment/subpath and adjusts
controls/sweep where required.
## Current limits
The 0.1.0 UI supplies drag/nudge, split at 50%, reverse and a read-only numeric
command table. It does not yet supply node modes, multi-selection, deletion,
conversion, open/close, join/break/combine, subpath-start changes, arbitrary
split parameter, pen creation, snapping, simplification or boolean operations.
Visual editing expands shorthand/relative commands to normalized absolute data;
the dialog/help discloses that source-form change.
+79
View File
@@ -0,0 +1,79 @@
# Security model
SVG is active content. Threats include script/event execution, navigation,
external fetches and tracking, CSS resource resolution, `foreignObject`, native
animation triggers, namespace confusion, entity/DOCTYPE processing, parser
differentials, oversized compressed/data content and algorithmic complexity.
## Source and projection
Canonical source is inert text and remains available even when unsafe. A
separate projection is built from a cloned semantic document. DOMPurify's SVG
profiles are followed by an application policy that:
- removes `script`, `foreignObject`, `animate`, `animateMotion`,
`animateTransform`, `set`, foreign namespaces and all `on*` attributes;
- disables links/navigation and executable or external URL attributes;
- allows only unique local fragment references and bounded base64 raster image
data (`png`, `jpeg`, `gif`, `webp`, `avif`);
- parses style declarations/stylesheets with css-tree and rejects `@import`,
unsafe legacy properties, parse failures, non-local URLs, `expression()` and
resource-producing functions such as `image-set()` and `paint()`;
- reports findings with source ranges wherever a semantic node/range exists.
DOCTYPE is excluded before DOM parsing; entity declarations invalidate the
semantic projection. Sanitized export removes temporary mapping attributes.
Sanitize is never applied to canonical source without preview and acceptance.
## Isolated preview
The projection is an iframe `srcdoc` with `sandbox="allow-scripts"` and no
`allow-same-origin`, giving it an opaque origin. Its child CSP is:
```text
default-src 'none'; connect-src 'none'; object-src 'none'; frame-src 'none';
base-uri 'none'; form-action 'none'; img-src data: blob:;
style-src 'unsafe-inline'; script-src <controller-origin>
```
The sole script is the packaged `canvas-frame-controller.js`. It is loaded with
anonymous CORS, accepts messages only from `parent` with a random channel,
validates message fields and removes its script element. The parent validates
both `event.source` and channel.
Because a sandbox without same-origin treats the script request as cross-origin,
serve this one app-relative asset with:
```text
Access-Control-Allow-Origin: *
Cross-Origin-Resource-Policy: cross-origin
```
Keep `Cross-Origin-Resource-Policy: same-origin` for other files. Use an Nginx
`map`/header value, not a nested `location` that would drop inherited security
headers. Toolbox Portal 0.10.0 implements and tests this exception.
## Limits and tests
Hard limits are defined in `src/app/limits.ts`. SVGZ uses streaming
decompression and stops once output crosses the source limit. Workers are
revisioned, timed out and cancelable. Tree/diagnostic rendering is capped.
Project-authored tests cover script, handlers, links, external image/use/filter,
CSS imports/URLs/parser failures/resource functions, native animation,
namespaces, duplicate IDs/cycles, malformed entities/XML, depth/attribute/path
and data limits. Browser tests assert no script callback and no request to the
hostile domain.
## Limitations and reporting
This policy protects this application's projections and exports; it is not a
general guarantee for arbitrary downstream embedding. Browser SVG/CSS parsers
and DOMPurify remain dependency/security boundaries. Bounded data URLs may still
decode expensive images within the raster-pixel limits. CSP header regressions
can break the fixed controller even when the content remains inert.
Report vulnerabilities privately through the repository owner/contact before
opening a public issue when disclosure could expose users. Include the SVG,
browser, deployment headers and observed network/execution behavior without
sensitive user files.
+41
View File
@@ -0,0 +1,41 @@
# Transform model
SVG transform functions are parsed without changing order: `matrix`,
`translate`, `scale`, `rotate` (with optional center), `skewX` and `skewY`.
The inspector shows each selected/ancestor source transform, accumulated matrix,
determinant/invertibility diagnostics and the candidate matrix.
A draft combines translate, origin, rotate, scale and skew. It affects only the
iframe/overlay until Apply. The proposed source and rendering are shown before
one revision-checked transaction; undo restores the exact pre-apply text.
## Bake support
| Element | Supported result |
| --------------------- | ----------------------------------------------------------------------------------------------------------- |
| `path` | all segment points transformed; arcs retained for safe conformal cases or converted/disclosed when required |
| `line` | transformed endpoints, remains `line` |
| `polyline`, `polygon` | transformed point list, same element |
| `rect` | remains `rect` for axis-aligned safe cases; otherwise candidate path |
| `circle` | remains circle under conformal scale; otherwise explicit ellipse/path as required |
| `ellipse` | remains ellipse for safe axis-aligned cases; otherwise path |
Numbers remain full precision internally and are serialized with stable bounded
precision only at the explicit operation boundary. Invalid/non-finite geometry,
negative radii and unsupported element types fail before patch creation.
## Policies and limits
- Non-conformal transforms with a visible stroke produce a warning; stroke
width/vector-effect is not silently rewritten.
- General arc conversion is disclosed in the preview.
- Shared gradients, patterns, markers, filters, masks and clips are indexed but
not cloned or remapped during bake.
- Text transforms remain attributes; text-to-path is not implemented.
- Group flattening and partial multi-child baking are not implemented.
- Consolidation is available through an appended matrix candidate; a full
editable transform-list UI/decomposition is future work.
Browser rendering, particularly text and filters, is the visual oracle; the
unit suite checks matrix and geometry invariants but does not claim pixel-perfect
equivalence for every renderer.
+43
View File
@@ -0,0 +1,43 @@
import js from "@eslint/js";
import globals from "globals";
import reactHooks from "eslint-plugin-react-hooks";
import reactRefresh from "eslint-plugin-react-refresh";
import tseslint from "typescript-eslint";
export default tseslint.config(
{
ignores: [
"dist",
"release",
"coverage",
"test-results",
"playwright-report",
],
},
{
extends: [js.configs.recommended, ...tseslint.configs.recommended],
files: ["**/*.{ts,tsx}"],
languageOptions: {
ecmaVersion: 2023,
globals: {
...globals.browser,
...globals.worker,
},
},
plugins: {
"react-hooks": reactHooks,
"react-refresh": reactRefresh,
},
rules: {
...reactHooks.configs.recommended.rules,
"react-refresh/only-export-components": [
"warn",
{ allowConstantExport: true },
],
},
},
{
files: ["scripts/**/*.mjs", "playwright.config.ts"],
languageOptions: { globals: globals.node },
},
);
+17
View File
@@ -0,0 +1,17 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta
name="description"
content="Inspect, edit, optimize and transform SVG documents locally in your browser."
/>
<link rel="icon" type="image/svg+xml" href="./favicon.svg" />
<title>SVG Tools</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="./src/main.tsx"></script>
</body>
</html>
+4453
View File
File diff suppressed because it is too large Load Diff
+85
View File
@@ -0,0 +1,85 @@
{
"name": "svg-tools",
"version": "0.1.0",
"description": "Inspect, edit, optimize and transform SVG documents locally in the browser.",
"license": "GPL-3.0-or-later",
"author": "Albrecht Degering",
"repository": {
"type": "git",
"url": "git+https://git.add-ideas.de/lotobo/svg-tools.git"
},
"homepage": "https://git.add-ideas.de/lotobo/svg-tools",
"bugs": {
"url": "https://git.add-ideas.de/lotobo/svg-tools/issues"
},
"private": true,
"type": "module",
"engines": {
"node": ">=22"
},
"scripts": {
"predev": "npm run manifest:generate",
"dev": "vite",
"prebuild": "npm run release:prepare && npm run manifest:generate",
"build": "tsc -b && vite build",
"preview": "vite preview",
"typecheck": "tsc -b --pretty false",
"lint": "eslint . --max-warnings=0",
"format": "prettier --write .",
"format:check": "prettier --check .",
"test": "vitest run",
"test:watch": "vitest",
"test:security": "vitest run tests/security",
"test:browser": "playwright test",
"manifest:generate": "node scripts/generate-toolbox-manifest.mjs",
"manifest:check": "node scripts/generate-toolbox-manifest.mjs --check",
"release:prepare": "node scripts/prepare-release-files.mjs",
"toolbox:check": "toolbox-check dist",
"package:release": "node scripts/package-release.mjs",
"portal:smoke": "node scripts/portal-assembly-smoke.mjs",
"check": "npm run manifest:check && npm run typecheck && npm run lint && npm run format:check && npm run test && npm run build && npm run toolbox:check",
"release:artifact": "npm run check && npm run test:security && npm run test:browser && npm run package:release -- --force && npm run portal:smoke"
},
"dependencies": {
"@add-ideas/toolbox-contract": "0.2.3",
"@add-ideas/toolbox-shell-react": "0.2.3",
"@codemirror/commands": "6.10.4",
"@codemirror/lang-xml": "6.1.0",
"@codemirror/language": "6.12.4",
"@codemirror/search": "6.7.1",
"@codemirror/state": "6.7.1",
"@codemirror/view": "6.43.6",
"@lezer/xml": "1.0.6",
"css-tree": "3.2.1",
"dompurify": "3.4.12",
"fflate": "0.8.3",
"react": "19.2.6",
"react-dom": "19.2.6",
"svgo": "4.0.2"
},
"devDependencies": {
"@add-ideas/toolbox-testkit": "0.2.3",
"@eslint/js": "10.0.1",
"@playwright/test": "1.61.1",
"@testing-library/jest-dom": "6.9.1",
"@testing-library/react": "16.3.2",
"@testing-library/user-event": "14.6.1",
"@types/css-tree": "2.3.11",
"@types/node": "25.8.0",
"@types/react": "19.2.14",
"@types/react-dom": "19.2.3",
"@vitejs/plugin-react": "6.0.2",
"eslint": "10.4.0",
"eslint-plugin-react-hooks": "7.1.1",
"eslint-plugin-react-refresh": "0.5.2",
"fast-check": "4.9.0",
"globals": "17.6.0",
"jsdom": "29.1.1",
"prettier": "3.8.3",
"typescript": "6.0.3",
"typescript-eslint": "8.59.3",
"vite": "8.2.0",
"vitest": "4.1.6"
},
"packageManager": "npm@11.17.0"
}
+24
View File
@@ -0,0 +1,24 @@
import { defineConfig, devices } from "@playwright/test";
export default defineConfig({
testDir: "./tests/browser",
fullyParallel: false,
workers: 2,
timeout: 120_000,
expect: { timeout: 10_000 },
reporter: [["list"]],
use: {
baseURL: "http://127.0.0.1:4173",
trace: "retain-on-failure",
},
webServer: {
command: "npm run build && node scripts/serve-test.mjs",
url: "http://127.0.0.1:4173",
reuseExistingServer: !process.env.CI,
timeout: 180_000,
},
projects: [
{ name: "chromium", use: { ...devices["Desktop Chrome"] } },
{ name: "firefox", use: { ...devices["Desktop Firefox"] } },
],
});
+84
View File
@@ -0,0 +1,84 @@
(() => {
"use strict";
const controllerScript = document.currentScript;
const channel = document.documentElement.dataset.svgToolsChannel;
if (!channel) {
controllerScript?.remove();
return;
}
const send = (message) => parent.postMessage({ ...message, channel }, "*");
const find = (key) =>
typeof key === "string"
? document.querySelector(`[data-svg-tools-node="${CSS.escape(key)}"]`)
: null;
const reportBox = (key) => {
const element = find(key);
let box = null;
try {
if (element && typeof element.getBBox === "function") {
const candidate = element.getBBox();
if (
[candidate.x, candidate.y, candidate.width, candidate.height].every(
Number.isFinite,
)
) {
box = {
x: candidate.x,
y: candidate.y,
width: candidate.width,
height: candidate.height,
};
}
}
} catch {
// Some SVG elements do not expose geometry in every browser.
}
send({ type: "selection-box", key, box });
};
document.addEventListener("click", (event) => {
const target =
event.target instanceof Element
? event.target.closest("[data-svg-tools-node]")
: null;
const key = target?.getAttribute("data-svg-tools-node");
if (key) send({ type: "select", key });
if (event.target instanceof Element && event.target.closest("a")) {
event.preventDefault();
}
});
addEventListener("message", (event) => {
if (event.source !== parent) return;
const message = event.data;
if (!message || message.channel !== channel) return;
if (message.type === "ping") send({ type: "ready" });
if (message.type === "selection" && typeof message.key === "string") {
reportBox(message.key);
}
if (
message.type === "path" &&
typeof message.key === "string" &&
typeof message.data === "string"
) {
find(message.key)?.setAttribute("d", message.data);
reportBox(message.key);
}
if (
message.type === "transform" &&
typeof message.key === "string" &&
(typeof message.value === "string" || message.value === null)
) {
const element = find(message.key);
if (!element) return;
if (message.value) element.setAttribute("transform", message.value);
else element.removeAttribute("transform");
reportBox(message.key);
}
});
send({ type: "ready" });
controllerScript?.remove();
})();
+13
View File
@@ -0,0 +1,13 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" role="img" aria-labelledby="title desc">
<title id="title">SVG Tools</title>
<desc id="desc">A vector document with an editable curve and path nodes.</desc>
<path fill="#f7f9fc" stroke="#233047" stroke-width="3" d="M12 5h28l12 12v42H12z"/>
<path fill="#dbe7ff" stroke="#233047" stroke-width="3" stroke-linejoin="round" d="M40 5v13h12"/>
<path fill="none" stroke="#7357ff" stroke-width="4" stroke-linecap="round" d="M20 45C26 22 39 52 47 29"/>
<g fill="#fff" stroke="#7357ff" stroke-width="2">
<rect x="17" y="42" width="6" height="6" rx="1"/>
<circle cx="33" cy="36" r="3"/>
<rect x="44" y="26" width="6" height="6" rx="1"/>
</g>
<path fill="none" stroke="#9a8cff" stroke-width="1.5" stroke-dasharray="2 2" d="M20 45L33 36 47 29"/>
</svg>

After

Width:  |  Height:  |  Size: 827 B

+49
View File
@@ -0,0 +1,49 @@
{
"$schema": "https://git.add-ideas.de/lotobo/toolbox-sdk/raw/branch/main/schemas/toolbox-app.v1.schema.json",
"schemaVersion": 1,
"id": "de.add-ideas.svg-tools",
"name": "SVG Tools",
"version": "0.1.0",
"description": "Inspect, edit, optimize and transform SVG documents locally in the browser.",
"entry": "./",
"icon": "./favicon.svg",
"categories": ["graphics", "design", "developer"],
"tags": [
"svg",
"vector",
"path",
"xml",
"transform",
"optimize",
"accessibility"
],
"integration": {
"contextVersion": 1,
"launchModes": ["navigate", "new-tab"],
"embedding": "unsupported"
},
"requirements": {
"secureContext": false,
"workers": true,
"indexedDb": false,
"crossOriginIsolated": false,
"topLevelContext": false
},
"privacy": {
"processing": "local",
"fileUploads": false,
"telemetry": false
},
"source": {
"repository": "https://git.add-ideas.de/lotobo/svg-tools",
"license": "GPL-3.0-or-later"
},
"actions": [
{
"id": "source",
"label": "Source",
"url": "https://git.add-ideas.de/lotobo/svg-tools"
}
],
"assets": ["./canvas-frame-controller.js"]
}
+69
View File
@@ -0,0 +1,69 @@
import { lstat, readFile, writeFile } from "node:fs/promises";
import { dirname, join, relative } from "node:path";
import { fileURLToPath } from "node:url";
import { format } from "prettier";
const root = join(dirname(fileURLToPath(import.meta.url)), "..");
const sourcePath = join(root, "src", "toolbox", "manifest.source.json");
const outputPath = join(root, "public", "toolbox-app.json");
const packagePath = join(root, "package.json");
const applicationVersionPath = join(root, "src", "version.ts");
const publicPath = join(root, "public");
const checkOnly = process.argv.includes("--check");
const source = JSON.parse(await readFile(sourcePath, "utf8"));
const packageJson = JSON.parse(await readFile(packagePath, "utf8"));
const applicationVersionSource = await readFile(applicationVersionPath, "utf8");
const applicationVersion =
/^export const APPLICATION_VERSION = "([^"]+)";$/mu.exec(
applicationVersionSource,
)?.[1];
if (
source.version !== packageJson.version ||
applicationVersion !== packageJson.version
) {
throw new Error(
`Version drift: manifest ${source.version}, application ${String(applicationVersion)}, package ${packageJson.version}`,
);
}
if (
source.source?.repository !== "https://git.add-ideas.de/lotobo/svg-tools" ||
source.source?.license !== "GPL-3.0-or-later"
) {
throw new Error("Manifest source identity is incomplete or inconsistent");
}
for (const asset of source.assets ?? []) {
if (
typeof asset !== "string" ||
!asset.startsWith("./") ||
asset.includes("\\") ||
asset.split("/").includes("..")
) {
throw new Error(`Unsafe manifest asset path: ${JSON.stringify(asset)}`);
}
const details = await lstat(join(publicPath, asset.slice(2))).catch(
() => null,
);
if (!details?.isFile() || details.isSymbolicLink()) {
throw new Error(`Manifest asset is missing or unsafe: ${asset}`);
}
}
const serialized = await format(JSON.stringify(source), {
filepath: outputPath,
});
if (checkOnly) {
const current = await readFile(outputPath, "utf8").catch(() => "");
if (current !== serialized) {
throw new Error(
`${relative(root, outputPath)} is stale; run npm run manifest:generate`,
);
}
console.log("Toolbox manifest is synchronized");
} else {
await writeFile(outputPath, serialized);
console.log(`Generated ${relative(root, outputPath)}`);
}
+240
View File
@@ -0,0 +1,240 @@
#!/usr/bin/env node
import { createHash } from "node:crypto";
import {
access,
lstat,
mkdir,
mkdtemp,
readFile,
readdir,
rename,
rm,
writeFile,
} from "node:fs/promises";
import path from "node:path";
import { deflateRawSync } from "node:zlib";
import { fileURLToPath } from "node:url";
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
const packageJson = JSON.parse(
await readFile(path.join(root, "package.json"), "utf8"),
);
const input = path.join(root, "dist");
const argument = (name, fallback) => {
const index = process.argv.indexOf(name);
return index >= 0 ? process.argv[index + 1] : fallback;
};
const output = path.resolve(
root,
argument("--output", `release/svg-tools-${packageJson.version}.zip`),
);
const checksumOutput = `${output}.sha256`;
const force = process.argv.includes("--force");
if (path.extname(output).toLowerCase() !== ".zip") {
throw new Error("Release output must use a .zip extension");
}
if (output === path.parse(output).root || output === root) {
throw new Error("Release output is not a safe file target");
}
const exists = (file) =>
access(file).then(
() => true,
() => false,
);
if (!force && (await exists(output))) {
throw new Error(`Release already exists (use --force): ${output}`);
}
if (!force && (await exists(checksumOutput))) {
throw new Error(`Checksum already exists (use --force): ${checksumOutput}`);
}
const required = [
"index.html",
"toolbox-app.json",
"favicon.svg",
"canvas-frame-controller.js",
"README.md",
"CHANGELOG.md",
"LICENSE",
"SOURCE.md",
"THIRD_PARTY_NOTICES.md",
"THIRD_PARTY_LICENSES.txt",
"LICENSES/README.md",
"LICENSES/npm-runtime-licenses.txt",
];
for (const name of required) {
const details = await lstat(path.join(input, name)).catch(() => null);
if (!details?.isFile() || details.isSymbolicLink()) {
throw new Error(`Release input is missing a regular file: ${name}`);
}
}
const assets = await lstat(path.join(input, "assets")).catch(() => null);
if (!assets?.isDirectory() || assets.isSymbolicLink()) {
throw new Error("Release input is missing its assets directory");
}
const manifest = JSON.parse(
await readFile(path.join(input, "toolbox-app.json"), "utf8"),
);
if (
manifest.id !== "de.add-ideas.svg-tools" ||
manifest.version !== packageJson.version ||
manifest.entry !== "./" ||
manifest.icon !== "./favicon.svg" ||
!manifest.assets?.includes("./canvas-frame-controller.js")
) {
throw new Error("Packaged Toolbox manifest identity/assets are invalid");
}
async function collect(directory, prefix = "") {
const files = [];
for (const entry of (await readdir(directory, { withFileTypes: true })).sort(
(left, right) =>
left.name < right.name ? -1 : left.name > right.name ? 1 : 0,
)) {
const absolute = path.join(directory, entry.name);
const relative = prefix ? `${prefix}/${entry.name}` : entry.name;
if (entry.isSymbolicLink()) {
throw new Error(`Release input contains a symbolic link: ${relative}`);
}
if (entry.isDirectory()) files.push(...(await collect(absolute, relative)));
else if (entry.isFile()) files.push({ absolute, relative });
else throw new Error(`Unsupported release entry: ${relative}`);
}
return files;
}
const files = await collect(input);
if (files.length > 65_535) throw new Error("Release contains too many files");
for (const { relative } of files) {
if (
relative.endsWith(".map") ||
/(?:^|\/)(?:\.env(?:\.|$)|id_rsa|id_ed25519|.*\.pem$|.*\.key$)/iu.test(
relative,
)
) {
throw new Error(`Forbidden release entry: ${relative}`);
}
if (relative.startsWith("/") || relative.split("/").includes("..")) {
throw new Error(`Unsafe release entry: ${relative}`);
}
}
const indexHtml = await readFile(path.join(input, "index.html"), "utf8");
if (/\b(?:src|href)=["']\//iu.test(indexHtml)) {
throw new Error("index.html contains a root-absolute asset reference");
}
const crcTable = new Uint32Array(256);
for (let index = 0; index < 256; index += 1) {
let value = index;
for (let bit = 0; bit < 8; bit += 1) {
value = value & 1 ? 0xedb88320 ^ (value >>> 1) : value >>> 1;
}
crcTable[index] = value >>> 0;
}
function crc32(bytes) {
let value = 0xffffffff;
for (const byte of bytes)
value = crcTable[(value ^ byte) & 0xff] ^ (value >>> 8);
return (value ^ 0xffffffff) >>> 0;
}
function header(length) {
return Buffer.alloc(length);
}
const localParts = [];
const centralParts = [];
let offset = 0;
for (const file of files) {
const source = await readFile(file.absolute);
const compressed = deflateRawSync(source, { level: 9 });
const name = Buffer.from(file.relative, "utf8");
const checksum = crc32(source);
if (
source.byteLength > 0xffffffff ||
compressed.byteLength > 0xffffffff ||
offset > 0xffffffff
) {
throw new Error("ZIP64 releases are not supported");
}
const local = header(30);
local.writeUInt32LE(0x04034b50, 0);
local.writeUInt16LE(20, 4);
local.writeUInt16LE(0x0800, 6);
local.writeUInt16LE(8, 8);
local.writeUInt16LE(0, 10);
local.writeUInt16LE(0x0021, 12);
local.writeUInt32LE(checksum, 14);
local.writeUInt32LE(compressed.byteLength, 18);
local.writeUInt32LE(source.byteLength, 22);
local.writeUInt16LE(name.byteLength, 26);
local.writeUInt16LE(0, 28);
localParts.push(local, name, compressed);
const central = header(46);
central.writeUInt32LE(0x02014b50, 0);
central.writeUInt16LE(0x0314, 4);
central.writeUInt16LE(20, 6);
central.writeUInt16LE(0x0800, 8);
central.writeUInt16LE(8, 10);
central.writeUInt16LE(0, 12);
central.writeUInt16LE(0x0021, 14);
central.writeUInt32LE(checksum, 16);
central.writeUInt32LE(compressed.byteLength, 20);
central.writeUInt32LE(source.byteLength, 24);
central.writeUInt16LE(name.byteLength, 28);
central.writeUInt16LE(0, 30);
central.writeUInt16LE(0, 32);
central.writeUInt16LE(0, 34);
central.writeUInt16LE(0, 36);
central.writeUInt32LE((0o100644 << 16) >>> 0, 38);
central.writeUInt32LE(offset, 42);
centralParts.push(central, name);
offset += local.byteLength + name.byteLength + compressed.byteLength;
}
const centralOffset = offset;
const centralSize = centralParts.reduce(
(sum, part) => sum + part.byteLength,
0,
);
if (centralOffset + centralSize > 0xffffffff) {
throw new Error("ZIP64 releases are not supported");
}
const end = header(22);
end.writeUInt32LE(0x06054b50, 0);
end.writeUInt16LE(0, 4);
end.writeUInt16LE(0, 6);
end.writeUInt16LE(files.length, 8);
end.writeUInt16LE(files.length, 10);
end.writeUInt32LE(centralSize, 12);
end.writeUInt32LE(centralOffset, 16);
end.writeUInt16LE(0, 20);
const archive = Buffer.concat([...localParts, ...centralParts, end]);
await mkdir(path.dirname(output), { recursive: true });
const staging = await mkdtemp(path.join(path.dirname(output), ".svg-release-"));
const stagedArchive = path.join(staging, path.basename(output));
const stagedChecksum = `${stagedArchive}.sha256`;
try {
await writeFile(stagedArchive, archive, { flag: "wx", mode: 0o644 });
const digest = createHash("sha256").update(archive).digest("hex");
await writeFile(stagedChecksum, `${digest} ${path.basename(output)}\n`, {
flag: "wx",
mode: 0o644,
});
if (force) {
await rm(output, { force: true });
await rm(checksumOutput, { force: true });
}
await rename(stagedArchive, output);
await rename(stagedChecksum, checksumOutput);
process.stdout.write(
`Created ${path.relative(root, output)} (${archive.byteLength} bytes, ${files.length} files)\nSHA-256 ${digest}\n`,
);
} finally {
await rm(staging, { recursive: true, force: true });
}
+158
View File
@@ -0,0 +1,158 @@
#!/usr/bin/env node
import { createHash } from "node:crypto";
import { spawn } from "node:child_process";
import {
access,
mkdtemp,
readFile,
rm,
stat,
writeFile,
} from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { fileURLToPath, pathToFileURL } from "node:url";
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
const workspace = path.dirname(root);
const portal = path.resolve(
process.env.TOOLBOX_PORTAL_DIR ?? path.join(workspace, "toolbox-portal"),
);
const appPackage = JSON.parse(
await readFile(path.join(root, "package.json"), "utf8"),
);
const portalPackage = JSON.parse(
await readFile(path.join(portal, "package.json"), "utf8"),
);
const artifact = path.join(
root,
"release",
`svg-tools-${appPackage.version}.zip`,
);
async function run(command, arguments_, cwd) {
await new Promise((resolve, reject) => {
const child = spawn(command, arguments_, {
cwd,
stdio: "inherit",
env: { ...process.env, CI: "1" },
});
child.once("error", reject);
child.once("exit", (code, signal) => {
if (code === 0) resolve();
else
reject(
new Error(
`${command} exited with ${code ?? `signal ${signal ?? "unknown"}`}`,
),
);
});
});
}
const details = await stat(artifact).catch(() => null);
if (!details?.isFile()) {
throw new Error(`Build the app release first: ${artifact}`);
}
const digest = createHash("sha256")
.update(await readFile(artifact))
.digest("hex");
const sidecar = await readFile(`${artifact}.sha256`, "utf8");
if (!sidecar.startsWith(`${digest} ${path.basename(artifact)}`)) {
throw new Error("The app release checksum sidecar does not match the ZIP");
}
await access(path.join(portal, "scripts", "assemble.mjs"));
await run(
process.platform === "win32" ? "npm.cmd" : "npm",
["run", "build"],
portal,
);
const temporary = await mkdtemp(
path.join(os.tmpdir(), "svg-tools-portal-smoke-"),
);
try {
const example = JSON.parse(
await readFile(
path.join(portal, "release", "toolbox.lock.example.json"),
"utf8",
),
);
const lock = {
...example,
releaseVersion: "0.10.0-smoke.0",
portalVersion: portalPackage.version,
apps: [
{
id: "de.add-ideas.svg-tools",
version: appPackage.version,
artifact: pathToFileURL(artifact).href,
sha256: digest,
target: "svg",
},
],
};
const lockFile = path.join(temporary, "toolbox.lock.json");
const output = path.join(temporary, "assembled", "toolbox");
const archive = path.join(temporary, "assembled", "toolbox.zip");
await writeFile(lockFile, `${JSON.stringify(lock, null, 2)}\n`, {
flag: "wx",
});
await run(
process.execPath,
[
path.join(portal, "scripts", "assemble.mjs"),
"--lock",
lockFile,
"--portal-dist",
path.join(portal, "dist"),
"--output",
output,
"--archive",
archive,
],
portal,
);
const catalogue = JSON.parse(
await readFile(path.join(output, "toolbox.catalog.json"), "utf8"),
);
const manifest = JSON.parse(
await readFile(
path.join(output, "apps", "svg", "toolbox-app.json"),
"utf8",
),
);
const appIndex = await readFile(
path.join(output, "apps", "svg", "index.html"),
"utf8",
);
if (
catalogue.apps.length !== 1 ||
catalogue.apps[0]?.manifest !== "./apps/svg/toolbox-app.json" ||
manifest.id !== "de.add-ideas.svg-tools" ||
manifest.version !== appPackage.version ||
!manifest.assets?.includes("./canvas-frame-controller.js") ||
/\b(?:src|href)=["']\//iu.test(appIndex)
) {
throw new Error(
"Assembled SVG Tools identity or relocatable assets changed",
);
}
for (const relative of [
"apps/svg/favicon.svg",
"apps/svg/canvas-frame-controller.js",
"apps/svg/LICENSE",
"apps/svg/SOURCE.md",
"toolbox.release.json",
]) {
const file = await stat(path.join(output, relative)).catch(() => null);
if (!file?.isFile()) throw new Error(`Assembly is missing ${relative}`);
}
process.stdout.write(
`Portal assembly smoke passed for SVG Tools ${appPackage.version} (${digest})\n`,
);
} finally {
await rm(temporary, { recursive: true, force: true });
}
+95
View File
@@ -0,0 +1,95 @@
import { cp, mkdir, readFile, readdir, rm, writeFile } from "node:fs/promises";
import path from "node:path";
import { fileURLToPath } from "node:url";
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
const publicDirectory = path.join(root, "public");
const required = [
"LICENSE",
"README.md",
"CHANGELOG.md",
"SOURCE.md",
"THIRD_PARTY_NOTICES.md",
];
await mkdir(publicDirectory, { recursive: true });
for (const name of required) {
const source = path.join(root, name);
await readFile(source);
await cp(source, path.join(publicDirectory, name));
}
const publicLicenses = path.join(publicDirectory, "LICENSES");
await rm(publicLicenses, { recursive: true, force: true });
await cp(path.join(root, "LICENSES"), publicLicenses, { recursive: true });
const publicDocs = path.join(publicDirectory, "docs");
await rm(publicDocs, { recursive: true, force: true });
await cp(path.join(root, "docs"), publicDocs, { recursive: true });
const lock = JSON.parse(
await readFile(path.join(root, "package-lock.json"), "utf8"),
);
const runtimeLicenseSections = [];
for (const [location, locked] of Object.entries(lock.packages ?? {}).sort(
([left], [right]) => (left < right ? -1 : left > right ? 1 : 0),
)) {
if (!location.includes("node_modules/") || locked.dev === true) continue;
const packageDirectory = path.join(root, location);
const details = JSON.parse(
await readFile(path.join(packageDirectory, "package.json"), "utf8"),
);
const candidates = (await readdir(packageDirectory))
.filter((name) => /^(?:licen[cs]e|copying|notice)(?:\.|$)/iu.test(name))
.sort();
const texts = [];
for (const candidate of candidates) {
try {
texts.push(
`--- ${candidate} ---\n${await readFile(path.join(packageDirectory, candidate), "utf8")}`,
);
} catch {
// Ignore directories or non-text aliases; another matching file may exist.
}
}
runtimeLicenseSections.push(
[
"=".repeat(78),
`${details.name}@${details.version}`,
`Declared licence: ${details.license ?? locked.license ?? "See upstream package"}`,
`Installed from: ${location}`,
"=".repeat(78),
texts.length
? texts.join("\n\n")
: "No package-local licence file was present; see THIRD_PARTY_NOTICES.md and the upstream source.",
].join("\n"),
);
}
await writeFile(
path.join(publicLicenses, "npm-runtime-licenses.txt"),
`${runtimeLicenseSections.join("\n\n")}\n`,
);
const packageJson = JSON.parse(
await readFile(path.join(root, "package.json"), "utf8"),
);
const rows = [
"# Runtime dependency licences",
"",
"Generated from the exact lock used for this build.",
"",
"| Package | Version | Licence |",
"| --- | --- | --- |",
];
for (const name of Object.keys(packageJson.dependencies).sort()) {
const manifestPath = path.join(root, "node_modules", name, "package.json");
const details = JSON.parse(await readFile(manifestPath, "utf8"));
rows.push(
`| \`${details.name}\` | ${details.version} | ${details.license ?? "See packaged notices"} |`,
);
}
await writeFile(
path.join(publicDirectory, "THIRD_PARTY_LICENSES.txt"),
`${rows.join("\n")}\n`,
);
console.log("Prepared static release notices");
+97
View File
@@ -0,0 +1,97 @@
import { createServer } from "node:http";
import { readFile, stat } from "node:fs/promises";
import path from "node:path";
import { fileURLToPath } from "node:url";
const root = path.resolve(
path.dirname(fileURLToPath(import.meta.url)),
"..",
"dist",
);
const nestedPrefix = "/deep/nested/svg/";
const mediaTypes = new Map([
[".css", "text/css; charset=utf-8"],
[".html", "text/html; charset=utf-8"],
[".js", "text/javascript; charset=utf-8"],
[".mjs", "text/javascript; charset=utf-8"],
[".json", "application/json; charset=utf-8"],
[".svg", "image/svg+xml"],
[".wasm", "application/wasm"],
]);
const catalogue = {
schemaVersion: 1,
id: "de.add-ideas.svg-tools.browser-test",
name: "SVG Tools browser-test Toolbox",
home: "./",
theme: { mode: "system", brand: "add·ideas" },
apps: [{ manifest: "./toolbox-app.json", enabled: true }],
};
const headers = {
"Content-Security-Policy":
"default-src 'self'; base-uri 'self'; object-src 'none'; frame-ancestors 'none'; form-action 'self'; script-src 'self' 'wasm-unsafe-eval'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob:; media-src 'self' blob:; font-src 'self' data:; connect-src 'self'; worker-src 'self' blob:; frame-src 'self' blob:; manifest-src 'self'",
"Cross-Origin-Opener-Policy": "same-origin",
"Cross-Origin-Resource-Policy": "same-origin",
"X-Content-Type-Options": "nosniff",
};
function safeFile(requestPath) {
const decoded = decodeURIComponent(requestPath);
const relative = decoded.startsWith(nestedPrefix)
? decoded.slice(nestedPrefix.length)
: decoded.replace(/^\/+/, "");
const normalized = path.posix.normalize(relative || "index.html");
if (
normalized === ".." ||
normalized.startsWith("../") ||
path.isAbsolute(normalized)
) {
return null;
}
return path.join(root, normalized);
}
const server = createServer(async (request, response) => {
try {
const url = new URL(request.url ?? "/", "http://127.0.0.1");
if (url.pathname === "/toolbox.catalog.json") {
response.writeHead(200, {
"Content-Type": "application/json; charset=utf-8",
"Cache-Control": "no-cache",
...headers,
});
response.end(JSON.stringify(catalogue));
return;
}
let file = safeFile(url.pathname);
if (!file) {
response.writeHead(400).end("Bad request");
return;
}
if ((await stat(file).catch(() => null))?.isDirectory()) {
file = path.join(file, "index.html");
}
const content = await readFile(file);
const isCanvasController =
path.basename(file) === "canvas-frame-controller.js";
response.writeHead(200, {
"Content-Type":
mediaTypes.get(path.extname(file)) ?? "application/octet-stream",
"Cache-Control": "no-cache",
...headers,
...(isCanvasController
? {
"Access-Control-Allow-Origin": "*",
"Cross-Origin-Resource-Policy": "cross-origin",
}
: {}),
});
response.end(content);
} catch {
response.writeHead(404, { "Content-Type": "text/plain; charset=utf-8" });
response.end("Not found");
}
});
server.listen(4173, "127.0.0.1", () => {
console.log("SVG Tools test server listening on http://127.0.0.1:4173");
});
+42
View File
@@ -0,0 +1,42 @@
import { lazy, Suspense, useState } from "react";
import { AppShell } from "@add-ideas/toolbox-shell-react";
import "@add-ideas/toolbox-shell-react/styles.css";
import "./styles.css";
import { AppErrorBoundary } from "./components/AppErrorBoundary";
import { HelpDialog } from "./components/HelpDialog";
import { manifest } from "./toolbox/manifest";
const Workbench = lazy(async () => {
const module = await import("./components/Workbench");
return { default: module.Workbench };
});
export function App() {
const [helpOpen, setHelpOpen] = useState(false);
return (
<AppErrorBoundary>
<AppShell
app={manifest}
manifestUrl="./toolbox-app.json"
helpAction={{ onClick: () => setHelpOpen(true) }}
onContextError={(error) => {
console.warn(
"Toolbox context unavailable; continuing standalone.",
error,
);
}}
>
<Suspense
fallback={
<p className="workbench-loading" role="status">
Preparing the local SVG workbench
</p>
}
>
<Workbench />
</Suspense>
</AppShell>
<HelpDialog open={helpOpen} onClose={() => setHelpOpen(false)} />
</AppErrorBoundary>
);
}
+130
View File
@@ -0,0 +1,130 @@
import type {
SemanticSvgDocument,
SourcePatch,
SourceRange,
} from "../document/document.types";
import { buildReferenceIndex } from "../structure/reference-index";
export interface AccessibilityFinding {
severity: "info" | "warning" | "error";
rule: string;
nodeKey: string;
evidence: string;
limitation: string;
suggestedFix: string;
automaticFix: "add-title" | "add-description" | "mark-decorative" | null;
sourceRange?: SourceRange;
}
export function auditAccessibility(
semantic: SemanticSvgDocument,
): AccessibilityFinding[] {
const root = semantic.nodes.get(semantic.rootKey)!;
const children = root.childKeys.map((key) => semantic.nodes.get(key)!);
const title = children.find((node) => node.localName === "title");
const description = children.find((node) => node.localName === "desc");
const role = root.attributes.role;
const labelledBy = root.attributes["aria-labelledby"];
const label = root.attributes["aria-label"];
const hidden = root.attributes["aria-hidden"] === "true";
const findings: AccessibilityFinding[] = [];
if (!hidden && !title && !label && !labelledBy) {
findings.push({
severity: "warning",
rule: "svg-accessible-name",
nodeKey: root.key,
evidence: "The root has no title, aria-label or aria-labelledby.",
limitation:
"The embedding page can provide an accessible name outside this file.",
suggestedFix:
"Add a concise root title or explicitly mark the image decorative.",
automaticFix: "add-title",
sourceRange: root.sourceRange.openTag,
});
}
if (!hidden && !description) {
findings.push({
severity: "info",
rule: "svg-description",
nodeKey: root.key,
evidence: "No root description element is present.",
limitation:
"Not every simple or decorative SVG needs a long description.",
suggestedFix:
"Add a description when the image communicates non-trivial content.",
automaticFix: "add-description",
sourceRange: root.sourceRange.openTag,
});
}
if (
!hidden &&
role &&
!["img", "graphics-document", "presentation", "none"].includes(role)
) {
findings.push({
severity: "info",
rule: "svg-role-review",
nodeKey: root.key,
evidence: `Root role is “${role}”.`,
limitation:
"Role validity depends on the embedding and interaction model.",
suggestedFix: "Review the role against the intended embedding context.",
automaticFix: null,
sourceRange: root.attributeRanges.role?.valueRange,
});
}
const references = buildReferenceIndex(semantic);
for (const edge of references.edges.filter(
(candidate) =>
candidate.attribute.startsWith("aria-") &&
candidate.status !== "resolved",
)) {
const node = semantic.nodes.get(edge.sourceKey)!;
findings.push({
severity: "error",
rule: "aria-reference",
nodeKey: edge.sourceKey,
evidence: `${edge.attribute} target “${edge.targetId}” is ${edge.status}.`,
limitation: "Only local SVG ID references are evaluated.",
suggestedFix: "Repair the referenced ID or remove the broken token.",
automaticFix: null,
sourceRange: node.attributeRanges[edge.attribute]?.valueRange,
});
}
for (const node of semantic.nodes.values()) {
if (node.localName === "path" && /text/i.test(node.id ?? "")) {
findings.push({
severity: "info",
rule: "text-as-path-review",
nodeKey: node.key,
evidence:
"A path ID suggests that visible text may have been outlined.",
limitation: "The audit cannot infer author intent from path geometry.",
suggestedFix:
"Prefer a text element when editable, selectable text is required.",
automaticFix: null,
sourceRange: node.sourceRange.openTag,
});
}
}
return findings;
}
export function accessibilityFixPatch(
semantic: SemanticSvgDocument,
fix: "add-title" | "add-description",
text: string,
): SourcePatch {
const root = semantic.nodes.get(semantic.rootKey)!;
const escaped = text
.replaceAll("&", "&amp;")
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;");
const tag = fix === "add-title" ? "title" : "desc";
return {
from: root.sourceRange.openTag.to,
to: root.sourceRange.openTag.to,
insert: `${semantic.preferences.newline}${semantic.preferences.indentation}<${tag}>${escaped}</${tag}>`,
label: fix === "add-title" ? "Add accessible title" : "Add description",
};
}
+23
View File
@@ -0,0 +1,23 @@
export interface AnimationKeyframe {
offset: number;
value: string;
easing?: string;
}
export interface AnimationDefinition {
id: string;
name: string;
targetNodeKey: string;
property: string;
kind: "attribute" | "style" | "transform";
enabled: boolean;
keyframes: AnimationKeyframe[];
timing: {
durationMs: number;
delayMs: number;
iterations: number | "infinite";
direction: "normal" | "reverse" | "alternate" | "alternate-reverse";
fillMode: "none" | "forwards" | "backwards" | "both";
easing: string;
};
}
+40
View File
@@ -0,0 +1,40 @@
import type { AnimationDefinition } from "./animation.types";
import { buildAnimationCss } from "./validation";
function cssString(value: string): string {
return Array.from(value, (character) => {
if (character === "\\") return "\\\\";
if (character === '"') return '\\"';
const code = character.codePointAt(0)!;
if (
code <= 0x1f ||
code === 0x7f ||
character === "<" ||
character === ">" ||
character === "&"
) {
return `\\${code.toString(16)} `;
}
return character;
}).join("");
}
export function animationStyle(
definitions: readonly AnimationDefinition[],
): string {
return buildAnimationCss(definitions, {
keyframeNamePrefix: "svg-tools-animation",
selectorFor: (definition) =>
`[data-svg-tools-node="${cssString(definition.targetNodeKey)}"]`,
});
}
export function withAnimationPreview(
sanitizedProjection: string,
definitions: readonly AnimationDefinition[],
): string {
const css = animationStyle(definitions);
if (!css) return sanitizedProjection;
const style = `<style data-svg-tools-preview="animation">${css.replaceAll("&", "&amp;").replaceAll("<", "&lt;")}</style>`;
return sanitizedProjection.replace(/<svg\b([^>]*)>/iu, `<svg$1>${style}`);
}
+342
View File
@@ -0,0 +1,342 @@
import { parse as parseCss, walk as walkCss } from "css-tree";
import { defaultSvgLimits } from "../app/limits";
import type { AnimationDefinition, AnimationKeyframe } from "./animation.types";
const MAXIMUM_KEYFRAMES = 10_000;
const MAXIMUM_LABEL_LENGTH = 4_096;
const MAXIMUM_VALUE_LENGTH = 65_536;
const MAXIMUM_EASING_LENGTH = 256;
const MAXIMUM_DURATION_MS = 604_800_000;
const MAXIMUM_ITERATIONS = 1_000_000;
const SAFE_PROPERTIES = new Set([
"color",
"fill",
"filter",
"opacity",
"stroke",
"stroke-width",
"transform",
"transform-origin",
"visibility",
]);
const DIRECTIONS = new Set([
"normal",
"reverse",
"alternate",
"alternate-reverse",
]);
const FILL_MODES = new Set(["none", "forwards", "backwards", "both"]);
const KINDS = new Set(["attribute", "style", "transform"]);
const EASING_KEYWORDS = new Set([
"linear",
"ease",
"ease-in",
"ease-out",
"ease-in-out",
"step-start",
"step-end",
]);
const RESOURCE_FUNCTIONS = new Set([
"cross-fade",
"element",
"image",
"image-set",
"paint",
"src",
"-webkit-image-set",
]);
const CSS_NUMBER = "[+-]?(?:\\d+(?:\\.\\d*)?|\\.\\d+)(?:e[+-]?\\d+)?";
const CUBIC_BEZIER = new RegExp(
`^cubic-bezier\\(\\s*(${CSS_NUMBER})\\s*,\\s*(${CSS_NUMBER})\\s*,\\s*(${CSS_NUMBER})\\s*,\\s*(${CSS_NUMBER})\\s*\\)$`,
"iu",
);
const STEPS =
/^steps\(\s*(\d+)\s*(?:,\s*(jump-start|jump-end|jump-none|jump-both|start|end)\s*)?\)$/iu;
export class AnimationValidationError extends Error {
readonly path: string;
readonly reason: string;
constructor(reason: string, path: string) {
super(`${reason} (${path})`);
this.name = "AnimationValidationError";
this.path = path;
this.reason = reason;
}
}
function fail(reason: string, path: string): never {
throw new AnimationValidationError(reason, path);
}
function record(value: unknown, path: string): Record<string, unknown> {
if (!value || typeof value !== "object" || Array.isArray(value)) {
fail("Expected an object", path);
}
return value as Record<string, unknown>;
}
function boundedText(
value: unknown,
path: string,
maximumLength: number,
allowEmpty = false,
): string {
if (
typeof value !== "string" ||
(!allowEmpty && value.length === 0) ||
value.length > maximumLength
) {
fail("Expected a bounded string", path);
}
return value;
}
function finiteNumber(
value: unknown,
path: string,
minimum: number,
maximum: number,
): number {
if (
typeof value !== "number" ||
!Number.isFinite(value) ||
value < minimum ||
value > maximum
) {
fail(`Expected a finite number from ${minimum} to ${maximum}`, path);
}
return value;
}
function safeEasing(value: unknown, path: string): string {
const easing = boundedText(value, path, MAXIMUM_EASING_LENGTH).trim();
if (EASING_KEYWORDS.has(easing.toLowerCase())) return easing;
const bezier = CUBIC_BEZIER.exec(easing);
if (bezier) {
const values = bezier.slice(1).map(Number);
if (
values.every(Number.isFinite) &&
values[0]! >= 0 &&
values[0]! <= 1 &&
values[2]! >= 0 &&
values[2]! <= 1
) {
return easing;
}
}
const steps = STEPS.exec(easing);
if (steps) {
const count = Number(steps[1]);
if (
Number.isSafeInteger(count) &&
count >= 1 &&
count <= MAXIMUM_ITERATIONS
) {
return easing;
}
}
fail("Unsupported or unsafe animation easing", path);
}
function safeCssValue(value: unknown, path: string): string {
const css = boundedText(value, path, MAXIMUM_VALUE_LENGTH);
const hasUnsafeControlCharacter = Array.from(css).some((character) => {
const code = character.charCodeAt(0);
return (
code <= 0x08 ||
code === 0x0b ||
code === 0x0c ||
(code >= 0x0e && code <= 0x1f) ||
code === 0x7f
);
});
if (
/[\\<>&;{}@]/u.test(css) ||
hasUnsafeControlCharacter ||
/\/\*|\*\//u.test(css)
) {
fail("Animation value contains unsafe CSS syntax", path);
}
let parseFailed = false;
let unsafeResource = false;
try {
const ast = parseCss(css, {
context: "value",
positions: false,
onParseError: () => {
parseFailed = true;
},
});
walkCss(ast, (node) => {
if (node.type === "Url") unsafeResource = true;
if (
node.type === "Function" &&
(node.name.toLowerCase() === "expression" ||
RESOURCE_FUNCTIONS.has(node.name.toLowerCase()))
) {
unsafeResource = true;
}
if (node.type === "Raw") parseFailed = true;
});
} catch {
parseFailed = true;
}
if (parseFailed) fail("Animation value is not valid CSS", path);
if (unsafeResource) {
fail("Animation values cannot resolve URLs or external resources", path);
}
return css;
}
function keyframes(value: unknown, path: string): AnimationKeyframe[] {
if (
!Array.isArray(value) ||
value.length < 1 ||
value.length > MAXIMUM_KEYFRAMES
) {
fail("Expected one or more bounded keyframes", path);
}
let previousOffset = -1;
return value.map((entry, index) => {
const framePath = `${path}[${index}]`;
const frame = record(entry, framePath);
const offset = finiteNumber(frame.offset, `${framePath}.offset`, 0, 1);
if (offset < previousOffset) {
fail("Keyframe offsets must be ordered", path);
}
previousOffset = offset;
return {
offset,
value: safeCssValue(frame.value, `${framePath}.value`),
...(frame.easing === undefined
? {}
: { easing: safeEasing(frame.easing, `${framePath}.easing`) }),
};
});
}
function definition(value: unknown, path: string): AnimationDefinition {
const item = record(value, path);
const property = boundedText(item.property, `${path}.property`, 64);
if (!SAFE_PROPERTIES.has(property)) {
fail(
`Animation property “${property}” is not application-safe`,
`${path}.property`,
);
}
const kind = boundedText(item.kind, `${path}.kind`, 32);
if (!KINDS.has(kind)) fail("Unsupported animation kind", `${path}.kind`);
if (typeof item.enabled !== "boolean") {
fail("Expected a boolean", `${path}.enabled`);
}
const timing = record(item.timing, `${path}.timing`);
const direction = boundedText(
timing.direction,
`${path}.timing.direction`,
32,
);
if (!DIRECTIONS.has(direction)) {
fail("Unsupported animation direction", `${path}.timing.direction`);
}
const fillMode = boundedText(timing.fillMode, `${path}.timing.fillMode`, 32);
if (!FILL_MODES.has(fillMode)) {
fail("Unsupported animation fill mode", `${path}.timing.fillMode`);
}
const iterations =
timing.iterations === "infinite"
? "infinite"
: finiteNumber(
timing.iterations,
`${path}.timing.iterations`,
0,
MAXIMUM_ITERATIONS,
);
return {
id: boundedText(item.id, `${path}.id`, MAXIMUM_LABEL_LENGTH),
name: boundedText(item.name, `${path}.name`, MAXIMUM_LABEL_LENGTH),
targetNodeKey: boundedText(
item.targetNodeKey,
`${path}.targetNodeKey`,
MAXIMUM_LABEL_LENGTH,
),
property,
kind: kind as AnimationDefinition["kind"],
enabled: item.enabled,
keyframes: keyframes(item.keyframes, `${path}.keyframes`),
timing: {
durationMs: finiteNumber(
timing.durationMs,
`${path}.timing.durationMs`,
1,
MAXIMUM_DURATION_MS,
),
delayMs: finiteNumber(
timing.delayMs,
`${path}.timing.delayMs`,
-MAXIMUM_DURATION_MS,
MAXIMUM_DURATION_MS,
),
iterations,
direction: direction as AnimationDefinition["timing"]["direction"],
fillMode: fillMode as AnimationDefinition["timing"]["fillMode"],
easing: safeEasing(timing.easing, `${path}.timing.easing`),
},
};
}
export function validateAnimationDefinitions(
value: unknown,
path = "$.animations",
): AnimationDefinition[] {
if (
!Array.isArray(value) ||
value.length > defaultSvgLimits.maximumAnimations
) {
fail("Expected a bounded animation array", path);
}
return value.map((entry, index) => definition(entry, `${path}[${index}]`));
}
export interface AnimationCssOptions {
keyframeNamePrefix: string;
selectorFor: (definition: AnimationDefinition, index: number) => string;
}
export function buildAnimationCss(
definitions: readonly AnimationDefinition[],
options: AnimationCssOptions,
): string {
if (!/^[A-Za-z][A-Za-z0-9_-]*$/u.test(options.keyframeNamePrefix)) {
throw new Error("Animation keyframe prefix is not a safe CSS identifier");
}
return validateAnimationDefinitions(definitions)
.filter((item) => item.enabled)
.map((item, index) => {
const name = `${options.keyframeNamePrefix}-${index}`;
const frames = item.keyframes
.map((frame) => {
const percent = Math.round(frame.offset * 100_000) / 1_000;
const frameEasing = frame.easing
? ` animation-timing-function: ${frame.easing};`
: "";
return `${percent}% { ${item.property}: ${frame.value};${frameEasing} }`;
})
.join(" ");
const iterations =
item.timing.iterations === "infinite"
? "infinite"
: String(item.timing.iterations);
const selector = options.selectorFor(item, index);
return `@keyframes ${name} { ${frames} }\n${selector} { animation: ${name} ${item.timing.durationMs}ms ${item.timing.easing} ${item.timing.delayMs}ms ${iterations} ${item.timing.direction} ${item.timing.fillMode}; }`;
})
.join("\n");
}
+65
View File
@@ -0,0 +1,65 @@
export interface SvgResourceLimits {
sourceSoftBytes: number;
sourceHardBytes: number;
maximumElements: number;
maximumDepth: number;
maximumAttributes: number;
maximumAttributeLength: number;
maximumTextLength: number;
maximumPathCommandsPerPath: number;
maximumPathCommandsTotal: number;
maximumCssRules: number;
maximumReferences: number;
maximumAnimations: number;
maximumFilterPrimitives: number;
maximumEmbeddedResourceBytes: number;
maximumHistoryEntries: number;
maximumHistoryBytes: number;
maximumOptimizationMs: number;
maximumRasterPixels: number;
}
export const defaultSvgLimits: Readonly<SvgResourceLimits> = {
sourceSoftBytes: 2 * 1024 * 1024,
sourceHardBytes: 20 * 1024 * 1024,
maximumElements: 100_000,
maximumDepth: 1_000,
maximumAttributes: 1_000_000,
maximumAttributeLength: 1_000_000,
maximumTextLength: 20 * 1024 * 1024,
maximumPathCommandsPerPath: 200_000,
maximumPathCommandsTotal: 1_000_000,
maximumCssRules: 100_000,
maximumReferences: 200_000,
maximumAnimations: 50_000,
maximumFilterPrimitives: 50_000,
maximumEmbeddedResourceBytes: 100 * 1024 * 1024,
maximumHistoryEntries: 500,
maximumHistoryBytes: 200 * 1024 * 1024,
maximumOptimizationMs: 30_000,
maximumRasterPixels: 100_000_000,
};
export const utf8ByteLength = (source: string): number => {
let bytes = 0;
for (let index = 0; index < source.length; index += 1) {
const code = source.charCodeAt(index);
if (code <= 0x7f) {
bytes += 1;
} else if (code <= 0x7ff) {
bytes += 2;
} else if (
code >= 0xd800 &&
code <= 0xdbff &&
source.charCodeAt(index + 1) >= 0xdc00 &&
source.charCodeAt(index + 1) <= 0xdfff
) {
bytes += 4;
index += 1;
} else {
// UTF-8 encoders replace any unpaired surrogate with U+FFFD.
bytes += 3;
}
}
return bytes;
};
+18
View File
@@ -0,0 +1,18 @@
export const STARTER_SVG = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 640 420" width="640" height="420" role="img" aria-labelledby="title desc">
<title id="title">SVG Tools starter document</title>
<desc id="desc">A small source-faithful vector editing example.</desc>
<defs>
<linearGradient id="sky" x1="0" y1="0" x2="1" y2="1">
<stop offset="0" stop-color="#725cff"/>
<stop offset="1" stop-color="#20b8a6"/>
</linearGradient>
</defs>
<rect id="background" x="20" y="20" width="600" height="380" rx="26" fill="#f6f8fc" stroke="#ccd4e2"/>
<circle id="sun" cx="500" cy="105" r="48" fill="#ffcb57"/>
<path id="curve" d="M90 300 L150 255 C210 175 285 345 350 250 S470 180 545 285 Q575 315 590 270 T615 250 A38 24 25 0 1 570 335" fill="none" stroke="url(#sky)" stroke-width="14" stroke-linecap="round"/>
<text id="label" x="72" y="105" font-family="system-ui, sans-serif" font-size="38" font-weight="700" fill="#233047">SVG Tools</text>
</svg>`;
export const EMPTY_SVG = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 640 420" width="640" height="420">
<title>Untitled SVG</title>
</svg>`;
+155
View File
@@ -0,0 +1,155 @@
import { defaultSvgLimits, utf8ByteLength } from "../app/limits";
import type {
DocumentTransaction,
SelectionState,
SourcePatch,
SvgDiagnostic,
} from "../document/document.types";
export interface HistorySnapshot {
past: readonly DocumentTransaction[];
future: readonly DocumentTransaction[];
bytes: number;
}
const EMPTY_SELECTION: SelectionState = {
nodeKeys: [],
primaryNodeKey: null,
};
export function createTransaction(input: {
label: string;
baseRevision: number;
sourceBefore: string;
sourceAfter: string;
patches?: SourcePatch[];
affectedNodeKeys?: string[];
selectionBefore?: SelectionState;
selectionAfter?: SelectionState;
diagnostics?: SvgDiagnostic[];
mergeKey?: string;
}): DocumentTransaction {
return {
id: globalThis.crypto?.randomUUID?.() ?? `transaction-${Date.now()}`,
label: input.label,
baseRevision: input.baseRevision,
sourceBefore: input.sourceBefore,
sourceAfter: input.sourceAfter,
sourcePatches: input.patches ?? [],
affectedNodeKeys: input.affectedNodeKeys ?? [],
selectionBefore: input.selectionBefore ?? EMPTY_SELECTION,
selectionAfter: input.selectionAfter ?? EMPTY_SELECTION,
diagnostics: input.diagnostics ?? [],
...(input.mergeKey ? { mergeKey: input.mergeKey } : {}),
timestamp: Date.now(),
};
}
function transactionBytes(transaction: DocumentTransaction): number {
return (
utf8ByteLength(transaction.sourceBefore) +
utf8ByteLength(transaction.sourceAfter)
);
}
export class CommandHistory {
readonly #maximumEntries: number;
readonly #maximumBytes: number;
#past: DocumentTransaction[] = [];
#future: DocumentTransaction[] = [];
#bytes = 0;
constructor(
maximumEntries = defaultSvgLimits.maximumHistoryEntries,
maximumBytes = defaultSvgLimits.maximumHistoryBytes,
) {
this.#maximumEntries = maximumEntries;
this.#maximumBytes = maximumBytes;
}
get snapshot(): HistorySnapshot {
return {
past: [...this.#past],
future: [...this.#future],
bytes: this.#bytes,
};
}
get canUndo(): boolean {
return this.#past.length > 0;
}
get canRedo(): boolean {
return this.#future.length > 0;
}
commit(transaction: DocumentTransaction): void {
const previous = this.#past.at(-1);
const merge =
previous !== undefined &&
Boolean(transaction.mergeKey) &&
transaction.mergeKey === previous?.mergeKey &&
transaction.timestamp - previous.timestamp < 1_000 &&
previous.sourceAfter === transaction.sourceBefore;
if (merge && previous) {
this.#bytes -= transactionBytes(previous);
this.#past[this.#past.length - 1] = {
...transaction,
id: previous.id,
sourceBefore: previous.sourceBefore,
selectionBefore: previous.selectionBefore,
sourcePatches: [
...previous.sourcePatches,
...transaction.sourcePatches,
],
};
} else {
this.#past.push(transaction);
}
this.#bytes += transactionBytes(this.#past.at(-1)!);
this.#future = [];
this.#trim();
}
undo(currentSource: string): DocumentTransaction | null {
const transaction = this.#past.at(-1);
if (!transaction) return null;
if (transaction.sourceAfter !== currentSource) {
throw new Error("Undo rejected because the source revision is stale");
}
this.#past.pop();
this.#future.push(transaction);
this.#bytes -= transactionBytes(transaction);
return transaction;
}
redo(currentSource: string): DocumentTransaction | null {
const transaction = this.#future.at(-1);
if (!transaction) return null;
if (transaction.sourceBefore !== currentSource) {
throw new Error("Redo rejected because the source revision is stale");
}
this.#future.pop();
this.#past.push(transaction);
this.#bytes += transactionBytes(transaction);
this.#trim();
return transaction;
}
clear(): void {
this.#past = [];
this.#future = [];
this.#bytes = 0;
}
#trim(): void {
while (
this.#past.length > this.#maximumEntries ||
this.#bytes > this.#maximumBytes
) {
const removed = this.#past.shift();
if (!removed) break;
this.#bytes -= transactionBytes(removed);
}
}
}
+42
View File
@@ -0,0 +1,42 @@
import { Component, type ErrorInfo, type ReactNode } from "react";
interface Props {
children: ReactNode;
}
interface State {
error: Error | null;
}
export class AppErrorBoundary extends Component<Props, State> {
state: State = { error: null };
static getDerivedStateFromError(error: Error): State {
return { error };
}
componentDidCatch(error: Error, info: ErrorInfo): void {
console.error(
"SVG Tools encountered an unrecoverable interface error",
error,
info,
);
}
render(): ReactNode {
if (!this.state.error) return this.props.children;
return (
<main className="fatal-error" role="alert">
<h1>SVG Tools could not continue</h1>
<p>{this.state.error.message}</p>
<button
type="button"
className="primary-button"
onClick={() => globalThis.location.reload()}
>
Reload application
</button>
</main>
);
}
}
+579
View File
@@ -0,0 +1,579 @@
import {
useCallback,
useEffect,
useMemo,
useRef,
useState,
type KeyboardEvent,
type PointerEvent as ReactPointerEvent,
} from "react";
import type { AnimationDefinition } from "../animation/animation.types";
import { withAnimationPreview } from "../animation/preview";
import type { SemanticSvgDocument } from "../document/document.types";
import {
applyToPoint,
invert,
matrixToTransform,
multiply,
type Matrix,
type Point,
} from "../domain/affine";
import { resolveTransformChain } from "../domain/transform-chain";
import {
movePathHandle,
parsePathData,
pathHandles,
serializePathData,
type PathHandle,
type PathModel,
} from "../domain/path";
export interface CanvasPaneProps {
projection: string;
semantic: SemanticSvgDocument;
selectedKey: string | null;
disabled: boolean;
stale: boolean;
pathEditing: boolean;
showGrid: boolean;
transformPreview: Matrix | null;
animations: readonly AnimationDefinition[];
animationPreview: boolean;
view: CanvasView;
onSelect: (nodeKey: string) => void;
onCommitPath: (data: string, mergeKey?: string) => void;
onPathError: (message: string) => void;
onShowGridChange: (show: boolean) => void;
onViewChange: (view: CanvasView) => void;
}
export interface CanvasView {
zoom: number;
pan: { x: number; y: number };
}
interface ViewBox {
x: number;
y: number;
width: number;
height: number;
}
interface SelectionBox {
x: number;
y: number;
width: number;
height: number;
}
interface CanvasFrameMessage {
channel: string;
type: "ready" | "select" | "selection-box";
key?: string;
box?: SelectionBox | null;
}
function viewBoxFor(semantic: SemanticSvgDocument): ViewBox {
const root = semantic.nodes.get(semantic.rootKey)!;
const values = (root.attributes.viewBox ?? "")
.trim()
.split(/[\s,]+/u)
.map(Number);
if (
values.length === 4 &&
values.every(Number.isFinite) &&
values[2]! > 0 &&
values[3]! > 0
) {
return {
x: values[0]!,
y: values[1]!,
width: values[2]!,
height: values[3]!,
};
}
const width = Number.parseFloat(root.attributes.width ?? "640");
const height = Number.parseFloat(root.attributes.height ?? "480");
return {
x: 0,
y: 0,
width: Number.isFinite(width) && width > 0 ? width : 640,
height: Number.isFinite(height) && height > 0 ? height : 480,
};
}
function frameHtml(
source: string,
channel: string,
controllerUrl: string,
): string {
const controllerOrigin = new URL(controllerUrl).origin;
const escapedControllerUrl = controllerUrl
.replaceAll("&", "&amp;")
.replaceAll('"', "&quot;");
return `<!doctype html><html data-svg-tools-channel="${channel}"><head><meta charset="utf-8"><meta http-equiv="Content-Security-Policy" content="default-src 'none'; script-src ${controllerOrigin}; connect-src 'none'; object-src 'none'; frame-src 'none'; base-uri 'none'; form-action 'none'; img-src data: blob:; style-src 'unsafe-inline'"><style>html,body{width:100%;height:100%;margin:0;overflow:hidden}body{display:grid;place-items:center;background:transparent}svg{display:block;width:100%;height:100%;max-width:100%;max-height:100%;overflow:visible}</style><script src="${escapedControllerUrl}" crossorigin="anonymous"></script></head><body>${source}</body></html>`;
}
export function CanvasPane({
projection,
semantic,
selectedKey,
disabled,
stale,
pathEditing,
showGrid,
transformPreview,
animations,
animationPreview,
view,
onSelect,
onCommitPath,
onPathError,
onShowGridChange,
onViewChange,
}: CanvasPaneProps) {
const iframeRef = useRef<HTMLIFrameElement>(null);
const [frameChannel] = useState(() => globalThis.crypto.randomUUID());
const [controllerUrl] = useState(
() =>
new URL("./canvas-frame-controller.js", globalThis.location.href).href,
);
const overlayRef = useRef<SVGSVGElement>(null);
const draftRef = useRef<PathModel | null>(null);
const dragRef = useRef<{
handle: PathHandle;
base: PathModel;
pointerId: number;
} | null>(null);
const [draft, setDraft] = useState<PathModel | null>(null);
const [selectionBox, setSelectionBox] = useState<SelectionBox | null>(null);
const [frameRevision, setFrameRevision] = useState(0);
const selectedNode = selectedKey
? semantic.nodes.get(selectedKey)
: undefined;
const transformChain = useMemo(
() => (selectedKey ? resolveTransformChain(semantic, selectedKey) : null),
[selectedKey, semantic],
);
const displayedMatrix = useMemo(
() =>
transformChain && transformPreview
? multiply(transformChain.matrix, transformPreview)
: (transformChain?.matrix ?? null),
[transformChain, transformPreview],
);
const displayedInverse = useMemo(
() => (displayedMatrix ? invert(displayedMatrix) : null),
[displayedMatrix],
);
const sourcePath =
selectedNode?.localName === "path"
? (selectedNode.attributes.d ?? "")
: null;
const parsedPath = useMemo(() => {
if (sourcePath === null) return null;
try {
return parsePathData(sourcePath);
} catch {
return null;
}
}, [sourcePath]);
const viewBox = useMemo(() => viewBoxFor(semantic), [semantic]);
const root = semantic.nodes.get(semantic.rootKey)!;
const preserveAspectRatio =
root.attributes.preserveAspectRatio ?? "xMidYMid meet";
const renderedProjection = useMemo(
() =>
animationPreview
? withAnimationPreview(projection, animations)
: projection,
[animationPreview, animations, projection],
);
const srcDoc = useMemo(
() => frameHtml(renderedProjection, frameChannel, controllerUrl),
[controllerUrl, frameChannel, renderedProjection],
);
const postFrame = useCallback(
(message: Record<string, unknown>) => {
iframeRef.current?.contentWindow?.postMessage(
{ ...message, channel: frameChannel },
"*",
);
},
[frameChannel],
);
useEffect(() => {
const receive = (event: MessageEvent<unknown>) => {
if (event.source !== iframeRef.current?.contentWindow) return;
const message = event.data as Partial<CanvasFrameMessage> | null;
if (!message || message.channel !== frameChannel) return;
if (message.type === "ready") {
setFrameRevision((value) => value + 1);
return;
}
if (message.type === "select" && typeof message.key === "string") {
onSelect(message.key);
return;
}
if (message.type === "selection-box" && message.key === selectedKey) {
const box = message.box;
setSelectionBox(
box && [box.x, box.y, box.width, box.height].every(Number.isFinite)
? box
: null,
);
}
};
globalThis.addEventListener("message", receive);
return () => globalThis.removeEventListener("message", receive);
}, [frameChannel, onSelect, selectedKey]);
useEffect(() => {
const next = pathEditing && parsedPath ? structuredClone(parsedPath) : null;
draftRef.current = next;
dragRef.current = null;
let active = true;
queueMicrotask(() => {
if (active) setDraft(next);
});
return () => {
active = false;
};
}, [parsedPath, pathEditing, selectedKey]);
useEffect(() => {
if (!selectedKey) {
queueMicrotask(() => setSelectionBox(null));
return;
}
postFrame({ type: "selection", key: selectedKey });
}, [draft, frameRevision, postFrame, selectedKey, transformPreview]);
useEffect(() => {
if (
pathEditing &&
transformChain &&
(!displayedInverse || transformChain.diagnostics.length)
) {
onPathError(
transformChain.diagnostics[0] ??
"Path handles cannot be edited through a non-invertible transform chain",
);
}
}, [displayedInverse, onPathError, pathEditing, transformChain]);
const selectionPoints = useMemo(() => {
if (!selectionBox || !displayedMatrix) return null;
return [
{ x: selectionBox.x, y: selectionBox.y },
{ x: selectionBox.x + selectionBox.width, y: selectionBox.y },
{
x: selectionBox.x + selectionBox.width,
y: selectionBox.y + selectionBox.height,
},
{ x: selectionBox.x, y: selectionBox.y + selectionBox.height },
].map((point) => applyToPoint(displayedMatrix, point));
}, [displayedMatrix, selectionBox]);
useEffect(() => {
if (!selectedKey) return;
const original = selectedNode?.attributes.transform ?? "";
const value = transformPreview
? `${original}${original ? " " : ""}${matrixToTransform(transformPreview)}`
: original || null;
postFrame({ type: "transform", key: selectedKey, value });
}, [frameRevision, postFrame, selectedKey, selectedNode, transformPreview]);
const updateFramePath = (model: PathModel) => {
if (!selectedKey) return;
postFrame({
type: "path",
key: selectedKey,
data: serializePathData(model),
});
};
const toSvgPoint = (event: {
clientX: number;
clientY: number;
}): Point | null => {
const overlay = overlayRef.current;
const matrix = overlay?.getScreenCTM();
if (!overlay || !matrix) return null;
const point = overlay.createSVGPoint();
point.x = event.clientX;
point.y = event.clientY;
const transformed = point.matrixTransform(matrix.inverse());
const rootPoint = { x: transformed.x, y: transformed.y };
return displayedInverse ? applyToPoint(displayedInverse, rootPoint) : null;
};
const beginDrag = (
event: ReactPointerEvent<SVGCircleElement>,
handle: PathHandle,
) => {
if (disabled || !draftRef.current || !displayedInverse) return;
event.preventDefault();
event.currentTarget.setPointerCapture(event.pointerId);
dragRef.current = {
handle,
base: structuredClone(draftRef.current),
pointerId: event.pointerId,
};
};
const drag = (event: ReactPointerEvent<SVGCircleElement>) => {
const state = dragRef.current;
if (!state || state.pointerId !== event.pointerId) return;
const point = toSvgPoint(event);
if (!point) return;
try {
const next = movePathHandle(state.base, state.handle, point);
draftRef.current = next;
setDraft(next);
updateFramePath(next);
} catch (error) {
onPathError(
error instanceof Error
? error.message
: "The path handle could not be moved",
);
}
};
const finishDrag = (event: ReactPointerEvent<SVGCircleElement>) => {
if (dragRef.current?.pointerId !== event.pointerId) return;
dragRef.current = null;
if (draftRef.current) onCommitPath(serializePathData(draftRef.current));
};
const nudge = (
event: KeyboardEvent<SVGCircleElement>,
handle: PathHandle,
) => {
if (
!draftRef.current ||
!["ArrowLeft", "ArrowRight", "ArrowUp", "ArrowDown"].includes(event.key)
)
return;
event.preventDefault();
const step = event.altKey ? 0.1 : event.shiftKey ? 10 : 1;
const delta = {
x:
event.key === "ArrowLeft"
? -step
: event.key === "ArrowRight"
? step
: 0,
y: event.key === "ArrowUp" ? -step : event.key === "ArrowDown" ? step : 0,
};
try {
const current = pathHandles(draftRef.current).find(
(candidate) => candidate.id === handle.id,
);
if (!current) return;
const next = movePathHandle(draftRef.current, current, {
x: current.point.x + delta.x,
y: current.point.y + delta.y,
});
draftRef.current = next;
setDraft(next);
updateFramePath(next);
onCommitPath(serializePathData(next), "path-keyboard");
} catch (error) {
onPathError(
error instanceof Error
? error.message
: "The path handle could not be moved",
);
}
};
const handles = draft ? pathHandles(draft) : [];
const controlLines = draft
? draft.segments.flatMap((segment, index) => {
if (segment.kind === "C") {
return [
{
id: `${index}:in`,
from: segment.from,
to: segment.control1,
derived: segment.derivedControl1,
},
{ id: `${index}:out`, from: segment.to, to: segment.control2 },
];
}
if (segment.kind === "Q") {
return [
{
id: `${index}:q-in`,
from: segment.from,
to: segment.control,
derived: segment.derivedControl,
},
{ id: `${index}:q-out`, from: segment.to, to: segment.control },
];
}
return [];
})
: [];
const projectPoint = (point: Point): Point =>
displayedMatrix ? applyToPoint(displayedMatrix, point) : point;
const setZoom = (zoom: number) => onViewChange({ ...view, zoom });
const panBy = (x: number, y: number) =>
onViewChange({ ...view, pan: { x: view.pan.x + x, y: view.pan.y + y } });
return (
<section className="panel canvas-panel" aria-labelledby="canvas-heading">
<div className="panel-heading compact canvas-heading">
<div>
<p className="eyebrow">Sanitized projection</p>
<h2 id="canvas-heading">Canvas</h2>
</div>
<div className="canvas-actions">
<button
type="button"
className="icon-button"
aria-label="Zoom out"
onClick={() => setZoom(Math.max(0.25, view.zoom / 1.2))}
>
</button>
<button
type="button"
className="zoom-readout"
onClick={() => onViewChange({ zoom: 1, pan: { x: 0, y: 0 } })}
aria-label="Reset canvas view"
>
{Math.round(view.zoom * 100)}%
</button>
<button
type="button"
className="icon-button"
aria-label="Zoom in"
onClick={() => setZoom(Math.min(8, view.zoom * 1.2))}
>
+
</button>
<button
type="button"
className="icon-button"
aria-label="Pan left"
onClick={() => panBy(-24, 0)}
>
</button>
<button
type="button"
className="icon-button"
aria-label="Pan up"
onClick={() => panBy(0, -24)}
>
</button>
<button
type="button"
className="icon-button"
aria-label="Pan down"
onClick={() => panBy(0, 24)}
>
</button>
<button
type="button"
className="icon-button"
aria-label="Pan right"
onClick={() => panBy(24, 0)}
>
</button>
<label className="compact-check">
<input
type="checkbox"
checked={showGrid}
onChange={(event) =>
onShowGridChange(event.currentTarget.checked)
}
/>{" "}
Grid
</label>
</div>
</div>
{stale ? (
<div className="stale-banner" role="status">
Showing the last valid canvas revision.
</div>
) : null}
<div className={`canvas-viewport${showGrid ? " has-grid" : ""}`}>
<div
className="canvas-stage"
style={{
transform: `translate(${view.pan.x}px, ${view.pan.y}px) scale(${view.zoom})`,
}}
>
<iframe
ref={iframeRef}
className="svg-preview-frame"
title="Sanitized SVG preview"
sandbox="allow-scripts"
srcDoc={srcDoc}
onLoad={() => postFrame({ type: "ping" })}
/>
<svg
ref={overlayRef}
className="canvas-overlay"
viewBox={`${viewBox.x} ${viewBox.y} ${viewBox.width} ${viewBox.height}`}
preserveAspectRatio={preserveAspectRatio}
aria-label={
pathEditing ? "Path editing handles" : "Canvas selection overlay"
}
>
{selectionPoints ? (
<polygon
className="selection-outline"
points={selectionPoints
.map((point) => `${point.x},${point.y}`)
.join(" ")}
vectorEffect="non-scaling-stroke"
/>
) : null}
{pathEditing && displayedInverse
? controlLines.map((line) => (
<line
className={`control-line${line.derived ? " is-derived" : ""}`}
key={line.id}
x1={projectPoint(line.from).x}
y1={projectPoint(line.from).y}
x2={projectPoint(line.to).x}
y2={projectPoint(line.to).y}
vectorEffect="non-scaling-stroke"
/>
))
: null}
{pathEditing && displayedInverse
? handles.map((handle) => (
<circle
key={handle.id}
className={`path-handle is-${handle.role}${handle.derived ? " is-derived" : ""}`}
cx={projectPoint(handle.point).x}
cy={projectPoint(handle.point).y}
r={handle.role === "anchor" ? 5 : 4}
vectorEffect="non-scaling-stroke"
role="button"
tabIndex={0}
aria-label={`${handle.derived ? "Derived " : ""}${handle.role.replaceAll("-", " ")} for segment ${handle.segmentIndex + 1}`}
onPointerDown={(event) => beginDrag(event, handle)}
onPointerMove={drag}
onPointerUp={finishDrag}
onPointerCancel={finishDrag}
onKeyDown={(event) => nudge(event, handle)}
/>
))
: null}
</svg>
</div>
</div>
</section>
);
}
+91
View File
@@ -0,0 +1,91 @@
import { useEffect, useRef } from "react";
export interface HelpDialogProps {
open: boolean;
onClose: () => void;
}
export function HelpDialog({ open, onClose }: HelpDialogProps) {
const dialogRef = useRef<HTMLDialogElement>(null);
useEffect(() => {
const dialog = dialogRef.current;
if (!dialog) return;
if (open && !dialog.open) dialog.showModal();
if (!open && dialog.open) dialog.close();
}, [open]);
return (
<dialog
ref={dialogRef}
className="tool-dialog help-dialog"
onClose={onClose}
>
<div className="dialog-heading">
<div>
<p className="eyebrow">Local-first vector workbench</p>
<h2>SVG Tools help</h2>
</div>
<button
type="button"
className="icon-button"
aria-label="Close help"
onClick={onClose}
>
×
</button>
</div>
<div className="dialog-body prose">
<p>
The source text is the canonical document. Tree, canvas and property
edits create precise source patches that can be undone.
</p>
<h3>Safety model</h3>
<p>
The canvas uses a sanitized, isolated editing projection. Scripts,
event handlers, navigation and external resources remain visible in
source diagnostics but do not run. Sanitizing source is always an
explicit preview-and-apply operation.
</p>
<h3>Invalid source</h3>
<p>
While XML is incomplete, source editing and undo stay available. The
canvas and tree deliberately retain the last valid revision; visual
edits are disabled until the source parses again.
</p>
<h3>Keyboard</h3>
<ul>
<li>
<kbd>Ctrl</kbd>/<kbd></kbd> + <kbd>O</kbd>: open a document
</li>
<li>
<kbd>Ctrl</kbd>/<kbd></kbd> + <kbd>S</kbd>: download SVG
</li>
<li>
<kbd>Ctrl</kbd>/<kbd></kbd> + <kbd>Z</kbd>: undo a visual/source
transaction
</li>
<li>
Arrow keys move a focused path handle; Shift moves ten units and Alt
moves one tenth.
</li>
</ul>
<p>
No files leave this browser. SVGZ decompression, optimization and
rasterization happen locally.
</p>
</div>
<div className="dialog-actions">
<a
className="secondary-button"
href="https://git.add-ideas.de/lotobo/svg-tools"
target="_blank"
rel="noreferrer"
>
Source and issues
</a>
<button type="button" className="primary-button" onClick={onClose}>
Done
</button>
</div>
</dialog>
);
}
+965
View File
@@ -0,0 +1,965 @@
import { useEffect, useMemo, useState, type FormEvent } from "react";
import type { AccessibilityFinding } from "../accessibility/audit";
import type { AnimationDefinition } from "../animation/animation.types";
import type {
SemanticSvgDocument,
SemanticSvgNode,
SvgDiagnostic,
} from "../document/document.types";
import {
diagnoseTransform,
multiply,
rotation,
scaling,
skewX,
skewY,
translation,
type Matrix,
} from "../domain/affine";
import {
describePathCommand,
parsePathData,
reversePath,
serializePathData,
splitSegment,
} from "../domain/path";
import { resolveTransformChain } from "../domain/transform-chain";
export type InspectorTab =
| "document"
| "element"
| "path"
| "transform"
| "animation"
| "accessibility";
export interface InspectorProps {
semantic: SemanticSvgDocument;
selectedKey: string | null;
disabled: boolean;
activeTab: InspectorTab;
pathEditing: boolean;
diagnostics: readonly SvgDiagnostic[];
accessibility: readonly AccessibilityFinding[];
animations: readonly AnimationDefinition[];
animationPreview: boolean;
onTabChange: (tab: InspectorTab) => void;
onPathEditingChange: (editing: boolean) => void;
onSetAttribute: (
name: string,
value: string | null,
mergeKey?: string,
) => void;
onCommitPath: (data: string, label: string) => void;
onTransformPreview: (matrix: Matrix | null) => void;
onApplyTransform: (matrix: Matrix, mode: "attribute" | "bake") => void;
onAccessibilityFix: (
fix: "add-title" | "add-description",
text: string,
) => void;
onAnimationsChange: (animations: AnimationDefinition[]) => void;
onAnimationPreviewChange: (preview: boolean) => void;
onApplyAnimations: () => void;
}
function Field({
label,
value,
type = "text",
onCommit,
}: {
label: string;
value: string;
type?: "text" | "number" | "color";
onCommit: (value: string) => void;
}) {
return (
<FieldEditor
key={`${label}:${value}`}
label={label}
value={value}
type={type}
onCommit={onCommit}
/>
);
}
function FieldEditor({
label,
value,
type,
onCommit,
}: {
label: string;
value: string;
type: "text" | "number" | "color";
onCommit: (value: string) => void;
}) {
const [draft, setDraft] = useState(value);
return (
<label className="field">
<span>{label}</span>
<input
type={type}
value={draft}
onChange={(event) => setDraft(event.currentTarget.value)}
onBlur={() => draft !== value && onCommit(draft)}
onKeyDown={(event) => {
if (event.key === "Enter") event.currentTarget.blur();
if (event.key === "Escape") {
setDraft(value);
event.currentTarget.blur();
}
}}
/>
</label>
);
}
function DocumentPanel({
semantic,
onSetAttribute,
}: Pick<InspectorProps, "semantic" | "onSetAttribute">) {
const root = semantic.nodes.get(semantic.rootKey)!;
return (
<div className="inspector-section">
<h3>Document geometry</h3>
<Field
label="ViewBox"
value={root.attributes.viewBox ?? ""}
onCommit={(value) => onSetAttribute("viewBox", value || null)}
/>
<div className="field-grid">
<Field
label="Width"
value={root.attributes.width ?? ""}
onCommit={(value) => onSetAttribute("width", value || null)}
/>
<Field
label="Height"
value={root.attributes.height ?? ""}
onCommit={(value) => onSetAttribute("height", value || null)}
/>
</div>
<Field
label="preserveAspectRatio"
value={root.attributes.preserveAspectRatio ?? "xMidYMid meet"}
onCommit={(value) =>
onSetAttribute("preserveAspectRatio", value || null)
}
/>
<h3>Metrics</h3>
<dl className="metric-grid">
<div>
<dt>Elements</dt>
<dd>{semantic.metrics.elementCount.toLocaleString()}</dd>
</div>
<div>
<dt>Attributes</dt>
<dd>{semantic.metrics.attributeCount.toLocaleString()}</dd>
</div>
<div>
<dt>Path commands</dt>
<dd>{semantic.metrics.pathCommandCount.toLocaleString()}</dd>
</div>
<div>
<dt>References</dt>
<dd>{semantic.metrics.referenceCount.toLocaleString()}</dd>
</div>
<div>
<dt>Animations</dt>
<dd>{semantic.metrics.animationCount.toLocaleString()}</dd>
</div>
<div>
<dt>Source bytes</dt>
<dd>{semantic.metrics.sourceBytes.toLocaleString()}</dd>
</div>
</dl>
<p className="hint">
Changing document geometry writes only the relevant root attribute. The
source is not reformatted.
</p>
</div>
);
}
function ElementPanel({
node,
onSetAttribute,
}: {
node: SemanticSvgNode | undefined;
onSetAttribute: InspectorProps["onSetAttribute"];
}) {
const [newName, setNewName] = useState("");
const [newValue, setNewValue] = useState("");
if (!node)
return <p className="empty-state">Select an element to inspect it.</p>;
const submit = (event: FormEvent) => {
event.preventDefault();
if (!newName.trim()) return;
onSetAttribute(newName.trim(), newValue);
setNewName("");
setNewValue("");
};
return (
<div className="inspector-section">
<div className="element-summary">
<span className="element-glyph">&lt;{node.name}&gt;</span>
<span>{node.id ? `#${node.id}` : "No ID"}</span>
</div>
<h3>Common styling</h3>
<div className="field-grid">
<Field
label="Fill"
value={node.attributes.fill ?? ""}
onCommit={(value) =>
onSetAttribute("fill", value || null, "style-fill")
}
/>
<Field
label="Stroke"
value={node.attributes.stroke ?? ""}
onCommit={(value) =>
onSetAttribute("stroke", value || null, "style-stroke")
}
/>
<Field
label="Stroke width"
value={node.attributes["stroke-width"] ?? ""}
onCommit={(value) =>
onSetAttribute("stroke-width", value || null, "style-stroke-width")
}
/>
<Field
label="Opacity"
value={node.attributes.opacity ?? ""}
onCommit={(value) =>
onSetAttribute("opacity", value || null, "style-opacity")
}
/>
</div>
<h3>Attributes</h3>
<div className="attribute-list">
{Object.entries(node.attributes).map(([name, value]) => (
<div className="attribute-row" key={name}>
<Field
label={name}
value={value}
onCommit={(next) =>
onSetAttribute(name, next, `attribute:${name}`)
}
/>
<button
type="button"
className="icon-button"
aria-label={`Remove ${name}`}
onClick={() => onSetAttribute(name, null)}
>
×
</button>
</div>
))}
</div>
<form className="add-attribute" onSubmit={submit}>
<label className="field">
<span>New name</span>
<input
value={newName}
onChange={(event) => setNewName(event.currentTarget.value)}
/>
</label>
<label className="field">
<span>Value</span>
<input
value={newValue}
onChange={(event) => setNewValue(event.currentTarget.value)}
/>
</label>
<button className="secondary-button" type="submit">
Add attribute
</button>
</form>
</div>
);
}
function PathPanel({
node,
pathEditing,
onPathEditingChange,
onCommitPath,
}: {
node: SemanticSvgNode | undefined;
pathEditing: boolean;
onPathEditingChange: (editing: boolean) => void;
onCommitPath: (data: string, label: string) => void;
}) {
const [segmentIndex, setSegmentIndex] = useState(1);
if (node?.localName !== "path") {
return (
<p className="empty-state">
Select a path element to edit commands and handles.
</p>
);
}
let model;
try {
model = parsePathData(node.attributes.d ?? "");
} catch (error) {
return (
<div className="inline-error" role="alert">
{error instanceof Error ? error.message : "Invalid path data"}
</div>
);
}
const drawable = model.segments.filter(
(segment) => segment.kind !== "M" && segment.kind !== "Z",
).length;
return (
<div className="inspector-section">
<label className="toggle-row">
<span>
<strong>Edit handles</strong>
<small>Drag on canvas or use arrow keys</small>
</span>
<input
type="checkbox"
checked={pathEditing}
onChange={(event) => onPathEditingChange(event.currentTarget.checked)}
/>
</label>
<dl className="metric-grid">
<div>
<dt>Segments</dt>
<dd>{model.segments.length}</dd>
</div>
<div>
<dt>Drawable</dt>
<dd>{drawable}</dd>
</div>
<div>
<dt>Subpaths</dt>
<dd>
{model.segments.filter((segment) => segment.kind === "M").length}
</dd>
</div>
</dl>
<div className="button-row">
<button
type="button"
className="secondary-button"
onClick={() =>
onCommitPath(serializePathData(reversePath(model)), "Reverse path")
}
>
Reverse path
</button>
</div>
<div className="split-row">
<label className="field">
<span>Segment number</span>
<input
type="number"
min="1"
max={model.segments.length}
value={segmentIndex}
onChange={(event) =>
setSegmentIndex(event.currentTarget.valueAsNumber || 1)
}
/>
</label>
<button
type="button"
className="secondary-button"
onClick={() => {
try {
onCommitPath(
serializePathData(splitSegment(model, segmentIndex - 1)),
"Split path segment",
);
} catch (error) {
globalThis.alert(
error instanceof Error
? error.message
: "Segment cannot be split",
);
}
}}
>
Split at 50%
</button>
</div>
<h3>Path commands</h3>
<div className="path-command-table-wrap">
<table className="path-command-table">
<caption className="sr-only">
Path source commands and resolved geometry
</caption>
<thead>
<tr>
<th scope="col">#</th>
<th scope="col">Source</th>
<th scope="col">Endpoint</th>
<th scope="col">Controls / arc</th>
</tr>
</thead>
<tbody>
{model.segments.map((segment, index) => {
const row = describePathCommand(segment, node.attributes.d ?? "");
return (
<tr key={`${index}-${segment.kind}`}>
<th scope="row">{index + 1}</th>
<td>
<span className="path-command-kind">
<code>{row.sourceCommand}</code>
{row.sourceCommand.toUpperCase() !==
row.normalizedCommand ? (
<span
aria-label={`normalizes to ${row.normalizedCommand}`}
>
{row.normalizedCommand}
</span>
) : null}
</span>
<small>{row.form}</small>
<code
className="path-source-fragment"
title={row.sourceFragment}
>
{row.sourceFragment}
</code>
</td>
<td>
<code>{row.endpoint}</code>
</td>
<td>
{row.details.map((detail) => (
<span className="path-command-detail" key={detail.label}>
<strong>{detail.label}</strong>{" "}
<code>{detail.value}</code>
{detail.derived ? (
<span
className="derived-control-cue"
title="Reflected from the previous control point"
>
derived
</span>
) : null}
</span>
))}
{!row.details.length ? (
<span className="path-command-empty"></span>
) : null}
</td>
</tr>
);
})}
</tbody>
</table>
</div>
<details>
<summary>Normalized path data</summary>
<code className="code-block">{serializePathData(model)}</code>
</details>
<p className="hint">
Editing normalizes shorthand and relative commands into explicit
absolute geometry. The preview shows the exact replacement before each
transaction is committed.
</p>
</div>
);
}
function TransformPanel({
semantic,
node,
onPreview,
onApply,
}: {
semantic: SemanticSvgDocument;
node: SemanticSvgNode | undefined;
onPreview: (matrix: Matrix | null) => void;
onApply: (matrix: Matrix, mode: "attribute" | "bake") => void;
}) {
const [tx, setTx] = useState(0);
const [ty, setTy] = useState(0);
const [originX, setOriginX] = useState(0);
const [originY, setOriginY] = useState(0);
const [angle, setAngle] = useState(0);
const [sx, setSx] = useState(1);
const [sy, setSy] = useState(1);
const [skewXAngle, setSkewXAngle] = useState(0);
const [skewYAngle, setSkewYAngle] = useState(0);
const [mode, setMode] = useState<"attribute" | "bake">("attribute");
const matrix = useMemo(() => {
const operation = multiply(
multiply(multiply(rotation(angle), scaling(sx, sy)), skewX(skewXAngle)),
skewY(skewYAngle),
);
const aroundOrigin = multiply(
multiply(translation(originX, originY), operation),
translation(-originX, -originY),
);
return multiply(translation(tx, ty), aroundOrigin);
}, [angle, originX, originY, skewXAngle, skewYAngle, sx, sy, tx, ty]);
useEffect(() => {
onPreview(node ? matrix : null);
return () => onPreview(null);
}, [matrix, node, onPreview]);
const report = diagnoseTransform(
`matrix(${matrix.a} ${matrix.b} ${matrix.c} ${matrix.d} ${matrix.e} ${matrix.f})`,
);
const chain = useMemo(
() => (node ? resolveTransformChain(semantic, node.key) : null),
[node, semantic],
);
const bakeSupported = Boolean(
node &&
[
"path",
"line",
"polyline",
"polygon",
"rect",
"circle",
"ellipse",
].includes(node.localName),
);
if (!node)
return <p className="empty-state">Select an element to transform it.</p>;
const number = (
label: string,
value: number,
setter: (value: number) => void,
step = 1,
) => (
<label className="field">
<span>{label}</span>
<input
type="number"
step={step}
value={value}
onChange={(event) => setter(event.currentTarget.valueAsNumber || 0)}
/>
</label>
);
return (
<div className="inspector-section">
<div className="field-grid">
{number("Translate X", tx, setTx)}
{number("Translate Y", ty, setTy)}
{number("Origin X", originX, setOriginX)}
{number("Origin Y", originY, setOriginY)}
{number("Rotate °", angle, setAngle)}
{number("Skew X °", skewXAngle, setSkewXAngle)}
{number("Skew Y °", skewYAngle, setSkewYAngle)}
{number("Scale X", sx, setSx, 0.1)}
{number("Scale Y", sy, setSy, 0.1)}
</div>
<div className="button-row">
<button
type="button"
className="secondary-button"
onClick={() => setSx((value) => -value)}
>
Flip horizontal
</button>
<button
type="button"
className="secondary-button"
onClick={() => setSy((value) => -value)}
>
Flip vertical
</button>
</div>
<label className="field">
<span>Apply as</span>
<select
value={mode}
onChange={(event) =>
setMode(event.currentTarget.value as typeof mode)
}
>
<option value="attribute">Transform attribute</option>
<option value="bake">Bake into geometry</option>
</select>
</label>
{mode === "bake" && !bakeSupported ? (
<p className="inline-warning">
Baking this element type is not deterministic. Keep its transform
attribute or edit source.
</p>
) : null}
{mode === "bake" && bakeSupported ? (
<p className="inline-warning">
The current local transform and this draft are baked together. Safe
primitives stay native; general rectangles and ellipses convert to
paths. Stroke consequences and the source diff are shown before Apply.
</p>
) : null}
<h3>Ancestor transform chain</h3>
{chain?.entries.map((entry) => (
<div className="transform-chain-row" key={entry.nodeKey}>
<strong>
&lt;{entry.elementName}&gt;
{entry.elementId ? `#${entry.elementId}` : ""}
</strong>
<code>{entry.source}</code>
<small>
matrix(
{[
entry.combined.a,
entry.combined.b,
entry.combined.c,
entry.combined.d,
entry.combined.e,
entry.combined.f,
]
.map((value) => value.toFixed(4))
.join(" ")}
)
</small>
</div>
))}
{!chain?.entries.length ? (
<p className="hint">
No source transform attributes in this selection chain.
</p>
) : null}
{chain?.diagnostics.map((message) => (
<p className="diagnostic is-warning" key={message}>
{message}
</p>
))}
{report.diagnostics.map((diagnostic) => (
<p
className={`diagnostic is-${diagnostic.severity}`}
key={diagnostic.code}
>
{diagnostic.message}
</p>
))}
<code className="code-block">
matrix(
{[matrix.a, matrix.b, matrix.c, matrix.d, matrix.e, matrix.f]
.map((value) => value.toFixed(4))
.join(" ")}
)
</code>
<div className="button-row end">
<button
type="button"
className="secondary-button"
onClick={() => {
setTx(0);
setTy(0);
setOriginX(0);
setOriginY(0);
setAngle(0);
setSx(1);
setSy(1);
setSkewXAngle(0);
setSkewYAngle(0);
}}
>
Reset
</button>
<button
type="button"
className="primary-button"
disabled={mode === "bake" && !bakeSupported}
onClick={() => onApply(matrix, mode)}
>
Preview transform
</button>
</div>
</div>
);
}
function AnimationPanel({
node,
animations,
preview,
onChange,
onPreviewChange,
onApply,
}: {
node: SemanticSvgNode | undefined;
animations: readonly AnimationDefinition[];
preview: boolean;
onChange: (animations: AnimationDefinition[]) => void;
onPreviewChange: (preview: boolean) => void;
onApply: () => void;
}) {
const add = () => {
if (!node) return;
onChange([
...animations,
{
id: globalThis.crypto.randomUUID(),
name: "Opacity pulse",
targetNodeKey: node.key,
property: "opacity",
kind: "style",
enabled: true,
keyframes: [
{ offset: 0, value: "0.25" },
{ offset: 1, value: "1" },
],
timing: {
durationMs: 1_000,
delayMs: 0,
iterations: "infinite",
direction: "alternate",
fillMode: "both",
easing: "ease-in-out",
},
},
]);
};
return (
<div className="inspector-section">
<label className="toggle-row">
<span>
<strong>Preview animations</strong>
<small>App-owned CSS in the isolated canvas only</small>
</span>
<input
type="checkbox"
checked={preview}
onChange={(event) => onPreviewChange(event.currentTarget.checked)}
/>
</label>
<button
type="button"
className="secondary-button"
disabled={!node}
onClick={add}
>
Add opacity animation
</button>
<div className="animation-list">
{animations.map((animation, index) => (
<article className="animation-card" key={animation.id}>
<div>
<strong>{animation.name}</strong>
<small>
{animation.property} · {animation.timing.durationMs} ms
</small>
</div>
<label>
<input
type="checkbox"
checked={animation.enabled}
onChange={(event) =>
onChange(
animations.map((candidate, candidateIndex) =>
candidateIndex === index
? { ...candidate, enabled: event.currentTarget.checked }
: candidate,
),
)
}
/>{" "}
enabled
</label>
<button
type="button"
className="icon-button"
aria-label={`Remove ${animation.name}`}
onClick={() =>
onChange(
animations.filter(
(_, candidateIndex) => candidateIndex !== index,
),
)
}
>
×
</button>
</article>
))}
{!animations.length ? (
<p className="empty-state">No app-side animations yet.</p>
) : null}
</div>
<button
type="button"
className="primary-button"
disabled={!animations.length}
onClick={onApply}
>
Apply animations to source
</button>
<p className="hint">
Preview definitions are stored in SVG Tools projects. Applying generates
explicit CSS in the SVG and requires stable target IDs.
</p>
</div>
);
}
function AccessibilityPanel({
findings,
diagnostics,
onFix,
}: {
findings: readonly AccessibilityFinding[];
diagnostics: readonly SvgDiagnostic[];
onFix: InspectorProps["onAccessibilityFix"];
}) {
return (
<div className="inspector-section">
<p className="hint">
This audit reports evidence and limitations; it cannot determine author
intent or replace testing with assistive technology.
</p>
<div className="finding-list">
{findings.map((finding, index) => (
<article
className={`finding is-${finding.severity}`}
key={`${finding.rule}-${index}`}
>
<div className="finding-title">
<strong>{finding.rule}</strong>
<span>{finding.severity}</span>
</div>
<p>{finding.evidence}</p>
<small>{finding.limitation}</small>
<p>
<strong>Suggestion:</strong> {finding.suggestedFix}
</p>
{finding.automaticFix === "add-title" ? (
<button
type="button"
className="secondary-button"
onClick={() => onFix("add-title", "Describe this image")}
>
Add title
</button>
) : null}
{finding.automaticFix === "add-description" ? (
<button
type="button"
className="secondary-button"
onClick={() =>
onFix("add-description", "Longer description of this image")
}
>
Add description
</button>
) : null}
</article>
))}
{!findings.length ? (
<p className="success-state">
No findings from the current local audit.
</p>
) : null}
</div>
{diagnostics
.filter((item) => item.code.startsWith("reference-"))
.map((item, index) => (
<p
className={`diagnostic is-${item.severity}`}
key={`${item.code}-${index}`}
>
{item.message}
</p>
))}
</div>
);
}
export function Inspector(props: InspectorProps) {
const node = props.selectedKey
? props.semantic.nodes.get(props.selectedKey)
: undefined;
const tabs: Array<{ id: InspectorTab; label: string }> = [
{ id: "document", label: "Document" },
{ id: "element", label: "Element" },
{ id: "path", label: "Path" },
{ id: "transform", label: "Transform" },
{ id: "animation", label: "Animate" },
{ id: "accessibility", label: "A11y" },
];
return (
<section
className="panel inspector-panel"
aria-labelledby="inspector-heading"
aria-disabled={props.disabled}
>
<div className="panel-heading compact">
<div>
<p className="eyebrow">Properties</p>
<h2 id="inspector-heading">Inspector</h2>
</div>
</div>
<div
className="inspector-tabs"
role="tablist"
aria-label="Inspector sections"
>
{tabs.map((tab) => (
<button
type="button"
role="tab"
aria-selected={props.activeTab === tab.id}
className={props.activeTab === tab.id ? "is-active" : ""}
key={tab.id}
onClick={() => props.onTabChange(tab.id)}
>
{tab.label}
</button>
))}
</div>
<div className="inspector-body" role="tabpanel">
<fieldset disabled={props.disabled} className="inspector-fieldset">
{props.activeTab === "document" ? (
<DocumentPanel
semantic={props.semantic}
onSetAttribute={props.onSetAttribute}
/>
) : null}
{props.activeTab === "element" ? (
<ElementPanel node={node} onSetAttribute={props.onSetAttribute} />
) : null}
{props.activeTab === "path" ? (
<PathPanel
node={node}
pathEditing={props.pathEditing}
onPathEditingChange={props.onPathEditingChange}
onCommitPath={props.onCommitPath}
/>
) : null}
{props.activeTab === "transform" ? (
<TransformPanel
semantic={props.semantic}
node={node}
onPreview={props.onTransformPreview}
onApply={props.onApplyTransform}
/>
) : null}
{props.activeTab === "animation" ? (
<AnimationPanel
node={node}
animations={props.animations}
preview={props.animationPreview}
onChange={props.onAnimationsChange}
onPreviewChange={props.onAnimationPreviewChange}
onApply={props.onApplyAnimations}
/>
) : null}
{props.activeTab === "accessibility" ? (
<AccessibilityPanel
findings={props.accessibility}
diagnostics={props.diagnostics}
onFix={props.onAccessibilityFix}
/>
) : null}
</fieldset>
</div>
</section>
);
}
+164
View File
@@ -0,0 +1,164 @@
import { useEffect, useRef } from "react";
import { defaultKeymap, indentWithTab } from "@codemirror/commands";
import { xml } from "@codemirror/lang-xml";
import {
bracketMatching,
defaultHighlightStyle,
foldGutter,
foldKeymap,
indentOnInput,
syntaxHighlighting,
} from "@codemirror/language";
import { highlightSelectionMatches, searchKeymap } from "@codemirror/search";
import { Annotation, EditorState } from "@codemirror/state";
import {
crosshairCursor,
drawSelection,
dropCursor,
EditorView,
highlightActiveLine,
highlightActiveLineGutter,
keymap,
lineNumbers,
rectangularSelection,
} from "@codemirror/view";
import type { SourceRange } from "../document/document.types";
const externalUpdate = Annotation.define<boolean>();
export interface SourceEditorProps {
value: string;
onChange: (value: string) => void;
onSelectionChange?: (offset: number) => void;
onUndo?: () => void;
onRedo?: () => void;
revealRange?: SourceRange;
ariaLabel?: string;
}
export function SourceEditor({
value,
onChange,
onSelectionChange,
onUndo,
onRedo,
revealRange,
ariaLabel = "SVG source",
}: SourceEditorProps) {
const hostRef = useRef<HTMLDivElement>(null);
const editorRef = useRef<EditorView | null>(null);
const onChangeRef = useRef(onChange);
const onSelectionChangeRef = useRef(onSelectionChange);
const onUndoRef = useRef(onUndo);
const onRedoRef = useRef(onRedo);
const initialValueRef = useRef(value);
useEffect(() => {
onChangeRef.current = onChange;
onSelectionChangeRef.current = onSelectionChange;
onUndoRef.current = onUndo;
onRedoRef.current = onRedo;
}, [onChange, onRedo, onSelectionChange, onUndo]);
useEffect(() => {
if (!hostRef.current) return;
const view = new EditorView({
state: EditorState.create({
doc: initialValueRef.current,
extensions: [
lineNumbers(),
highlightActiveLineGutter(),
foldGutter(),
drawSelection(),
dropCursor(),
EditorState.allowMultipleSelections.of(true),
indentOnInput(),
syntaxHighlighting(defaultHighlightStyle, { fallback: true }),
bracketMatching(),
rectangularSelection(),
crosshairCursor(),
highlightActiveLine(),
highlightSelectionMatches(),
xml(),
keymap.of([
{
key: "Mod-z",
run: () => {
onUndoRef.current?.();
return true;
},
},
{
key: "Shift-Mod-z",
run: () => {
onRedoRef.current?.();
return true;
},
},
{
key: "Mod-y",
run: () => {
onRedoRef.current?.();
return true;
},
},
indentWithTab,
...defaultKeymap,
...searchKeymap,
...foldKeymap,
]),
EditorView.lineWrapping,
EditorView.contentAttributes.of({ "aria-label": ariaLabel }),
EditorView.theme({
"&": { height: "100%", background: "transparent" },
".cm-scroller": { overflow: "auto", fontFamily: "var(--svg-mono)" },
".cm-gutters": {
background: "color-mix(in srgb, Canvas 94%, currentColor 6%)",
},
"&.cm-focused": { outline: "none" },
}),
EditorView.updateListener.of((update) => {
const external = update.transactions.some((transaction) =>
transaction.annotation(externalUpdate),
);
if (update.docChanged && !external) {
onChangeRef.current(update.state.doc.toString());
}
if (update.selectionSet && !external) {
onSelectionChangeRef.current?.(update.state.selection.main.head);
}
}),
],
}),
parent: hostRef.current,
});
editorRef.current = view;
return () => {
editorRef.current = null;
view.destroy();
};
}, [ariaLabel]);
useEffect(() => {
const view = editorRef.current;
if (!view || view.state.doc.toString() === value) return;
view.dispatch({
changes: { from: 0, to: view.state.doc.length, insert: value },
annotations: externalUpdate.of(true),
});
}, [value]);
useEffect(() => {
const view = editorRef.current;
if (!view || !revealRange) return;
const from = Math.min(revealRange.from, view.state.doc.length);
const to = Math.min(Math.max(from, revealRange.to), view.state.doc.length);
view.dispatch({
selection: { anchor: from, head: to },
effects: EditorView.scrollIntoView(from, { y: "center" }),
annotations: externalUpdate.of(true),
});
}, [revealRange]);
return <div className="source-editor-host" ref={hostRef} />;
}
+264
View File
@@ -0,0 +1,264 @@
import { useMemo, useRef, useState, type KeyboardEvent } from "react";
import type {
SemanticSvgDocument,
SemanticSvgNode,
SvgDiagnostic,
} from "../document/document.types";
interface VisibleNode {
node: SemanticSvgNode;
level: number;
}
const MAXIMUM_VISIBLE_TREE_ROWS = 2_000;
export interface StructureTreeProps {
semantic: SemanticSvgDocument;
selectedKey: string | null;
expanded: ReadonlySet<string>;
diagnostics: readonly SvgDiagnostic[];
disabled: boolean;
onSelect: (nodeKey: string) => void;
onExpandedChange: (expanded: Set<string>) => void;
}
function searchable(node: SemanticSvgNode): string {
return `${node.name} ${node.id ?? ""} ${node.classes.join(" ")} ${Object.keys(node.attributes).join(" ")}`.toLowerCase();
}
function iconFor(node: SemanticSvgNode): string {
if (node.localName === "svg") return "◇";
if (node.localName === "path") return "⌁";
if (["circle", "ellipse"].includes(node.localName)) return "○";
if (["rect", "image"].includes(node.localName)) return "□";
if (["text", "tspan", "textPath"].includes(node.localName)) return "T";
if (["g", "defs", "symbol"].includes(node.localName)) return "▱";
return "·";
}
export function StructureTree({
semantic,
selectedKey,
expanded,
diagnostics,
disabled,
onSelect,
onExpandedChange,
}: StructureTreeProps) {
const [query, setQuery] = useState("");
const treeRef = useRef<HTMLDivElement>(null);
const normalized = query.trim().toLowerCase();
const included = useMemo(() => {
if (!normalized) return null;
const result = new Set<string>();
for (const node of semantic.nodes.values()) {
if (!searchable(node).includes(normalized)) continue;
let current: SemanticSvgNode | undefined = node;
while (current) {
result.add(current.key);
current = current.parentKey
? semantic.nodes.get(current.parentKey)
: undefined;
}
}
return result;
}, [normalized, semantic]);
const visibleResult = useMemo(() => {
const rows: VisibleNode[] = [];
let truncated = false;
const visit = (key: string, level: number) => {
if (rows.length >= MAXIMUM_VISIBLE_TREE_ROWS) {
truncated = true;
return;
}
const node = semantic.nodes.get(key);
if (!node || (included && !included.has(key))) return;
rows.push({ node, level });
if (normalized || expanded.has(key)) {
for (const child of node.childKeys) {
visit(child, level + 1);
if (truncated) break;
}
}
};
visit(semantic.rootKey, 1);
return { rows, truncated };
}, [expanded, included, normalized, semantic]);
const visible = visibleResult.rows;
const counts = useMemo(() => {
const map = new Map<string, number>();
for (const diagnostic of diagnostics) {
if (diagnostic.nodeKey)
map.set(diagnostic.nodeKey, (map.get(diagnostic.nodeKey) ?? 0) + 1);
}
return map;
}, [diagnostics]);
const toggle = (node: SemanticSvgNode) => {
const next = new Set(expanded);
if (next.has(node.key)) next.delete(node.key);
else next.add(node.key);
onExpandedChange(next);
};
const focusAt = (index: number) => {
const bounded = Math.max(0, Math.min(visible.length - 1, index));
const key = visible[bounded]?.node.key;
if (!key) return;
onSelect(key);
requestAnimationFrame(() => {
treeRef.current
?.querySelector<HTMLElement>(`[data-node-key="${CSS.escape(key)}"]`)
?.focus();
});
};
const onKeyDown = (
event: KeyboardEvent,
index: number,
node: SemanticSvgNode,
) => {
switch (event.key) {
case "ArrowDown":
event.preventDefault();
focusAt(index + 1);
break;
case "ArrowUp":
event.preventDefault();
focusAt(index - 1);
break;
case "Home":
event.preventDefault();
focusAt(0);
break;
case "End":
event.preventDefault();
focusAt(visible.length - 1);
break;
case "ArrowRight":
event.preventDefault();
if (node.childKeys.length && !expanded.has(node.key)) toggle(node);
else if (node.childKeys.length) focusAt(index + 1);
break;
case "ArrowLeft":
event.preventDefault();
if (expanded.has(node.key)) toggle(node);
else if (node.parentKey) onSelect(node.parentKey);
break;
case "Enter":
case " ":
event.preventDefault();
onSelect(node.key);
break;
}
};
return (
<section className="panel tree-panel" aria-labelledby="structure-heading">
<div className="panel-heading compact">
<div>
<p className="eyebrow">Document</p>
<h2 id="structure-heading">Structure</h2>
</div>
<span className="count-badge">{semantic.metrics.elementCount}</span>
</div>
<label className="tree-search">
<span className="sr-only">Filter elements</span>
<input
type="search"
value={query}
placeholder="Filter elements…"
onChange={(event) => setQuery(event.currentTarget.value)}
/>
</label>
<div
className="semantic-tree"
role="tree"
aria-label="SVG element structure"
aria-disabled={disabled}
ref={treeRef}
>
{visible.map(({ node, level }, index) => {
const children = node.childKeys.length > 0;
const selected = node.key === selectedKey;
return (
<div
className="tree-row"
key={node.key}
style={{ "--tree-depth": level } as React.CSSProperties}
>
<button
type="button"
className="tree-expander"
aria-label={
children
? `${expanded.has(node.key) ? "Collapse" : "Expand"} ${node.name}`
: "No children"
}
disabled={!children}
tabIndex={-1}
onClick={() => toggle(node)}
>
{children
? expanded.has(node.key) || normalized
? "▾"
: "▸"
: ""}
</button>
<button
type="button"
role="treeitem"
aria-level={level}
aria-expanded={
children
? expanded.has(node.key) || Boolean(normalized)
: undefined
}
aria-selected={selected}
tabIndex={selected || (!selectedKey && index === 0) ? 0 : -1}
className={`tree-item${selected ? " is-selected" : ""}`}
data-node-key={node.key}
disabled={disabled}
onClick={() => onSelect(node.key)}
onKeyDown={(event) => onKeyDown(event, index, node)}
>
<span className="tree-icon" aria-hidden="true">
{iconFor(node)}
</span>
<span className="tree-label">
<strong>{node.name}</strong>
{node.id ? <span>#{node.id}</span> : null}
{node.classes.slice(0, 2).map((name) => (
<span key={name}>.{name}</span>
))}
</span>
{!node.rendered ? (
<span className="tree-kind" title="Definition or metadata">
def
</span>
) : null}
{counts.has(node.key) ? (
<span
className="diagnostic-count"
aria-label={`${counts.get(node.key)} diagnostics`}
>
{counts.get(node.key)}
</span>
) : null}
</button>
</div>
);
})}
{!visible.length ? (
<p className="empty-state">No elements match this filter.</p>
) : null}
{visibleResult.truncated ? (
<p className="tree-limit-note" role="status">
Showing the first {MAXIMUM_VISIBLE_TREE_ROWS.toLocaleString()} rows.
Narrow the filter or collapse branches to inspect the rest.
</p>
) : null}
</div>
</section>
);
}
File diff suppressed because it is too large Load Diff
+741
View File
@@ -0,0 +1,741 @@
import { useEffect, useMemo, useRef, useState } from "react";
import { EMPTY_SVG, STARTER_SVG } from "../app/sample";
import type { SemanticSvgDocument } from "../document/document.types";
import { downloadBlob } from "../export/file-name";
import {
createSelectedSvgSource,
createSymbolSpriteSource,
} from "../export/derived-svg-export";
import {
rasterizeProjection,
type RasterFormat,
} from "../export/raster-export";
import { createSvgExport } from "../export/svg-export";
import { lineDiff } from "../format/diff";
import { formatSvgSource } from "../format/formatter";
import { OptimizerClient } from "../optimization/optimizer-client";
import {
configForProfile,
optionalOptimizationPlugins,
optimizationProfiles,
type OptionalOptimizationPlugin,
type OptimizationProfile,
} from "../optimization/profiles";
import { createSanitizedCandidate } from "../security/sanitize-svg";
import { SVGO_VERSION } from "../version";
function useModal(open: boolean) {
const ref = useRef<HTMLDialogElement>(null);
useEffect(() => {
const dialog = ref.current;
if (!dialog) return;
if (open && !dialog.open) dialog.showModal();
if (!open && dialog.open) dialog.close();
}, [open]);
return ref;
}
export function ChangePreviewDialog({
open,
title,
description,
before,
after,
applyLabel = "Apply change",
onApply,
onClose,
}: {
open: boolean;
title: string;
description: string;
before: string;
after: string;
applyLabel?: string;
onApply: () => void;
onClose: () => void;
}) {
const ref = useModal(open);
const lines = useMemo(() => lineDiff(before, after), [after, before]);
const changed = lines.filter((line) => line.kind !== "same").length;
return (
<dialog className="tool-dialog preview-dialog" ref={ref} onClose={onClose}>
<div className="dialog-heading">
<div>
<p className="eyebrow">Preview required</p>
<h2>{title}</h2>
</div>
<button
type="button"
className="icon-button"
aria-label={`Close ${title}`}
onClick={onClose}
>
×
</button>
</div>
<div className="dialog-body">
<p>{description}</p>
<p className="change-summary">
{changed.toLocaleString()} changed lines ·{" "}
{before.length.toLocaleString()} {after.length.toLocaleString()}{" "}
characters
</p>
<div className="source-diff" aria-label="Source change preview">
{lines.map((line, index) => (
<div
className={`diff-line is-${line.kind}`}
key={`${index}-${line.kind}`}
>
<span>
{line.kind === "add" ? "+" : line.kind === "remove" ? "" : " "}
</span>
<code>{line.text || " "}</code>
</div>
))}
</div>
</div>
<div className="dialog-actions">
<button type="button" className="secondary-button" onClick={onClose}>
Cancel
</button>
<button
type="button"
className="primary-button"
disabled={before === after}
onClick={onApply}
>
{applyLabel}
</button>
</div>
</dialog>
);
}
export function OptimizeDialog({
open,
source,
revision,
fileName,
onApply,
onStatus,
onClose,
}: {
open: boolean;
source: string;
revision: number;
fileName: string;
onApply: (source: string, expectedRevision: number) => void;
onStatus: (message: string) => void;
onClose: () => void;
}) {
const ref = useModal(open);
const clientRef = useRef(new OptimizerClient());
const [profile, setProfile] = useState<OptimizationProfile>("conservative");
const [optionalPlugins, setOptionalPlugins] = useState<
OptionalOptimizationPlugin[]
>([]);
const pluginNames = useMemo(
() =>
(configForProfile(profile, optionalPlugins).plugins ?? []).map(
(plugin) => (typeof plugin === "string" ? plugin : plugin.name),
),
[optionalPlugins, profile],
);
const [result, setResult] = useState<{
source: string;
inputBytes: number;
outputBytes: number;
elapsedMs: number;
profile: OptimizationProfile;
optionalPlugins: OptionalOptimizationPlugin[];
revision: number;
} | null>(null);
const [error, setError] = useState("");
const [running, setRunning] = useState(false);
useEffect(() => {
if (open) return;
clientRef.current.cancel();
let active = true;
queueMicrotask(() => {
if (!active) return;
setRunning(false);
setResult(null);
setError("");
});
return () => {
active = false;
};
}, [open]);
const run = async () => {
setError("");
setRunning(true);
setResult(null);
try {
const optimized = await clientRef.current.optimize(
source,
profile,
optionalPlugins,
);
setResult({ ...optimized, revision });
} catch (failure) {
setError(
failure instanceof Error ? failure.message : "Optimization failed",
);
} finally {
setRunning(false);
}
};
const downloadOptimized = () => {
if (!result) return;
try {
const requestedName = fileName.replace(/\.svgz?$/iu, "-optimized.svg");
const artifact = createSvgExport(result.source, "svg", requestedName);
downloadBlob(artifact.blob, artifact.fileName);
onStatus(
`${artifact.fileName} downloaded without changing the current document.`,
);
} catch (failure) {
onStatus(
failure instanceof Error ? failure.message : "Optimized export failed",
);
}
};
const lines = useMemo(
() => (result ? lineDiff(source, result.source) : []),
[result, source],
);
return (
<dialog className="tool-dialog optimize-dialog" ref={ref} onClose={onClose}>
<div className="dialog-heading">
<div>
<p className="eyebrow">Worker-backed preview</p>
<h2>Optimize SVG</h2>
</div>
<button
type="button"
className="icon-button"
aria-label="Close optimizer"
onClick={onClose}
>
×
</button>
</div>
<div className="dialog-body">
<p className="change-summary">
SVGO {SVGO_VERSION} runs locally in a dedicated worker.
</p>
<div className="plugin-list" aria-label="Active SVGO plugins">
{pluginNames.map((name) => (
<code key={name}>{name}</code>
))}
</div>
<div className="profile-grid">
{optimizationProfiles.map((candidate) => (
<label
className={`profile-card${profile === candidate.id ? " is-selected" : ""}`}
key={candidate.id}
>
<input
type="radio"
name="profile"
value={candidate.id}
checked={profile === candidate.id}
onChange={() => {
setProfile(candidate.id);
setResult(null);
}}
/>
<strong>{candidate.label}</strong>
<span>{candidate.description}</span>
<small>{candidate.risk}</small>
</label>
))}
</div>
<fieldset className="optional-plugin-list">
<legend>Optional plugins</legend>
{optionalOptimizationPlugins.map((plugin) => {
const checked = optionalPlugins.includes(plugin.id);
return (
<label
className={`plugin-option${checked ? " is-selected" : ""}`}
key={plugin.id}
>
<input
type="checkbox"
checked={checked}
onChange={() => {
setOptionalPlugins((current) =>
checked
? current.filter((id) => id !== plugin.id)
: [...current, plugin.id],
);
setResult(null);
}}
/>
<span>
<strong>{plugin.label}</strong>
<small>{plugin.risk}</small>
</span>
</label>
);
})}
</fieldset>
<p className="inline-warning">
Optimization is not sanitization. Compare the source and rendering
before applying structural changes.
</p>
<button
type="button"
className="secondary-button"
disabled={running}
onClick={() => void run()}
>
{running ? "Optimizing locally…" : "Generate preview"}
</button>
{error ? (
<p className="inline-error" role="alert">
{error}
</p>
) : null}
{result ? (
<>
<p className="change-summary">
{result.inputBytes.toLocaleString()} {" "}
{result.outputBytes.toLocaleString()} bytes (
{Math.round(
(1 - result.outputBytes / Math.max(1, result.inputBytes)) * 100,
)}
% smaller) · {Math.round(result.elapsedMs)} ms
</p>
<button
type="button"
className="secondary-button"
onClick={downloadOptimized}
>
Download optimized SVG
</button>
<div
className="source-diff"
aria-label="Optimization source preview"
>
{lines.map((line, index) => (
<div
className={`diff-line is-${line.kind}`}
key={`${index}-${line.kind}`}
>
<span>
{line.kind === "add"
? "+"
: line.kind === "remove"
? ""
: " "}
</span>
<code>{line.text || " "}</code>
</div>
))}
</div>
</>
) : null}
</div>
<div className="dialog-actions">
<button type="button" className="secondary-button" onClick={onClose}>
Cancel
</button>
<button
type="button"
className="primary-button"
disabled={
!result ||
result.revision !== revision ||
result.profile !== profile ||
result.optionalPlugins.join("|") !== optionalPlugins.join("|")
}
onClick={() => result && onApply(result.source, result.revision)}
>
Apply optimized source
</button>
</div>
</dialog>
);
}
export function ExportDialog({
open,
source,
projection,
fileName,
valid,
semantic,
selectedKeys,
onSaveProject,
onStatus,
onClose,
}: {
open: boolean;
source: string;
projection: string;
fileName: string;
valid: boolean;
semantic: SemanticSvgDocument;
selectedKeys: readonly string[];
onSaveProject: () => void;
onStatus: (message: string) => void;
onClose: () => void;
}) {
const ref = useModal(open);
const [format, setFormat] = useState<RasterFormat>("png");
const [width, setWidth] = useState("");
const [height, setHeight] = useState("");
const [scale, setScale] = useState(1);
const [background, setBackground] = useState("");
const [busy, setBusy] = useState(false);
const svg = (kind: "svg" | "svgz") => {
try {
const artifact = createSvgExport(source, kind, fileName);
downloadBlob(artifact.blob, artifact.fileName);
onStatus(`${artifact.fileName} downloaded locally.`);
} catch (error) {
onStatus(error instanceof Error ? error.message : "Export failed");
}
};
const derived = (
kind: "formatted" | "sanitized" | "selection" | "sprite",
) => {
try {
let output: { source: string; warnings: string[] };
if (kind === "formatted") {
output = {
source: formatSvgSource(source, semantic.preferences.indentation),
warnings: [
"Formatting rewrites whitespace and attribute order in this downloaded copy only.",
],
};
} else if (kind === "sanitized") {
const candidate = createSanitizedCandidate(semantic);
output = {
source: candidate.source,
warnings: [
`Editing-projection policy v1 applied ${candidate.findings.length} security finding(s).`,
],
};
} else if (kind === "selection") {
output = createSelectedSvgSource(semantic, selectedKeys);
} else {
output = createSymbolSpriteSource(semantic, selectedKeys);
}
const suffix = kind === "selection" ? "selected" : kind;
const requestedName = fileName.replace(/\.svgz?$/iu, `-${suffix}.svg`);
const artifact = createSvgExport(output.source, "svg", requestedName);
downloadBlob(artifact.blob, artifact.fileName);
onStatus(
`${artifact.fileName} downloaded locally. ${output.warnings.join(" ")}`,
);
} catch (error) {
onStatus(
error instanceof Error ? error.message : "Derived SVG export failed",
);
}
};
const raster = async () => {
setBusy(true);
try {
const artifact = await rasterizeProjection(projection, {
format,
fileName,
...(width ? { width: Number(width) } : {}),
...(height ? { height: Number(height) } : {}),
scale,
...(background ? { background } : {}),
});
downloadBlob(artifact.blob, artifact.fileName);
onStatus(
`${artifact.fileName} exported at ${artifact.size.width} × ${artifact.size.height}.`,
);
} catch (error) {
onStatus(error instanceof Error ? error.message : "Raster export failed");
} finally {
setBusy(false);
}
};
return (
<dialog className="tool-dialog export-dialog" ref={ref} onClose={onClose}>
<div className="dialog-heading">
<div>
<p className="eyebrow">Local download</p>
<h2>Export</h2>
</div>
<button
type="button"
className="icon-button"
aria-label="Close export"
onClick={onClose}
>
×
</button>
</div>
<div className="dialog-body export-sections">
<section>
<h3>Source-faithful vector</h3>
<p>
SVG retains the exact canonical UTF-8 source. SVGZ adds
deterministic gzip compression.
</p>
<div className="button-row">
<button
type="button"
className="secondary-button"
disabled={!valid}
onClick={() => svg("svg")}
>
Download SVG
</button>
<button
type="button"
className="secondary-button"
disabled={!valid}
onClick={() => svg("svgz")}
>
Download SVGZ
</button>
</div>
</section>
<section>
<h3>Derived SVG copies</h3>
<p>
These downloads are generated explicitly without replacing canonical
source. Selection and sprite exports use the sanitized editing
projection.
</p>
<div className="button-row">
<button
type="button"
className="secondary-button"
disabled={!valid}
onClick={() => derived("formatted")}
>
Formatted SVG
</button>
<button
type="button"
className="secondary-button"
disabled={!valid}
onClick={() => derived("sanitized")}
>
Sanitized SVG
</button>
<button
type="button"
className="secondary-button"
disabled={!valid || selectedKeys.length === 0}
onClick={() => derived("selection")}
>
Selected object
</button>
<button
type="button"
className="secondary-button"
disabled={!valid}
onClick={() => derived("sprite")}
>
Symbol sprite
</button>
</div>
</section>
<section>
<h3>SVG Tools project</h3>
<p>
Preserves canonical source, selection, viewport, panels and app-side
animation definitions.
</p>
<button
type="button"
className="secondary-button"
onClick={onSaveProject}
>
Download project
</button>
</section>
<section>
<h3>Rasterized projection</h3>
<p>
Raster export uses the sanitized editing projection and preserves
aspect ratio when only one dimension is supplied.
</p>
<div className="field-grid">
<label className="field">
<span>Format</span>
<select
value={format}
onChange={(event) =>
setFormat(event.currentTarget.value as RasterFormat)
}
>
<option value="png">PNG</option>
<option value="jpeg">JPEG</option>
<option value="webp">WebP</option>
</select>
</label>
<label className="field">
<span>Scale</span>
<input
type="number"
min="0.1"
max="16"
step="0.1"
value={scale}
onChange={(event) =>
setScale(event.currentTarget.valueAsNumber || 1)
}
/>
</label>
<label className="field">
<span>Width (optional)</span>
<input
type="number"
min="1"
value={width}
onChange={(event) => setWidth(event.currentTarget.value)}
/>
</label>
<label className="field">
<span>Height (optional)</span>
<input
type="number"
min="1"
value={height}
onChange={(event) => setHeight(event.currentTarget.value)}
/>
</label>
<label className="field">
<span>Background (optional)</span>
<input
value={background}
placeholder={format === "jpeg" ? "#ffffff" : "transparent"}
onChange={(event) => setBackground(event.currentTarget.value)}
/>
</label>
</div>
<button
type="button"
className="primary-button"
disabled={busy || !valid}
onClick={() => void raster()}
>
{busy ? "Rasterizing…" : `Export ${format.toUpperCase()}`}
</button>
</section>
</div>
<div className="dialog-actions">
<button type="button" className="primary-button" onClick={onClose}>
Done
</button>
</div>
</dialog>
);
}
export function NewDocumentDialog({
open,
onCreate,
onClose,
}: {
open: boolean;
onCreate: (source: string, fileName: string) => void;
onClose: () => void;
}) {
const ref = useModal(open);
const [template, setTemplate] = useState<"starter" | "empty">("starter");
const [width, setWidth] = useState(640);
const [height, setHeight] = useState(420);
const create = () => {
const source =
template === "starter"
? STARTER_SVG
: EMPTY_SVG.replaceAll("640", String(width)).replaceAll(
"420",
String(height),
);
onCreate(source, "untitled.svg");
};
return (
<dialog className="tool-dialog new-dialog" ref={ref} onClose={onClose}>
<div className="dialog-heading">
<div>
<p className="eyebrow">Fresh document</p>
<h2>New SVG</h2>
</div>
<button
type="button"
className="icon-button"
aria-label="Close new document"
onClick={onClose}
>
×
</button>
</div>
<div className="dialog-body">
<div className="profile-grid">
<label
className={`profile-card${template === "starter" ? " is-selected" : ""}`}
>
<input
type="radio"
checked={template === "starter"}
onChange={() => setTemplate("starter")}
/>
<strong>Starter artwork</strong>
<span>
Gradients, text and an editable path with every major curve
family.
</span>
</label>
<label
className={`profile-card${template === "empty" ? " is-selected" : ""}`}
>
<input
type="radio"
checked={template === "empty"}
onChange={() => setTemplate("empty")}
/>
<strong>Empty document</strong>
<span>A minimal SVG root and accessible title.</span>
</label>
</div>
{template === "empty" ? (
<div className="field-grid">
<label className="field">
<span>Width</span>
<input
type="number"
min="1"
value={width}
onChange={(event) =>
setWidth(event.currentTarget.valueAsNumber || 640)
}
/>
</label>
<label className="field">
<span>Height</span>
<input
type="number"
min="1"
value={height}
onChange={(event) =>
setHeight(event.currentTarget.valueAsNumber || 420)
}
/>
</label>
</div>
) : null}
</div>
<div className="dialog-actions">
<button type="button" className="secondary-button" onClick={onClose}>
Cancel
</button>
<button type="button" className="primary-button" onClick={create}>
Create document
</button>
</div>
</dialog>
);
}
+144
View File
@@ -0,0 +1,144 @@
import type {
SemanticSvgDocument,
SemanticSvgNode,
SvgDiagnostic,
} from "../document/document.types";
const NUMBER_ATTRIBUTES = new Set([
"x",
"y",
"x1",
"y1",
"x2",
"y2",
"cx",
"cy",
"r",
"rx",
"ry",
"width",
"height",
"stroke-width",
]);
function nodeFinding(
node: SemanticSvgNode,
code: string,
message: string,
attribute?: string,
severity: SvgDiagnostic["severity"] = "warning",
): SvgDiagnostic {
return {
severity,
code,
message,
nodeKey: node.key,
range:
(attribute ? node.attributeRanges[attribute]?.valueRange : undefined) ??
node.sourceRange.openTag,
};
}
export function geometryDiagnostics(
semantic: SemanticSvgDocument,
): SvgDiagnostic[] {
const diagnostics: SvgDiagnostic[] = [];
for (const node of semantic.nodes.values()) {
for (const [name, value] of Object.entries(node.attributes)) {
if (NUMBER_ATTRIBUTES.has(name)) {
const numeric = Number.parseFloat(value);
if (!Number.isFinite(numeric)) {
diagnostics.push(
nodeFinding(
node,
"non-finite-geometry",
`${name} is not finite.`,
name,
"error",
),
);
}
}
}
if (["rect", "image", "svg"].includes(node.localName)) {
for (const name of ["width", "height"] as const) {
const value = node.attributes[name];
if (value !== undefined && Number.parseFloat(value) < 0) {
diagnostics.push(
nodeFinding(
node,
"negative-dimension",
`${name} cannot be negative.`,
name,
"error",
),
);
} else if (value !== undefined && Number.parseFloat(value) === 0) {
diagnostics.push(
nodeFinding(
node,
"zero-dimension",
`${name} is zero, so the element has no area.`,
name,
),
);
}
}
}
if (
node.localName === "circle" &&
Number.parseFloat(node.attributes.r ?? "0") <= 0
) {
diagnostics.push(
nodeFinding(
node,
"degenerate-circle",
"Circle radius is zero or negative.",
"r",
),
);
}
if (node.localName === "path") {
const data = node.attributes.d ?? "";
if (!data.trim()) {
diagnostics.push(
nodeFinding(node, "empty-path", "Path data is empty.", "d"),
);
} else if (!/[LlHhVvCcSsQqTtAaZz]/u.test(data)) {
diagnostics.push(
nodeFinding(
node,
"move-only-path",
"Path contains no drawable segment.",
"d",
),
);
}
if (/\b(?:NaN|Infinity|-Infinity)\b/u.test(data)) {
diagnostics.push(
nodeFinding(
node,
"non-finite-path",
"Path data contains a non-finite value.",
"d",
"error",
),
);
}
}
if (
node.attributes.transform &&
/matrix\([^)]*\b0(?:[\s,]+0){3}/u.test(node.attributes.transform)
) {
diagnostics.push(
nodeFinding(
node,
"possibly-non-invertible-transform",
"Transform may be non-invertible; handle dragging will be disabled if its determinant is zero.",
"transform",
),
);
}
}
return diagnostics;
}
+115
View File
@@ -0,0 +1,115 @@
export interface SourceRange {
from: number;
to: number;
}
export type DiagnosticSeverity = "info" | "warning" | "error";
export interface SvgDiagnostic {
severity: DiagnosticSeverity;
code: string;
message: string;
range?: SourceRange;
nodeKey?: string;
fixable?: boolean;
}
export interface AttributeSourceRange {
name: string;
nameRange: SourceRange;
valueRange: SourceRange;
fullRange: SourceRange;
quote: '"' | "'";
}
export interface ElementSourceRange {
openTag: SourceRange;
name: SourceRange;
content?: SourceRange;
full: SourceRange;
}
export interface SemanticSvgNode {
key: string;
name: string;
localName: string;
namespaceUri: string | null;
parentKey: string | null;
childKeys: string[];
depth: number;
id?: string;
classes: string[];
attributes: Readonly<Record<string, string>>;
attributeRanges: Readonly<Record<string, AttributeSourceRange>>;
sourceRange: ElementSourceRange;
rendered: boolean;
text: string;
}
export interface SvgSourcePreferences {
newline: "\n" | "\r\n";
indentation: string;
attributeQuote: '"' | "'";
}
export interface SvgDocumentMetrics {
sourceBytes: number;
elementCount: number;
attributeCount: number;
maximumDepth: number;
pathCommandCount: number;
referenceCount: number;
cssRuleCount: number;
animationCount: number;
filterPrimitiveCount: number;
textLength: number;
embeddedResourceBytes: number;
}
export interface SemanticSvgDocument {
revision: number;
source: string;
rootKey: string;
nodes: ReadonlyMap<string, SemanticSvgNode>;
order: readonly string[];
document: XMLDocument;
preferences: SvgSourcePreferences;
diagnostics: readonly SvgDiagnostic[];
metrics: SvgDocumentMetrics;
}
export interface SvgParseResult {
revision: number;
source: string;
valid: boolean;
semantic: SemanticSvgDocument | null;
diagnostics: readonly SvgDiagnostic[];
preferences: SvgSourcePreferences;
}
export interface SourcePatch {
from: number;
to: number;
insert: string;
label: string;
}
export interface SelectionState {
nodeKeys: string[];
primaryNodeKey: string | null;
}
export interface DocumentTransaction {
id: string;
label: string;
baseRevision: number;
sourceBefore: string;
sourceAfter: string;
sourcePatches: SourcePatch[];
affectedNodeKeys: string[];
selectionBefore: SelectionState;
selectionAfter: SelectionState;
diagnostics: SvgDiagnostic[];
mergeKey?: string;
timestamp: number;
}
+830
View File
@@ -0,0 +1,830 @@
import { xmlLanguage } from "@codemirror/lang-xml";
import {
defaultSvgLimits,
utf8ByteLength,
type SvgResourceLimits,
} from "../app/limits";
import type {
AttributeSourceRange,
ElementSourceRange,
SemanticSvgDocument,
SemanticSvgNode,
SvgDiagnostic,
SvgDocumentMetrics,
SvgParseResult,
SvgSourcePreferences,
} from "./document.types";
const SVG_NAMESPACE = "http://www.w3.org/2000/svg";
const NON_RENDERING_ELEMENTS = new Set([
"defs",
"title",
"desc",
"metadata",
"style",
"script",
"symbol",
"linearGradient",
"radialGradient",
"pattern",
"marker",
"clipPath",
"mask",
"filter",
"animate",
"animateMotion",
"animateTransform",
"set",
]);
const ANIMATION_ELEMENTS = new Set([
"animate",
"animateMotion",
"animateTransform",
"set",
]);
const FILTER_PRIMITIVE_PATTERN = /^fe[A-Z]/u;
const REFERENCE_ATTRIBUTE_PATTERN = /(?:^|\s)url\(\s*#[^)]+\)|^#[^\s]+$/u;
const PATH_COMMAND_PATTERN =
/([MmLlHhVvCcSsQqTtAaZz])([^MmLlHhVvCcSsQqTtAaZz]*)/gu;
const PATH_NUMBER_PATTERN =
/[-+]?(?:(?:\d+\.\d*)|(?:\.\d+)|(?:\d+))(?:[eE][-+]?\d+)?/gu;
const PATH_ARITY: Readonly<Record<string, number>> = {
M: 2,
L: 2,
H: 1,
V: 1,
C: 6,
S: 4,
Q: 4,
T: 2,
};
interface ScannedElement {
name: string;
nameFrom: number;
nameTo: number;
openFrom: number;
openTo: number;
fullTo: number;
selfClosing: boolean;
attributes: Record<string, AttributeSourceRange>;
}
function preferencesFor(source: string): SvgSourcePreferences {
const indentation = /\r?\n([\t ]+)\S/u.exec(source)?.[1] ?? " ";
const singleQuotes = (source.match(/='[^']*'/gu) ?? []).length;
const doubleQuotes = (source.match(/="[^"]*"/gu) ?? []).length;
return {
newline: source.includes("\r\n") ? "\r\n" : "\n",
indentation,
attributeQuote: singleQuotes > doubleQuotes ? "'" : '"',
};
}
function findTagEnd(source: string, from: number): number {
let quote: string | null = null;
for (let index = from; index < source.length; index += 1) {
const character = source[index];
if (quote) {
if (character === quote) quote = null;
} else if (character === '"' || character === "'") {
quote = character;
} else if (character === ">") {
return index + 1;
}
}
return source.length;
}
function scanAttributes(
source: string,
start: number,
end: number,
): Record<string, AttributeSourceRange> {
const attributes: Record<string, AttributeSourceRange> = {};
let index = start;
while (index < end) {
const whitespaceStart = index;
while (/\s/u.test(source[index] ?? "")) index += 1;
if (index >= end || source[index] === "/" || source[index] === ">") break;
const nameFrom = index;
while (/[^\s=/>]/u.test(source[index] ?? "")) index += 1;
const nameTo = index;
const name = source.slice(nameFrom, nameTo);
while (/\s/u.test(source[index] ?? "")) index += 1;
if (source[index] !== "=") {
while (index < end && !/\s|>/u.test(source[index] ?? "")) index += 1;
continue;
}
index += 1;
while (/\s/u.test(source[index] ?? "")) index += 1;
const quote = source[index];
if (quote !== '"' && quote !== "'") {
while (index < end && !/\s|>/u.test(source[index] ?? "")) index += 1;
continue;
}
index += 1;
const valueFrom = index;
while (index < end && source[index] !== quote) index += 1;
const valueTo = index;
if (source[index] === quote) index += 1;
attributes[name] = {
name,
nameRange: { from: nameFrom, to: nameTo },
valueRange: { from: valueFrom, to: valueTo },
fullRange: { from: whitespaceStart, to: index },
quote,
};
}
return attributes;
}
function scanElements(
source: string,
diagnostics: SvgDiagnostic[],
): ScannedElement[] {
const elements: ScannedElement[] = [];
const stack: number[] = [];
let index = 0;
while (index < source.length) {
const opening = source.indexOf("<", index);
if (opening < 0) break;
if (source.startsWith("<!--", opening)) {
const end = source.indexOf("-->", opening + 4);
if (end < 0) {
diagnostics.push({
severity: "error",
code: "xml-unclosed-comment",
message: "XML comment is not closed.",
range: { from: opening, to: source.length },
});
}
index = end < 0 ? source.length : end + 3;
continue;
}
if (source.startsWith("<![CDATA[", opening)) {
const end = source.indexOf("]]>", opening + 9);
if (end < 0) {
diagnostics.push({
severity: "error",
code: "xml-unclosed-cdata",
message: "CDATA section is not closed.",
range: { from: opening, to: source.length },
});
}
index = end < 0 ? source.length : end + 3;
continue;
}
if (source.startsWith("<?", opening)) {
const end = source.indexOf("?>", opening + 2);
if (end < 0) {
diagnostics.push({
severity: "error",
code: "xml-unclosed-processing-instruction",
message: "Processing instruction is not closed.",
range: { from: opening, to: source.length },
});
}
index = end < 0 ? source.length : end + 2;
continue;
}
if (source.startsWith("<!", opening)) {
index = findTagEnd(source, opening + 2);
continue;
}
if (source.startsWith("</", opening)) {
const closeTo = findTagEnd(source, opening + 2);
if (source[closeTo - 1] !== ">") {
diagnostics.push({
severity: "error",
code: "xml-unclosed-closing-tag",
message: "Closing tag is not closed.",
range: { from: opening, to: source.length },
});
}
let cursor = opening + 2;
while (/\s/u.test(source[cursor] ?? "")) cursor += 1;
const nameFrom = cursor;
while (/[^\s>]/u.test(source[cursor] ?? "")) cursor += 1;
const closingName = source.slice(nameFrom, cursor);
const last = stack.pop();
if (last === undefined) {
diagnostics.push({
severity: "error",
code: "xml-unexpected-closing-tag",
message: `Unexpected closing element </${closingName}>.`,
range: { from: opening, to: closeTo },
});
} else {
const expected = elements[last]!;
expected.fullTo = closeTo;
if (expected.name !== closingName) {
diagnostics.push({
severity: "error",
code: "xml-mismatched-closing-tag",
message: `Expected </${expected.name}> but found </${closingName}>.`,
range: { from: opening, to: closeTo },
});
}
}
index = closeTo;
continue;
}
const openTo = findTagEnd(source, opening + 1);
if (source[openTo - 1] !== ">") {
diagnostics.push({
severity: "error",
code: "xml-unclosed-start-tag",
message: "Start tag is not closed.",
range: { from: opening, to: source.length },
});
}
let cursor = opening + 1;
while (/\s/u.test(source[cursor] ?? "")) cursor += 1;
const nameFrom = cursor;
while (/[^\s/>]/u.test(source[cursor] ?? "")) cursor += 1;
const nameTo = cursor;
const name = source.slice(nameFrom, nameTo);
const selfClosing = /\/\s*>$/u.test(source.slice(opening, openTo));
const element: ScannedElement = {
name,
nameFrom,
nameTo,
openFrom: opening,
openTo,
fullTo: openTo,
selfClosing,
attributes: scanAttributes(source, nameTo, openTo - 1),
};
elements.push(element);
if (!selfClosing) stack.push(elements.length - 1);
index = openTo;
}
for (const elementIndex of stack) {
const element = elements[elementIndex]!;
diagnostics.push({
severity: "error",
code: "xml-unclosed-element",
message: `Element <${element.name}> is not closed.`,
range: { from: element.openFrom, to: element.openTo },
});
}
return elements;
}
function syntaxDiagnostics(source: string): SvgDiagnostic[] {
const diagnostics: SvgDiagnostic[] = [];
const tree = xmlLanguage.parser.parse(source);
tree.iterate({
enter(node) {
if (node.type.isError) {
diagnostics.push({
severity: "error",
code: "xml-syntax",
message: "Malformed XML syntax.",
range: { from: node.from, to: Math.max(node.to, node.from + 1) },
});
}
},
});
return diagnostics;
}
function countArcGroups(rawNumbers: readonly string[]): number {
let index = 0;
let remainder: string | null = null;
const number = (): string | null => {
if (remainder !== null) {
const value = remainder;
remainder = null;
return value;
}
const value = rawNumbers[index];
if (value === undefined) return null;
index += 1;
return value;
};
const flag = (): boolean => {
const value = number();
if (!value || (value[0] !== "0" && value[0] !== "1")) return false;
if (value.length > 1) remainder = value.slice(1);
return true;
};
let groups = 0;
while (index < rawNumbers.length || remainder !== null) {
if (number() === null || number() === null || number() === null) break;
if (!flag() || !flag()) break;
if (number() === null || number() === null) break;
groups += 1;
}
return groups;
}
function countPathCommands(source: string): number {
let count = 0;
for (const match of source.matchAll(PATH_COMMAND_PATTERN)) {
const command = match[1]!.toUpperCase();
if (command === "Z") {
count += 1;
continue;
}
const numbers = Array.from(
match[2]!.matchAll(PATH_NUMBER_PATTERN),
(numberMatch) => numberMatch[0],
);
count +=
command === "A"
? countArcGroups(numbers)
: Math.floor(numbers.length / (PATH_ARITY[command] ?? Infinity));
}
return count;
}
function hashIdentity(value: string): string {
let hash = 2_166_136_261;
for (let index = 0; index < value.length; index += 1) {
hash ^= value.charCodeAt(index);
hash = Math.imul(hash, 16_777_619);
}
return (hash >>> 0).toString(36);
}
function uniqueKey(
element: Element,
token: ScannedElement,
structuralPath: string,
duplicateIds: ReadonlySet<string>,
used: Set<string>,
): string {
const id = element.getAttribute("id");
const preferred =
id && !duplicateIds.has(id)
? `id:${id}`
: `node:${hashIdentity(`${structuralPath}|${token.openFrom}|${element.localName}|${id ?? ""}`)}`;
let key = preferred;
let suffix = 2;
while (used.has(key)) {
key = `${preferred}:${suffix}`;
suffix += 1;
}
used.add(key);
return key;
}
function elementChildren(element: Element): Element[] {
return Array.from(element.children);
}
function collectElements(root: Element): Element[] {
const result: Element[] = [];
const pending = [root];
while (pending.length > 0) {
const element = pending.pop()!;
result.push(element);
const children = elementChildren(element);
for (let index = children.length - 1; index >= 0; index -= 1) {
pending.push(children[index]!);
}
}
return result;
}
function idCounts(elements: readonly Element[]): Map<string, number> {
const counts = new Map<string, number>();
for (const element of elements) {
const id = element.getAttribute("id");
if (id) counts.set(id, (counts.get(id) ?? 0) + 1);
}
return counts;
}
export function parseSvgSource(
source: string,
revision: number,
limits: Readonly<SvgResourceLimits> = defaultSvgLimits,
): SvgParseResult {
const sourceBytes = utf8ByteLength(source);
if (sourceBytes > limits.sourceHardBytes) {
return {
revision,
source,
valid: false,
semantic: null,
diagnostics: [
{
severity: "error",
code: "source-hard-limit",
message: `Source exceeds the ${limits.sourceHardBytes.toLocaleString()} byte hard limit.`,
range: { from: 0, to: source.length },
},
],
preferences: {
newline: "\n",
indentation: " ",
attributeQuote: '"',
},
};
}
const preferences = preferencesFor(source);
const diagnostics = syntaxDiagnostics(source);
if (sourceBytes > limits.sourceSoftBytes) {
diagnostics.push({
severity: "warning",
code: "source-soft-limit",
message: "Large document: live projection updates may be throttled.",
range: { from: 0, to: Math.min(source.length, 1) },
});
}
const doctype = /<!DOCTYPE\b[^>]*>/iu.exec(source);
if (doctype) {
diagnostics.push({
severity: "warning",
code: "doctype-preserved",
message:
"DOCTYPE is preserved in source but ignored by the editing projection.",
range: { from: doctype.index, to: doctype.index + doctype[0].length },
});
}
const entityDeclaration = /<!ENTITY\b/iu.exec(source);
if (entityDeclaration) {
diagnostics.push({
severity: "error",
code: "entity-declaration-blocked",
message:
"Entity declarations are preserved in source but are not parsed by the local editing model.",
range: {
from: entityDeclaration.index,
to: entityDeclaration.index + entityDeclaration[0].length,
},
});
}
const tokens = scanElements(source, diagnostics);
if (diagnostics.some((diagnostic) => diagnostic.severity === "error")) {
return {
revision,
source,
valid: false,
semantic: null,
diagnostics,
preferences,
};
}
const parserSource = doctype
? `${source.slice(0, doctype.index)}${source.slice(doctype.index + doctype[0].length)}`
: source;
const document = new DOMParser().parseFromString(
parserSource,
"image/svg+xml",
);
const parserError = Array.from(
document.getElementsByTagName("parsererror"),
)[0];
if (parserError) {
diagnostics.push({
severity: "error",
code: "xml-parser-error",
message:
parserError.textContent?.trim() ||
"The XML parser rejected this source.",
range: { from: 0, to: Math.min(source.length, 1) },
});
}
const root = document.documentElement;
if (
!parserError &&
(root.localName !== "svg" || root.namespaceURI !== SVG_NAMESPACE)
) {
diagnostics.push({
severity: "error",
code: "non-svg-root",
message: "The document root must be an SVG element in the SVG namespace.",
range: { from: 0, to: Math.min(source.length, 1) },
});
}
if (diagnostics.some((diagnostic) => diagnostic.severity === "error")) {
return {
revision,
source,
valid: false,
semantic: null,
diagnostics,
preferences,
};
}
const elements = collectElements(root);
if (tokens.length !== elements.length) {
diagnostics.push({
severity: "error",
code: "source-map-mismatch",
message: "Could not map every XML element to an exact source range.",
range: { from: 0, to: source.length },
});
return {
revision,
source,
valid: false,
semantic: null,
diagnostics,
preferences,
};
}
if (elements.length > limits.maximumElements) {
diagnostics.push({
severity: "error",
code: "element-limit",
message: "Element count exceeds the configured processing limit.",
range: { from: 0, to: source.length },
});
return {
revision,
source,
valid: false,
semantic: null,
diagnostics,
preferences,
};
}
const counts = idCounts(elements);
const duplicateIds = new Set(
[...counts].filter(([, count]) => count > 1).map(([id]) => id),
);
const keyByElement = new Map<Element, string>();
const elementIndexByElement = new Map(
elements.map((element, index) => [element, index] as const),
);
const depthByElement = new Map<Element, number>();
const usedKeys = new Set<string>();
let attributeCount = 0;
let maximumDepth = 0;
let pathCommandCount = 0;
let referenceCount = 0;
let cssRuleCount = 0;
let animationCount = 0;
let filterPrimitiveCount = 0;
let textLength = 0;
let embeddedResourceBytes = 0;
const pendingKeys: Array<{
element: Element;
path: string;
depth: number;
}> = [{ element: root, path: "0", depth: 0 }];
while (pendingKeys.length > 0) {
const { element, path, depth } = pendingKeys.pop()!;
const elementIndex = elementIndexByElement.get(element)!;
const token = tokens[elementIndex]!;
keyByElement.set(
element,
uniqueKey(element, token, path, duplicateIds, usedKeys),
);
depthByElement.set(element, depth);
maximumDepth = Math.max(maximumDepth, depth);
const children = elementChildren(element);
for (let index = children.length - 1; index >= 0; index -= 1) {
pendingKeys.push({
element: children[index]!,
path: `${path}.${index}`,
depth: depth + 1,
});
}
}
const nodes = new Map<string, SemanticSvgNode>();
const firstNodeById = new Map<string, SemanticSvgNode>();
const order: string[] = [];
elements.forEach((element, index) => {
const token = tokens[index]!;
const key = keyByElement.get(element)!;
const parent = element.parentElement;
const attributes = Object.fromEntries(
Array.from(element.attributes).map((attribute) => [
attribute.name,
attribute.value,
]),
);
attributeCount += element.attributes.length;
for (const attribute of Array.from(element.attributes)) {
if (attribute.value.length > limits.maximumAttributeLength) {
diagnostics.push({
severity: "error",
code: "attribute-length-limit",
message: `Attribute “${attribute.name}” exceeds the configured length limit.`,
nodeKey: key,
range: token.attributes[attribute.name]?.valueRange ?? {
from: token.openFrom,
to: token.openTo,
},
});
}
if (/^data:/iu.test(attribute.value)) {
const bytes = utf8ByteLength(attribute.value);
embeddedResourceBytes += bytes;
if (bytes > limits.maximumEmbeddedResourceBytes) {
diagnostics.push({
severity: "error",
code: "embedded-resource-limit",
message: `Embedded data in “${attribute.name}” exceeds the configured size limit.`,
nodeKey: key,
range: token.attributes[attribute.name]?.valueRange,
});
}
}
}
if (element.localName === "path") {
const commandCount = countPathCommands(element.getAttribute("d") ?? "");
pathCommandCount += commandCount;
if (commandCount > limits.maximumPathCommandsPerPath) {
diagnostics.push({
severity: "error",
code: "path-command-per-element-limit",
message:
"Path command count exceeds the per-element processing limit.",
nodeKey: key,
range: token.attributes.d?.valueRange ?? {
from: token.openFrom,
to: token.openTo,
},
});
}
}
if (element.localName === "style") {
cssRuleCount += (element.textContent?.match(/[{]/gu) ?? []).length;
}
referenceCount += Object.values(attributes).filter((value) =>
REFERENCE_ATTRIBUTE_PATTERN.test(value),
).length;
if (ANIMATION_ELEMENTS.has(element.localName)) animationCount += 1;
if (FILTER_PRIMITIVE_PATTERN.test(element.localName))
filterPrimitiveCount += 1;
const sourceRange: ElementSourceRange = {
openTag: { from: token.openFrom, to: token.openTo },
name: { from: token.nameFrom, to: token.nameTo },
full: { from: token.openFrom, to: token.fullTo },
};
if (!token.selfClosing && token.fullTo > token.openTo) {
const closingStart = source.lastIndexOf("</", token.fullTo);
sourceRange.content = {
from: token.openTo,
to: Math.max(token.openTo, closingStart),
};
}
const node: SemanticSvgNode = {
key,
name: element.tagName,
localName: element.localName,
namespaceUri: element.namespaceURI,
parentKey: parent ? (keyByElement.get(parent) ?? null) : null,
childKeys: elementChildren(element).map(
(child) => keyByElement.get(child)!,
),
depth: depthByElement.get(element)!,
...(element.id ? { id: element.id } : {}),
classes: Array.from(element.classList),
attributes,
attributeRanges: token.attributes,
sourceRange,
rendered: !NON_RENDERING_ELEMENTS.has(element.localName),
text:
elementChildren(element).length === 0
? (element.textContent ?? "")
: "",
};
textLength += node.text.length;
if (node.text.length > limits.maximumTextLength) {
diagnostics.push({
severity: "error",
code: "text-length-limit",
message: "Element text exceeds the configured length limit.",
nodeKey: key,
range: sourceRange.content ?? sourceRange.full,
});
}
nodes.set(key, node);
if (node.id && !firstNodeById.has(node.id)) {
firstNodeById.set(node.id, node);
}
order.push(key);
});
for (const id of duplicateIds) {
const node = firstNodeById.get(id);
diagnostics.push({
severity: "warning",
code: "duplicate-id",
message: `The ID “${id}” is used more than once.`,
nodeKey: node?.key,
range: node?.attributeRanges.id?.valueRange,
fixable: true,
});
}
if (attributeCount > limits.maximumAttributes) {
diagnostics.push({
severity: "error",
code: "attribute-limit",
message: "Attribute count exceeds the configured processing limit.",
range: { from: 0, to: source.length },
});
}
if (maximumDepth > limits.maximumDepth) {
diagnostics.push({
severity: "error",
code: "depth-limit",
message: "XML depth exceeds the configured processing limit.",
range: { from: 0, to: source.length },
});
}
if (pathCommandCount > limits.maximumPathCommandsTotal) {
diagnostics.push({
severity: "error",
code: "path-command-limit",
message:
"Total path command count exceeds the configured processing limit.",
range: { from: 0, to: source.length },
});
}
if (referenceCount > limits.maximumReferences) {
diagnostics.push({
severity: "error",
code: "reference-limit",
message: "Reference count exceeds the configured processing limit.",
range: { from: 0, to: source.length },
});
}
if (cssRuleCount > limits.maximumCssRules) {
diagnostics.push({
severity: "error",
code: "css-rule-limit",
message: "CSS rule count exceeds the configured processing limit.",
range: { from: 0, to: source.length },
});
}
if (animationCount > limits.maximumAnimations) {
diagnostics.push({
severity: "error",
code: "animation-limit",
message: "Animation count exceeds the configured processing limit.",
range: { from: 0, to: source.length },
});
}
if (filterPrimitiveCount > limits.maximumFilterPrimitives) {
diagnostics.push({
severity: "error",
code: "filter-primitive-limit",
message:
"Filter primitive count exceeds the configured processing limit.",
range: { from: 0, to: source.length },
});
}
if (textLength > limits.maximumTextLength) {
diagnostics.push({
severity: "error",
code: "total-text-limit",
message: "Total text length exceeds the configured processing limit.",
range: { from: 0, to: source.length },
});
}
if (embeddedResourceBytes > limits.maximumEmbeddedResourceBytes) {
diagnostics.push({
severity: "error",
code: "embedded-resource-total-limit",
message: "Total embedded data exceeds the configured size limit.",
range: { from: 0, to: source.length },
});
}
const metrics: SvgDocumentMetrics = {
sourceBytes,
elementCount: elements.length,
attributeCount,
maximumDepth,
pathCommandCount,
referenceCount,
cssRuleCount,
animationCount,
filterPrimitiveCount,
textLength,
embeddedResourceBytes,
};
const semantic: SemanticSvgDocument = {
revision,
source,
rootKey: keyByElement.get(root)!,
nodes,
order,
document,
preferences,
diagnostics,
metrics,
};
return {
revision,
source,
valid: !diagnostics.some((diagnostic) => diagnostic.severity === "error"),
semantic,
diagnostics,
preferences,
};
}
+127
View File
@@ -0,0 +1,127 @@
import type {
SemanticSvgNode,
SourcePatch,
SvgSourcePreferences,
} from "./document.types";
const XML_NAME_PATTERN = /^[A-Za-z_][A-Za-z0-9_.:-]*$/u;
export function escapeXmlAttribute(value: string, quote: '"' | "'"): string {
const common = value.replaceAll("&", "&amp;").replaceAll("<", "&lt;");
return quote === '"'
? common.replaceAll('"', "&quot;")
: common.replaceAll("'", "&apos;");
}
export function applySourcePatches(
source: string,
patches: readonly SourcePatch[],
): string {
const ordered = [...patches].sort((left, right) => right.from - left.from);
let previousFrom = source.length + 1;
let result = source;
for (const patch of ordered) {
if (
patch.from < 0 ||
patch.to < patch.from ||
patch.to > source.length ||
patch.to > previousFrom
) {
throw new Error(`Invalid or overlapping source patch: ${patch.label}`);
}
result = `${result.slice(0, patch.from)}${patch.insert}${result.slice(patch.to)}`;
previousFrom = patch.from;
}
return result;
}
export function patchAttribute(
source: string,
node: SemanticSvgNode,
name: string,
value: string | null,
preferences: SvgSourcePreferences,
): SourcePatch | null {
if (!XML_NAME_PATTERN.test(name)) {
throw new Error(`Invalid XML attribute name: ${name}`);
}
const existing = node.attributeRanges[name];
if (existing && value === null) {
return {
from: existing.fullRange.from,
to: existing.fullRange.to,
insert: "",
label: `Remove ${name}`,
};
}
if (existing && value !== null) {
return {
from: existing.valueRange.from,
to: existing.valueRange.to,
insert: escapeXmlAttribute(value, existing.quote),
label: `Set ${name}`,
};
}
if (!existing && value === null) return null;
const open = source.slice(
node.sourceRange.openTag.from,
node.sourceRange.openTag.to,
);
const closingOffset = /\/\s*>$/u.test(open)
? open.lastIndexOf("/")
: open.lastIndexOf(">");
const insertion = node.sourceRange.openTag.from + closingOffset;
const quote = preferences.attributeQuote;
return {
from: insertion,
to: insertion,
insert: ` ${name}=${quote}${escapeXmlAttribute(value ?? "", quote)}${quote}`,
label: `Add ${name}`,
};
}
export function patchTextContent(
node: SemanticSvgNode,
value: string,
): SourcePatch {
const content = node.sourceRange.content;
if (!content)
throw new Error("Cannot edit text content of a self-closing element");
const escaped = value
.replaceAll("&", "&amp;")
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;");
return {
from: content.from,
to: content.to,
insert: escaped,
label: "Edit text content",
};
}
export function replaceSubtree(
node: SemanticSvgNode,
serialized: string,
label = "Replace element",
): SourcePatch {
return {
from: node.sourceRange.full.from,
to: node.sourceRange.full.to,
insert: serialized,
label,
};
}
export function sourceWithAttribute(
source: string,
node: SemanticSvgNode,
name: string,
value: string | null,
preferences: SvgSourcePreferences,
): { source: string; patch: SourcePatch | null } {
const patch = patchAttribute(source, node, name, value, preferences);
return {
source: patch ? applySourcePatches(source, [patch]) : source,
patch,
};
}
+437
View File
@@ -0,0 +1,437 @@
export interface Point {
x: number;
y: number;
}
export interface Matrix {
a: number;
b: number;
c: number;
d: number;
e: number;
f: number;
}
export const IDENTITY: Readonly<Matrix> = Object.freeze({
a: 1,
b: 0,
c: 0,
d: 1,
e: 0,
f: 0,
});
const DEGREES = Math.PI / 180;
const NUMBER_PATTERN = /^[+-]?(?:(?:\d+\.\d*|\.\d+|\d+)(?:[eE][+-]?\d+)?)/u;
export function multiply(left: Matrix, right: Matrix): Matrix {
return {
a: left.a * right.a + left.c * right.b,
b: left.b * right.a + left.d * right.b,
c: left.a * right.c + left.c * right.d,
d: left.b * right.c + left.d * right.d,
e: left.a * right.e + left.c * right.f + left.e,
f: left.b * right.e + left.d * right.f + left.f,
};
}
export function determinant(matrix: Matrix): number {
return matrix.a * matrix.d - matrix.b * matrix.c;
}
export function invert(matrix: Matrix, epsilon = 1e-12): Matrix | null {
const value = determinant(matrix);
const scale = Math.max(
Math.abs(matrix.a),
Math.abs(matrix.b),
Math.abs(matrix.c),
Math.abs(matrix.d),
);
if (
!Number.isFinite(value) ||
scale === 0 ||
Math.abs(value) <= epsilon * scale * scale
) {
return null;
}
return {
a: matrix.d / value,
b: -matrix.b / value,
c: -matrix.c / value,
d: matrix.a / value,
e: (matrix.c * matrix.f - matrix.d * matrix.e) / value,
f: (matrix.b * matrix.e - matrix.a * matrix.f) / value,
};
}
export function applyToPoint(matrix: Matrix, point: Point): Point {
return {
x: matrix.a * point.x + matrix.c * point.y + matrix.e,
y: matrix.b * point.x + matrix.d * point.y + matrix.f,
};
}
export function matrixNearlyEqual(
left: Matrix,
right: Matrix,
epsilon = 1e-10,
): boolean {
return (["a", "b", "c", "d", "e", "f"] as const).every((key) => {
const scale = Math.max(1, Math.abs(left[key]), Math.abs(right[key]));
return Math.abs(left[key] - right[key]) <= epsilon * scale;
});
}
export const translation = (tx: number, ty = 0): Matrix => ({
a: 1,
b: 0,
c: 0,
d: 1,
e: tx,
f: ty,
});
export const scaling = (sx: number, sy = sx): Matrix => ({
a: sx,
b: 0,
c: 0,
d: sy,
e: 0,
f: 0,
});
export function rotation(angleDegrees: number): Matrix {
const angle = angleDegrees * DEGREES;
const cosine = Math.cos(angle);
const sine = Math.sin(angle);
return { a: cosine, b: sine, c: -sine, d: cosine, e: 0, f: 0 };
}
export const skewX = (angleDegrees: number): Matrix => ({
a: 1,
b: 0,
c: Math.tan(angleDegrees * DEGREES),
d: 1,
e: 0,
f: 0,
});
export const skewY = (angleDegrees: number): Matrix => ({
a: 1,
b: Math.tan(angleDegrees * DEGREES),
c: 0,
d: 1,
e: 0,
f: 0,
});
export type TransformFunction =
| { kind: "matrix"; value: Matrix }
| { kind: "translate"; tx: number; ty?: number }
| { kind: "scale"; sx: number; sy?: number }
| { kind: "rotate"; angle: number; cx?: number; cy?: number }
| { kind: "skewX"; angle: number }
| { kind: "skewY"; angle: number };
export class TransformSyntaxError extends SyntaxError {
readonly offset: number;
constructor(message: string, offset: number) {
super(`${message} at offset ${offset}`);
this.name = "TransformSyntaxError";
this.offset = offset;
}
}
function parseNumberList(source: string, baseOffset: number): number[] {
const values: number[] = [];
let offset = 0;
const skipWhitespace = () => {
const before = offset;
while (/\s/u.test(source[offset] ?? "")) offset += 1;
return offset !== before;
};
skipWhitespace();
while (offset < source.length) {
const match = NUMBER_PATTERN.exec(source.slice(offset));
if (!match) {
throw new TransformSyntaxError("Expected a number", baseOffset + offset);
}
const value = Number(match[0]);
if (!Number.isFinite(value)) {
throw new TransformSyntaxError(
"Number is not finite",
baseOffset + offset,
);
}
values.push(value);
offset += match[0].length;
const spaced = skipWhitespace();
let comma = false;
if (source[offset] === ",") {
comma = true;
offset += 1;
skipWhitespace();
}
if (offset < source.length && !spaced && !comma) {
throw new TransformSyntaxError(
"Expected whitespace or a comma between numbers",
baseOffset + offset,
);
}
if (comma && offset === source.length) {
throw new TransformSyntaxError("Trailing comma", baseOffset + offset - 1);
}
}
return values;
}
function transformFromValues(
name: string,
values: readonly number[],
offset: number,
): TransformFunction {
const expect = (...arities: number[]) => {
if (!arities.includes(values.length)) {
throw new TransformSyntaxError(
`${name}() expects ${arities.join(" or ")} arguments, received ${values.length}`,
offset,
);
}
};
const at = (index: number): number => {
const value = values[index];
if (value === undefined)
throw new TransformSyntaxError("Missing argument", offset);
return value;
};
switch (name) {
case "matrix":
expect(6);
return {
kind: "matrix",
value: {
a: at(0),
b: at(1),
c: at(2),
d: at(3),
e: at(4),
f: at(5),
},
};
case "translate":
expect(1, 2);
return values.length === 1
? { kind: "translate", tx: at(0) }
: { kind: "translate", tx: at(0), ty: at(1) };
case "scale":
expect(1, 2);
return values.length === 1
? { kind: "scale", sx: at(0) }
: { kind: "scale", sx: at(0), sy: at(1) };
case "rotate":
expect(1, 3);
return values.length === 1
? { kind: "rotate", angle: at(0) }
: { kind: "rotate", angle: at(0), cx: at(1), cy: at(2) };
case "skewX":
expect(1);
return { kind: "skewX", angle: at(0) };
case "skewY":
expect(1);
return { kind: "skewY", angle: at(0) };
default:
throw new TransformSyntaxError(`Unsupported transform ${name}()`, offset);
}
}
export function parseTransformList(source: string): TransformFunction[] {
if (source.trim() === "" || source.trim() === "none") return [];
const result: TransformFunction[] = [];
let offset = 0;
while (offset < source.length) {
while (/[\s,]/u.test(source[offset] ?? "")) offset += 1;
if (offset === source.length) break;
const start = offset;
const nameMatch = /^[A-Za-z][A-Za-z0-9]*/u.exec(source.slice(offset));
if (!nameMatch)
throw new TransformSyntaxError("Expected a transform name", offset);
const name = nameMatch[0];
offset += name.length;
while (/\s/u.test(source[offset] ?? "")) offset += 1;
if (source[offset] !== "(") {
throw new TransformSyntaxError(`Expected “(” after ${name}`, offset);
}
offset += 1;
const argumentsFrom = offset;
while (offset < source.length && source[offset] !== ")") {
if (source[offset] === "(") {
throw new TransformSyntaxError(
"Nested parentheses are not allowed",
offset,
);
}
offset += 1;
}
if (offset >= source.length) {
throw new TransformSyntaxError(`Unclosed ${name}()`, start);
}
result.push(
transformFromValues(
name,
parseNumberList(source.slice(argumentsFrom, offset), argumentsFrom),
start,
),
);
offset += 1;
}
return result;
}
export function transformToMatrix(transform: TransformFunction): Matrix {
switch (transform.kind) {
case "matrix":
return transform.value;
case "translate":
return translation(transform.tx, transform.ty ?? 0);
case "scale":
return scaling(transform.sx, transform.sy ?? transform.sx);
case "rotate": {
const rotated = rotation(transform.angle);
if (transform.cx === undefined || transform.cy === undefined)
return rotated;
return multiply(
multiply(translation(transform.cx, transform.cy), rotated),
translation(-transform.cx, -transform.cy),
);
}
case "skewX":
return skewX(transform.angle);
case "skewY":
return skewY(transform.angle);
}
}
export function composeTransformList(
transforms: readonly TransformFunction[],
): Matrix {
return transforms.reduce<Matrix>(
(current, transform) => multiply(current, transformToMatrix(transform)),
{ ...IDENTITY },
);
}
export function formatNumber(value: number, precision = 8): string {
if (!Number.isFinite(value))
throw new RangeError("Cannot serialize a non-finite number");
const rounded = Number(value.toFixed(Math.max(0, Math.min(15, precision))));
return Object.is(rounded, -0) ? "0" : String(rounded);
}
export function matrixToTransform(matrix: Matrix): string {
return `matrix(${[matrix.a, matrix.b, matrix.c, matrix.d, matrix.e, matrix.f]
.map((value) => formatNumber(value))
.join(" ")})`;
}
export interface CanonicalDecomposition {
translateX: number;
translateY: number;
rotationDegrees: number;
scaleX: number;
scaleY: number;
skewXDegrees: number;
determinant: number;
reflected: boolean;
singular: boolean;
residual: number;
}
export function recomposeCanonical(value: CanonicalDecomposition): Matrix {
return multiply(
multiply(
multiply(
translation(value.translateX, value.translateY),
rotation(value.rotationDegrees),
),
scaling(value.scaleX, value.scaleY),
),
skewX(value.skewXDegrees),
);
}
export function decomposeCanonical(matrix: Matrix): CanonicalDecomposition {
const value = determinant(matrix);
const scaleX = Math.hypot(matrix.a, matrix.b);
let rotationDegrees: number;
let scaleY: number;
let shear: number;
if (scaleX === 0) {
scaleY = Math.hypot(matrix.c, matrix.d);
rotationDegrees =
scaleY === 0 ? 0 : Math.atan2(-matrix.c, matrix.d) / DEGREES;
shear = 0;
} else {
rotationDegrees = Math.atan2(matrix.b, matrix.a) / DEGREES;
scaleY = value / scaleX;
shear = (matrix.a * matrix.c + matrix.b * matrix.d) / (scaleX * scaleX);
}
const result: CanonicalDecomposition = {
translateX: matrix.e,
translateY: matrix.f,
rotationDegrees,
scaleX,
scaleY,
skewXDegrees: Math.atan(shear) / DEGREES,
determinant: value,
reflected: value < 0,
singular: Math.abs(value) < 1e-12,
residual: 0,
};
const recomposed = recomposeCanonical(result);
result.residual = Math.max(
...(["a", "b", "c", "d", "e", "f"] as const).map((key) =>
Math.abs(matrix[key] - recomposed[key]),
),
);
return result;
}
export interface TransformDiagnostic {
severity: "info" | "warning" | "error";
code: string;
message: string;
}
export function diagnoseTransform(source: string): {
transforms: TransformFunction[];
matrix: Matrix;
decomposition: CanonicalDecomposition;
diagnostics: TransformDiagnostic[];
} {
const transforms = parseTransformList(source);
const matrix = composeTransformList(transforms);
const decomposition = decomposeCanonical(matrix);
const diagnostics: TransformDiagnostic[] = [];
if (decomposition.singular) {
diagnostics.push({
severity: "error",
code: "singular-transform",
message: "This transform collapses geometry and cannot be inverted.",
});
} else if (Math.abs(decomposition.determinant) < 1e-8) {
diagnostics.push({
severity: "warning",
code: "near-singular-transform",
message: "This transform is close to collapsing geometry.",
});
}
if (decomposition.reflected) {
diagnostics.push({
severity: "info",
code: "reflected-transform",
message: "This transform reflects the selected geometry.",
});
}
return { transforms, matrix, decomposition, diagnostics };
}
+451
View File
@@ -0,0 +1,451 @@
import type {
SemanticSvgNode,
SourcePatch,
SvgSourcePreferences,
} from "../document/document.types";
import { patchAttribute } from "../document/source-patcher";
import { applyToPoint, determinant, type Matrix, type Point } from "./affine";
import {
parsePathData,
serializePathData,
transformPath,
type PathModel,
} from "./path";
export interface BakeTransformResult {
patches: SourcePatch[];
outputElement: string;
convertedToPath: boolean;
warnings: string[];
}
const GEOMETRY_ATTRIBUTES: Record<string, readonly string[]> = {
line: ["x1", "y1", "x2", "y2"],
polyline: ["points"],
polygon: ["points"],
rect: ["x", "y", "width", "height", "rx", "ry"],
circle: ["cx", "cy", "r"],
ellipse: ["cx", "cy", "rx", "ry"],
};
const SVG_NUMBER_PATTERN =
/^[+-]?(?:(?:\d+\.\d*)|(?:\.\d+)|(?:\d+))(?:[eE][+-]?\d+)?$/u;
function finiteAttribute(
node: SemanticSvgNode,
name: string,
fallback?: number,
): number {
const raw = node.attributes[name];
if (raw === undefined && fallback !== undefined) return fallback;
const normalized = raw?.trim() ?? "";
const value = Number(normalized);
if (!SVG_NUMBER_PATTERN.test(normalized) || !Number.isFinite(value)) {
throw new Error(
`<${node.name}> requires a finite ${name} attribute for deterministic baking`,
);
}
return value;
}
function nonnegativeAttribute(
node: SemanticSvgNode,
name: string,
fallback?: number,
): number {
const value = finiteAttribute(node, name, fallback);
if (value < 0) {
throw new Error(`<${node.name}> requires a non-negative ${name} attribute`);
}
return value;
}
function number(value: number): string {
if (!Number.isFinite(value)) throw new Error("Baked geometry is not finite");
const serialized = Number(value.toPrecision(15));
return String(Object.is(serialized, -0) ? 0 : serialized);
}
function setAttribute(
source: string,
node: SemanticSvgNode,
name: string,
value: string | null,
preferences: SvgSourcePreferences,
patches: SourcePatch[],
): void {
const patch = patchAttribute(source, node, name, value, preferences);
if (patch) patches.push(patch);
}
function parsePoints(value: string): Point[] {
const values = value
.trim()
.split(/[\s,]+/u)
.filter(Boolean)
.map((token) => (SVG_NUMBER_PATTERN.test(token) ? Number(token) : NaN));
if (
values.length < 2 ||
values.length % 2 !== 0 ||
!values.every(Number.isFinite)
) {
throw new Error(
"Polyline and polygon points must contain finite coordinate pairs",
);
}
const points: Point[] = [];
for (let index = 0; index < values.length; index += 2) {
points.push({ x: values[index]!, y: values[index + 1]! });
}
return points;
}
function ellipsePath(
cx: number,
cy: number,
rx: number,
ry: number,
): PathModel {
if (rx < 0 || ry < 0) throw new Error("Ellipse radii cannot be negative");
return {
segments: [
{ kind: "M", to: { x: cx + rx, y: cy } },
{
kind: "A",
from: { x: cx + rx, y: cy },
to: { x: cx - rx, y: cy },
rx,
ry,
rotation: 0,
largeArc: false,
sweep: true,
},
{
kind: "A",
from: { x: cx - rx, y: cy },
to: { x: cx + rx, y: cy },
rx,
ry,
rotation: 0,
largeArc: false,
sweep: true,
},
{
kind: "Z",
from: { x: cx + rx, y: cy },
to: { x: cx + rx, y: cy },
},
],
};
}
function rectPath(node: SemanticSvgNode): PathModel {
const x = finiteAttribute(node, "x", 0);
const y = finiteAttribute(node, "y", 0);
const width = finiteAttribute(node, "width");
const height = finiteAttribute(node, "height");
if (width < 0 || height < 0)
throw new Error("Rectangle dimensions cannot be negative");
const rawRx =
node.attributes.rx === undefined
? undefined
: nonnegativeAttribute(node, "rx");
const rawRy =
node.attributes.ry === undefined
? undefined
: nonnegativeAttribute(node, "ry");
const rx = Math.min(width / 2, Math.max(0, rawRx ?? rawRy ?? 0));
const ry = Math.min(height / 2, Math.max(0, rawRy ?? rawRx ?? 0));
if (rx === 0 || ry === 0) {
return {
segments: [
{ kind: "M", to: { x, y } },
{ kind: "L", from: { x, y }, to: { x: x + width, y } },
{
kind: "L",
from: { x: x + width, y },
to: { x: x + width, y: y + height },
},
{
kind: "L",
from: { x: x + width, y: y + height },
to: { x, y: y + height },
},
{ kind: "Z", from: { x, y: y + height }, to: { x, y } },
],
};
}
return {
segments: [
{ kind: "M", to: { x: x + rx, y } },
{ kind: "L", from: { x: x + rx, y }, to: { x: x + width - rx, y } },
{
kind: "A",
from: { x: x + width - rx, y },
to: { x: x + width, y: y + ry },
rx,
ry,
rotation: 0,
largeArc: false,
sweep: true,
},
{
kind: "L",
from: { x: x + width, y: y + ry },
to: { x: x + width, y: y + height - ry },
},
{
kind: "A",
from: { x: x + width, y: y + height - ry },
to: { x: x + width - rx, y: y + height },
rx,
ry,
rotation: 0,
largeArc: false,
sweep: true,
},
{
kind: "L",
from: { x: x + width - rx, y: y + height },
to: { x: x + rx, y: y + height },
},
{
kind: "A",
from: { x: x + rx, y: y + height },
to: { x, y: y + height - ry },
rx,
ry,
rotation: 0,
largeArc: false,
sweep: true,
},
{ kind: "L", from: { x, y: y + height - ry }, to: { x, y: y + ry } },
{
kind: "A",
from: { x, y: y + ry },
to: { x: x + rx, y },
rx,
ry,
rotation: 0,
largeArc: false,
sweep: true,
},
{ kind: "Z", from: { x: x + rx, y }, to: { x: x + rx, y } },
],
};
}
function renameElementPatches(
source: string,
node: SemanticSvgNode,
localName: string,
): SourcePatch[] {
const prefix = node.name.includes(":")
? `${node.name.slice(0, node.name.indexOf(":"))}:`
: "";
const replacementName = `${prefix}${localName}`;
const openSource = source.slice(
node.sourceRange.openTag.from,
node.sourceRange.openTag.to,
);
const nameOffset = openSource.indexOf(node.name);
if (nameOffset < 0)
throw new Error("Element name source range is unavailable");
const patches: SourcePatch[] = [
{
from: node.sourceRange.openTag.from + nameOffset,
to: node.sourceRange.openTag.from + nameOffset + node.name.length,
insert: replacementName,
label: `Convert ${node.localName} to ${localName}`,
},
];
if (node.sourceRange.content) {
const full = source.slice(
node.sourceRange.full.from,
node.sourceRange.full.to,
);
const closing = full.lastIndexOf(`</${node.name}`);
if (closing < 0)
throw new Error("Closing element name source range is unavailable");
const from = node.sourceRange.full.from + closing + 2;
patches.push({
from,
to: from + node.name.length,
insert: replacementName,
label: `Rename closing ${node.localName}`,
});
}
return patches;
}
function strokeWarnings(node: SemanticSvgNode, matrix: Matrix): string[] {
if (!node.attributes.stroke || node.attributes.stroke === "none") return [];
const sx = Math.hypot(matrix.a, matrix.b);
if (node.attributes["vector-effect"] === "non-scaling-stroke") {
return [
"The element uses non-scaling-stroke. Geometry is baked while stroke properties remain unchanged.",
];
}
if (isConformal(matrix)) {
if (Math.abs(sx - 1) <= 1e-10) return [];
return [
"The transform scales the rendered stroke. This preview keeps stroke properties unchanged; review stroke width, dashes and markers before applying.",
];
}
return [
"Non-uniform scale or skew cannot be represented by one exact stroke-width. Geometry is exact, while stroke properties remain unchanged.",
];
}
function nearlyZero(value: number, scale = 1): boolean {
return Math.abs(value) <= 1e-12 * Math.max(1, scale);
}
function isAxisAligned(matrix: Matrix): boolean {
return nearlyZero(matrix.b) && nearlyZero(matrix.c);
}
function isConformal(matrix: Matrix): boolean {
const firstLength = Math.hypot(matrix.a, matrix.b);
const secondLength = Math.hypot(matrix.c, matrix.d);
const dot = matrix.a * matrix.c + matrix.b * matrix.d;
return (
nearlyZero(firstLength - secondLength, firstLength) &&
nearlyZero(dot, firstLength * secondLength)
);
}
export function bakeElementTransform(
source: string,
node: SemanticSvgNode,
matrix: Matrix,
preferences: SvgSourcePreferences,
): BakeTransformResult {
if (Math.abs(determinant(matrix)) <= 1e-12) {
throw new Error(
"A singular transform cannot be baked into editable geometry",
);
}
const patches: SourcePatch[] = [];
const warnings = strokeWarnings(node, matrix);
let convertedToPath = false;
let outputElement = node.localName;
const set = (name: string, value: string | null) =>
setAttribute(source, node, name, value, preferences, patches);
if (node.localName === "path") {
const transformed = transformPath(
parsePathData(node.attributes.d ?? ""),
matrix,
);
set("d", serializePathData(transformed, 15));
} else if (node.localName === "line") {
const first = applyToPoint(matrix, {
x: finiteAttribute(node, "x1", 0),
y: finiteAttribute(node, "y1", 0),
});
const second = applyToPoint(matrix, {
x: finiteAttribute(node, "x2", 0),
y: finiteAttribute(node, "y2", 0),
});
set("x1", number(first.x));
set("y1", number(first.y));
set("x2", number(second.x));
set("y2", number(second.y));
} else if (node.localName === "polyline" || node.localName === "polygon") {
const points = parsePoints(node.attributes.points ?? "").map((point) =>
applyToPoint(matrix, point),
);
set(
"points",
points.map((point) => `${number(point.x)},${number(point.y)}`).join(" "),
);
} else if (node.localName === "rect" && isAxisAligned(matrix)) {
const x = finiteAttribute(node, "x", 0);
const y = finiteAttribute(node, "y", 0);
const width = nonnegativeAttribute(node, "width");
const height = nonnegativeAttribute(node, "height");
const first = applyToPoint(matrix, { x, y });
const second = applyToPoint(matrix, { x: x + width, y: y + height });
set("x", number(Math.min(first.x, second.x)));
set("y", number(Math.min(first.y, second.y)));
set("width", number(Math.abs(second.x - first.x)));
set("height", number(Math.abs(second.y - first.y)));
if (node.attributes.rx !== undefined || node.attributes.ry !== undefined) {
const rx =
node.attributes.rx === undefined
? nonnegativeAttribute(node, "ry")
: nonnegativeAttribute(node, "rx");
const ry =
node.attributes.ry === undefined
? nonnegativeAttribute(node, "rx")
: nonnegativeAttribute(node, "ry");
set("rx", number(Math.abs(rx * matrix.a)));
set("ry", number(Math.abs(ry * matrix.d)));
}
} else if (node.localName === "circle" && isConformal(matrix)) {
const center = applyToPoint(matrix, {
x: finiteAttribute(node, "cx", 0),
y: finiteAttribute(node, "cy", 0),
});
const scale = Math.hypot(matrix.a, matrix.b);
set("cx", number(center.x));
set("cy", number(center.y));
set("r", number(nonnegativeAttribute(node, "r") * scale));
} else if (node.localName === "circle" && isAxisAligned(matrix)) {
const center = applyToPoint(matrix, {
x: finiteAttribute(node, "cx", 0),
y: finiteAttribute(node, "cy", 0),
});
const radius = nonnegativeAttribute(node, "r");
patches.push(...renameElementPatches(source, node, "ellipse"));
outputElement = "ellipse";
set("cx", number(center.x));
set("cy", number(center.y));
set("r", null);
set("rx", number(Math.abs(radius * matrix.a)));
set("ry", number(Math.abs(radius * matrix.d)));
warnings.push("The non-uniformly scaled circle becomes an ellipse.");
} else if (node.localName === "ellipse" && isAxisAligned(matrix)) {
const center = applyToPoint(matrix, {
x: finiteAttribute(node, "cx", 0),
y: finiteAttribute(node, "cy", 0),
});
set("cx", number(center.x));
set("cy", number(center.y));
set("rx", number(Math.abs(nonnegativeAttribute(node, "rx") * matrix.a)));
set("ry", number(Math.abs(nonnegativeAttribute(node, "ry") * matrix.d)));
} else {
let path: PathModel;
if (node.localName === "rect") path = rectPath(node);
else if (node.localName === "circle") {
path = ellipsePath(
finiteAttribute(node, "cx", 0),
finiteAttribute(node, "cy", 0),
nonnegativeAttribute(node, "r"),
nonnegativeAttribute(node, "r"),
);
} else if (node.localName === "ellipse") {
path = ellipsePath(
finiteAttribute(node, "cx", 0),
finiteAttribute(node, "cy", 0),
nonnegativeAttribute(node, "rx"),
nonnegativeAttribute(node, "ry"),
);
} else {
throw new Error(
`Transform baking for <${node.name}> is not deterministic in this release`,
);
}
convertedToPath = true;
outputElement = "path";
warnings.push(
`<${node.localName}> is converted to a path because the complete affine result is represented explicitly.`,
);
patches.push(...renameElementPatches(source, node, "path"));
for (const name of GEOMETRY_ATTRIBUTES[node.localName] ?? [])
set(name, null);
set("d", serializePathData(transformPath(path, matrix), 15));
}
set("transform", null);
return { patches, outputElement, convertedToPath, warnings };
}
+1059
View File
File diff suppressed because it is too large Load Diff
+81
View File
@@ -0,0 +1,81 @@
import type { SemanticSvgDocument } from "../document/document.types";
import {
IDENTITY,
composeTransformList,
invert,
matrixNearlyEqual,
multiply,
parseTransformList,
type Matrix,
} from "./affine";
export interface TransformChainEntry {
nodeKey: string;
elementName: string;
elementId?: string;
source: string;
local: Matrix;
combined: Matrix;
}
export interface TransformChainResult {
entries: TransformChainEntry[];
matrix: Matrix;
inverse: Matrix | null;
diagnostics: string[];
}
export function resolveTransformChain(
semantic: SemanticSvgDocument,
nodeKey: string,
): TransformChainResult {
const lineage = [];
let current = semantic.nodes.get(nodeKey);
while (current) {
lineage.push(current);
current = current.parentKey
? semantic.nodes.get(current.parentKey)
: undefined;
}
lineage.reverse();
const entries: TransformChainEntry[] = [];
const diagnostics: string[] = [];
let combined: Matrix = { ...IDENTITY };
for (const node of lineage) {
const source = node.attributes.transform?.trim() ?? "";
let local: Matrix = { ...IDENTITY };
if (source) {
try {
local = composeTransformList(parseTransformList(source));
} catch (error) {
diagnostics.push(
`<${node.name}>${node.id ? `#${node.id}` : ""}: ${error instanceof Error ? error.message : "Invalid transform"}`,
);
}
}
if (/(?:^|;)\s*transform\s*:/iu.test(node.attributes.style ?? "")) {
diagnostics.push(
`<${node.name}>${node.id ? `#${node.id}` : ""}: inline CSS transforms are rendered by the browser but are not included in deterministic path-handle coordinates.`,
);
}
combined = multiply(combined, local);
if (source || !matrixNearlyEqual(local, IDENTITY)) {
entries.push({
nodeKey: node.key,
elementName: node.name,
...(node.id ? { elementId: node.id } : {}),
source,
local,
combined: { ...combined },
});
}
}
return {
entries,
matrix: combined,
inverse: diagnostics.length ? null : invert(combined),
diagnostics,
};
}
+184
View File
@@ -0,0 +1,184 @@
import type { SemanticSvgDocument } from "../document/document.types";
import { createEditingProjection } from "../security/sanitize-svg";
import { assertExportableSvg } from "./svg-export";
const SVG_NAMESPACE = "http://www.w3.org/2000/svg";
const INFRASTRUCTURE_ELEMENTS = new Set(["defs", "style"]);
export interface DerivedSvgExport {
source: string;
warnings: string[];
}
function ancestorsOf(semantic: SemanticSvgDocument, key: string): string[] {
const ancestors: string[] = [];
let current = semantic.nodes.get(key)?.parentKey ?? null;
while (current) {
ancestors.push(current);
current = semantic.nodes.get(current)?.parentKey ?? null;
}
return ancestors;
}
function descendantsOf(semantic: SemanticSvgDocument, key: string): string[] {
const descendants: string[] = [];
const pending = [...(semantic.nodes.get(key)?.childKeys ?? [])];
while (pending.length > 0) {
const current = pending.pop()!;
descendants.push(current);
pending.push(...(semantic.nodes.get(current)?.childKeys ?? []));
}
return descendants;
}
function projectionDocument(semantic: SemanticSvgDocument): XMLDocument {
const projection = createEditingProjection(semantic);
const document = new DOMParser().parseFromString(
projection.source,
"image/svg+xml",
);
if (document.documentElement.localName !== "svg") {
throw new Error("The sanitized projection did not produce an SVG document");
}
return document;
}
function stripEditorMetadata(root: Element): void {
root.removeAttribute("data-svg-tools-node");
for (const element of Array.from(
root.querySelectorAll("[data-svg-tools-node]"),
)) {
element.removeAttribute("data-svg-tools-node");
}
}
function serialize(root: Element): string {
stripEditorMetadata(root);
const source = new XMLSerializer().serializeToString(root);
assertExportableSvg(source);
return source;
}
function selectedKeysThatExist(
semantic: SemanticSvgDocument,
selectedKeys: readonly string[],
): string[] {
return [...new Set(selectedKeys)].filter((key) => semantic.nodes.has(key));
}
export function createSelectedSvgSource(
semantic: SemanticSvgDocument,
selectedKeys: readonly string[],
): DerivedSvgExport {
const selected = selectedKeysThatExist(semantic, selectedKeys);
if (selected.length === 0)
throw new Error("Select at least one SVG element to export");
const keep = new Set<string>([semantic.rootKey]);
for (const key of selected) {
keep.add(key);
for (const ancestor of ancestorsOf(semantic, key)) keep.add(ancestor);
for (const descendant of descendantsOf(semantic, key)) keep.add(descendant);
}
for (const key of semantic.order) {
const node = semantic.nodes.get(key)!;
if (!INFRASTRUCTURE_ELEMENTS.has(node.localName)) continue;
keep.add(key);
for (const ancestor of ancestorsOf(semantic, key)) keep.add(ancestor);
for (const descendant of descendantsOf(semantic, key)) keep.add(descendant);
}
const document = projectionDocument(semantic);
const byKey = new Map<string, Element>();
for (const element of [
document.documentElement,
...Array.from(document.documentElement.querySelectorAll("*")),
]) {
const key = element.getAttribute("data-svg-tools-node");
if (key) byKey.set(key, element);
}
for (const key of [...semantic.order].reverse()) {
if (keep.has(key)) continue;
byKey.get(key)?.remove();
}
return {
source: serialize(document.documentElement),
warnings: [
"Selected-object export uses the sanitized editing projection.",
"The original document viewport is retained; crop-to-selection is not applied.",
],
};
}
function copyRootViewport(from: Element, to: Element): void {
for (const name of ["viewBox", "width", "height", "preserveAspectRatio"]) {
const value = from.getAttribute(name);
if (value !== null) to.setAttribute(name, value);
}
}
function appendSharedDefinitions(
sourceRoot: Element,
targetRoot: Element,
): void {
const definitions = Array.from(sourceRoot.children).filter(
(element) => element.localName === "defs",
);
for (const definition of definitions) {
const clone = definition.cloneNode(true) as Element;
for (const symbol of Array.from(clone.querySelectorAll("symbol")))
symbol.remove();
if (clone.children.length > 0) targetRoot.append(clone);
}
}
export function createSymbolSpriteSource(
semantic: SemanticSvgDocument,
selectedKeys: readonly string[],
): DerivedSvgExport {
const document = projectionDocument(semantic);
const sourceRoot = document.documentElement;
const outputDocument = document.implementation.createDocument(
SVG_NAMESPACE,
"svg",
null,
);
const outputRoot = outputDocument.documentElement;
copyRootViewport(sourceRoot, outputRoot);
appendSharedDefinitions(sourceRoot, outputRoot);
const symbols = Array.from(sourceRoot.querySelectorAll("symbol"));
if (symbols.length > 0) {
for (const symbol of symbols)
outputRoot.append(outputDocument.importNode(symbol, true));
} else {
const selected = createSelectedSvgSource(semantic, selectedKeys);
const selectedDocument = new DOMParser().parseFromString(
selected.source,
"image/svg+xml",
);
const symbol = outputDocument.createElementNS(SVG_NAMESPACE, "symbol");
const firstSelected = selectedKeysThatExist(semantic, selectedKeys)[0]!;
const requestedId = semantic.nodes.get(firstSelected)?.id ?? "selection";
symbol.setAttribute(
"id",
`symbol-${requestedId.replace(/[^A-Za-z0-9_.:-]+/gu, "-")}`,
);
const viewBox = selectedDocument.documentElement.getAttribute("viewBox");
if (viewBox) symbol.setAttribute("viewBox", viewBox);
for (const child of Array.from(selectedDocument.documentElement.children)) {
if (child.localName === "defs") continue;
symbol.append(outputDocument.importNode(child, true));
}
outputRoot.append(symbol);
}
return {
source: serialize(outputRoot),
warnings: [
"Sprite export uses the sanitized editing projection.",
symbols.length > 0
? `${symbols.length} existing symbol element(s) were exported.`
: "No existing symbol was found; the current selection was wrapped in a symbol.",
],
};
}
+54
View File
@@ -0,0 +1,54 @@
export type ExportKind = "svg" | "svgz" | "project" | "png" | "jpeg" | "webp";
// eslint-disable-next-line no-control-regex -- all control characters are invalid in download names.
const UNSAFE = /[\u0000-\u001f\u007f<>:"/\\|?*\u202a-\u202e\u2066-\u2069]/gu;
const KNOWN_EXTENSION = /(?:\.svgtools\.json|\.svgz?|\.png|\.jpe?g|\.webp)$/iu;
const RESERVED = /^(?:con|prn|aux|nul|com[1-9]|lpt[1-9])(?:\.|$)/iu;
function truncate(value: string, maximumBytes: number): string {
const encoder = new TextEncoder();
let result = "";
for (const character of value) {
if (encoder.encode(result + character).byteLength > maximumBytes) break;
result += character;
}
return result;
}
export function sanitizeFileName(input: string, maximumBytes = 180): string {
let value = input
.normalize("NFKC")
.replace(UNSAFE, "-")
.replace(/\s+/gu, " ")
.replace(/-{2,}/gu, "-")
.replace(/^[. -]+|[. ]+$/gu, "");
if (!value || value === "." || value === "..") value = "drawing";
if (RESERVED.test(value)) value = `_${value}`;
return truncate(value, maximumBytes).replace(/[. ]+$/u, "") || "drawing";
}
export function exportFileName(
input: string | undefined,
kind: ExportKind,
): string {
const extension: Record<ExportKind, string> = {
svg: ".svg",
svgz: ".svgz",
project: ".svgtools.json",
png: ".png",
jpeg: ".jpg",
webp: ".webp",
};
const suffix = extension[kind];
return `${sanitizeFileName((input ?? "drawing").replace(KNOWN_EXTENSION, ""), 180 - suffix.length)}${suffix}`;
}
export function downloadBlob(blob: Blob, fileName: string): void {
const url = URL.createObjectURL(blob);
const anchor = document.createElement("a");
anchor.href = url;
anchor.download = sanitizeFileName(fileName);
anchor.rel = "noopener";
anchor.click();
globalThis.setTimeout(() => URL.revokeObjectURL(url), 0);
}
+223
View File
@@ -0,0 +1,223 @@
import { defaultSvgLimits } from "../app/limits";
import { exportFileName } from "./file-name";
export type RasterFormat = "png" | "jpeg" | "webp";
export interface RasterOptions {
format: RasterFormat;
width?: number;
height?: number;
scale?: number;
quality?: number;
background?: string;
fileName?: string;
signal?: AbortSignal;
}
export interface RasterSize {
width: number;
height: number;
aspectRatio: number;
}
const SAFE_DATA = /^data:image\/(?:png|jpeg|gif|webp|avif);base64,/iu;
const URL_PATTERN = /url\(\s*(["']?)(.*?)\1\s*\)/giu;
function parseSafeProjection(source: string): SVGSVGElement {
if (/<!doctype\b|<!entity\b|<\?xml-stylesheet\b/iu.test(source)) {
throw new Error(
"Raster export does not allow DTDs, entities or XML stylesheets",
);
}
const document = new DOMParser().parseFromString(source, "image/svg+xml");
if (
document.documentElement.localName !== "svg" ||
document.querySelector("parsererror")
) {
throw new Error("Raster export requires a well-formed SVG projection");
}
for (const element of Array.from(document.querySelectorAll("*"))) {
if (
["script", "foreignobject", "iframe", "object", "embed"].includes(
element.localName.toLowerCase(),
)
) {
throw new Error(`Raster export blocked <${element.localName}>`);
}
for (const attribute of Array.from(element.attributes)) {
const name = attribute.name.toLowerCase();
if (name.startsWith("on"))
throw new Error(`Raster export blocked ${attribute.name}`);
if (["href", "xlink:href", "src"].includes(name)) {
const value = attribute.value.trim();
if (!value.startsWith("#") && !SAFE_DATA.test(value)) {
throw new Error("Raster export blocked an external resource");
}
}
for (const match of attribute.value.matchAll(URL_PATTERN)) {
if (!(match[2] ?? "").trim().startsWith("#")) {
throw new Error("Raster export blocked an external CSS resource");
}
}
}
}
return document.documentElement as unknown as SVGSVGElement;
}
function absoluteLength(value: string | null): number | undefined {
const match = /^\s*(\d+(?:\.\d+)?|\.\d+)\s*(px|in|cm|mm|pt|pc)?\s*$/iu.exec(
value ?? "",
);
if (!match) return undefined;
const numeric = Number(match[1]);
switch ((match[2] ?? "px").toLowerCase()) {
case "in":
return numeric * 96;
case "cm":
return (numeric * 96) / 2.54;
case "mm":
return (numeric * 96) / 25.4;
case "pt":
return (numeric * 96) / 72;
case "pc":
return numeric * 16;
default:
return numeric;
}
}
export function resolveRasterSize(
source: string,
request: Pick<RasterOptions, "width" | "height" | "scale"> = {},
): RasterSize {
const root = parseSafeProjection(source);
const viewBox = (root.getAttribute("viewBox") ?? "")
.trim()
.split(/[\s,]+/u)
.map(Number);
const viewWidth =
viewBox.length === 4 && viewBox[2]! > 0 ? viewBox[2] : undefined;
const viewHeight =
viewBox.length === 4 && viewBox[3]! > 0 ? viewBox[3] : undefined;
const intrinsicWidth = absoluteLength(root.getAttribute("width"));
const intrinsicHeight = absoluteLength(root.getAttribute("height"));
const aspectRatio =
viewWidth && viewHeight
? viewWidth / viewHeight
: intrinsicWidth && intrinsicHeight
? intrinsicWidth / intrinsicHeight
: 1;
const requestedWidth = request.width;
const requestedHeight = request.height;
const scale = request.scale ?? 1;
if (
[requestedWidth, requestedHeight]
.filter((value): value is number => value !== undefined)
.some((value) => !Number.isFinite(value) || value <= 0) ||
!Number.isFinite(scale) ||
scale <= 0 ||
scale > 16
) {
throw new Error(
"Raster dimensions and scale must be positive finite values",
);
}
let width = requestedWidth;
let height = requestedHeight;
if (width && !height) height = width / aspectRatio;
else if (height && !width) width = height * aspectRatio;
else if (!width && !height) {
width = intrinsicWidth ?? viewWidth ?? 1024;
height = intrinsicHeight ?? viewHeight ?? width / aspectRatio;
}
const finalWidth = Math.max(1, Math.round(width! * scale));
const finalHeight = Math.max(1, Math.round(height! * scale));
if (
finalWidth > 16_384 ||
finalHeight > 16_384 ||
finalWidth * finalHeight > defaultSvgLimits.maximumRasterPixels
) {
throw new Error(
`The requested ${finalWidth} × ${finalHeight} raster exceeds the canvas safety limit`,
);
}
return { width: finalWidth, height: finalHeight, aspectRatio };
}
function mimeType(format: RasterFormat): string {
return format === "png"
? "image/png"
: format === "jpeg"
? "image/jpeg"
: "image/webp";
}
export async function rasterizeProjection(
sanitizedProjection: string,
options: RasterOptions,
): Promise<{ blob: Blob; fileName: string; size: RasterSize }> {
if (options.signal?.aborted)
throw new DOMException("Raster export cancelled", "AbortError");
parseSafeProjection(sanitizedProjection);
const size = resolveRasterSize(sanitizedProjection, options);
const imageUrl = URL.createObjectURL(
new Blob([sanitizedProjection], { type: "image/svg+xml" }),
);
try {
const image = new Image();
image.decoding = "async";
await new Promise<void>((resolve, reject) => {
const timeout = globalThis.setTimeout(
() =>
reject(new Error("SVG decoding exceeded the 15-second safety limit")),
15_000,
);
const abort = () =>
reject(new DOMException("Raster export cancelled", "AbortError"));
options.signal?.addEventListener("abort", abort, { once: true });
image.onload = () => {
globalThis.clearTimeout(timeout);
options.signal?.removeEventListener("abort", abort);
resolve();
};
image.onerror = () => {
globalThis.clearTimeout(timeout);
options.signal?.removeEventListener("abort", abort);
reject(new Error("The browser could not decode the sanitized SVG"));
};
image.src = imageUrl;
});
const canvas = document.createElement("canvas");
canvas.width = size.width;
canvas.height = size.height;
const context = canvas.getContext("2d");
if (!context) throw new Error("The browser could not create a 2D canvas");
const background =
options.background ?? (options.format === "jpeg" ? "#ffffff" : undefined);
if (background) {
if (/url\s*\(/iu.test(background) || background.length > 128)
throw new Error("Unsafe raster background value");
context.fillStyle = background;
context.fillRect(0, 0, size.width, size.height);
}
context.drawImage(image, 0, 0, size.width, size.height);
const type = mimeType(options.format);
const blob = await new Promise<Blob>((resolve, reject) => {
canvas.toBlob(
(result) =>
result
? resolve(result)
: reject(new Error(`The browser could not encode ${type}`)),
type,
options.format === "png" ? undefined : (options.quality ?? 0.92),
);
});
return {
blob,
fileName: exportFileName(options.fileName, options.format),
size,
};
} finally {
URL.revokeObjectURL(imageUrl);
}
}
+122
View File
@@ -0,0 +1,122 @@
import { Gunzip, gzipSync } from "fflate";
import { defaultSvgLimits, utf8ByteLength } from "../app/limits";
import { parseSvgSource } from "../document/source-parser";
import { exportFileName } from "./file-name";
const SVG_NAMESPACE = "http://www.w3.org/2000/svg";
export function assertExportableSvg(source: string): void {
const parsed = parseSvgSource(source, 0);
if (
parsed.diagnostics.some((diagnostic) => diagnostic.code === "non-svg-root")
) {
throw new Error(
"The document root must be an SVG element in the SVG namespace",
);
}
if (!parsed.valid || !parsed.semantic) {
throw new Error("The current source is not well-formed XML");
}
const root = parsed.semantic.document.documentElement;
if (root.localName !== "svg" || root.namespaceURI !== SVG_NAMESPACE) {
throw new Error(
"The document root must be an SVG element in the SVG namespace",
);
}
}
export function gunzipWithLimit(
bytes: Uint8Array,
maximumBytes: number,
): Uint8Array<ArrayBuffer> {
const chunks: Uint8Array[] = [];
let total = 0;
const gunzip = new Gunzip((chunk) => {
if (total + chunk.byteLength > maximumBytes) {
throw new RangeError("Decompressed SVG exceeds the processing limit");
}
total += chunk.byteLength;
chunks.push(Uint8Array.from(chunk));
});
const inputChunkBytes = 16 * 1024;
for (let offset = 0; offset < bytes.byteLength; offset += inputChunkBytes) {
const to = Math.min(bytes.byteLength, offset + inputChunkBytes);
gunzip.push(bytes.subarray(offset, to), to === bytes.byteLength);
}
if (bytes.byteLength === 0) gunzip.push(bytes, true);
const result = new Uint8Array(total);
let offset = 0;
for (const chunk of chunks) {
result.set(chunk, offset);
offset += chunk.byteLength;
}
return result;
}
export function createSvgExport(
source: string,
format: "svg" | "svgz",
requestedName?: string,
): { blob: Blob; bytes: Uint8Array; fileName: string } {
if (utf8ByteLength(source) > defaultSvgLimits.sourceHardBytes) {
throw new Error("SVG source exceeds the export limit");
}
const sourceBytes = new TextEncoder().encode(source);
assertExportableSvg(source);
const bytes =
format === "svg"
? sourceBytes
: gzipSync(sourceBytes, { level: 9, mtime: 0 });
const stableBytes = Uint8Array.from(bytes);
return {
bytes: stableBytes,
blob: new Blob([stableBytes], {
type: format === "svg" ? "image/svg+xml" : "application/gzip",
}),
fileName: exportFileName(requestedName, format),
};
}
export async function readSvgFile(
file: File,
): Promise<{ source: string; fileName: string }> {
if (file.size > defaultSvgLimits.sourceHardBytes * 2) {
throw new Error("Selected file exceeds the processing limit");
}
const bytes = new Uint8Array(await file.arrayBuffer());
const compressed =
/\.svgz$/iu.test(file.name) || (bytes[0] === 0x1f && bytes[1] === 0x8b);
let decoded = bytes;
if (compressed) {
try {
decoded = gunzipWithLimit(bytes, defaultSvgLimits.sourceHardBytes);
} catch (error) {
if (
error instanceof RangeError &&
error.message.includes("processing limit")
) {
throw error;
}
throw new Error("The SVGZ file could not be decompressed", {
cause: error,
});
}
}
let source: string;
try {
source = new TextDecoder("utf-8", { fatal: true })
.decode(decoded)
.replace(/^\uFEFF/u, "");
} catch {
throw new Error(
compressed
? "The decompressed SVG is not valid UTF-8"
: "The SVG file is not valid UTF-8",
);
}
if (utf8ByteLength(source) > defaultSvgLimits.sourceHardBytes) {
throw new Error("Decompressed SVG exceeds the processing limit");
}
assertExportableSvg(source);
return { source, fileName: file.name.replace(/\.svgz?$/iu, ".svg") };
}
+66
View File
@@ -0,0 +1,66 @@
export interface DiffLine {
kind: "same" | "add" | "remove";
text: string;
oldLine?: number;
newLine?: number;
}
export function lineDiff(
before: string,
after: string,
limit = 2_000,
): DiffLine[] {
const left = before.split(/\r?\n/u);
const right = after.split(/\r?\n/u);
if (left.length * right.length > limit * limit) {
return [
{
kind: "remove",
text: `${left.length} lines (${before.length} characters)`,
oldLine: 1,
},
{
kind: "add",
text: `${right.length} lines (${after.length} characters)`,
newLine: 1,
},
];
}
const lengths = Array.from(
{ length: left.length + 1 },
() => new Uint32Array(right.length + 1),
);
for (let i = left.length - 1; i >= 0; i -= 1) {
for (let j = right.length - 1; j >= 0; j -= 1) {
lengths[i]![j] =
left[i] === right[j]
? lengths[i + 1]![j + 1]! + 1
: Math.max(lengths[i + 1]![j]!, lengths[i]![j + 1]!);
}
}
const output: DiffLine[] = [];
let i = 0;
let j = 0;
while (i < left.length || j < right.length) {
if (i < left.length && j < right.length && left[i] === right[j]) {
output.push({
kind: "same",
text: left[i]!,
oldLine: i + 1,
newLine: j + 1,
});
i += 1;
j += 1;
} else if (
j < right.length &&
(i === left.length || lengths[i]![j + 1]! >= lengths[i + 1]![j]!)
) {
output.push({ kind: "add", text: right[j]!, newLine: j + 1 });
j += 1;
} else {
output.push({ kind: "remove", text: left[i]!, oldLine: i + 1 });
i += 1;
}
}
return output;
}
+78
View File
@@ -0,0 +1,78 @@
const XML_NAMESPACE = "http://www.w3.org/2000/xmlns/";
function escapeText(value: string): string {
return value
.replaceAll("&", "&amp;")
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;");
}
function escapeAttribute(value: string): string {
return escapeText(value).replaceAll('"', "&quot;");
}
function serializeNode(
node: Node,
depth: number,
indentation: string,
newline: string,
): string {
const prefix = indentation.repeat(depth);
if (node.nodeType === Node.COMMENT_NODE)
return `${prefix}<!--${node.nodeValue ?? ""}-->`;
if (node.nodeType === Node.CDATA_SECTION_NODE)
return `${prefix}<![CDATA[${node.nodeValue ?? ""}]]>`;
if (node.nodeType === Node.PROCESSING_INSTRUCTION_NODE) {
const instruction = node as ProcessingInstruction;
return `${prefix}<?${instruction.target} ${instruction.data}?>`;
}
if (node.nodeType === Node.TEXT_NODE) {
const value = node.nodeValue ?? "";
return value.trim() ? `${prefix}${escapeText(value.trim())}` : "";
}
if (node.nodeType !== Node.ELEMENT_NODE) return "";
const element = node as Element;
const attributes = Array.from(element.attributes)
.sort((left, right) => {
const leftNamespace = left.namespaceURI === XML_NAMESPACE ? 0 : 1;
const rightNamespace = right.namespaceURI === XML_NAMESPACE ? 0 : 1;
return (
leftNamespace - rightNamespace || left.name.localeCompare(right.name)
);
})
.map(
(attribute) => ` ${attribute.name}="${escapeAttribute(attribute.value)}"`,
)
.join("");
const children = Array.from(element.childNodes)
.map((child) => serializeNode(child, depth + 1, indentation, newline))
.filter(Boolean);
if (children.length === 0)
return `${prefix}<${element.tagName}${attributes}/>`;
const inlineText =
element.childNodes.length === 1 &&
element.firstChild?.nodeType === Node.TEXT_NODE;
if (inlineText) {
return `${prefix}<${element.tagName}${attributes}>${escapeText(element.textContent ?? "")}</${element.tagName}>`;
}
return `${prefix}<${element.tagName}${attributes}>${newline}${children.join(newline)}${newline}${prefix}</${element.tagName}>`;
}
export function formatSvgSource(source: string, indentation = " "): string {
const document = new DOMParser().parseFromString(source, "image/svg+xml");
if (
document.documentElement.localName === "parsererror" ||
document.querySelector("parsererror")
) {
throw new Error("Only well-formed SVG can be formatted");
}
const newline = source.includes("\r\n") ? "\r\n" : "\n";
const declaration = /^\s*<\?xml[^?]*\?>/iu.exec(source)?.[0].trim();
const doctype = /<!DOCTYPE\b[^>]*>/iu.exec(source)?.[0];
const parts = [
declaration,
doctype,
serializeNode(document.documentElement, 0, indentation, newline),
].filter(Boolean);
return `${parts.join(newline)}${newline}`;
}
+9
View File
@@ -0,0 +1,9 @@
import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import { App } from "./App";
createRoot(document.getElementById("root")!).render(
<StrictMode>
<App />
</StrictMode>,
);
+31
View File
@@ -0,0 +1,31 @@
import type {
OptimizationProfile,
OptionalOptimizationPlugin,
} from "./profiles";
export interface OptimizationRequest {
type: "optimize";
jobId: number;
source: string;
profile: OptimizationProfile;
optionalPlugins: OptionalOptimizationPlugin[];
}
export interface OptimizationResult {
type: "result";
jobId: number;
profile: OptimizationProfile;
optionalPlugins: OptionalOptimizationPlugin[];
source: string;
inputBytes: number;
outputBytes: number;
elapsedMs: number;
}
export interface OptimizationFailure {
type: "error";
jobId: number;
message: string;
}
export type OptimizationResponse = OptimizationResult | OptimizationFailure;
+113
View File
@@ -0,0 +1,113 @@
import { defaultSvgLimits } from "../app/limits";
import type {
OptimizationResponse,
OptimizationResult,
} from "./optimization.types";
import type {
OptimizationProfile,
OptionalOptimizationPlugin,
} from "./profiles";
export class OptimizationCancelledError extends Error {
constructor(message = "Optimization was cancelled") {
super(message);
this.name = "OptimizationCancelledError";
}
}
export class OptimizerClient {
#jobId = 0;
#pending: { jobId: number; cancel: () => void } | null = null;
cancel(): void {
this.#jobId += 1;
this.#pending?.cancel();
}
async optimize(
source: string,
profile: OptimizationProfile,
optionalPlugins: readonly OptionalOptimizationPlugin[] = [],
signal?: AbortSignal,
): Promise<OptimizationResult> {
this.cancel();
const jobId = this.#jobId;
if (signal?.aborted) throw new OptimizationCancelledError();
const worker = new Worker(
new URL("./optimizer.worker.ts", import.meta.url),
{
type: "module",
name: "svg-tools-optimizer",
},
);
return new Promise<OptimizationResult>((resolve, reject) => {
let settled = false;
const cleanup = () => {
globalThis.clearTimeout(timeout);
signal?.removeEventListener("abort", abort);
worker.removeEventListener("error", onError);
worker.removeEventListener("message", onMessage);
worker.terminate();
if (this.#pending?.jobId === jobId) this.#pending = null;
};
const resolveOnce = (result: OptimizationResult) => {
if (settled) return;
settled = true;
cleanup();
resolve(result);
};
const rejectOnce = (error: Error) => {
if (settled) return;
settled = true;
cleanup();
reject(error);
};
const abort = () => {
rejectOnce(new OptimizationCancelledError());
};
const onError = (event: ErrorEvent) => {
rejectOnce(
new Error(event.message || "The optimization worker failed"),
);
};
const onMessage = (event: MessageEvent<OptimizationResponse>) => {
if (event.data.jobId !== jobId) return;
if (event.data.type === "error") {
rejectOnce(new Error(event.data.message));
} else {
resolveOnce(event.data);
}
};
this.#pending = { jobId, cancel: abort };
const timeout = globalThis.setTimeout(() => {
rejectOnce(
new Error(
`Optimization exceeded the ${defaultSvgLimits.maximumOptimizationMs / 1_000}-second safety limit`,
),
);
}, defaultSvgLimits.maximumOptimizationMs);
signal?.addEventListener("abort", abort, { once: true });
worker.addEventListener("error", onError, { once: true });
worker.addEventListener("message", onMessage);
if (signal?.aborted) {
abort();
return;
}
try {
worker.postMessage({
type: "optimize",
jobId,
source,
profile,
optionalPlugins: [...optionalPlugins],
});
} catch (error) {
rejectOnce(
error instanceof Error
? error
: new Error("The optimization worker could not be started"),
);
}
});
}
}
+48
View File
@@ -0,0 +1,48 @@
/// <reference lib="webworker" />
import { optimize } from "svgo/browser";
import { defaultSvgLimits, utf8ByteLength } from "../app/limits";
import type {
OptimizationRequest,
OptimizationResponse,
} from "./optimization.types";
import { configForProfile } from "./profiles";
const scope: DedicatedWorkerGlobalScope = self as DedicatedWorkerGlobalScope;
scope.addEventListener(
"message",
(event: MessageEvent<OptimizationRequest>) => {
const request = event.data;
if (request.type !== "optimize") return;
const started = performance.now();
let response: OptimizationResponse;
try {
const inputBytes = utf8ByteLength(request.source);
if (inputBytes > defaultSvgLimits.sourceHardBytes) {
throw new Error("Source exceeds the optimization hard limit");
}
const result = optimize(
request.source,
configForProfile(request.profile, request.optionalPlugins),
);
response = {
type: "result",
jobId: request.jobId,
profile: request.profile,
optionalPlugins: request.optionalPlugins,
source: result.data,
inputBytes,
outputBytes: utf8ByteLength(result.data),
elapsedMs: performance.now() - started,
};
} catch (error) {
response = {
type: "error",
jobId: request.jobId,
message: error instanceof Error ? error.message : "Optimization failed",
};
}
scope.postMessage(response);
},
);
+153
View File
@@ -0,0 +1,153 @@
import type { Config } from "svgo/browser";
export type OptimizationProfile = "conservative" | "standard" | "aggressive";
export type OptionalOptimizationPlugin =
| "convertStyleToAttrs"
| "removeDimensions"
| "removeOffCanvasPaths"
| "reusePaths";
export interface OptimizationProfileDescription {
id: OptimizationProfile;
label: string;
description: string;
risk: string;
}
export const optimizationProfiles: readonly OptimizationProfileDescription[] = [
{
id: "conservative",
label: "Conservative",
description:
"Cleans metadata and syntax while retaining IDs, viewBox, shapes and path spelling where possible.",
risk: "Low, but every optimizer can expose renderer differences; review the preview.",
},
{
id: "standard",
label: "Standard",
description:
"Applies SVGO defaults while retaining IDs and the viewBox, then sorts attributes.",
risk: "May rewrite path data, styles and groups without changing intended appearance.",
},
{
id: "aggressive",
label: "Aggressive",
description:
"Uses multiple passes, converts basic shapes to paths and collapses eligible groups.",
risk: "Largest source changes; animation, scripts or editor workflows may depend on original structure.",
},
];
export const optionalOptimizationPlugins: readonly {
id: OptionalOptimizationPlugin;
label: string;
risk: string;
}[] = [
{
id: "convertStyleToAttrs",
label: "Convert style to attributes",
risk: "Rewrites inline CSS declarations.",
},
{
id: "removeDimensions",
label: "Remove width and height",
risk: "Makes sizing depend on viewBox and embedding CSS.",
},
{
id: "removeOffCanvasPaths",
label: "Remove off-canvas paths",
risk: "Can remove content intended for later animation or viewport changes.",
},
{
id: "reusePaths",
label: "Reuse duplicate paths",
risk: "Introduces definitions and use references.",
},
];
const optionalPluginIds = new Set(
optionalOptimizationPlugins.map((plugin) => plugin.id),
);
function withOptionalPlugins(
config: Config,
selected: readonly OptionalOptimizationPlugin[],
): Config {
const unique = [...new Set(selected)];
for (const plugin of unique) {
if (!optionalPluginIds.has(plugin)) {
throw new Error(`Unsupported optional SVGO plugin: ${plugin}`);
}
}
return {
...config,
plugins: [...(config.plugins ?? []), ...unique],
};
}
export function configForProfile(
profile: OptimizationProfile,
optionalPlugins: readonly OptionalOptimizationPlugin[] = [],
): Config {
if (profile === "conservative") {
return withOptionalPlugins(
{
multipass: false,
floatPrecision: 6,
plugins: [
{
name: "preset-default",
params: {
overrides: {
cleanupIds: false,
removeViewBox: false,
convertPathData: false,
convertShapeToPath: false,
collapseGroups: false,
removeUnknownsAndDefaults: false,
},
},
},
],
js2svg: { pretty: true, indent: 2 },
},
optionalPlugins,
);
}
if (profile === "standard") {
return withOptionalPlugins(
{
multipass: false,
floatPrecision: 4,
plugins: [
{
name: "preset-default",
params: {
overrides: { cleanupIds: false, removeViewBox: false },
},
},
"sortAttrs",
],
},
optionalPlugins,
);
}
return withOptionalPlugins(
{
multipass: true,
floatPrecision: 3,
plugins: [
{
name: "preset-default",
params: {
overrides: { cleanupIds: false, removeViewBox: false },
},
},
"convertShapeToPath",
"collapseGroups",
"sortAttrs",
],
},
optionalPlugins,
);
}
+295
View File
@@ -0,0 +1,295 @@
import { defaultSvgLimits, utf8ByteLength } from "../app/limits";
import type { AnimationDefinition } from "../animation/animation.types";
import {
AnimationValidationError,
validateAnimationDefinitions,
} from "../animation/validation";
export const PROJECT_FORMAT = "de.add-ideas.svg-tools/project";
export const PROJECT_SCHEMA_VERSION = 1;
export const PROJECT_MIME = "application/vnd.add-ideas.svg-tools+json";
export interface ProjectUiState {
selectedNodeKey: string | null;
expandedNodeKeys: string[];
activePanel: string;
zoom: number;
pan: { x: number; y: number };
showGrid: boolean;
sourceSelection?: { anchor: number; head: number };
}
export interface SvgToolsProject {
format: typeof PROJECT_FORMAT;
schemaVersion: typeof PROJECT_SCHEMA_VERSION;
appVersion: string;
document: { source: string };
ui: ProjectUiState;
animations: AnimationDefinition[];
metadata: {
title: string;
originalFileName?: string;
createdAt: string;
updatedAt: string;
};
}
export class ProjectFormatError extends Error {
readonly code: string;
readonly path?: string;
constructor(code: string, message: string, path?: string) {
super(path ? `${message} (${path})` : message);
this.name = "ProjectFormatError";
this.code = code;
this.path = path;
}
}
function record(value: unknown, path: string): Record<string, unknown> {
if (!value || typeof value !== "object" || Array.isArray(value)) {
throw new ProjectFormatError("INVALID_PROJECT", "Expected an object", path);
}
return value as Record<string, unknown>;
}
function text(value: unknown, path: string, allowEmpty = false): string {
if (
typeof value !== "string" ||
(!allowEmpty && !value) ||
value.length > 1_000_000
) {
throw new ProjectFormatError(
"INVALID_PROJECT",
"Expected a bounded string",
path,
);
}
return value;
}
function finite(
value: unknown,
path: string,
minimum: number,
maximum: number,
): number {
if (
typeof value !== "number" ||
!Number.isFinite(value) ||
value < minimum ||
value > maximum
) {
throw new ProjectFormatError(
"INVALID_PROJECT",
`Expected a number from ${minimum} to ${maximum}`,
path,
);
}
return value;
}
function stringArray(value: unknown, path: string, limit: number): string[] {
if (!Array.isArray(value) || value.length > limit) {
throw new ProjectFormatError(
"INVALID_PROJECT",
"Expected a bounded array",
path,
);
}
const result = value.map((entry, index) => text(entry, `${path}[${index}]`));
if (new Set(result).size !== result.length) {
throw new ProjectFormatError(
"INVALID_PROJECT",
"Duplicate values are not allowed",
path,
);
}
return result;
}
export function validateProject(value: unknown): SvgToolsProject {
const root = record(value, "$");
if (root.format !== PROJECT_FORMAT) {
throw new ProjectFormatError(
"UNSUPPORTED_FORMAT",
"This is not an SVG Tools project",
"$.format",
);
}
if (root.schemaVersion !== PROJECT_SCHEMA_VERSION) {
throw new ProjectFormatError(
"UNSUPPORTED_SCHEMA_VERSION",
`Project schema ${String(root.schemaVersion)} is not supported`,
"$.schemaVersion",
);
}
const document = record(root.document, "$.document");
const source = text(document.source, "$.document.source", true);
if (utf8ByteLength(source) > defaultSvgLimits.sourceHardBytes) {
throw new ProjectFormatError(
"SOURCE_TOO_LARGE",
"Project SVG source exceeds the hard limit",
"$.document.source",
);
}
const ui = record(root.ui, "$.ui");
const pan = record(ui.pan, "$.ui.pan");
const selectedNodeKey =
ui.selectedNodeKey === null
? null
: text(ui.selectedNodeKey, "$.ui.selectedNodeKey");
if (typeof ui.showGrid !== "boolean") {
throw new ProjectFormatError(
"INVALID_PROJECT",
"Expected a boolean",
"$.ui.showGrid",
);
}
let sourceSelection: ProjectUiState["sourceSelection"];
if (ui.sourceSelection !== undefined) {
const selection = record(ui.sourceSelection, "$.ui.sourceSelection");
sourceSelection = {
anchor: finite(
selection.anchor,
"$.ui.sourceSelection.anchor",
0,
source.length,
),
head: finite(
selection.head,
"$.ui.sourceSelection.head",
0,
source.length,
),
};
}
let animations: AnimationDefinition[];
try {
animations = validateAnimationDefinitions(root.animations);
} catch (error) {
if (error instanceof AnimationValidationError) {
throw new ProjectFormatError("INVALID_PROJECT", error.reason, error.path);
}
throw error;
}
const metadata = record(root.metadata, "$.metadata");
const originalFileName =
metadata.originalFileName === undefined
? undefined
: text(metadata.originalFileName, "$.metadata.originalFileName");
return {
format: PROJECT_FORMAT,
schemaVersion: PROJECT_SCHEMA_VERSION,
appVersion: text(root.appVersion, "$.appVersion"),
document: { source },
ui: {
selectedNodeKey,
expandedNodeKeys: stringArray(
ui.expandedNodeKeys,
"$.ui.expandedNodeKeys",
100_000,
),
activePanel: text(ui.activePanel, "$.ui.activePanel"),
zoom: finite(ui.zoom, "$.ui.zoom", 0.01, 128),
pan: {
x: finite(pan.x, "$.ui.pan.x", -1e9, 1e9),
y: finite(pan.y, "$.ui.pan.y", -1e9, 1e9),
},
showGrid: ui.showGrid,
...(sourceSelection ? { sourceSelection } : {}),
},
animations,
metadata: {
title: text(metadata.title, "$.metadata.title"),
createdAt: text(metadata.createdAt, "$.metadata.createdAt"),
updatedAt: text(metadata.updatedAt, "$.metadata.updatedAt"),
...(originalFileName ? { originalFileName } : {}),
},
};
}
function stableJson(value: unknown): string {
if (
value === null ||
typeof value === "boolean" ||
typeof value === "number"
) {
return JSON.stringify(value);
}
if (typeof value === "string") return JSON.stringify(value);
if (Array.isArray(value)) return `[${value.map(stableJson).join(",")}]`;
if (value && typeof value === "object") {
const data = value as Record<string, unknown>;
return `{${Object.keys(data)
.sort()
.map((key) => `${JSON.stringify(key)}:${stableJson(data[key])}`)
.join(",")}}`;
}
throw new ProjectFormatError(
"INVALID_PROJECT",
"Project contains a non-JSON value",
);
}
export function createProject(
input: Omit<SvgToolsProject, "format" | "schemaVersion">,
): SvgToolsProject {
return validateProject({
format: PROJECT_FORMAT,
schemaVersion: PROJECT_SCHEMA_VERSION,
...input,
});
}
export function serializeProject(project: SvgToolsProject): string {
const source = `${stableJson(validateProject(project))}\n`;
if (utf8ByteLength(source) > defaultSvgLimits.sourceHardBytes * 4) {
throw new ProjectFormatError(
"PROJECT_TOO_LARGE",
"Project exceeds the processing limit",
);
}
return source;
}
export function parseProject(source: string): SvgToolsProject {
if (utf8ByteLength(source) > defaultSvgLimits.sourceHardBytes * 4) {
throw new ProjectFormatError(
"PROJECT_TOO_LARGE",
"Project exceeds the processing limit",
);
}
try {
return validateProject(
JSON.parse(source.replace(/^\uFEFF/u, "")) as unknown,
);
} catch (error) {
if (error instanceof ProjectFormatError) throw error;
throw new ProjectFormatError(
"INVALID_JSON",
"The selected project is not valid JSON",
);
}
}
export async function readProject(file: Blob): Promise<SvgToolsProject> {
if (file.size > defaultSvgLimits.sourceHardBytes * 4) {
throw new ProjectFormatError(
"PROJECT_TOO_LARGE",
"Project exceeds the processing limit",
);
}
const bytes = new Uint8Array(await file.arrayBuffer());
try {
return parseProject(
new TextDecoder("utf-8", { fatal: true }).decode(bytes),
);
} catch (error) {
if (error instanceof ProjectFormatError) throw error;
throw new ProjectFormatError(
"INVALID_UTF8",
"The selected project is not valid UTF-8",
);
}
}
+432
View File
@@ -0,0 +1,432 @@
import DOMPurify from "dompurify";
import { parse as parseCss, walk as walkCss } from "css-tree";
import { defaultSvgLimits } from "../app/limits";
import type {
SemanticSvgDocument,
SemanticSvgNode,
} from "../document/document.types";
import type { SvgProjection, SvgSecurityFinding } from "./security.types";
const SVG_NAMESPACE = "http://www.w3.org/2000/svg";
const BLOCKED_ELEMENTS = new Set([
"script",
"foreignObject",
"animate",
"animateMotion",
"animateTransform",
"set",
]);
const UNSAFE_CSS_PROPERTIES = new Set(["behavior", "-moz-binding"]);
const UNSAFE_CSS_FUNCTIONS = new Set([
"cross-fade",
"image",
"image-set",
"-webkit-image-set",
"paint",
"src",
]);
const URL_ATTRIBUTES = new Set([
"href",
"xlink:href",
"src",
"fill",
"stroke",
"filter",
"clip-path",
"mask",
"marker-start",
"marker-mid",
"marker-end",
"cursor",
]);
const SAFE_IMAGE_DATA = /^data:image\/(?:png|jpeg|gif|webp|avif);base64,/iu;
const LOCAL_FRAGMENT = /^#[A-Za-z_][A-Za-z0-9_.:-]*$/u;
const URL_FUNCTION = /url\(\s*(["']?)(.*?)\1\s*\)/giu;
function finding(
node: SemanticSvgNode,
code: string,
description: string,
action: SvgSecurityFinding["editingProjectionAction"],
severity: SvgSecurityFinding["severity"] = "error",
attribute?: string,
): SvgSecurityFinding {
return {
severity,
code,
nodeKey: node.key,
sourceRange:
(attribute ? node.attributeRanges[attribute]?.fullRange : undefined) ??
node.sourceRange.openTag,
description,
sourcePreserved: true,
editingProjectionAction: action,
};
}
function isUniqueLocalFragment(
value: string,
idCounts: ReadonlyMap<string, number>,
): boolean {
const target = value.trim();
return LOCAL_FRAGMENT.test(target) && idCounts.get(target.slice(1)) === 1;
}
function isSafeLocalReference(
value: string,
idCounts: ReadonlyMap<string, number>,
): boolean {
if (isUniqueLocalFragment(value, idCounts)) return true;
let safe = true;
let encountered = false;
value.replace(URL_FUNCTION, (_match, _quote: string, target: string) => {
encountered = true;
if (!isUniqueLocalFragment(target, idCounts)) safe = false;
return "";
});
return encountered && safe;
}
interface CssPolicyResult {
code: string;
description: string;
}
function inspectCssPolicy(
css: string,
context: "stylesheet" | "declarationList",
idCounts: ReadonlyMap<string, number>,
): CssPolicyResult | null {
let parseFailed = false;
let issue: CssPolicyResult | null = null;
try {
const ast = parseCss(css, {
context,
positions: false,
onParseError: () => {
parseFailed = true;
},
});
walkCss(ast, (node) => {
if (issue) return;
if (node.type === "Atrule" && node.name.toLowerCase() === "import") {
issue = {
code: "css-import",
description: "CSS @import is removed from the projection.",
};
} else if (
node.type === "Declaration" &&
UNSAFE_CSS_PROPERTIES.has(node.property.toLowerCase())
) {
issue = {
code: "css-behavior",
description: `CSS property “${node.property}” is not allowed in the projection.`,
};
} else if (
node.type === "Url" &&
!isUniqueLocalFragment(node.value, idCounts)
) {
issue = {
code: "css-external-url",
description:
"CSS URLs must resolve to one unique local fragment in the projection.",
};
} else if (
node.type === "Function" &&
node.name.toLowerCase() === "expression"
) {
issue = {
code: "css-expression",
description:
"CSS expression-like functions are not allowed in the projection.",
};
} else if (
node.type === "Function" &&
UNSAFE_CSS_FUNCTIONS.has(node.name.toLowerCase())
) {
issue = {
code: "css-resource-function",
description: `CSS function “${node.name}()” can resolve external resources and is not allowed in the projection.`,
};
}
});
} catch {
parseFailed = true;
}
return (
issue ??
(parseFailed
? {
code: "css-parser-error",
description:
"CSS could not be parsed safely and is removed from the projection.",
}
: null)
);
}
function inspectNode(
node: SemanticSvgNode,
idCounts: ReadonlyMap<string, number>,
): SvgSecurityFinding[] {
const findings: SvgSecurityFinding[] = [];
if (node.namespaceUri !== null && node.namespaceUri !== SVG_NAMESPACE) {
findings.push(
finding(
node,
"unknown-namespace",
`Element namespace “${node.namespaceUri}” is not rendered.`,
"removed",
),
);
}
if (BLOCKED_ELEMENTS.has(node.localName)) {
findings.push(
finding(
node,
`blocked-${node.localName.toLowerCase()}`,
`<${node.localName}> is preserved in source but removed from all rendered projections.`,
"removed",
),
);
}
for (const [name, value] of Object.entries(node.attributes)) {
const lowerName = name.toLowerCase();
if (lowerName.startsWith("on")) {
findings.push(
finding(
node,
"event-handler",
`Executable event attribute “${name}” is removed from the projection.`,
"removed",
"error",
name,
),
);
continue;
}
if (URL_ATTRIBUTES.has(lowerName)) {
const trimmed = value.trim();
const imageDataAllowed =
node.localName === "image" &&
(lowerName === "href" || lowerName === "xlink:href") &&
SAFE_IMAGE_DATA.test(trimmed) &&
trimmed.length <= defaultSvgLimits.maximumEmbeddedResourceBytes * 1.4;
const localAllowed = isSafeLocalReference(trimmed, idCounts);
const plainPaint =
["fill", "stroke"].includes(lowerName) && !/url\s*\(/iu.test(trimmed);
if (
!imageDataAllowed &&
!localAllowed &&
!plainPaint &&
trimmed !== "none"
) {
findings.push(
finding(
node,
"unsafe-url",
`External or executable URL in “${name}” is neutralized in the projection.`,
"neutralized",
"error",
name,
),
);
}
}
const cssIssue =
lowerName === "style"
? inspectCssPolicy(value, "declarationList", idCounts)
: null;
if (cssIssue) {
findings.push(
finding(
node,
"unsafe-inline-css",
cssIssue.description,
"removed",
"error",
name,
),
);
}
}
if (node.localName === "style") {
const cssIssue = inspectCssPolicy(node.text, "stylesheet", idCounts);
if (cssIssue) {
findings.push(
finding(node, "unsafe-stylesheet", cssIssue.description, "removed"),
);
}
}
return findings;
}
export function inspectSvgSecurity(
semantic: SemanticSvgDocument,
): SvgSecurityFinding[] {
const idCounts = new Map<string, number>();
for (const node of semantic.nodes.values()) {
if (node.id) idCounts.set(node.id, (idCounts.get(node.id) ?? 0) + 1);
}
const findings = semantic.order.flatMap((key) =>
inspectNode(semantic.nodes.get(key)!, idCounts),
);
if (/<!DOCTYPE\b/iu.test(semantic.source)) {
findings.push({
severity: "warning",
code: "doctype-ignored",
description:
"DOCTYPE remains in source but is excluded from rendering and no external entity is resolved.",
sourcePreserved: true,
editingProjectionAction: "removed",
});
}
return findings;
}
function matchingSemanticNode(
element: Element,
order: readonly string[],
semantic: SemanticSvgDocument,
index: number,
): SemanticSvgNode | null {
const exact = semantic.nodes.get(order[index] ?? "");
if (exact?.localName === element.localName) return exact;
const id = element.getAttribute("id");
if (id) {
return (
[...semantic.nodes.values()].find((candidate) => candidate.id === id) ??
null
);
}
return exact ?? null;
}
function postSanitize(
document: XMLDocument,
findings: readonly SvgSecurityFinding[],
): void {
const findingsByNode = new Map<string, SvgSecurityFinding[]>();
for (const item of findings) {
if (!item.nodeKey) continue;
const existing = findingsByNode.get(item.nodeKey) ?? [];
existing.push(item);
findingsByNode.set(item.nodeKey, existing);
}
const idCounts = new Map<string, number>();
for (const element of Array.from(document.querySelectorAll("[id]"))) {
const id = element.getAttribute("id");
if (id) idCounts.set(id, (idCounts.get(id) ?? 0) + 1);
}
for (const element of Array.from(document.querySelectorAll("*"))) {
const nodeKey = element.getAttribute("data-svg-tools-node");
if (nodeKey && findingsByNode.has(nodeKey)) {
const nodeFindings = findingsByNode.get(nodeKey)!;
if (
nodeFindings.some(
(item) =>
item.code.startsWith("blocked-") ||
item.code === "unknown-namespace",
)
) {
element.remove();
continue;
}
for (const attribute of Array.from(element.attributes)) {
const lower = attribute.name.toLowerCase();
if (lower.startsWith("on")) element.removeAttribute(attribute.name);
if (
URL_ATTRIBUTES.has(lower) &&
!isSafeLocalReference(attribute.value, idCounts) &&
!(
element.localName === "image" &&
SAFE_IMAGE_DATA.test(attribute.value) &&
attribute.value.length <=
defaultSvgLimits.maximumEmbeddedResourceBytes * 1.4
) &&
!(
["fill", "stroke"].includes(lower) &&
!/url\s*\(/iu.test(attribute.value)
)
) {
element.removeAttribute(attribute.name);
}
}
if (
/unsafe-inline-css|unsafe-stylesheet/u.test(
nodeFindings.map((item) => item.code).join(" "),
)
) {
element.removeAttribute("style");
if (element.localName === "style") element.remove();
}
}
if (element.localName === "a") {
element.removeAttribute("href");
element.removeAttribute("xlink:href");
element.removeAttribute("target");
}
}
}
export function createEditingProjection(
semantic: SemanticSvgDocument,
): SvgProjection {
const sourceDocument = semantic.document.cloneNode(true) as XMLDocument;
const elements = [
sourceDocument.documentElement,
...Array.from(sourceDocument.documentElement.querySelectorAll("*")),
];
elements.forEach((element, index) => {
const node = matchingSemanticNode(element, semantic.order, semantic, index);
if (node) element.setAttribute("data-svg-tools-node", node.key);
});
const findings = inspectSvgSecurity(semantic);
const originalElementCount = elements.length;
const purified = DOMPurify.sanitize(
new XMLSerializer().serializeToString(sourceDocument.documentElement),
{
USE_PROFILES: { svg: true, svgFilters: true },
FORBID_TAGS: [...BLOCKED_ELEMENTS],
ALLOW_DATA_ATTR: true,
RETURN_DOM: false,
},
);
const projectionDocument = new DOMParser().parseFromString(
purified,
"image/svg+xml",
);
if (projectionDocument.documentElement.localName !== "svg") {
throw new Error("Sanitizer did not produce an SVG root");
}
postSanitize(projectionDocument, findings);
const projectionElementCount =
1 + projectionDocument.documentElement.querySelectorAll("*").length;
return {
source: new XMLSerializer().serializeToString(
projectionDocument.documentElement,
),
findings,
removedCount: Math.max(0, originalElementCount - projectionElementCount),
policy: "editing-projection-v1",
};
}
export function createSanitizedCandidate(
semantic: SemanticSvgDocument,
): SvgProjection {
const projection = createEditingProjection(semantic);
const document = new DOMParser().parseFromString(
projection.source,
"image/svg+xml",
);
for (const element of Array.from(
document.querySelectorAll("[data-svg-tools-node]"),
)) {
element.removeAttribute("data-svg-tools-node");
}
return {
...projection,
source: new XMLSerializer().serializeToString(document.documentElement),
};
}
+18
View File
@@ -0,0 +1,18 @@
import type { SourceRange } from "../document/document.types";
export interface SvgSecurityFinding {
severity: "info" | "warning" | "error";
code: string;
nodeKey?: string;
sourceRange?: SourceRange;
description: string;
sourcePreserved: boolean;
editingProjectionAction: "allowed" | "neutralized" | "removed" | "replaced";
}
export interface SvgProjection {
source: string;
findings: SvgSecurityFinding[];
removedCount: number;
policy: "editing-projection-v1";
}
+251
View File
@@ -0,0 +1,251 @@
import type {
SemanticSvgDocument,
SourcePatch,
SvgDiagnostic,
} from "../document/document.types";
import { escapeXmlAttribute } from "../document/source-patcher";
const DIRECT_REFERENCE_ATTRIBUTES = new Set([
"href",
"xlink:href",
"aria-labelledby",
"aria-describedby",
]);
const URL_REFERENCE_ATTRIBUTES = new Set([
"fill",
"stroke",
"filter",
"clip-path",
"mask",
"marker-start",
"marker-mid",
"marker-end",
"cursor",
]);
const URL_REFERENCE_PATTERN = /url\(\s*(["']?)#([^)'"\s]+)\1\s*\)/giu;
export interface SvgReferenceEdge {
sourceKey: string;
attribute: string;
targetId: string;
targetKey: string | null;
status: "resolved" | "missing" | "ambiguous" | "cyclic";
}
export interface SvgReferenceIndex {
ids: ReadonlyMap<string, readonly string[]>;
edges: readonly SvgReferenceEdge[];
incomingById: ReadonlyMap<string, readonly SvgReferenceEdge[]>;
diagnostics: readonly SvgDiagnostic[];
}
function targetsForAttribute(name: string, value: string): string[] {
if (DIRECT_REFERENCE_ATTRIBUTES.has(name)) {
if (name.startsWith("aria-")) {
return value.trim().split(/\s+/u).filter(Boolean);
}
return value.startsWith("#") ? [value.slice(1)] : [];
}
if (!URL_REFERENCE_ATTRIBUTES.has(name) && name !== "style") return [];
return Array.from(
value.matchAll(URL_REFERENCE_PATTERN),
(match) => match[2]!,
).filter(Boolean);
}
function markCycles(
edges: SvgReferenceEdge[],
idByKey: ReadonlyMap<string, string>,
) {
const outgoing = new Map<string, SvgReferenceEdge[]>();
for (const edge of edges) {
const list = outgoing.get(edge.sourceKey) ?? [];
list.push(edge);
outgoing.set(edge.sourceKey, list);
}
const state = new Map<string, "visiting" | "visited">();
for (const start of idByKey.keys()) {
if (state.has(start)) continue;
const activeIndex = new Map<string, number>();
const stack: Array<{
key: string;
edges: SvgReferenceEdge[];
next: number;
via?: SvgReferenceEdge;
}> = [
{
key: start,
edges: outgoing.get(start) ?? [],
next: 0,
},
];
state.set(start, "visiting");
activeIndex.set(start, 0);
while (stack.length > 0) {
const frame = stack.at(-1)!;
if (frame.next >= frame.edges.length) {
state.set(frame.key, "visited");
activeIndex.delete(frame.key);
stack.pop();
continue;
}
const edge = frame.edges[frame.next]!;
frame.next += 1;
if (!edge.targetKey) continue;
const targetState = state.get(edge.targetKey);
if (targetState === "visiting") {
edge.status = "cyclic";
const cycleStart = activeIndex.get(edge.targetKey)!;
for (let index = cycleStart + 1; index < stack.length; index += 1) {
if (stack[index]!.via) stack[index]!.via!.status = "cyclic";
}
} else if (!targetState) {
state.set(edge.targetKey, "visiting");
activeIndex.set(edge.targetKey, stack.length);
stack.push({
key: edge.targetKey,
edges: outgoing.get(edge.targetKey) ?? [],
next: 0,
via: edge,
});
}
}
}
}
export function buildReferenceIndex(
semantic: SemanticSvgDocument,
): SvgReferenceIndex {
const ids = new Map<string, string[]>();
const idByKey = new Map<string, string>();
for (const node of semantic.nodes.values()) {
if (!node.id) continue;
const list = ids.get(node.id) ?? [];
list.push(node.key);
ids.set(node.id, list);
idByKey.set(node.key, node.id);
}
const edges: SvgReferenceEdge[] = [];
for (const node of semantic.nodes.values()) {
for (const [attribute, value] of Object.entries(node.attributes)) {
for (const targetId of targetsForAttribute(attribute, value)) {
const targets = ids.get(targetId) ?? [];
edges.push({
sourceKey: node.key,
attribute,
targetId,
targetKey: targets.length === 1 ? targets[0]! : null,
status:
targets.length === 0
? "missing"
: targets.length > 1
? "ambiguous"
: "resolved",
});
}
}
}
markCycles(edges, idByKey);
const incomingById = new Map<string, SvgReferenceEdge[]>();
for (const edge of edges) {
const list = incomingById.get(edge.targetId) ?? [];
list.push(edge);
incomingById.set(edge.targetId, list);
}
const diagnostics: SvgDiagnostic[] = edges
.filter((edge) => edge.status !== "resolved")
.map((edge) => {
const node = semantic.nodes.get(edge.sourceKey)!;
return {
severity: edge.status === "cyclic" ? "warning" : "error",
code: `reference-${edge.status}`,
message: `${edge.attribute} references “${edge.targetId}” (${edge.status}).`,
nodeKey: edge.sourceKey,
range: node.attributeRanges[edge.attribute]?.valueRange,
};
});
return { ids, edges, incomingById, diagnostics };
}
export interface IdRenamePreview {
oldId: string;
newId: string;
patches: SourcePatch[];
affectedNodeKeys: string[];
warnings: string[];
}
export function previewIdRename(
semantic: SemanticSvgDocument,
nodeKey: string,
newId: string,
): IdRenamePreview {
if (!/^[A-Za-z_][A-Za-z0-9_.:-]*$/u.test(newId)) {
throw new Error("ID must be a portable XML name");
}
const node = semantic.nodes.get(nodeKey);
if (!node?.id) throw new Error("Selected element has no ID");
const index = buildReferenceIndex(semantic);
if (index.ids.has(newId) && newId !== node.id) {
throw new Error(`ID “${newId}” already exists`);
}
const patches: SourcePatch[] = [];
const idRange = node.attributeRanges.id?.valueRange;
if (!idRange) throw new Error("ID source range is unavailable");
patches.push({
from: idRange.from,
to: idRange.to,
insert: escapeXmlAttribute(newId, node.attributeRanges.id!.quote),
label: "Rename ID",
});
const affected = new Set([nodeKey]);
const patchedAttributes = new Set<string>();
for (const edge of index.incomingById.get(node.id) ?? []) {
const patchIdentity = `${edge.sourceKey}\u0000${edge.attribute}`;
if (patchedAttributes.has(patchIdentity)) continue;
patchedAttributes.add(patchIdentity);
const sourceNode = semantic.nodes.get(edge.sourceKey)!;
const range = sourceNode.attributeRanges[edge.attribute];
if (!range) continue;
const value = sourceNode.attributes[edge.attribute]!;
let replaced: string;
if (edge.attribute.startsWith("aria-")) {
replaced = value
.split(/(\s+)/u)
.map((part) => (part === node.id ? newId : part))
.join("");
} else if (DIRECT_REFERENCE_ATTRIBUTES.has(edge.attribute)) {
replaced = value === `#${node.id}` ? `#${newId}` : value;
} else {
replaced = value.replace(
URL_REFERENCE_PATTERN,
(match, _quote: string, targetId: string) =>
targetId === node.id
? match.replace(`#${node.id}`, `#${newId}`)
: match,
);
}
patches.push({
from: range.valueRange.from,
to: range.valueRange.to,
insert: escapeXmlAttribute(replaced, range.quote),
label: `Update ${edge.attribute} reference`,
});
affected.add(edge.sourceKey);
}
const warnings = [...semantic.nodes.values()].some(
(candidate) =>
candidate.localName === "style" && candidate.text.includes(`#${node.id}`),
)
? [
"Stylesheet selectors reference this ID; automatic CSS rewriting is intentionally not applied yet.",
]
: [];
return {
oldId: node.id,
newId,
patches,
affectedNodeKeys: [...affected],
warnings,
};
}
+1476
View File
File diff suppressed because it is too large Load Diff
+65
View File
@@ -0,0 +1,65 @@
import "@testing-library/jest-dom/vitest";
import { cleanup } from "@testing-library/react";
import { afterEach, vi } from "vitest";
class TestResizeObserver implements ResizeObserver {
observe(): void {}
unobserve(): void {}
disconnect(): void {}
}
Object.defineProperty(globalThis, "ResizeObserver", {
configurable: true,
value: TestResizeObserver,
});
if (!globalThis.CSS) {
Object.defineProperty(globalThis, "CSS", {
configurable: true,
value: {},
});
}
if (!globalThis.CSS.escape) {
globalThis.CSS.escape = (value: string) =>
value.replace(/[^A-Za-z0-9_-]/gu, (character) => `\\${character}`);
}
Object.defineProperty(URL, "createObjectURL", {
configurable: true,
value: vi.fn(() => "blob:svg-tools-test"),
});
Object.defineProperty(URL, "revokeObjectURL", {
configurable: true,
value: vi.fn(),
});
if (
!(
HTMLDialogElement.prototype as HTMLDialogElement & {
showModal?: () => void;
}
).showModal
) {
HTMLDialogElement.prototype.showModal = function showModal() {
this.setAttribute("open", "");
};
}
const nativeDialogClose = HTMLDialogElement.prototype.close;
HTMLDialogElement.prototype.close = function close(returnValue?: string) {
if (nativeDialogClose) {
try {
nativeDialogClose.call(this, returnValue);
return;
} catch {
// jsdom versions without a complete dialog implementation use the fallback.
}
}
this.removeAttribute("open");
};
afterEach(() => {
cleanup();
vi.clearAllMocks();
});
+49
View File
@@ -0,0 +1,49 @@
{
"$schema": "https://git.add-ideas.de/lotobo/toolbox-sdk/raw/branch/main/schemas/toolbox-app.v1.schema.json",
"schemaVersion": 1,
"id": "de.add-ideas.svg-tools",
"name": "SVG Tools",
"version": "0.1.0",
"description": "Inspect, edit, optimize and transform SVG documents locally in the browser.",
"entry": "./",
"icon": "./favicon.svg",
"categories": ["graphics", "design", "developer"],
"tags": [
"svg",
"vector",
"path",
"xml",
"transform",
"optimize",
"accessibility"
],
"integration": {
"contextVersion": 1,
"launchModes": ["navigate", "new-tab"],
"embedding": "unsupported"
},
"requirements": {
"secureContext": false,
"workers": true,
"indexedDb": false,
"crossOriginIsolated": false,
"topLevelContext": false
},
"privacy": {
"processing": "local",
"fileUploads": false,
"telemetry": false
},
"source": {
"repository": "https://git.add-ideas.de/lotobo/svg-tools",
"license": "GPL-3.0-or-later"
},
"actions": [
{
"id": "source",
"label": "Source",
"url": "https://git.add-ideas.de/lotobo/svg-tools"
}
],
"assets": ["./canvas-frame-controller.js"]
}
+4
View File
@@ -0,0 +1,4 @@
import { defineToolboxApp, parseToolboxApp } from "@add-ideas/toolbox-contract";
import manifestSource from "./manifest.source.json";
export const manifest = defineToolboxApp(parseToolboxApp(manifestSource));
+5
View File
@@ -0,0 +1,5 @@
export const APPLICATION_VERSION = "0.1.0";
export const DOMPURIFY_VERSION = "3.4.12";
export const SVGO_VERSION = "4.0.2";
export const SVG_PATH_COMMANDER_VERSION = "2.2.1";
export const CSS_TREE_VERSION = "3.2.1";
+70
View File
@@ -0,0 +1,70 @@
import { describe, expect, it } from "vitest";
import type { AnimationDefinition } from "../../src/animation/animation.types";
import {
animationStyle,
withAnimationPreview,
} from "../../src/animation/preview";
const fade: AnimationDefinition = {
id: "fade",
name: "Fade",
targetNodeKey: "id:shape",
property: "opacity",
kind: "style",
enabled: true,
keyframes: [
{ offset: 0, value: "0" },
{ offset: 1, value: "1" },
],
timing: {
durationMs: 500,
delayMs: 20,
iterations: "infinite",
direction: "alternate",
fillMode: "both",
easing: "ease-in-out",
},
};
describe("isolated animation preview", () => {
it("creates project-side CSS targeting only projection metadata", () => {
const css = animationStyle([fade]);
expect(css).toContain("@keyframes svg-tools-animation-0");
expect(css).toContain('[data-svg-tools-node="id:shape"]');
expect(css).toContain("500ms ease-in-out 20ms infinite alternate both");
});
it("injects preview metadata without mutating the input projection", () => {
const source =
'<svg xmlns="http://www.w3.org/2000/svg"><circle data-svg-tools-node="id:shape"/></svg>';
const result = withAnimationPreview(source, [fade]);
expect(source).not.toContain("data-svg-tools-preview");
expect(result).toContain('data-svg-tools-preview="animation"');
});
it("rejects unsafe properties and CSS rule or URL injection", () => {
expect(() =>
animationStyle([{ ...fade, property: "background-image" }]),
).toThrow(/not application-safe/u);
expect(() =>
animationStyle([
{
...fade,
keyframes: [{ offset: 0, value: "url(https://evil.test)" }],
},
]),
).toThrow(/URLs or external resources/u);
expect(() =>
animationStyle([
{
...fade,
timing: { ...fade.timing, easing: "linear; color:red" },
},
]),
).toThrow(/unsafe animation easing/u);
});
it("omits disabled animations", () => {
expect(animationStyle([{ ...fade, enabled: false }])).toBe("");
});
});
+126
View File
@@ -0,0 +1,126 @@
import { describe, expect, it } from "vitest";
import type { AnimationDefinition } from "../../src/animation/animation.types";
import {
AnimationValidationError,
buildAnimationCss,
validateAnimationDefinitions,
} from "../../src/animation/validation";
const animation: AnimationDefinition = {
id: "pulse",
name: "Pulse",
targetNodeKey: "id:shape",
property: "opacity",
kind: "style",
enabled: true,
keyframes: [
{ offset: 0, value: "0.25", easing: "cubic-bezier(0.2, 0, 0.8, 1)" },
{ offset: 1, value: "calc(1 - 0.1)" },
],
timing: {
durationMs: 500,
delayMs: -20,
iterations: 2.5,
direction: "alternate",
fillMode: "both",
easing: "steps(4, jump-end)",
},
};
function invalid(mutator: (value: AnimationDefinition) => void): () => void {
const value = structuredClone(animation);
mutator(value);
return () => validateAnimationDefinitions([value]);
}
describe("application-owned animation validation", () => {
it("builds source-safe CSS through the same validated serializer", () => {
const css = buildAnimationCss([animation], {
keyframeNamePrefix: "svg-tools",
selectorFor: () => "#shape",
});
expect(css).toContain("@keyframes svg-tools-0");
expect(css).toContain("#shape { animation: svg-tools-0 500ms");
expect(css).toContain(
"animation-timing-function: cubic-bezier(0.2, 0, 0.8, 1)",
);
});
it.each([
[
"NaN offset",
invalid((value) => (value.keyframes[0]!.offset = Number.NaN)),
],
["large offset", invalid((value) => (value.keyframes[0]!.offset = 1.1))],
[
"infinite duration",
invalid((value) => (value.timing.durationMs = Number.POSITIVE_INFINITY)),
],
[
"infinite delay",
invalid((value) => (value.timing.delayMs = Number.NEGATIVE_INFINITY)),
],
[
"infinite iterations",
invalid((value) => (value.timing.iterations = Number.POSITIVE_INFINITY)),
],
["negative iterations", invalid((value) => (value.timing.iterations = -1))],
])("rejects non-finite or out-of-range timing: %s", (_label, action) => {
expect(action).toThrow(AnimationValidationError);
});
it.each([
["property", invalid((value) => (value.property = "opacity; stroke: red"))],
[
"declaration",
invalid((value) => (value.keyframes[0]!.value = "0; stroke: red")),
],
[
"closing style element",
invalid(
(value) =>
(value.keyframes[0]!.value = "0</style><script>alert(1)</script>"),
),
],
[
"external URL",
invalid(
(value) =>
(value.keyframes[0]!.value = "url(https://attacker.invalid/a.svg)"),
),
],
[
"escaped external URL",
invalid(
(value) =>
(value.keyframes[0]!.value = String.raw`u\72 l("https://attacker.invalid/a.svg")`),
),
],
[
"timing easing",
invalid((value) => (value.timing.easing = "linear; stroke: red")),
],
[
"keyframe easing",
invalid(
(value) => (value.keyframes[0]!.easing = "linear } body { color:red"),
),
],
])("rejects CSS injection through %s", (_label, action) => {
expect(action).toThrow(AnimationValidationError);
});
it("validates disabled definitions instead of retaining dormant payloads", () => {
const value = structuredClone(animation);
value.enabled = false;
value.keyframes[0]!.value = "url(https://attacker.invalid/payload)";
expect(() =>
buildAnimationCss([value], {
keyframeNamePrefix: "svg-tools",
selectorFor: () => "#shape",
}),
).toThrow(AnimationValidationError);
});
});
+148
View File
@@ -0,0 +1,148 @@
import { expect, test } from "@playwright/test";
test("loads from a deep path and keeps the last valid projection while source is invalid", async ({
page,
}) => {
const pageErrors: string[] = [];
page.on("pageerror", (error) => pageErrors.push(error.message));
await page.goto("/deep/nested/svg/");
await expect(page).toHaveTitle("SVG Tools");
await expect(
page.getByText("Source synchronized", { exact: true }),
).toBeVisible();
await expect(
page.getByRole("tree", { name: "SVG element structure" }),
).toBeVisible();
await expect(page.getByTitle("Sanitized SVG preview")).toHaveAttribute(
"sandbox",
"allow-scripts",
);
const treeItems = page.getByRole("treeitem");
const initialTreeCount = await treeItems.count();
expect(initialTreeCount).toBeGreaterThan(1);
await page
.frameLocator('iframe[title="Sanitized SVG preview"]')
.locator("#sun")
.click();
await expect(page.locator('[data-node-key="id:sun"]')).toHaveAttribute(
"aria-selected",
"true",
);
const source = page.locator(".cm-content");
await source.click();
await page.keyboard.press("Control+A");
// insertText bypasses CodeMirror's helpful XML close-tag completion so this
// exercise really leaves the canonical source in an incomplete state.
await page.keyboard.insertText('<svg xmlns="http://www.w3.org/2000/svg"><g>');
await expect(page.getByText("Source invalid", { exact: true })).toBeVisible();
await expect(
page.getByText("Showing the last valid canvas revision."),
).toBeVisible();
expect(await treeItems.count()).toBe(initialTreeCount);
await expect(page.getByRole("treeitem").first()).toBeVisible();
await expect(
page.getByRole("button", { name: "Download SVG" }).last(),
).toBeDisabled();
await source.click();
await page.keyboard.press("Control+z");
await expect(
page.getByText("Source synchronized", { exact: true }),
).toBeVisible();
expect(pageErrors).toEqual([]);
});
test("synchronizes tree selection, source patches, undo and redo", async ({
page,
}) => {
await page.goto("/deep/nested/svg/");
await expect(
page.getByText("Source synchronized", { exact: true }),
).toBeVisible();
await page.getByPlaceholder("Filter elements…").fill("path");
const pathRow = page.getByRole("treeitem").filter({ hasText: "path" }).last();
await pathRow.click();
await expect(pathRow).toHaveAttribute("aria-selected", "true");
await page.getByRole("tab", { name: "Element" }).click();
const fill = page.getByLabel("Fill", { exact: true });
await fill.fill("#123456");
await fill.press("Enter");
await expect(page.locator(".cm-content")).toContainText('fill="#123456"');
await page.getByRole("button", { name: "Undo" }).click();
await expect(page.locator(".cm-content")).not.toContainText('fill="#123456"');
await page.getByRole("button", { name: "Redo" }).click();
await expect(page.locator(".cm-content")).toContainText('fill="#123456"');
});
test("connects to a valid same-origin Toolbox catalogue from the nested build", async ({
page,
}) => {
await page.goto("/deep/nested/svg/?toolbox=%2Ftoolbox.catalog.json");
await expect(
page.getByText("Source synchronized", { exact: true }),
).toBeVisible();
await expect(page.locator(".toolbox-shell")).toHaveAttribute(
"data-toolbox-context",
"connected",
);
await page.getByRole("button", { name: "Apps" }).click();
const switcher = page.getByRole("navigation", {
name: "Toolbox applications",
});
await expect(switcher).toBeVisible();
await expect(
switcher.getByRole("link", { name: "SVG Tools" }),
).toHaveAttribute("aria-current", "page");
});
test("opens hostile SVG locally without executing scripts or fetching external URLs", async ({
page,
}) => {
const forbiddenRequests: string[] = [];
let executed = false;
page.on("request", (request) => {
if (request.url().includes("invalid.example"))
forbiddenRequests.push(request.url());
});
await page.exposeFunction("svgToolsExecuted", () => {
executed = true;
});
await page.goto("/deep/nested/svg/");
const hostile = `
<!DOCTYPE svg SYSTEM "https://invalid.example/tracker.dtd">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20">
<script>parent.svgToolsExecuted()</script>
<image href="https://invalid.example/tracker.png" width="20" height="20"/>
<rect width="20" height="20" onclick="parent.svgToolsExecuted()"/>
</svg>`;
await page.locator('input[type="file"]').evaluate((element, contents) => {
const transfer = new DataTransfer();
transfer.items.add(
new File([contents], "hostile.svg", { type: "image/svg+xml" }),
);
(element as HTMLInputElement).files = transfer.files;
element.dispatchEvent(new Event("change", { bubbles: true }));
}, hostile);
await expect(
page.getByText("Source synchronized", { exact: true }),
).toBeVisible();
await expect(
page.getByText(/blocked-script|event-handler|unsafe-url/).first(),
).toBeVisible();
const frame = page.frameLocator('iframe[title="Sanitized SVG preview"]');
await expect(frame.locator("script")).toHaveCount(0);
await expect(frame.locator("image")).not.toHaveAttribute(
"href",
/invalid\.example/u,
);
expect(executed).toBe(false);
expect(forbiddenRequests).toEqual([]);
});
+79
View File
@@ -0,0 +1,79 @@
import { describe, expect, it, vi } from "vitest";
import { CommandHistory, createTransaction } from "../../src/commands/history";
describe("source transaction history", () => {
it("undoes and redoes exact source without normalization", () => {
const history = new CommandHistory();
const transaction = createTransaction({
label: "Set fill",
baseRevision: 1,
sourceBefore: "<svg><path/></svg>\r\n",
sourceAfter: '<svg><path fill="red"/></svg>\r\n',
});
history.commit(transaction);
expect(history.undo(transaction.sourceAfter)?.sourceBefore).toBe(
transaction.sourceBefore,
);
expect(history.redo(transaction.sourceBefore)?.sourceAfter).toBe(
transaction.sourceAfter,
);
});
it("coalesces rapid source-editor transactions with a shared merge key", () => {
vi.spyOn(Date, "now").mockReturnValueOnce(100).mockReturnValueOnce(200);
const history = new CommandHistory();
history.commit(
createTransaction({
label: "Type",
baseRevision: 0,
sourceBefore: "a",
sourceAfter: "ab",
mergeKey: "typing",
}),
);
history.commit(
createTransaction({
label: "Type",
baseRevision: 1,
sourceBefore: "ab",
sourceAfter: "abc",
mergeKey: "typing",
}),
);
expect(history.snapshot.past).toHaveLength(1);
expect(history.snapshot.past[0]).toMatchObject({
sourceBefore: "a",
sourceAfter: "abc",
});
});
it("rejects stale undo and bounds history", () => {
const history = new CommandHistory(2, 10_000);
history.commit(
createTransaction({
label: "one",
baseRevision: 0,
sourceBefore: "a",
sourceAfter: "b",
}),
);
expect(() => history.undo("not-b")).toThrow(/stale/u);
history.commit(
createTransaction({
label: "two",
baseRevision: 1,
sourceBefore: "b",
sourceAfter: "c",
}),
);
history.commit(
createTransaction({
label: "three",
baseRevision: 2,
sourceBefore: "c",
sourceAfter: "d",
}),
);
expect(history.snapshot.past).toHaveLength(2);
});
});
+112
View File
@@ -0,0 +1,112 @@
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import {
defaultSvgLimits,
utf8ByteLength,
type SvgResourceLimits,
} from "../../src/app/limits";
import { parseSvgSource } from "../../src/document/source-parser";
const mutableLimits = defaultSvgLimits as SvgResourceLimits;
let original: SvgResourceLimits;
beforeEach(() => {
original = { ...defaultSvgLimits };
Object.assign(mutableLimits, {
maximumAttributeLength: 30,
maximumPathCommandsPerPath: 2,
maximumPathCommandsTotal: 3,
maximumCssRules: 1,
maximumReferences: 1,
maximumAnimations: 1,
maximumFilterPrimitives: 1,
maximumTextLength: 3,
maximumEmbeddedResourceBytes: 10,
});
});
afterEach(() => {
Object.assign(mutableLimits, original);
});
describe("configured SVG resource limits", () => {
it("counts UTF-8 bytes without allocating an encoded copy", () => {
expect(utf8ByteLength("Aé🙂\ud800")).toBe(10);
});
it("rejects a hard-limit document before XML or semantic processing", () => {
const result = parseSvgSource(
'<svg xmlns="http://www.w3.org/2000/svg"/>',
0,
{ ...original, sourceHardBytes: 8, sourceSoftBytes: 4 },
);
expect(result.valid).toBe(false);
expect(result.semantic).toBeNull();
expect(result.diagnostics).toEqual([
expect.objectContaining({ code: "source-hard-limit" }),
]);
});
it("reports every bounded resource class with source-preserving diagnostics", () => {
const result = parseSvgSource(
`<svg xmlns="http://www.w3.org/2000/svg" data-long="1234567890123456789012345678901">
<style>.a{} .b{}</style>
<defs><filter id="f"><feBlend/><feGaussianBlur/></filter></defs>
<path d="M0 0L1 1L2 2L3 3" fill="url(#a)" stroke="url(#b)"/>
<animate attributeName="opacity"/><set attributeName="fill"/>
<text>long</text>
<image href="data:image/png;base64,abcdef"/>
</svg>`,
1,
);
const codes = result.diagnostics.map((diagnostic) => diagnostic.code);
expect(result.valid).toBe(false);
expect(result.semantic).not.toBeNull();
expect(codes).toEqual(
expect.arrayContaining([
"attribute-length-limit",
"path-command-per-element-limit",
"path-command-limit",
"css-rule-limit",
"reference-limit",
"animation-limit",
"filter-primitive-limit",
"text-length-limit",
"total-text-limit",
"embedded-resource-limit",
]),
);
});
it("does not count exponent markers as path commands", () => {
Object.assign(mutableLimits, {
maximumPathCommandsPerPath: 10,
maximumPathCommandsTotal: 10,
maximumTextLength: original.maximumTextLength,
maximumAttributeLength: original.maximumAttributeLength,
});
const result = parseSvgSource(
'<svg xmlns="http://www.w3.org/2000/svg"><path d="M1e2 1e-2L3e1 4e0"/></svg>',
1,
);
expect(result.semantic?.metrics.pathCommandCount).toBe(2);
});
it("bounds aggregate embedded data even when each resource is below the limit", () => {
const perResource = "data:image/png;base64,aaaaaaaaaaaaaaaaaaaaaaaa";
const result = parseSvgSource(
`<svg xmlns="http://www.w3.org/2000/svg"><image href="${perResource}"/><image href="${perResource}"/></svg>`,
2,
{
...original,
maximumEmbeddedResourceBytes: 60,
},
);
const codes = result.diagnostics.map((diagnostic) => diagnostic.code);
expect(codes).not.toContain("embedded-resource-limit");
expect(codes).toContain("embedded-resource-total-limit");
expect(result.valid).toBe(false);
});
});
+170
View File
@@ -0,0 +1,170 @@
import { describe, expect, it } from "vitest";
import { defaultSvgLimits } from "../../src/app/limits";
import { parseSvgSource } from "../../src/document/source-parser";
import {
applySourcePatches,
patchAttribute,
} from "../../src/document/source-patcher";
const source = `<?xml version="1.0"?>
<!--preserve me-->
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 80">
<vendor:thing xmlns:vendor="urn:vendor" unusual="yes"><circle id="dot" cx="10" cy="20" r="4"/></vendor:thing>
</svg>`;
describe("source-faithful SVG parsing", () => {
it("retains canonical source and maps unknown elements to exact ranges", () => {
const result = parseSvgSource(source, 7);
expect(result.valid).toBe(true);
expect(result.semantic?.source).toBe(source);
expect(result.semantic?.revision).toBe(7);
const vendor = [...result.semantic!.nodes.values()].find(
(node) => node.name === "vendor:thing",
)!;
expect(
source.slice(vendor.sourceRange.full.from, vendor.sourceRange.full.to),
).toContain("vendor:thing");
expect(vendor.attributes.unusual).toBe("yes");
});
it("rejects malformed XML without constructing a semantic model", () => {
const result = parseSvgSource(
'<svg xmlns="http://www.w3.org/2000/svg"><g>',
2,
);
expect(result.valid).toBe(false);
expect(result.semantic).toBeNull();
expect(result.diagnostics.some((item) => item.severity === "error")).toBe(
true,
);
expect(result.diagnostics).toContainEqual(
expect.objectContaining({ code: "xml-unclosed-element" }),
);
});
it("preserves but refuses entity declarations before invoking the XML parser", () => {
const entitySource = `<!DOCTYPE svg [<!ENTITY local "blocked">]><svg xmlns="http://www.w3.org/2000/svg"><text>&local;</text></svg>`;
const result = parseSvgSource(entitySource, 8);
expect(result.valid).toBe(false);
expect(result.semantic).toBeNull();
expect(result.source).toBe(entitySource);
expect(result.diagnostics).toContainEqual(
expect.objectContaining({
code: "entity-declaration-blocked",
severity: "error",
}),
);
});
it("preserves a simple doctype while excluding it from the editing parser", () => {
const doctypeSource = `<!DOCTYPE svg SYSTEM "https://invalid.example/svg.dtd"><svg xmlns="http://www.w3.org/2000/svg"><rect width="2" height="3"/></svg>`;
const result = parseSvgSource(doctypeSource, 9);
expect(result.valid).toBe(true);
expect(result.semantic?.source).toBe(doctypeSource);
expect(result.diagnostics).toContainEqual(
expect.objectContaining({
code: "doctype-preserved",
severity: "warning",
}),
);
});
it("treats duplicate IDs as editable diagnostics, not XML invalidity", () => {
const result = parseSvgSource(
'<svg xmlns="http://www.w3.org/2000/svg"><g id="same"/><path id="same" d="M0 0L1 1"/></svg>',
3,
);
expect(result.valid).toBe(true);
expect(result.semantic).not.toBeNull();
expect(result.diagnostics).toContainEqual(
expect.objectContaining({ code: "duplicate-id", severity: "warning" }),
);
});
it("patches one attribute while preserving comments, quote style and unknown content", () => {
const result = parseSvgSource(source, 1);
const dot = [...result.semantic!.nodes.values()].find(
(node) => node.id === "dot",
)!;
const patch = patchAttribute(source, dot, "cx", "42", result.preferences)!;
const next = applySourcePatches(source, [patch]);
expect(next).toBe(source.replace('cx="10"', 'cx="42"'));
expect(next).toContain("<!--preserve me-->");
expect(next).toContain("vendor:thing");
});
it("rejects overlapping or stale patches", () => {
expect(() =>
applySourcePatches("abcdef", [
{ from: 1, to: 4, insert: "x", label: "first" },
{ from: 3, to: 5, insert: "y", label: "second" },
]),
).toThrow(/overlapping/u);
});
it("enforces each configurable structural resource limit", () => {
const constrained = {
...defaultSvgLimits,
maximumAttributeLength: 12,
maximumPathCommandsPerPath: 2,
maximumPathCommandsTotal: 2,
maximumCssRules: 1,
maximumReferences: 0,
maximumAnimations: 0,
maximumFilterPrimitives: 0,
maximumTextLength: 4,
maximumEmbeddedResourceBytes: 8,
};
const result = parseSvgSource(
`<svg xmlns="http://www.w3.org/2000/svg">
<style>.a{fill:red}.b{stroke:blue}</style>
<defs><filter id="fx"><feGaussianBlur stdDeviation="1"/></filter></defs>
<path d="M0 0L1 1L2 2" filter="url(#fx)"/>
<image href="data:image/png;base64,AAAAAAAAAAAA"/>
<animate attributeName="opacity" values="0;1"/>
<text>longer text</text>
</svg>`,
9,
constrained,
);
const codes = result.diagnostics.map((item) => item.code);
expect(result.valid).toBe(false);
expect(codes).toEqual(
expect.arrayContaining([
"attribute-length-limit",
"embedded-resource-limit",
"path-command-per-element-limit",
"path-command-limit",
"css-rule-limit",
"reference-limit",
"animation-limit",
"filter-primitive-limit",
"text-length-limit",
"total-text-limit",
]),
);
});
it("counts SVG path commands without mistaking exponent notation for commands", () => {
const result = parseSvgSource(
'<svg xmlns="http://www.w3.org/2000/svg"><path d="M1e2 0L2e2 1"/></svg>',
4,
);
expect(result.semantic?.metrics.pathCommandCount).toBe(2);
});
it("counts implicit path groups and packed arc flags as resolved commands", () => {
const result = parseSvgSource(
'<svg xmlns="http://www.w3.org/2000/svg"><path d="M0 0 1 1 2 2 A5 6 0 0110 20"/></svg>',
5,
{ ...defaultSvgLimits, maximumPathCommandsPerPath: 3 },
);
expect(result.semantic?.metrics.pathCommandCount).toBe(4);
expect(result.diagnostics).toContainEqual(
expect.objectContaining({ code: "path-command-per-element-limit" }),
);
});
});
+92
View File
@@ -0,0 +1,92 @@
import fc from "fast-check";
import { describe, expect, it } from "vitest";
import {
IDENTITY,
applyToPoint,
composeTransformList,
decomposeCanonical,
diagnoseTransform,
invert,
matrixNearlyEqual,
multiply,
parseTransformList,
recomposeCanonical,
rotation,
scaling,
translation,
} from "../../src/domain/affine";
describe("SVG affine transforms", () => {
it("parses and composes source order according to SVG matrix semantics", () => {
const matrix = composeTransformList(
parseTransformList("translate(10 20) scale(2)"),
);
expect(matrix).toEqual({ a: 2, b: 0, c: 0, d: 2, e: 10, f: 20 });
expect(applyToPoint(matrix, { x: 1, y: 1 })).toEqual({ x: 12, y: 22 });
});
it("inverts and canonically decomposes a reflected shear", () => {
const matrix = composeTransformList(
parseTransformList("translate(8 -2) rotate(23) scale(3 -2) skewX(14)"),
);
expect(matrixNearlyEqual(multiply(matrix, invert(matrix)!), IDENTITY)).toBe(
true,
);
const decomposition = decomposeCanonical(matrix);
expect(decomposition.reflected).toBe(true);
expect(matrixNearlyEqual(recomposeCanonical(decomposition), matrix)).toBe(
true,
);
expect(decomposition.residual).toBeLessThan(1e-10);
});
it("rejects malformed arities and diagnoses singular matrices", () => {
expect(() => parseTransformList("rotate(10 20)")).toThrow(
/expects 1 or 3/u,
);
expect(diagnoseTransform("scale(1 0)").diagnostics).toContainEqual(
expect.objectContaining({
code: "singular-transform",
severity: "error",
}),
);
});
it("round-trips bounded points through generated nonsingular transform chains", () => {
const coordinate = fc
.integer({ min: -10_000, max: 10_000 })
.map((value) => value / 10);
const nonzeroScale = fc
.integer({ min: -100, max: 100 })
.filter((value) => value !== 0)
.map((value) => value / 10);
fc.assert(
fc.property(
coordinate,
coordinate,
fc.integer({ min: -720, max: 720 }),
nonzeroScale,
nonzeroScale,
coordinate,
coordinate,
(tx, ty, angle, sx, sy, x, y) => {
const matrix = multiply(
translation(tx, ty),
multiply(rotation(angle), scaling(sx, sy)),
);
const inverse = invert(matrix);
if (!inverse) return false;
const recovered = applyToPoint(
inverse,
applyToPoint(matrix, { x, y }),
);
return (
Math.abs(recovered.x - x) < 1e-8 && Math.abs(recovered.y - y) < 1e-8
);
},
),
{ numRuns: 250 },
);
});
});
+114
View File
@@ -0,0 +1,114 @@
import { describe, expect, it } from "vitest";
import { applySourcePatches } from "../../src/document/source-patcher";
import { parseSvgSource } from "../../src/document/source-parser";
import {
composeTransformList,
parseTransformList,
} from "../../src/domain/affine";
import { bakeElementTransform } from "../../src/domain/bake-transform";
function bake(source: string, id: string) {
const parsed = parseSvgSource(source, 1);
expect(parsed.semantic).not.toBeNull();
const semantic = parsed.semantic!;
const node = [...semantic.nodes.values()].find(
(candidate) => candidate.id === id,
)!;
const matrix = composeTransformList(
parseTransformList(node.attributes.transform ?? ""),
);
const result = bakeElementTransform(
source,
node,
matrix,
semantic.preferences,
);
return { result, source: applySourcePatches(source, result.patches) };
}
describe("element transform baking", () => {
it("retains lines, polygons, and safe rectangles as native elements", () => {
const line = bake(
'<svg xmlns="http://www.w3.org/2000/svg"><line id="shape" x1="1" y1="2" x2="3" y2="4" transform="translate(10 20)"/></svg>',
"shape",
);
expect(line.source).toContain(
'<line id="shape" x1="11" y1="22" x2="13" y2="24"',
);
expect(line.source).not.toContain("transform=");
expect(line.result.warnings).toEqual([]);
const polygon = bake(
'<svg xmlns="http://www.w3.org/2000/svg"><polygon id="shape" points="0,0 2,0 2,2" transform="scale(2 3)"/></svg>',
"shape",
);
expect(polygon.source).toContain('points="0,0 4,0 4,6"');
const rectangle = bake(
'<svg xmlns="http://www.w3.org/2000/svg"><rect id="shape" x="2" y="3" width="4" height="5" rx="1" transform="translate(1 2) scale(2 3)"/></svg>',
"shape",
);
expect(rectangle.result.convertedToPath).toBe(false);
expect(rectangle.source).toContain(
'<rect id="shape" x="5" y="11" width="8" height="15" rx="2"',
);
expect(rectangle.source).toContain('ry="3"');
});
it("converts general rectangles to paths and preserves ordinary attributes", () => {
const baked = bake(
'<svg xmlns="http://www.w3.org/2000/svg"><rect id="shape" class="important" x="0" y="0" width="10" height="5" rx="2" transform="rotate(30)"/></svg>',
"shape",
);
expect(baked.result).toMatchObject({
outputElement: "path",
convertedToPath: true,
});
expect(baked.source).toContain('<path id="shape" class="important"');
expect(baked.source).toContain(' d="M ');
expect(baked.source).not.toMatch(/\s(?:x|y|width|height|rx|transform)=/u);
});
it("retains uniform circles and converts axis-scaled circles to ellipses", () => {
const circle = bake(
'<svg xmlns="http://www.w3.org/2000/svg"><circle id="shape" cx="4" cy="5" r="3" transform="rotate(40) scale(2)"/></svg>',
"shape",
);
expect(circle.result.outputElement).toBe("circle");
expect(circle.source).toContain('r="6"');
const ellipse = bake(
'<svg xmlns="http://www.w3.org/2000/svg"><circle id="shape" cx="4" cy="5" r="3" transform="scale(2 4)"/></svg>',
"shape",
);
expect(ellipse.result.outputElement).toBe("ellipse");
expect(ellipse.source).toContain('<ellipse id="shape"');
expect(ellipse.source).toContain('rx="6"');
expect(ellipse.source).toContain('ry="12"');
expect(ellipse.source).not.toContain(' r="');
});
it("bakes paths with high precision and reports stroke consequences", () => {
const baked = bake(
'<svg xmlns="http://www.w3.org/2000/svg"><path id="shape" d="M0 0A10 4 20 0 1 20 0" stroke="red" transform="matrix(-2 .5 .25 3 4 8)"/></svg>',
"shape",
);
expect(baked.source).toContain('<path id="shape" d="M 4 8 A ');
expect(baked.source).not.toContain("transform=");
expect(baked.result.warnings).toContainEqual(
expect.stringContaining("stroke-width"),
);
});
it("refuses unsupported text baking instead of partially applying", () => {
const source =
'<svg xmlns="http://www.w3.org/2000/svg"><text id="shape" transform="rotate(2)">Text</text></svg>';
expect(() => bake(source, "shape")).toThrow(/not deterministic/u);
});
it("refuses non-SVG numeric spellings instead of coercing geometry", () => {
const source =
'<svg xmlns="http://www.w3.org/2000/svg"><rect id="shape" width="0x10" height="4" transform="translate(1)"/></svg>';
expect(() => bake(source, "shape")).toThrow(/finite width/u);
});
});
+168
View File
@@ -0,0 +1,168 @@
import { describe, expect, it } from "vitest";
import {
arcEndpointToCenter,
describePathCommand,
movePathHandle,
parsePathData,
pathHandles,
pointOnArc,
reversePath,
serializePathData,
splitSegment,
transformPath,
} from "../../src/domain/path";
describe("application-owned SVG path model", () => {
it("normalizes every command family, relatives, repeats and shorthand controls", () => {
const model = parsePathData(
"m 10 10 5 5 h 10 v 10 c 1 2 3 4 5 6 s 7 8 9 10 q 2 3 4 5 t 6 7 a 8 9 30 0 1 10 11 z",
);
expect(model.segments.map((segment) => segment.kind)).toEqual([
"M",
"L",
"L",
"L",
"C",
"C",
"Q",
"Q",
"A",
"Z",
]);
const firstCubic = model.segments[4];
const smoothCubic = model.segments[5];
expect(firstCubic?.kind).toBe("C");
expect(smoothCubic?.kind).toBe("C");
if (firstCubic?.kind === "C" && smoothCubic?.kind === "C") {
expect(smoothCubic.control1).toEqual({
x: 2 * smoothCubic.from.x - firstCubic.control2.x,
y: 2 * smoothCubic.from.y - firstCubic.control2.y,
});
expect(smoothCubic).toMatchObject({
derivedControl1: true,
sourceForm: { command: "s", relative: true },
});
const derived = pathHandles(model).find(
(handle) => handle.segmentIndex === 5 && handle.role === "control-1",
)!;
expect(derived.derived).toBe(true);
const explicit = movePathHandle(model, derived, {
x: derived.point.x + 1,
y: derived.point.y,
});
expect(explicit.segments[5]).toMatchObject({ derivedControl1: false });
}
expect(model.segments[7]).toMatchObject({
derivedControl: true,
sourceForm: { command: "t" },
});
expect(
model.segments.every(
({ sourceForm }) =>
sourceForm !== undefined &&
sourceForm.sourceRange.to > sourceForm.sourceRange.from,
),
).toBe(true);
});
it("supports compact numbers, exponent notation and packed arc flags", () => {
const model = parsePathData("M.5.6L10-5e-1A5 6 0 0110 20");
expect(model.segments).toHaveLength(3);
expect(model.segments[0]).toMatchObject({ to: { x: 0.5, y: 0.6 } });
expect(model.segments[1]).toMatchObject({ to: { x: 10, y: -0.5 } });
expect(model.segments[2]).toMatchObject({
kind: "A",
largeArc: false,
sweep: true,
to: { x: 10, y: 20 },
});
});
it("round-trips normalized geometry and rejects malformed data", () => {
const model = parsePathData(
"M0 0 C1 2 3 4 5 6 Q7 8 9 10 A4 3 20 1 0 12 13 Z",
);
expect(serializePathData(parsePathData(serializePathData(model)))).toBe(
serializePathData(model),
);
expect(() => parsePathData("L 1 2")).toThrow(/begin with a moveto/u);
expect(() => parsePathData("M 0 0 A 5 5 0 2 0 10 10")).toThrow(
/flag must be 0 or 1/u,
);
expect(() => parsePathData("M 0,")).toThrow(/comma/iu);
expect(() => parsePathData("M0 0 1 1 2 2", 2)).toThrow(/segment limit/u);
});
it("splits cubic geometry with de Casteljau and reverses arc sweep", () => {
const cubic = parsePathData("M0 0 C10 0 10 10 20 10");
const split = splitSegment(cubic, 1, 0.5);
expect(split.segments).toHaveLength(3);
expect(split.segments[1]).toMatchObject({ kind: "C", to: { x: 10, y: 5 } });
expect(split.segments[2]).toMatchObject({
kind: "C",
from: { x: 10, y: 5 },
});
const arc = parsePathData("M0 0 A10 5 20 0 1 20 0");
const reversed = reversePath(arc);
expect(reversed.segments[1]).toMatchObject({ kind: "A", sweep: false });
expect(serializePathData(reversePath(reversed))).toBe(
serializePathData(arc),
);
});
it("derives arc center/radius controls and moves path handles", () => {
const model = parsePathData("M0 0 A4 3 30 0 1 12 0");
const segment = model.segments[1];
expect(segment?.kind).toBe("A");
if (segment?.kind !== "A") return;
const center = arcEndpointToCenter(segment)!;
expect(pointOnArc(center, center.startAngle).x).toBeCloseTo(segment.from.x);
const anchor = pathHandles(model).find(
(handle) => handle.role === "anchor" && handle.segmentIndex === 1,
)!;
expect(
movePathHandle(model, anchor, { x: 14, y: 2 }).segments[1],
).toMatchObject({ to: { x: 14, y: 2 } });
});
it("bakes nonsingular affine matrices and flips arc sweep under reflection", () => {
const model = parsePathData("M0 0 A10 5 20 0 1 20 0");
const transformed = transformPath(model, {
a: -2,
b: 0.5,
c: 0.25,
d: 3,
e: 4,
f: 8,
});
expect(transformed.segments[1]).toMatchObject({ kind: "A", sweep: false });
expect(() =>
transformPath(model, { a: 1, b: 0, c: 0, d: 0, e: 0, f: 0 }),
).toThrow(/singular/u);
});
it("describes source form, endpoints, controls, derived shorthand and arc flags", () => {
const source = "m 1 2 3 4 s 5 6 7 8 t 9 10 a 11 12 30 1 0 13 14";
const segments = parsePathData(source).segments;
expect(describePathCommand(segments[1]!, source)).toMatchObject({
sourceCommand: "m",
normalizedCommand: "L",
sourceFragment: "3 4",
form: "relative · implicit repeat",
endpoint: "4, 6",
});
expect(describePathCommand(segments[2]!, source).details).toEqual([
{ label: "C1", value: "4, 6", derived: true },
{ label: "C2", value: "9, 12", derived: false },
]);
expect(describePathCommand(segments[3]!, source).details[0]).toMatchObject({
label: "C",
derived: true,
});
expect(describePathCommand(segments[4]!, source).details).toEqual([
{ label: "Radii", value: "11 × 12", derived: false },
{ label: "Rotation", value: "30°", derived: false },
{ label: "Flags", value: "large 1 · sweep 0", derived: false },
]);
});
});
+45
View File
@@ -0,0 +1,45 @@
import { describe, expect, it } from "vitest";
import { parseSvgSource } from "../../src/document/source-parser";
import { applyToPoint } from "../../src/domain/affine";
import { resolveTransformChain } from "../../src/domain/transform-chain";
function semantic(source: string) {
const result = parseSvgSource(source, 1);
expect(result.semantic).not.toBeNull();
return result.semantic!;
}
describe("ancestor transform chains", () => {
it("composes root, ancestor and local transforms in SVG order", () => {
const document = semantic(
'<svg xmlns="http://www.w3.org/2000/svg" transform="translate(2 3)"><g id="parent" transform="rotate(90)"><path id="child" transform="scale(2 3)" d="M0 0L1 1"/></g></svg>',
);
const child = [...document.nodes.values()].find(
(node) => node.id === "child",
)!;
const chain = resolveTransformChain(document, child.key);
expect(chain.entries).toHaveLength(3);
expect(applyToPoint(chain.matrix, { x: 1, y: 1 })).toMatchObject({
x: -1,
y: 5,
});
const restored = applyToPoint(
chain.inverse!,
applyToPoint(chain.matrix, { x: 7, y: -4 }),
);
expect(restored.x).toBeCloseTo(7);
expect(restored.y).toBeCloseTo(-4);
});
it("refuses deterministic coordinate editing through singular or CSS transforms", () => {
const document = semantic(
'<svg xmlns="http://www.w3.org/2000/svg"><g transform="scale(1 0)"><path id="p" style="transform: rotate(2deg)" d="M0 0L1 1"/></g></svg>',
);
const path = [...document.nodes.values()].find((node) => node.id === "p")!;
const chain = resolveTransformChain(document, path.key);
expect(chain.inverse).toBeNull();
expect(chain.diagnostics).toContainEqual(
expect.stringContaining("CSS transforms"),
);
});
});
+65
View File
@@ -0,0 +1,65 @@
import { describe, expect, it } from "vitest";
import { parseSvgSource } from "../../src/document/source-parser";
import {
createSelectedSvgSource,
createSymbolSpriteSource,
} from "../../src/export/derived-svg-export";
function semantic(source: string) {
const parsed = parseSvgSource(source, 1);
if (!parsed.semantic) throw new Error("Fixture did not parse");
return parsed.semantic;
}
describe("derived SVG exports", () => {
it("retains selected ancestry and definitions while using a safe projection", () => {
const document = semantic(`
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20">
<defs><linearGradient id="paint"><stop offset="1"/></linearGradient></defs>
<g transform="translate(2 3)"><path id="keep" d="M0 0L1 1" fill="url(#paint)"/></g>
<circle id="remove" r="4"/>
<script>alert(1)</script>
</svg>`);
const selected = document.order.find(
(key) => document.nodes.get(key)?.id === "keep",
)!;
const result = createSelectedSvgSource(document, [selected]);
expect(result.source).toContain("translate(2 3)");
expect(result.source).toContain("linearGradient");
expect(result.source).toContain('id="keep"');
expect(result.source).not.toContain('id="remove"');
expect(result.source).not.toContain("script");
expect(result.source).not.toContain("data-svg-tools-node");
});
it("exports existing symbols and shared definitions as a sprite", () => {
const document = semantic(`
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16">
<defs>
<linearGradient id="paint"><stop offset="1"/></linearGradient>
<symbol id="mark" viewBox="0 0 4 4"><path d="M0 0L4 4"/></symbol>
</defs>
</svg>`);
const result = createSymbolSpriteSource(document, []);
expect(result.source).toContain('<symbol id="mark"');
expect(result.source).toContain("linearGradient");
expect(result.source.match(/<symbol/gu)).toHaveLength(1);
expect(result.source).not.toContain("data-svg-tools-node");
});
it("wraps a selection in a symbol when the source has no symbols", () => {
const document = semantic(`
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 10 10">
<path id="arrow" d="M0 5L10 5"/>
</svg>`);
const selected = document.order.find(
(key) => document.nodes.get(key)?.id === "arrow",
)!;
const result = createSymbolSpriteSource(document, [selected]);
expect(result.source).toContain('id="symbol-arrow"');
expect(result.source).toContain('id="arrow"');
});
});
+120
View File
@@ -0,0 +1,120 @@
import { gzipSync, gunzipSync, strFromU8 } from "fflate";
import { describe, expect, it } from "vitest";
import { exportFileName, sanitizeFileName } from "../../src/export/file-name";
import { resolveRasterSize } from "../../src/export/raster-export";
import {
createSvgExport,
gunzipWithLimit,
readSvgFile,
} from "../../src/export/svg-export";
const source =
'<svg xmlns="http://www.w3.org/2000/svg" width="200" height="100" viewBox="0 0 200 100">\r\n <text>ä😀</text>\r\n</svg>\r\n';
describe("SVG, SVGZ and filename export", () => {
it("exports exact UTF-8 SVG bytes without formatting or editor metadata", () => {
const artifact = createSvgExport(source, "svg", "../CON: diagram.svgz");
expect(new TextDecoder().decode(artifact.bytes)).toBe(source);
expect(artifact.fileName).toBe("CON- diagram.svg");
expect(artifact.blob.type).toBe("image/svg+xml");
});
it("creates deterministic SVGZ and decompresses to the exact source", () => {
const first = createSvgExport(source, "svgz", "drawing.svg");
const second = createSvgExport(source, "svgz", "drawing.svg");
expect(first.bytes).toEqual(second.bytes);
expect(strFromU8(gunzipSync(first.bytes))).toBe(source);
expect(first.fileName).toBe("drawing.svgz");
expect(first.blob.type).toBe("application/gzip");
});
it("reads plain SVG and magic-byte SVGZ locally", async () => {
const plain = await readSvgFile(
new File([source], "drawing.svg", { type: "image/svg+xml" }),
);
expect(plain.source).toBe(source);
const compressed = createSvgExport(source, "svgz").bytes;
const zipped = await readSvgFile(
new File([compressed.buffer as ArrayBuffer], "mystery.bin"),
);
expect(zipped.source).toBe(source);
});
it("stops SVGZ expansion at the configured output boundary", () => {
const compressed = gzipSync(new TextEncoder().encode("x".repeat(512)));
expect(() => gunzipWithLimit(compressed, 64)).toThrow(/processing limit/u);
});
it("rejects malformed XML and non-SVG roots", () => {
expect(() => createSvgExport("<svg><g", "svg")).toThrow(/well-formed XML/u);
expect(() =>
createSvgExport('<root xmlns="http://www.w3.org/2000/svg"/>', "svg"),
).toThrow(/root must be an SVG/u);
});
it("normalizes dangerous names and preserves the requested extension", () => {
expect(sanitizeFileName("../../\u202eaux:<x>?*.svg")).not.toMatch(
/[<>:"/\\|?*\u202e]/u,
);
expect(exportFileName("image.jpeg", "webp")).toBe("image.webp");
expect(exportFileName("diagram.svgtools.json", "project")).toBe(
"diagram.svgtools.json",
);
expect(
new TextEncoder().encode(exportFileName("😀".repeat(100), "project"))
.byteLength,
).toBeLessThanOrEqual(180);
});
});
describe("raster safety and sizing", () => {
it("preserves aspect ratio and applies scale after resolving size", () => {
expect(resolveRasterSize(source, { height: 300 })).toEqual({
width: 600,
height: 300,
aspectRatio: 2,
});
expect(resolveRasterSize(source, { scale: 2 })).toEqual({
width: 400,
height: 200,
aspectRatio: 2,
});
});
it("rejects active content and external resources at the raster boundary", () => {
expect(() =>
resolveRasterSize(
'<svg xmlns="http://www.w3.org/2000/svg"><script/></svg>',
),
).toThrow(/blocked/u);
expect(() =>
resolveRasterSize(
'<svg xmlns="http://www.w3.org/2000/svg" onload="go()"/>',
),
).toThrow(/blocked/u);
expect(() =>
resolveRasterSize(
'<svg xmlns="http://www.w3.org/2000/svg"><image href="https://example.test/x.png"/></svg>',
),
).toThrow(/external resource/u);
expect(() =>
resolveRasterSize(
'<svg xmlns="http://www.w3.org/2000/svg"><rect fill="url(https://example.test/p)"/></svg>',
),
).toThrow(/external CSS/u);
});
it("allows local references and bounded image data in the sanitized projection", () => {
expect(() =>
resolveRasterSize(
'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1 1"><defs><linearGradient id="p"/></defs><rect fill="url(#p)"/><image href="data:image/png;base64,iVBORw0KGgo="/></svg>',
),
).not.toThrow();
});
it("enforces canvas safety limits", () => {
expect(() =>
resolveRasterSize(source, { width: 16_000, height: 16_000 }),
).toThrow(/canvas safety limit/u);
});
});
+34
View File
@@ -0,0 +1,34 @@
import { describe, expect, it } from "vitest";
import { lineDiff } from "../../src/format/diff";
import { formatSvgSource } from "../../src/format/formatter";
describe("explicit formatting and source diff", () => {
it("formats only when explicitly requested and retains CRLF style", () => {
const source =
'<svg xmlns="http://www.w3.org/2000/svg" z="2" a="1">\r\n<g><title>A &amp; B</title></g>\r\n</svg>';
const formatted = formatSvgSource(source);
expect(formatted).toContain("\r\n <g>\r\n <title>A &amp; B</title>");
expect(formatted).toContain(' a="1" z="2"');
expect(formatted.endsWith("\r\n")).toBe(true);
});
it("refuses to format malformed XML", () => {
expect(() => formatSvgSource("<svg><g")).toThrow(/well-formed/u);
});
it("produces stable line additions, removals and context", () => {
expect(lineDiff("a\nb\nc", "a\nx\nc")).toEqual([
{ kind: "same", text: "a", oldLine: 1, newLine: 1 },
{ kind: "add", text: "x", newLine: 2 },
{ kind: "remove", text: "b", oldLine: 2 },
{ kind: "same", text: "c", oldLine: 3, newLine: 3 },
]);
});
it("falls back to bounded summaries for large comparisons", () => {
expect(lineDiff("a\nb\nc", "d\ne\nf", 2)).toEqual([
{ kind: "remove", text: "3 lines (5 characters)", oldLine: 1 },
{ kind: "add", text: "3 lines (5 characters)", newLine: 1 },
]);
});
});
+129
View File
@@ -0,0 +1,129 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import {
OptimizationCancelledError,
OptimizerClient,
} from "../../src/optimization/optimizer-client";
import type {
OptimizationRequest,
OptimizationResult,
} from "../../src/optimization/optimization.types";
class TestWorker extends EventTarget {
static instances: TestWorker[] = [];
readonly messages: unknown[] = [];
terminateCount = 0;
constructor() {
super();
TestWorker.instances.push(this);
}
postMessage(message: unknown): void {
this.messages.push(message);
}
terminate(): void {
this.terminateCount += 1;
}
respond(response: OptimizationResult): void {
this.dispatchEvent(new MessageEvent("message", { data: response }));
}
}
function resultFor(worker: TestWorker, source = "<svg/>"): OptimizationResult {
const request = worker.messages[0] as OptimizationRequest;
return {
type: "result",
jobId: request.jobId,
source,
profile: request.profile,
optionalPlugins: request.optionalPlugins,
inputBytes: source.length,
outputBytes: source.length,
elapsedMs: 1,
};
}
describe("OptimizerClient cancellation", () => {
beforeEach(() => {
TestWorker.instances = [];
vi.useFakeTimers();
vi.stubGlobal("Worker", TestWorker);
});
afterEach(() => {
vi.useRealTimers();
vi.unstubAllGlobals();
});
it("rejects a cancelled job immediately and cleans up exactly once", async () => {
const client = new OptimizerClient();
const pending = client.optimize("<svg/>", "conservative");
const rejected = expect(pending).rejects.toBeInstanceOf(
OptimizationCancelledError,
);
const worker = TestWorker.instances[0]!;
expect(vi.getTimerCount()).toBe(1);
client.cancel();
client.cancel();
await rejected;
expect(worker.terminateCount).toBe(1);
expect(vi.getTimerCount()).toBe(0);
worker.respond(resultFor(worker, '<svg id="late"/>'));
expect(worker.terminateCount).toBe(1);
});
it("promptly rejects a superseded job while the replacement can finish", async () => {
const client = new OptimizerClient();
const first = client.optimize('<svg id="first"/>', "conservative");
const firstRejected = expect(first).rejects.toBeInstanceOf(
OptimizationCancelledError,
);
const firstWorker = TestWorker.instances[0]!;
const second = client.optimize('<svg id="second"/>', "standard");
const secondWorker = TestWorker.instances[1]!;
await firstRejected;
expect(firstWorker.terminateCount).toBe(1);
expect(secondWorker.terminateCount).toBe(0);
expect(vi.getTimerCount()).toBe(1);
const expected = resultFor(secondWorker, '<svg id="optimized"/>');
secondWorker.respond(expected);
await expect(second).resolves.toEqual(expected);
expect(secondWorker.terminateCount).toBe(1);
expect(vi.getTimerCount()).toBe(0);
client.cancel();
expect(secondWorker.terminateCount).toBe(1);
});
it("uses the same race-safe cleanup for AbortSignal cancellation", async () => {
const controller = new AbortController();
const client = new OptimizerClient();
const pending = client.optimize(
"<svg/>",
"aggressive",
[],
controller.signal,
);
const rejected = expect(pending).rejects.toBeInstanceOf(
OptimizationCancelledError,
);
const worker = TestWorker.instances[0]!;
controller.abort();
await rejected;
expect(worker.terminateCount).toBe(1);
expect(vi.getTimerCount()).toBe(0);
client.cancel();
expect(worker.terminateCount).toBe(1);
});
});
+60
View File
@@ -0,0 +1,60 @@
import { describe, expect, it } from "vitest";
import {
configForProfile,
optimizationProfiles,
} from "../../src/optimization/profiles";
describe("explicit optimization profiles", () => {
it("exposes three clearly ordered risk levels", () => {
expect(optimizationProfiles.map((profile) => profile.id)).toEqual([
"conservative",
"standard",
"aggressive",
]);
expect(
optimizationProfiles.every(
(profile) => profile.description && profile.risk,
),
).toBe(true);
});
it("retains IDs and viewBox in every profile", () => {
for (const name of ["conservative", "standard", "aggressive"] as const) {
const config = configForProfile(name);
const preset = config.plugins?.find(
(plugin) =>
typeof plugin === "object" && plugin.name === "preset-default",
);
expect(preset).toMatchObject({
params: { overrides: { cleanupIds: false, removeViewBox: false } },
});
}
});
it("only enables multipass and structural rewrites in aggressive mode", () => {
expect(configForProfile("conservative").multipass).toBe(false);
expect(configForProfile("standard").multipass).toBe(false);
const aggressive = configForProfile("aggressive");
expect(aggressive.multipass).toBe(true);
expect(aggressive.plugins).toEqual(
expect.arrayContaining(["convertShapeToPath", "collapseGroups"]),
);
});
it("adds only explicitly allow-listed optional plugins", () => {
const config = configForProfile("conservative", [
"removeDimensions",
"reusePaths",
"removeDimensions",
]);
expect(config.plugins).toEqual(
expect.arrayContaining(["removeDimensions", "reusePaths"]),
);
expect(
config.plugins?.filter((plugin) => plugin === "removeDimensions"),
).toHaveLength(1);
expect(() =>
configForProfile("standard", ["unknown" as "reusePaths"]),
).toThrow("Unsupported optional SVGO plugin");
});
});
+165
View File
@@ -0,0 +1,165 @@
import { describe, expect, it } from "vitest";
import {
PROJECT_FORMAT,
PROJECT_SCHEMA_VERSION,
ProjectFormatError,
createProject,
parseProject,
readProject,
serializeProject,
validateProject,
} from "../../src/project/project-format";
function project(
source = '<svg xmlns="http://www.w3.org/2000/svg">\r\n <path d="M0 0"/>\r\n</svg>',
) {
return createProject({
appVersion: "0.1.0",
document: { source },
ui: {
selectedNodeKey: "id:path",
expandedNodeKeys: ["id:root"],
activePanel: "path",
zoom: 1.25,
pan: { x: 12, y: -4 },
showGrid: true,
sourceSelection: { anchor: 1, head: 4 },
},
animations: [
{
id: "fade",
name: "Fade",
targetNodeKey: "id:path",
property: "opacity",
kind: "style",
enabled: true,
keyframes: [
{ offset: 0, value: "0" },
{ offset: 1, value: "1", easing: "linear" },
],
timing: {
durationMs: 750,
delayMs: 0,
iterations: 1,
direction: "normal",
fillMode: "both",
easing: "ease-in-out",
},
},
],
metadata: {
title: "Exact source",
originalFileName: "drawing.svg",
createdAt: "2026-07-31T00:00:00.000Z",
updatedAt: "2026-07-31T00:00:00.000Z",
},
});
}
describe("SVG Tools project format", () => {
it("round-trips canonical source byte-for-byte, including temporarily invalid XML", () => {
const source =
'<svg xmlns="http://www.w3.org/2000/svg">\r\n <g data-x="&amp;">\r\n';
const original = project(source);
const restored = parseProject(serializeProject(original));
expect(restored.document.source).toBe(source);
expect(restored).toEqual(original);
});
it("serializes deterministically with the versioned identity", () => {
const value = project();
const first = serializeProject(value);
expect(serializeProject(value)).toBe(first);
expect(first.endsWith("\n")).toBe(true);
expect(JSON.parse(first)).toMatchObject({
format: PROJECT_FORMAT,
schemaVersion: PROJECT_SCHEMA_VERSION,
appVersion: "0.1.0",
});
});
it("accepts a UTF-8 BOM and rejects incompatible schemas", () => {
const serialized = serializeProject(project());
expect(parseProject(`\ufeff${serialized}`).document.source).toContain(
"<svg",
);
const unsupported = JSON.parse(serialized) as Record<string, unknown>;
unsupported.schemaVersion = 999;
expect(() => parseProject(JSON.stringify(unsupported))).toThrowError(
expect.objectContaining({ code: "UNSUPPORTED_SCHEMA_VERSION" }),
);
});
it("rejects invalid projects instead of silently repairing them", () => {
const invalid = JSON.parse(serializeProject(project())) as {
animations: Array<{ keyframes: Array<{ offset: number }> }>;
ui: { expandedNodeKeys: string[] };
};
invalid.animations[0]!.keyframes[0]!.offset = 1;
invalid.animations[0]!.keyframes[1]!.offset = 0;
expect(() => parseProject(JSON.stringify(invalid))).toThrowError(
ProjectFormatError,
);
invalid.animations[0]!.keyframes[0]!.offset = 0;
invalid.animations[0]!.keyframes[1]!.offset = 1;
invalid.ui.expandedNodeKeys = ["same", "same"];
expect(() => parseProject(JSON.stringify(invalid))).toThrow(
/Duplicate values/u,
);
expect(() => parseProject("not json")).toThrowError(
expect.objectContaining({ code: "INVALID_JSON" }),
);
});
it("applies the animation CSS policy to imported project definitions", () => {
type ImportedProject = {
animations: Array<{
property: string;
keyframes: Array<{ offset: number; value: string; easing?: string }>;
timing: {
durationMs: number;
delayMs: number;
iterations: number | "infinite";
easing: string;
};
}>;
};
const original = JSON.parse(serializeProject(project())) as ImportedProject;
const expectInvalid = (mutate: (input: ImportedProject) => void) => {
const input = structuredClone(original);
mutate(input);
expect(() => validateProject(input)).toThrowError(
expect.objectContaining({ code: "INVALID_PROJECT" }),
);
};
expectInvalid((input) => {
input.animations[0]!.property = "opacity; stroke: red";
});
expectInvalid((input) => {
input.animations[0]!.keyframes[0]!.value =
"0</style><script>alert(1)</script>";
});
expectInvalid((input) => {
input.animations[0]!.timing.easing = "linear; stroke: red";
});
expectInvalid((input) => {
input.animations[0]!.keyframes[0]!.offset = Number.NaN;
});
expectInvalid((input) => {
input.animations[0]!.timing.durationMs = Number.POSITIVE_INFINITY;
});
expectInvalid((input) => {
input.animations[0]!.timing.iterations = Number.POSITIVE_INFINITY;
});
});
it("rejects invalid UTF-8 project bytes", async () => {
await expect(
readProject(new Blob([Uint8Array.of(0xc3, 0x28)])),
).rejects.toMatchObject({
code: "INVALID_UTF8",
});
});
});
+72
View File
@@ -0,0 +1,72 @@
import { describe, expect, it } from "vitest";
import { parseSvgSource } from "../../src/document/source-parser";
import {
createEditingProjection,
inspectSvgSecurity,
} from "../../src/security/sanitize-svg";
function parse(source: string) {
const result = parseSvgSource(source, 1);
expect(result.semantic).not.toBeNull();
return result.semantic!;
}
describe("application-owned adversarial SVG policy", () => {
it("allows only references that resolve to one unique local ID", () => {
const semantic = parse(`<svg xmlns="http://www.w3.org/2000/svg">
<defs><linearGradient id="unique"/><linearGradient id="duplicate"/><linearGradient id="duplicate"/></defs>
<rect id="ok" fill="url(#unique)"/>
<rect id="missing" fill="url(#absent)"/>
<rect id="ambiguous" fill="url(#duplicate)"/>
</svg>`);
const projection = createEditingProjection(semantic).source;
expect(projection).toContain('id="ok" fill="url(#unique)"');
expect(projection).not.toContain("url(#absent)");
expect(projection).not.toContain('id="ambiguous" fill=');
});
it("parses CSS and removes imports, external URLs and behavior-like properties", () => {
const semantic = parse(`<svg xmlns="http://www.w3.org/2000/svg">
<style>@import "https://evil.example/theme.css"; rect { fill: red }</style>
<rect id="external" style="fill: url(https://evil.example/a); stroke: red"/>
<circle id="behavior" style="behavior: url(#x)"/>
</svg>`);
const findings = inspectSvgSecurity(semantic);
expect(findings.map((item) => item.code)).toEqual(
expect.arrayContaining(["unsafe-stylesheet", "unsafe-inline-css"]),
);
const projection = createEditingProjection(semantic).source;
expect(projection).not.toContain("evil.example");
expect(projection).not.toContain("behavior:");
expect(projection).not.toContain("<style");
});
it("keeps the normal editing canvas static by stripping source animations", () => {
const semantic = parse(`<svg xmlns="http://www.w3.org/2000/svg">
<circle id="dot" r="2"><animate attributeName="opacity" values="0;1" dur="1s"/></circle>
<set attributeName="fill" to="red" begin="click"/>
</svg>`);
const findings = inspectSvgSecurity(semantic);
expect(findings.map((item) => item.code)).toEqual(
expect.arrayContaining(["blocked-animate", "blocked-set"]),
);
const projection = createEditingProjection(semantic).source;
expect(projection).not.toMatch(/<animate|<set\b/iu);
expect(semantic.source).toContain("<animate");
});
it("neutralizes executable schemes, external use targets and non-SVG element namespaces", () => {
const semantic =
parse(`<svg xmlns="http://www.w3.org/2000/svg" xmlns:x="urn:active">
<a href="javascript:alert(1)"><rect width="1" height="1"/></a>
<use href="https://evil.example/icons.svg#mark"/>
<x:widget x:run="yes"/>
<image href="data:text/html;base64,PHNjcmlwdD4="/>
</svg>`);
const projection = createEditingProjection(semantic).source;
expect(projection).not.toMatch(
/javascript:|evil\.example|data:text\/html|x:widget/iu,
);
expect(semantic.source).toContain("javascript:");
});
});
+148
View File
@@ -0,0 +1,148 @@
import { describe, expect, it } from "vitest";
import { parseSvgSource } from "../../src/document/source-parser";
import {
createEditingProjection,
createSanitizedCandidate,
inspectSvgSecurity,
} from "../../src/security/sanitize-svg";
function parse(source: string) {
const result = parseSvgSource(source, 1);
expect(result.semantic).not.toBeNull();
return result.semantic!;
}
describe("hostile SVG editing projection", () => {
it("preserves active content in canonical source but removes it from projection", () => {
const semantic =
parse(`<svg xmlns="http://www.w3.org/2000/svg" onload="steal()">
<script>alert(1)</script>
<foreignObject><div xmlns="http://www.w3.org/1999/xhtml">HTML</div></foreignObject>
<a href="https://evil.example/"><rect width="10" height="10"/></a>
<image href="https://evil.example/tracker.png"/>
<path style="fill:url(https://evil.example/paint)" d="M0 0L1 1"/>
</svg>`);
const findings = inspectSvgSecurity(semantic);
const codes = findings.map((item) => item.code);
expect(codes).toEqual(
expect.arrayContaining([
"event-handler",
"blocked-script",
"blocked-foreignobject",
"unsafe-url",
]),
);
expect(codes.some((code) => code.startsWith("unsafe-inline-css"))).toBe(
true,
);
expect(semantic.source).toContain("steal()");
const projection = createEditingProjection(semantic);
expect(projection.source).not.toMatch(
/<script|foreignObject|steal\(\)|evil\.example/iu,
);
expect(projection.source).toContain("data-svg-tools-node");
});
it("allows local fragment paint and bounded embedded raster data", () => {
const semantic = parse(
`<svg xmlns="http://www.w3.org/2000/svg"><defs><linearGradient id="p"/></defs><rect fill="url(#p)"/><image href="data:image/png;base64,iVBORw0KGgo="/></svg>`,
);
const projection = createEditingProjection(semantic);
expect(projection.source).toContain("url(#p)");
expect(projection.source).toContain("data:image/png;base64");
});
it("removes internal mapping metadata from explicit sanitized source", () => {
const semantic = parse(
'<svg xmlns="http://www.w3.org/2000/svg"><circle id="c" r="2"/></svg>',
);
const candidate = createSanitizedCandidate(semantic);
expect(candidate.source).not.toContain("data-svg-tools-node");
expect(candidate.source).toContain('id="c"');
});
it("parses CSS and removes imports, external URLs and behavior-like properties", () => {
const semantic = parse(`<svg xmlns="http://www.w3.org/2000/svg">
<style>@import/**/url(https://evil.example/a.css); .x { fill: red }</style>
<path class="x" style="-moz-binding:url(https://evil.example/x);fill:red" d="M0 0L1 1"/>
</svg>`);
const findings = inspectSvgSecurity(semantic);
expect(
findings.some((item) => item.code.startsWith("unsafe-stylesheet")),
).toBe(true);
expect(
findings.some((item) => item.code.startsWith("unsafe-inline-css")),
).toBe(true);
const projection = createEditingProjection(semantic);
expect(projection.source).not.toContain("evil.example");
expect(projection.source).not.toContain("-moz-binding");
});
it("blocks CSS resource functions even when a URL is represented as a string", () => {
const semantic = parse(
'<svg xmlns="http://www.w3.org/2000/svg"><style>.x { background-image: image-set("tracker.png" 1x) }</style><rect class="x" width="2" height="2"/></svg>',
);
const findings = inspectSvgSecurity(semantic);
expect(findings).toContainEqual(
expect.objectContaining({
code: "unsafe-stylesheet",
description: expect.stringContaining("image-set()"),
}),
);
expect(createEditingProjection(semantic).source).not.toContain(
"tracker.png",
);
});
it("allows only references that resolve to one unique local ID", () => {
const semantic = parse(`<svg xmlns="http://www.w3.org/2000/svg">
<defs><linearGradient id="unique"/><linearGradient id="duplicate"/><linearGradient id="duplicate"/></defs>
<rect id="ok" fill="url(#unique)"/>
<rect id="missing" fill="url(#absent)"/>
<rect id="ambiguous" fill="url(#duplicate)"/>
</svg>`);
const projection = createEditingProjection(semantic).source;
const document = new DOMParser().parseFromString(
projection,
"image/svg+xml",
);
expect(document.getElementById("ok")?.getAttribute("fill")).toBe(
"url(#unique)",
);
expect(document.getElementById("missing")?.hasAttribute("fill")).toBe(
false,
);
expect(document.getElementById("ambiguous")?.hasAttribute("fill")).toBe(
false,
);
});
it("keeps the normal canvas static by removing declarative animation elements", () => {
const semantic = parse(`<svg xmlns="http://www.w3.org/2000/svg">
<circle id="dot" r="4"><animate attributeName="opacity" values="0;1" begin="click"/></circle>
<set attributeName="fill" to="red"/>
</svg>`);
const findings = inspectSvgSecurity(semantic);
expect(findings.map((item) => item.code)).toEqual(
expect.arrayContaining(["blocked-animate", "blocked-set"]),
);
const projection = createEditingProjection(semantic).source;
expect(projection).not.toMatch(/<animate|<set/iu);
expect(projection).toContain('id="dot"');
});
it("reports and removes elements in unknown namespaces", () => {
const semantic =
parse(`<svg xmlns="http://www.w3.org/2000/svg" xmlns:evil="urn:evil">
<evil:widget id="active"><circle r="2"/></evil:widget>
</svg>`);
expect(inspectSvgSecurity(semantic)).toContainEqual(
expect.objectContaining({
code: "unknown-namespace",
editingProjectionAction: "removed",
}),
);
expect(createEditingProjection(semantic).source).not.toContain("widget");
});
});
@@ -0,0 +1,83 @@
import { describe, expect, it } from "vitest";
import {
accessibilityFixPatch,
auditAccessibility,
} from "../../src/accessibility/audit";
import { parseSvgSource } from "../../src/document/source-parser";
import { applySourcePatches } from "../../src/document/source-patcher";
import {
buildReferenceIndex,
previewIdRename,
} from "../../src/structure/reference-index";
function semantic(source: string) {
return parseSvgSource(source, 1).semantic!;
}
describe("references and accessibility", () => {
it("indexes resolved, missing and cyclic local references", () => {
const document = semantic(
`<svg xmlns="http://www.w3.org/2000/svg"><defs><linearGradient id="paint" href="#paint"/></defs><rect id="shape" fill="url(#paint)" aria-labelledby="missing"/></svg>`,
);
const index = buildReferenceIndex(document);
expect(index.edges).toEqual(
expect.arrayContaining([
expect.objectContaining({ targetId: "paint", status: "cyclic" }),
expect.objectContaining({ targetId: "missing", status: "missing" }),
]),
);
});
it("marks cycle members without mislabelling an upstream reference", () => {
const document = semantic(
'<svg xmlns="http://www.w3.org/2000/svg"><defs><linearGradient id="upstream" href="#left"/><linearGradient id="left" href="#right"/><linearGradient id="right" href="#left"/></defs></svg>',
);
const edges = buildReferenceIndex(document).edges;
expect(
edges.find(
(edge) =>
edge.sourceKey === "id:upstream" &&
edge.targetId === "left" &&
edge.status === "resolved",
),
).toBeDefined();
expect(
edges
.filter((edge) => edge.status === "cyclic")
.map((edge) => edge.targetId),
).toEqual(expect.arrayContaining(["left", "right"]));
});
it("renames an ID and its typed references without unsafe global replacement", () => {
const source =
'<svg xmlns="http://www.w3.org/2000/svg"><defs><linearGradient id="old"/><linearGradient id="oldish"/></defs><rect fill="url( #old )" stroke="url(#oldish)" aria-describedby="old oldish old"/></svg>';
const document = semantic(source);
const target = [...document.nodes.values()].find(
(node) => node.id === "old",
)!;
const preview = previewIdRename(document, target.key, "newPaint");
const result = applySourcePatches(source, preview.patches);
expect(result).toContain('id="newPaint"');
expect(result).toContain("url( #newPaint )");
expect(result).toContain("url(#oldish)");
expect(result).toContain('aria-describedby="newPaint oldish newPaint"');
});
it("reports missing root naming and provides an exact title patch", () => {
const source =
'<svg xmlns="http://www.w3.org/2000/svg"><rect width="4" height="4"/></svg>';
const document = semantic(source);
const findings = auditAccessibility(document);
expect(findings).toContainEqual(
expect.objectContaining({
rule: "svg-accessible-name",
automaticFix: "add-title",
}),
);
const fixed = applySourcePatches(source, [
accessibilityFixPatch(document, "add-title", "Small square"),
]);
expect(fixed).toContain("<title>Small square</title>");
});
});
+27
View File
@@ -0,0 +1,27 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
"target": "ES2023",
"lib": ["ES2023", "DOM", "DOM.Iterable", "WebWorker"],
"module": "ESNext",
"types": ["vite/client"],
"allowArbitraryExtensions": true,
"skipLibCheck": true,
"moduleResolution": "Bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
"jsx": "react-jsx",
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true,
"strict": true,
"noUncheckedIndexedAccess": true,
"useDefineForClassFields": true,
"resolveJsonModule": true,
"isolatedModules": true
},
"include": ["src", "tests"]
}
+7
View File
@@ -0,0 +1,7 @@
{
"files": [],
"references": [
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" }
]
}
+24
View File
@@ -0,0 +1,24 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
"target": "ES2023",
"lib": ["ES2023"],
"module": "ESNext",
"types": ["node"],
"skipLibCheck": true,
"moduleResolution": "Bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true
},
"include": [
"vite.config.ts",
"playwright.config.ts",
"eslint.config.mjs",
"scripts/**/*.mjs"
]
}
+18
View File
@@ -0,0 +1,18 @@
/// <reference types="vitest/config" />
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
export default defineConfig({
base: "./",
plugins: [react()],
worker: {
format: "es",
},
test: {
environment: "jsdom",
setupFiles: "./src/test/setup.ts",
css: true,
restoreMocks: true,
exclude: ["tests/browser/**", "node_modules/**", "dist/**"],
},
});