feat: introduce local-first SVG workbench
This commit is contained in:
+19
@@ -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/
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
@add-ideas:registry=https://git.add-ideas.de/api/packages/lotobo/npm/
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
dist
|
||||||
|
release
|
||||||
|
coverage
|
||||||
|
playwright-report
|
||||||
|
test-results
|
||||||
|
package-lock.json
|
||||||
|
public/toolbox-app.json
|
||||||
|
public/THIRD_PARTY_LICENSES.txt
|
||||||
@@ -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
|
||||||
@@ -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>.
|
||||||
@@ -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.
|
||||||
@@ -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).
|
||||||
@@ -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.
|
||||||
@@ -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.
|
||||||
@@ -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.
|
||||||
@@ -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.
|
||||||
@@ -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.
|
||||||
@@ -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.
|
||||||
@@ -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.
|
||||||
@@ -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.
|
||||||
@@ -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.
|
||||||
@@ -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.
|
||||||
@@ -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
@@ -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>
|
||||||
Generated
+4453
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||||
|
}
|
||||||
@@ -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"] } },
|
||||||
|
],
|
||||||
|
});
|
||||||
@@ -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();
|
||||||
|
})();
|
||||||
@@ -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 |
@@ -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"]
|
||||||
|
}
|
||||||
@@ -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)}`);
|
||||||
|
}
|
||||||
@@ -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 });
|
||||||
|
}
|
||||||
@@ -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 });
|
||||||
|
}
|
||||||
@@ -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");
|
||||||
@@ -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
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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("&", "&")
|
||||||
|
.replaceAll("<", "<")
|
||||||
|
.replaceAll(">", ">");
|
||||||
|
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",
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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("&", "&").replaceAll("<", "<")}</style>`;
|
||||||
|
return sanitizedProjection.replace(/<svg\b([^>]*)>/iu, `<svg$1>${style}`);
|
||||||
|
}
|
||||||
@@ -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");
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
};
|
||||||
@@ -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>`;
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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("&", "&")
|
||||||
|
.replaceAll('"', """);
|
||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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"><{node.name}></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>
|
||||||
|
<{entry.elementName}>
|
||||||
|
{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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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} />;
|
||||||
|
}
|
||||||
@@ -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
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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("&", "&").replaceAll("<", "<");
|
||||||
|
return quote === '"'
|
||||||
|
? common.replaceAll('"', """)
|
||||||
|
: common.replaceAll("'", "'");
|
||||||
|
}
|
||||||
|
|
||||||
|
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("&", "&")
|
||||||
|
.replaceAll("<", "<")
|
||||||
|
.replaceAll(">", ">");
|
||||||
|
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,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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 };
|
||||||
|
}
|
||||||
@@ -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
File diff suppressed because it is too large
Load Diff
@@ -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,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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.",
|
||||||
|
],
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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") };
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
const XML_NAMESPACE = "http://www.w3.org/2000/xmlns/";
|
||||||
|
|
||||||
|
function escapeText(value: string): string {
|
||||||
|
return value
|
||||||
|
.replaceAll("&", "&")
|
||||||
|
.replaceAll("<", "<")
|
||||||
|
.replaceAll(">", ">");
|
||||||
|
}
|
||||||
|
|
||||||
|
function escapeAttribute(value: string): string {
|
||||||
|
return escapeText(value).replaceAll('"', """);
|
||||||
|
}
|
||||||
|
|
||||||
|
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}`;
|
||||||
|
}
|
||||||
@@ -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>,
|
||||||
|
);
|
||||||
@@ -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;
|
||||||
@@ -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"),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
},
|
||||||
|
);
|
||||||
@@ -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,
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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),
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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";
|
||||||
|
}
|
||||||
@@ -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
File diff suppressed because it is too large
Load Diff
@@ -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();
|
||||||
|
});
|
||||||
@@ -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"]
|
||||||
|
}
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
import { defineToolboxApp, parseToolboxApp } from "@add-ideas/toolbox-contract";
|
||||||
|
import manifestSource from "./manifest.source.json";
|
||||||
|
|
||||||
|
export const manifest = defineToolboxApp(parseToolboxApp(manifestSource));
|
||||||
@@ -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";
|
||||||
@@ -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("");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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([]);
|
||||||
|
});
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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" }),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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 },
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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 },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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"),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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"');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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 & B</title></g>\r\n</svg>';
|
||||||
|
const formatted = formatSvgSource(source);
|
||||||
|
expect(formatted).toContain("\r\n <g>\r\n <title>A & 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 },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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="&">\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",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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:");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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>");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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"]
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
{
|
||||||
|
"files": [],
|
||||||
|
"references": [
|
||||||
|
{ "path": "./tsconfig.app.json" },
|
||||||
|
{ "path": "./tsconfig.node.json" }
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -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/**"],
|
||||||
|
},
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user