first commit
This commit is contained in:
@@ -0,0 +1,2 @@
|
||||
[alias]
|
||||
xtask = "run --package xtask --"
|
||||
@@ -1,2 +1,7 @@
|
||||
*~
|
||||
plan/
|
||||
/target
|
||||
/dist
|
||||
/corpus
|
||||
/log
|
||||
.DS_Store
|
||||
|
||||
+75
@@ -0,0 +1,75 @@
|
||||
[workspace]
|
||||
resolver = "3"
|
||||
members = ["crates/*", "apps/*", "xtask"]
|
||||
default-members = ["crates/*", "apps/designcraft", "apps/designcraft-cli", "xtask"]
|
||||
|
||||
[workspace.package]
|
||||
version = "0.1.0"
|
||||
edition = "2024"
|
||||
license = "MIT OR Apache-2.0"
|
||||
rust-version = "1.90"
|
||||
repository = "https://github.com/storytold/designcraft"
|
||||
|
||||
[workspace.dependencies]
|
||||
# internal
|
||||
designcraft-geom = { path = "crates/geom" }
|
||||
designcraft-color = { path = "crates/color" }
|
||||
designcraft-doc = { path = "crates/doc" }
|
||||
designcraft-fonts = { path = "crates/fonts" }
|
||||
designcraft-compose = { path = "crates/compose" }
|
||||
designcraft-images = { path = "crates/images" }
|
||||
designcraft-render = { path = "crates/render" }
|
||||
designcraft-pdf = { path = "crates/pdf" }
|
||||
designcraft-idml = { path = "crates/idml" }
|
||||
designcraft-format = { path = "crates/format" }
|
||||
designcraft-tools = { path = "crates/tools" }
|
||||
designcraft-engine = { path = "crates/engine" }
|
||||
designcraft-ui-egui = { path = "crates/ui-egui" }
|
||||
designcraft-mcp = { path = "crates/mcp" }
|
||||
designcraft-testkit = { path = "crates/testkit" }
|
||||
|
||||
# foundation
|
||||
kurbo = { version = "0.13", features = ["serde"] }
|
||||
thiserror = "2"
|
||||
serde = { version = "1", features = ["derive", "rc"] }
|
||||
serde_json = { version = "1", features = ["float_roundtrip"] }
|
||||
log = "0.4"
|
||||
proptest = "1"
|
||||
rayon = "1"
|
||||
|
||||
# text / rendering / formats
|
||||
skrifa = "0.47"
|
||||
harfrust = "0.13"
|
||||
vello_cpu = "0.2"
|
||||
vello_common = { version = "0.2", default-features = false }
|
||||
image = { version = "0.25", default-features = false, features = ["png", "jpeg", "webp", "gif", "tiff"] }
|
||||
krilla = "0.8"
|
||||
quick-xml = "0.38"
|
||||
zip = { version = "2.6", default-features = false, features = ["deflate"] }
|
||||
|
||||
# ui
|
||||
egui = "0.36"
|
||||
egui_extras = { version = "0.36", default-features = false, features = ["svg"] }
|
||||
eframe = { version = "0.36", default-features = false }
|
||||
egui_kittest = "0.36"
|
||||
rfd = "0.17"
|
||||
arboard = { version = "3.6", default-features = false }
|
||||
|
||||
[workspace.lints.rust]
|
||||
unsafe_code = "deny"
|
||||
|
||||
[workspace.lints.clippy]
|
||||
dbg_macro = "warn"
|
||||
todo = "warn"
|
||||
|
||||
[profile.dev.package."*"]
|
||||
opt-level = 2
|
||||
|
||||
[profile.dev]
|
||||
opt-level = 1
|
||||
|
||||
[profile.release]
|
||||
opt-level = 3
|
||||
lto = "thin"
|
||||
codegen-units = 16
|
||||
debug = 0
|
||||
+177
@@ -0,0 +1,177 @@
|
||||
|
||||
Apache License
|
||||
Version 2.0, January 2004
|
||||
http://www.apache.org/licenses/
|
||||
|
||||
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
|
||||
|
||||
1. Definitions.
|
||||
|
||||
"License" shall mean the terms and conditions for use, reproduction,
|
||||
and distribution as defined by Sections 1 through 9 of this document.
|
||||
|
||||
"Licensor" shall mean the copyright owner or entity authorized by
|
||||
the copyright owner that is granting the License.
|
||||
|
||||
"Legal Entity" shall mean the union of the acting entity and all
|
||||
other entities that control, are controlled by, or are under common
|
||||
control with that entity. For the purposes of this definition,
|
||||
"control" means (i) the power, direct or indirect, to cause the
|
||||
direction or management of such entity, whether by contract or
|
||||
otherwise, or (ii) ownership of fifty percent (50%) or more of the
|
||||
outstanding shares, or (iii) beneficial ownership of such entity.
|
||||
|
||||
"You" (or "Your") shall mean an individual or Legal Entity
|
||||
exercising permissions granted by this License.
|
||||
|
||||
"Source" form shall mean the preferred form for making modifications,
|
||||
including but not limited to software source code, documentation
|
||||
source, and configuration files.
|
||||
|
||||
"Object" form shall mean any form resulting from mechanical
|
||||
transformation or translation of a Source form, including but
|
||||
not limited to compiled object code, generated documentation,
|
||||
and conversions to other media types.
|
||||
|
||||
"Work" shall mean the work of authorship, whether in Source or
|
||||
Object form, made available under the License, as indicated by a
|
||||
copyright notice that is included in or attached to the work
|
||||
(an example is provided in the Appendix below).
|
||||
|
||||
"Derivative Works" shall mean any work, whether in Source or Object
|
||||
form, that is based on (or derived from) the Work and for which the
|
||||
editorial revisions, annotations, elaborations, or other modifications
|
||||
represent, as a whole, an original work of authorship. For the purposes
|
||||
of this License, Derivative Works shall not include works that remain
|
||||
separable from, or merely link (or bind by name) to the interfaces of,
|
||||
the Work and Derivative Works thereof.
|
||||
|
||||
"Contribution" shall mean any work of authorship, including
|
||||
the original version of the Work and any modifications or additions
|
||||
to that Work or Derivative Works thereof, that is intentionally
|
||||
submitted to Licensor for inclusion in the Work by the copyright owner
|
||||
or by an individual or Legal Entity authorized to submit on behalf of
|
||||
the copyright owner. For the purposes of this definition, "submitted"
|
||||
means any form of electronic, verbal, or written communication sent
|
||||
to the Licensor or its representatives, including but not limited to
|
||||
communication on electronic mailing lists, source code control systems,
|
||||
and issue tracking systems that are managed by, or on behalf of, the
|
||||
Licensor for the purpose of discussing and improving the Work, but
|
||||
excluding communication that is conspicuously marked or otherwise
|
||||
designated in writing by the copyright owner as "Not a Contribution."
|
||||
|
||||
"Contributor" shall mean Licensor and any individual or Legal Entity
|
||||
on behalf of whom a Contribution has been received by Licensor and
|
||||
subsequently incorporated within the Work.
|
||||
|
||||
2. Grant of Copyright License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
copyright license to reproduce, prepare Derivative Works of,
|
||||
publicly display, publicly perform, sublicense, and distribute the
|
||||
Work and such Derivative Works in Source or Object form.
|
||||
|
||||
3. Grant of Patent License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
(except as stated in this section) patent license to make, have made,
|
||||
use, offer to sell, sell, import, and otherwise transfer the Work,
|
||||
where such license applies only to those patent claims licensable
|
||||
by such Contributor that are necessarily infringed by their
|
||||
Contribution(s) alone or by combination of their Contribution(s)
|
||||
with the Work to which such Contribution(s) was submitted. If You
|
||||
institute patent litigation against any entity (including a
|
||||
cross-claim or counterclaim in a lawsuit) alleging that the Work
|
||||
or a Contribution incorporated within the Work constitutes direct
|
||||
or contributory patent infringement, then any patent licenses
|
||||
granted to You under this License for that Work shall terminate
|
||||
as of the date such litigation is filed.
|
||||
|
||||
4. Redistribution. You may reproduce and distribute copies of the
|
||||
Work or Derivative Works thereof in any medium, with or without
|
||||
modifications, and in Source or Object form, provided that You
|
||||
meet the following conditions:
|
||||
|
||||
(a) You must give any other recipients of the Work or
|
||||
Derivative Works a copy of this License; and
|
||||
|
||||
(b) You must cause any modified files to carry prominent notices
|
||||
stating that You changed the files; and
|
||||
|
||||
(c) You must retain, in the Source form of any Derivative Works
|
||||
that You distribute, all copyright, patent, trademark, and
|
||||
attribution notices from the Source form of the Work,
|
||||
excluding those notices that do not pertain to any part of
|
||||
the Derivative Works; and
|
||||
|
||||
(d) If the Work includes a "NOTICE" text file as part of its
|
||||
distribution, then any Derivative Works that You distribute must
|
||||
include a readable copy of the attribution notices contained
|
||||
within such NOTICE file, excluding those notices that do not
|
||||
pertain to any part of the Derivative Works, in at least one
|
||||
of the following places: within a NOTICE text file distributed
|
||||
as part of the Derivative Works; within the Source form or
|
||||
documentation, if provided along with the Derivative Works; or,
|
||||
within a display generated by the Derivative Works, if and
|
||||
wherever such third-party notices normally appear. The contents
|
||||
of the NOTICE file are for informational purposes only and
|
||||
do not modify the License. You may add Your own attribution
|
||||
notices within Derivative Works that You distribute, alongside
|
||||
or as an addendum to the NOTICE text from the Work, provided
|
||||
that such additional attribution notices cannot be construed
|
||||
as modifying the License.
|
||||
|
||||
You may add Your own copyright statement to Your modifications and
|
||||
may provide additional or different license terms and conditions
|
||||
for use, reproduction, or distribution of Your modifications, or
|
||||
for any such Derivative Works as a whole, provided Your use,
|
||||
reproduction, and distribution of the Work otherwise complies with
|
||||
the conditions stated in this License.
|
||||
|
||||
5. Submission of Contributions. Unless You explicitly state otherwise,
|
||||
any Contribution intentionally submitted for inclusion in the Work
|
||||
by You to the Licensor shall be under the terms and conditions of
|
||||
this License, without any additional terms or conditions.
|
||||
Notwithstanding the above, nothing herein shall supersede or modify
|
||||
the terms of any separate license agreement you may have executed
|
||||
with Licensor regarding such Contributions.
|
||||
|
||||
6. Trademarks. This License does not grant permission to use the trade
|
||||
names, trademarks, service marks, or product names of the Licensor,
|
||||
except as required for reasonable and customary use in describing the
|
||||
origin of the Work and reproducing the content of the NOTICE file.
|
||||
|
||||
7. Disclaimer of Warranty. Unless required by applicable law or
|
||||
agreed to in writing, Licensor provides the Work (and each
|
||||
Contributor provides its Contributions) on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
|
||||
implied, including, without limitation, any warranties or conditions
|
||||
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
|
||||
PARTICULAR PURPOSE. You are solely responsible for determining the
|
||||
appropriateness of using or redistributing the Work and assume any
|
||||
risks associated with Your exercise of permissions under this License.
|
||||
|
||||
8. Limitation of Liability. In no event and under no legal theory,
|
||||
whether in tort (including negligence), contract, or otherwise,
|
||||
unless required by applicable law (such as deliberate and grossly
|
||||
negligent acts) or agreed to in writing, shall any Contributor be
|
||||
liable to You for damages, including any direct, indirect, special,
|
||||
incidental, or consequential damages of any character arising as a
|
||||
result of this License or out of the use or inability to use the
|
||||
Work (including but not limited to damages for loss of goodwill,
|
||||
work stoppage, computer failure or malfunction, or any and all
|
||||
other commercial damages or losses), even if such Contributor
|
||||
has been advised of the possibility of such damages.
|
||||
|
||||
9. Accepting Warranty or Additional Liability. While redistributing
|
||||
the Work or Derivative Works thereof, You may choose to offer,
|
||||
and charge a fee for, acceptance of support, warranty, indemnity,
|
||||
or other liability obligations and/or rights consistent with this
|
||||
License. However, in accepting such obligations, You may act only
|
||||
on Your own behalf and on Your sole responsibility, not on behalf
|
||||
of any other Contributor, and only if You agree to indemnify,
|
||||
defend, and hold each Contributor harmless for any liability
|
||||
incurred by, or claims asserted against, such Contributor by reason
|
||||
of your accepting any such warranty or additional liability.
|
||||
|
||||
END OF TERMS AND CONDITIONS
|
||||
+21
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2026 The PrintCraft Contributors
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,92 @@
|
||||
Copyright (c) 2016 The Inter Project Authors (https://github.com/rsms/inter)
|
||||
|
||||
This Font Software is licensed under the SIL Open Font License, Version 1.1.
|
||||
This license is copied below, and is also available with a FAQ at:
|
||||
http://scripts.sil.org/OFL
|
||||
|
||||
-----------------------------------------------------------
|
||||
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
|
||||
-----------------------------------------------------------
|
||||
|
||||
PREAMBLE
|
||||
The goals of the Open Font License (OFL) are to stimulate worldwide
|
||||
development of collaborative font projects, to support the font creation
|
||||
efforts of academic and linguistic communities, and to provide a free and
|
||||
open framework in which fonts may be shared and improved in partnership
|
||||
with others.
|
||||
|
||||
The OFL allows the licensed fonts to be used, studied, modified and
|
||||
redistributed freely as long as they are not sold by themselves. The
|
||||
fonts, including any derivative works, can be bundled, embedded,
|
||||
redistributed and/or sold with any software provided that any reserved
|
||||
names are not used by derivative works. The fonts and derivatives,
|
||||
however, cannot be released under any other type of license. The
|
||||
requirement for fonts to remain under this license does not apply
|
||||
to any document created using the fonts or their derivatives.
|
||||
|
||||
DEFINITIONS
|
||||
"Font Software" refers to the set of files released by the Copyright
|
||||
Holder(s) under this license and clearly marked as such. This may
|
||||
include source files, build scripts and documentation.
|
||||
|
||||
"Reserved Font Name" refers to any names specified as such after the
|
||||
copyright statement(s).
|
||||
|
||||
"Original Version" refers to the collection of Font Software components as
|
||||
distributed by the Copyright Holder(s).
|
||||
|
||||
"Modified Version" refers to any derivative made by adding to, deleting,
|
||||
or substituting -- in part or in whole -- any of the components of the
|
||||
Original Version, by changing formats or by porting the Font Software to a
|
||||
new environment.
|
||||
|
||||
"Author" refers to any designer, engineer, programmer, technical
|
||||
writer or other person who contributed to the Font Software.
|
||||
|
||||
PERMISSION AND CONDITIONS
|
||||
Permission is hereby granted, free of charge, to any person obtaining
|
||||
a copy of the Font Software, to use, study, copy, merge, embed, modify,
|
||||
redistribute, and sell modified and unmodified copies of the Font
|
||||
Software, subject to the following conditions:
|
||||
|
||||
1) Neither the Font Software nor any of its individual components,
|
||||
in Original or Modified Versions, may be sold by itself.
|
||||
|
||||
2) Original or Modified Versions of the Font Software may be bundled,
|
||||
redistributed and/or sold with any software, provided that each copy
|
||||
contains the above copyright notice and this license. These can be
|
||||
included either as stand-alone text files, human-readable headers or
|
||||
in the appropriate machine-readable metadata fields within text or
|
||||
binary files as long as those fields can be easily viewed by the user.
|
||||
|
||||
3) No Modified Version of the Font Software may use the Reserved Font
|
||||
Name(s) unless explicit written permission is granted by the corresponding
|
||||
Copyright Holder. This restriction only applies to the primary font name as
|
||||
presented to the users.
|
||||
|
||||
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
|
||||
Software shall not be used to promote, endorse or advertise any
|
||||
Modified Version, except to acknowledge the contribution(s) of the
|
||||
Copyright Holder(s) and the Author(s) or with their explicit written
|
||||
permission.
|
||||
|
||||
5) The Font Software, modified or unmodified, in part or in whole,
|
||||
must be distributed entirely under this license, and must not be
|
||||
distributed under any other license. The requirement for fonts to
|
||||
remain under this license does not apply to any document created
|
||||
using the Font Software.
|
||||
|
||||
TERMINATION
|
||||
This license becomes null and void if any of the above conditions are
|
||||
not met.
|
||||
|
||||
DISCLAIMER
|
||||
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
||||
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
|
||||
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
|
||||
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
|
||||
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
|
||||
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
|
||||
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
||||
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
|
||||
OTHER DEALINGS IN THE FONT SOFTWARE.
|
||||
@@ -0,0 +1,93 @@
|
||||
Copyright 2020 The JetBrains Mono Project Authors (https://github.com/JetBrains/JetBrainsMono)
|
||||
|
||||
This Font Software is licensed under the SIL Open Font License, Version 1.1.
|
||||
This license is copied below, and is also available with a FAQ at:
|
||||
https://openfontlicense.org
|
||||
|
||||
|
||||
-----------------------------------------------------------
|
||||
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
|
||||
-----------------------------------------------------------
|
||||
|
||||
PREAMBLE
|
||||
The goals of the Open Font License (OFL) are to stimulate worldwide
|
||||
development of collaborative font projects, to support the font creation
|
||||
efforts of academic and linguistic communities, and to provide a free and
|
||||
open framework in which fonts may be shared and improved in partnership
|
||||
with others.
|
||||
|
||||
The OFL allows the licensed fonts to be used, studied, modified and
|
||||
redistributed freely as long as they are not sold by themselves. The
|
||||
fonts, including any derivative works, can be bundled, embedded,
|
||||
redistributed and/or sold with any software provided that any reserved
|
||||
names are not used by derivative works. The fonts and derivatives,
|
||||
however, cannot be released under any other type of license. The
|
||||
requirement for fonts to remain under this license does not apply
|
||||
to any document created using the fonts or their derivatives.
|
||||
|
||||
DEFINITIONS
|
||||
"Font Software" refers to the set of files released by the Copyright
|
||||
Holder(s) under this license and clearly marked as such. This may
|
||||
include source files, build scripts and documentation.
|
||||
|
||||
"Reserved Font Name" refers to any names specified as such after the
|
||||
copyright statement(s).
|
||||
|
||||
"Original Version" refers to the collection of Font Software components as
|
||||
distributed by the Copyright Holder(s).
|
||||
|
||||
"Modified Version" refers to any derivative made by adding to, deleting,
|
||||
or substituting -- in part or in whole -- any of the components of the
|
||||
Original Version, by changing formats or by porting the Font Software to a
|
||||
new environment.
|
||||
|
||||
"Author" refers to any designer, engineer, programmer, technical
|
||||
writer or other person who contributed to the Font Software.
|
||||
|
||||
PERMISSION & CONDITIONS
|
||||
Permission is hereby granted, free of charge, to any person obtaining
|
||||
a copy of the Font Software, to use, study, copy, merge, embed, modify,
|
||||
redistribute, and sell modified and unmodified copies of the Font
|
||||
Software, subject to the following conditions:
|
||||
|
||||
1) Neither the Font Software nor any of its individual components,
|
||||
in Original or Modified Versions, may be sold by itself.
|
||||
|
||||
2) Original or Modified Versions of the Font Software may be bundled,
|
||||
redistributed and/or sold with any software, provided that each copy
|
||||
contains the above copyright notice and this license. These can be
|
||||
included either as stand-alone text files, human-readable headers or
|
||||
in the appropriate machine-readable metadata fields within text or
|
||||
binary files as long as those fields can be easily viewed by the user.
|
||||
|
||||
3) No Modified Version of the Font Software may use the Reserved Font
|
||||
Name(s) unless explicit written permission is granted by the corresponding
|
||||
Copyright Holder. This restriction only applies to the primary font name as
|
||||
presented to the users.
|
||||
|
||||
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
|
||||
Software shall not be used to promote, endorse or advertise any
|
||||
Modified Version, except to acknowledge the contribution(s) of the
|
||||
Copyright Holder(s) and the Author(s) or with their explicit written
|
||||
permission.
|
||||
|
||||
5) The Font Software, modified or unmodified, in part or in whole,
|
||||
must be distributed entirely under this license, and must not be
|
||||
distributed under any other license. The requirement for fonts to
|
||||
remain under this license does not apply to any document created
|
||||
using the Font Software.
|
||||
|
||||
TERMINATION
|
||||
This license becomes null and void if any of the above conditions are
|
||||
not met.
|
||||
|
||||
DISCLAIMER
|
||||
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
||||
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
|
||||
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
|
||||
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
|
||||
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
|
||||
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
|
||||
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
||||
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
|
||||
OTHER DEALINGS IN THE FONT SOFTWARE.
|
||||
@@ -0,0 +1,93 @@
|
||||
Copyright 2010-2024 Adobe (http://www.adobe.com/), with Reserved Font Name 'Source'. All Rights Reserved. Source is a trademark of Adobe in the United States and/or other countries.
|
||||
|
||||
This Font Software is licensed under the SIL Open Font License, Version 1.1.
|
||||
|
||||
This license is copied below, and is also available with a FAQ at: http://scripts.sil.org/OFL
|
||||
|
||||
|
||||
-----------------------------------------------------------
|
||||
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
|
||||
-----------------------------------------------------------
|
||||
|
||||
PREAMBLE
|
||||
The goals of the Open Font License (OFL) are to stimulate worldwide
|
||||
development of collaborative font projects, to support the font creation
|
||||
efforts of academic and linguistic communities, and to provide a free and
|
||||
open framework in which fonts may be shared and improved in partnership
|
||||
with others.
|
||||
|
||||
The OFL allows the licensed fonts to be used, studied, modified and
|
||||
redistributed freely as long as they are not sold by themselves. The
|
||||
fonts, including any derivative works, can be bundled, embedded,
|
||||
redistributed and/or sold with any software provided that any reserved
|
||||
names are not used by derivative works. The fonts and derivatives,
|
||||
however, cannot be released under any other type of license. The
|
||||
requirement for fonts to remain under this license does not apply
|
||||
to any document created using the fonts or their derivatives.
|
||||
|
||||
DEFINITIONS
|
||||
"Font Software" refers to the set of files released by the Copyright
|
||||
Holder(s) under this license and clearly marked as such. This may
|
||||
include source files, build scripts and documentation.
|
||||
|
||||
"Reserved Font Name" refers to any names specified as such after the
|
||||
copyright statement(s).
|
||||
|
||||
"Original Version" refers to the collection of Font Software components as
|
||||
distributed by the Copyright Holder(s).
|
||||
|
||||
"Modified Version" refers to any derivative made by adding to, deleting,
|
||||
or substituting -- in part or in whole -- any of the components of the
|
||||
Original Version, by changing formats or by porting the Font Software to a
|
||||
new environment.
|
||||
|
||||
"Author" refers to any designer, engineer, programmer, technical
|
||||
writer or other person who contributed to the Font Software.
|
||||
|
||||
PERMISSION & CONDITIONS
|
||||
Permission is hereby granted, free of charge, to any person obtaining
|
||||
a copy of the Font Software, to use, study, copy, merge, embed, modify,
|
||||
redistribute, and sell modified and unmodified copies of the Font
|
||||
Software, subject to the following conditions:
|
||||
|
||||
1) Neither the Font Software nor any of its individual components,
|
||||
in Original or Modified Versions, may be sold by itself.
|
||||
|
||||
2) Original or Modified Versions of the Font Software may be bundled,
|
||||
redistributed and/or sold with any software, provided that each copy
|
||||
contains the above copyright notice and this license. These can be
|
||||
included either as stand-alone text files, human-readable headers or
|
||||
in the appropriate machine-readable metadata fields within text or
|
||||
binary files as long as those fields can be easily viewed by the user.
|
||||
|
||||
3) No Modified Version of the Font Software may use the Reserved Font
|
||||
Name(s) unless explicit written permission is granted by the corresponding
|
||||
Copyright Holder. This restriction only applies to the primary font name as
|
||||
presented to the users.
|
||||
|
||||
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
|
||||
Software shall not be used to promote, endorse or advertise any
|
||||
Modified Version, except to acknowledge the contribution(s) of the
|
||||
Copyright Holder(s) and the Author(s) or with their explicit written
|
||||
permission.
|
||||
|
||||
5) The Font Software, modified or unmodified, in part or in whole,
|
||||
must be distributed entirely under this license, and must not be
|
||||
distributed under any other license. The requirement for fonts to
|
||||
remain under this license does not apply to any document created
|
||||
using the Font Software.
|
||||
|
||||
TERMINATION
|
||||
This license becomes null and void if any of the above conditions are
|
||||
not met.
|
||||
|
||||
DISCLAIMER
|
||||
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
||||
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
|
||||
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
|
||||
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
|
||||
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
|
||||
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
|
||||
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
||||
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
|
||||
OTHER DEALINGS IN THE FONT SOFTWARE.
|
||||
@@ -0,0 +1,93 @@
|
||||
Copyright 2014 - 2023 Adobe (http://www.adobe.com/), with Reserved Font Name ‘Source’. All Rights Reserved. Source is a trademark of Adobe in the United States and/or other countries.
|
||||
|
||||
This Font Software is licensed under the SIL Open Font License, Version 1.1.
|
||||
|
||||
This license is copied below, and is also available with a FAQ at: http://scripts.sil.org/OFL
|
||||
|
||||
|
||||
-----------------------------------------------------------
|
||||
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
|
||||
-----------------------------------------------------------
|
||||
|
||||
PREAMBLE
|
||||
The goals of the Open Font License (OFL) are to stimulate worldwide
|
||||
development of collaborative font projects, to support the font creation
|
||||
efforts of academic and linguistic communities, and to provide a free and
|
||||
open framework in which fonts may be shared and improved in partnership
|
||||
with others.
|
||||
|
||||
The OFL allows the licensed fonts to be used, studied, modified and
|
||||
redistributed freely as long as they are not sold by themselves. The
|
||||
fonts, including any derivative works, can be bundled, embedded,
|
||||
redistributed and/or sold with any software provided that any reserved
|
||||
names are not used by derivative works. The fonts and derivatives,
|
||||
however, cannot be released under any other type of license. The
|
||||
requirement for fonts to remain under this license does not apply
|
||||
to any document created using the fonts or their derivatives.
|
||||
|
||||
DEFINITIONS
|
||||
"Font Software" refers to the set of files released by the Copyright
|
||||
Holder(s) under this license and clearly marked as such. This may
|
||||
include source files, build scripts and documentation.
|
||||
|
||||
"Reserved Font Name" refers to any names specified as such after the
|
||||
copyright statement(s).
|
||||
|
||||
"Original Version" refers to the collection of Font Software components as
|
||||
distributed by the Copyright Holder(s).
|
||||
|
||||
"Modified Version" refers to any derivative made by adding to, deleting,
|
||||
or substituting -- in part or in whole -- any of the components of the
|
||||
Original Version, by changing formats or by porting the Font Software to a
|
||||
new environment.
|
||||
|
||||
"Author" refers to any designer, engineer, programmer, technical
|
||||
writer or other person who contributed to the Font Software.
|
||||
|
||||
PERMISSION & CONDITIONS
|
||||
Permission is hereby granted, free of charge, to any person obtaining
|
||||
a copy of the Font Software, to use, study, copy, merge, embed, modify,
|
||||
redistribute, and sell modified and unmodified copies of the Font
|
||||
Software, subject to the following conditions:
|
||||
|
||||
1) Neither the Font Software nor any of its individual components,
|
||||
in Original or Modified Versions, may be sold by itself.
|
||||
|
||||
2) Original or Modified Versions of the Font Software may be bundled,
|
||||
redistributed and/or sold with any software, provided that each copy
|
||||
contains the above copyright notice and this license. These can be
|
||||
included either as stand-alone text files, human-readable headers or
|
||||
in the appropriate machine-readable metadata fields within text or
|
||||
binary files as long as those fields can be easily viewed by the user.
|
||||
|
||||
3) No Modified Version of the Font Software may use the Reserved Font
|
||||
Name(s) unless explicit written permission is granted by the corresponding
|
||||
Copyright Holder. This restriction only applies to the primary font name as
|
||||
presented to the users.
|
||||
|
||||
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
|
||||
Software shall not be used to promote, endorse or advertise any
|
||||
Modified Version, except to acknowledge the contribution(s) of the
|
||||
Copyright Holder(s) and the Author(s) or with their explicit written
|
||||
permission.
|
||||
|
||||
5) The Font Software, modified or unmodified, in part or in whole,
|
||||
must be distributed entirely under this license, and must not be
|
||||
distributed under any other license. The requirement for fonts to
|
||||
remain under this license does not apply to any document created
|
||||
using the Font Software.
|
||||
|
||||
TERMINATION
|
||||
This license becomes null and void if any of the above conditions are
|
||||
not met.
|
||||
|
||||
DISCLAIMER
|
||||
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
||||
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
|
||||
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
|
||||
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
|
||||
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
|
||||
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
|
||||
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
||||
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
|
||||
OTHER DEALINGS IN THE FONT SOFTWARE.
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,19 @@
|
||||
[package]
|
||||
name = "designcraft-color"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
description = "DesignCraft color"
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
|
||||
[dependencies]
|
||||
kurbo = { workspace = true }
|
||||
serde = { workspace = true }
|
||||
thiserror = { workspace = true }
|
||||
moxcms = "0.8"
|
||||
|
||||
[dev-dependencies]
|
||||
proptest = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
@@ -0,0 +1,100 @@
|
||||
//! Blend modes (the 16 modes of InDesign's Transparency panel, same as PDF's).
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||
pub enum BlendMode {
|
||||
#[default]
|
||||
Normal,
|
||||
Darken,
|
||||
Multiply,
|
||||
ColorBurn,
|
||||
Lighten,
|
||||
Screen,
|
||||
ColorDodge,
|
||||
Overlay,
|
||||
SoftLight,
|
||||
HardLight,
|
||||
Difference,
|
||||
Exclusion,
|
||||
Hue,
|
||||
Saturation,
|
||||
Color,
|
||||
Luminosity,
|
||||
}
|
||||
|
||||
impl BlendMode {
|
||||
/// In Transparency-panel order (grouped as InDesign groups them).
|
||||
pub const ALL: [BlendMode; 16] = [
|
||||
BlendMode::Normal,
|
||||
BlendMode::Darken,
|
||||
BlendMode::Multiply,
|
||||
BlendMode::ColorBurn,
|
||||
BlendMode::Lighten,
|
||||
BlendMode::Screen,
|
||||
BlendMode::ColorDodge,
|
||||
BlendMode::Overlay,
|
||||
BlendMode::SoftLight,
|
||||
BlendMode::HardLight,
|
||||
BlendMode::Difference,
|
||||
BlendMode::Exclusion,
|
||||
BlendMode::Hue,
|
||||
BlendMode::Saturation,
|
||||
BlendMode::Color,
|
||||
BlendMode::Luminosity,
|
||||
];
|
||||
|
||||
pub fn label(self) -> &'static str {
|
||||
match self {
|
||||
BlendMode::Normal => "Normal",
|
||||
BlendMode::Darken => "Darken",
|
||||
BlendMode::Multiply => "Multiply",
|
||||
BlendMode::ColorBurn => "Color Burn",
|
||||
BlendMode::Lighten => "Lighten",
|
||||
BlendMode::Screen => "Screen",
|
||||
BlendMode::ColorDodge => "Color Dodge",
|
||||
BlendMode::Overlay => "Overlay",
|
||||
BlendMode::SoftLight => "Soft Light",
|
||||
BlendMode::HardLight => "Hard Light",
|
||||
BlendMode::Difference => "Difference",
|
||||
BlendMode::Exclusion => "Exclusion",
|
||||
BlendMode::Hue => "Hue",
|
||||
BlendMode::Saturation => "Saturation",
|
||||
BlendMode::Color => "Color",
|
||||
BlendMode::Luminosity => "Luminosity",
|
||||
}
|
||||
}
|
||||
/// Index of the separator group (the panel draws a divider between groups).
|
||||
pub fn group(self) -> u8 {
|
||||
match self {
|
||||
BlendMode::Normal => 0,
|
||||
BlendMode::Darken | BlendMode::Multiply | BlendMode::ColorBurn => 1,
|
||||
BlendMode::Lighten | BlendMode::Screen | BlendMode::ColorDodge => 2,
|
||||
BlendMode::Overlay | BlendMode::SoftLight | BlendMode::HardLight => 3,
|
||||
BlendMode::Difference | BlendMode::Exclusion => 4,
|
||||
_ => 5,
|
||||
}
|
||||
}
|
||||
/// Parse a label or identifier, case/space-insensitively.
|
||||
pub fn parse(s: &str) -> Option<Self> {
|
||||
let norm = |x: &str| x.to_ascii_lowercase().replace([' ', '_', '-'], "");
|
||||
let want = norm(s);
|
||||
Self::ALL.into_iter().find(|m| norm(m.label()) == want)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn parse_labels() {
|
||||
assert_eq!(BlendMode::parse("color burn"), Some(BlendMode::ColorBurn));
|
||||
assert_eq!(BlendMode::parse("ColorBurn"), Some(BlendMode::ColorBurn));
|
||||
assert_eq!(BlendMode::parse("soft_light"), Some(BlendMode::SoftLight));
|
||||
assert_eq!(BlendMode::parse("nope"), None);
|
||||
for m in BlendMode::ALL {
|
||||
assert_eq!(BlendMode::parse(m.label()), Some(m));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,453 @@
|
||||
//! "DesignCraft Generic CMYK (SWOP-like)": our own parametric press model.
|
||||
//!
|
||||
//! We can't redistribute Adobe's or ECI's characterisation profiles, so the built-in CMYK space is
|
||||
//! a small physical model instead of an ICC LUT. It is *ours* and only SWOP-*like*: the numbers
|
||||
//! are rounded, typical values for coated web offset, not a measured dataset.
|
||||
//!
|
||||
//! * **Forward (CMYK → XYZ):** each ink's nominal dot area is widened by a parabolic dot-gain
|
||||
//! curve (`a + 4·g·a·(1−a)`, i.e. `g` gain at 50%); the effective areas mix the eight CMY
|
||||
//! Neugebauer primaries with Demichel weights; black is mixed in the same way against
|
||||
//! "primary + K" colours whose optical densities add but saturate towards a maximum density
|
||||
//! (normalised so K on paper is exact), so rich blacks don't multiply to an unrealistic depth.
|
||||
//! * **Inverse (Lab → CMYK):** damped, bounded Gauss–Newton on the forward model solves CMY for a
|
||||
//! fixed K. Black generation is GCR: solve CMY with K = 0, take the grey component
|
||||
//! `min(C, M, Y)`, map it through the black-generation curve to K, re-solve CMY. Under-colour
|
||||
//! removal enforces the total area coverage (TAC) limit by adding K and re-solving, then scaling
|
||||
//! CMY as a last resort.
|
||||
//! * **Rendering intents:** colorimetric intents map the source white to paper (relative) or not
|
||||
//! (absolute) and clip; perceptual applies black-point compensation plus soft chroma compression
|
||||
//! against a gamut boundary descriptor (segment maxima of the forward model); saturation keeps
|
||||
//! chroma by pulling out-of-gamut colours towards the hue's cusp lightness.
|
||||
|
||||
use std::sync::OnceLock;
|
||||
|
||||
use super::Intent;
|
||||
use super::lab::{D50, Lab, delta_e2000, lab_to_xyz, srgb_to_lab, xyz_to_lab, xyz_to_srgb};
|
||||
|
||||
/// Black generation strength.
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub struct BlackGeneration {
|
||||
/// Grey component below which no black is generated.
|
||||
pub start: f32,
|
||||
/// Maximum black.
|
||||
pub max_k: f32,
|
||||
/// Curve exponent (>1 = lighter black in the midtones).
|
||||
pub gamma: f32,
|
||||
}
|
||||
|
||||
/// Parameters of the parametric press model.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct GenericCmykParams {
|
||||
pub name: &'static str,
|
||||
pub paper: Lab,
|
||||
/// Solid overprints, Demichel order: C, M, CM, Y, CY, MY, CMY.
|
||||
pub solids: [Lab; 7],
|
||||
pub black: Lab,
|
||||
/// Dot gain at 50% per ink (C, M, Y, K).
|
||||
pub dot_gain: [f32; 4],
|
||||
/// Total area coverage limit (sum of inks, 1.0 = 100%).
|
||||
pub tac: f32,
|
||||
pub bg: BlackGeneration,
|
||||
}
|
||||
|
||||
/// Rounded, typical SWOP-like values (not measured data).
|
||||
pub fn swop_like() -> GenericCmykParams {
|
||||
GenericCmykParams {
|
||||
name: super::GENERIC_CMYK,
|
||||
paper: Lab::new(93.0, -0.5, 2.5),
|
||||
solids: [
|
||||
Lab::new(55.0, -37.0, -50.0), // C
|
||||
Lab::new(48.0, 74.0, -3.0), // M
|
||||
Lab::new(24.0, 22.0, -46.0), // CM (blue)
|
||||
Lab::new(89.0, -5.0, 93.0), // Y
|
||||
Lab::new(50.0, -65.0, 27.0), // CY (green)
|
||||
Lab::new(47.0, 68.0, 48.0), // MY (red)
|
||||
Lab::new(24.0, 1.0, 0.0), // CMY
|
||||
],
|
||||
black: Lab::new(19.0, 0.5, 0.5),
|
||||
dot_gain: [0.20, 0.20, 0.20, 0.22],
|
||||
tac: 3.0,
|
||||
bg: BlackGeneration { start: 0.1, max_k: 0.95, gamma: 1.25 },
|
||||
}
|
||||
}
|
||||
|
||||
/// Gamut boundary descriptor: max chroma per (L, hue) cell in media-relative Lab.
|
||||
struct Gbd {
|
||||
cmax: Vec<f32>,
|
||||
/// Lightness of maximum chroma per hue bin.
|
||||
cusp_l: Vec<f32>,
|
||||
}
|
||||
|
||||
const GBD_L: usize = 51; // L 0..100, step 2
|
||||
const GBD_H: usize = 72; // 5° bins
|
||||
|
||||
pub struct GenericCmyk {
|
||||
pub params: GenericCmykParams,
|
||||
/// XYZ of the 8 CMY primaries (index = c | m<<1 | y<<2), without and with K.
|
||||
prim: [[f32; 3]; 8],
|
||||
prim_k: [[f32; 3]; 8],
|
||||
paper_xyz: [f32; 3],
|
||||
/// Media-relative XYZ of the darkest printable black (for black-point compensation).
|
||||
black_rel: [f32; 3],
|
||||
gbd: OnceLock<Gbd>,
|
||||
}
|
||||
|
||||
impl GenericCmyk {
|
||||
pub fn new(params: GenericCmykParams) -> Self {
|
||||
let paper_xyz = lab_to_xyz(params.paper);
|
||||
let mut prim = [[0.0f32; 3]; 8];
|
||||
prim[0] = paper_xyz;
|
||||
for (p, lab) in prim[1..].iter_mut().zip(params.solids.iter()) {
|
||||
*p = lab_to_xyz(*lab);
|
||||
}
|
||||
let k_xyz = lab_to_xyz(params.black);
|
||||
// Black over other inks: optical densities add, but saturate towards a maximum density
|
||||
// (ink trapping / surface reflection), normalised so that K on paper is exact.
|
||||
const D_MAX: f32 = 2.0;
|
||||
let sat = |d: f32| D_MAX * (1.0 - (-d / D_MAX).exp());
|
||||
let mut prim_k = [[0.0f32; 3]; 8];
|
||||
for i in 0..8 {
|
||||
for ch in 0..3 {
|
||||
let di = -(prim[i][ch] / paper_xyz[ch]).max(1e-6).log10();
|
||||
let dk = -(k_xyz[ch] / paper_xyz[ch]).max(1e-6).log10();
|
||||
let d = sat(di + dk) * dk / sat(dk).max(1e-6);
|
||||
prim_k[i][ch] = paper_xyz[ch] * 10f32.powf(-d.max(di));
|
||||
}
|
||||
}
|
||||
let mut g = Self { params, prim, prim_k, paper_xyz, black_rel: [0.0; 3], gbd: OnceLock::new() };
|
||||
// Darkest black within TAC: search a few rich blacks.
|
||||
let mut best = [1.0f32; 3];
|
||||
for k in [0.8f32, 0.9, 1.0] {
|
||||
let rest = (g.params.tac - k).max(0.0) / 3.0;
|
||||
let cmy = rest.min(1.0);
|
||||
let x = g.to_xyz_rel([cmy, cmy, cmy, k]);
|
||||
if x[1] < best[1] {
|
||||
best = x;
|
||||
}
|
||||
}
|
||||
g.black_rel = best;
|
||||
g
|
||||
}
|
||||
|
||||
pub fn name(&self) -> &str {
|
||||
self.params.name
|
||||
}
|
||||
|
||||
fn gain(a: f32, g: f32) -> f32 {
|
||||
let a = a.clamp(0.0, 1.0);
|
||||
(a + 4.0 * g * a * (1.0 - a)).clamp(0.0, 1.0)
|
||||
}
|
||||
|
||||
/// CMYK (0..1) → absolute XYZ (D50).
|
||||
pub fn to_xyz(&self, cmyk: [f32; 4]) -> [f32; 3] {
|
||||
let dg = self.params.dot_gain;
|
||||
let c = Self::gain(cmyk[0], dg[0]);
|
||||
let m = Self::gain(cmyk[1], dg[1]);
|
||||
let y = Self::gain(cmyk[2], dg[2]);
|
||||
let k = Self::gain(cmyk[3], dg[3]);
|
||||
let mut out = [0.0f32; 3];
|
||||
for i in 0..8 {
|
||||
let w = (if i & 1 != 0 { c } else { 1.0 - c }) * (if i & 2 != 0 { m } else { 1.0 - m }) * (if i & 4 != 0 { y } else { 1.0 - y });
|
||||
if w == 0.0 {
|
||||
continue;
|
||||
}
|
||||
for (ch, o) in out.iter_mut().enumerate() {
|
||||
*o += w * ((1.0 - k) * self.prim[i][ch] + k * self.prim_k[i][ch]);
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Media-relative XYZ (paper = D50 white).
|
||||
pub fn to_xyz_rel(&self, cmyk: [f32; 4]) -> [f32; 3] {
|
||||
let x = self.to_xyz(cmyk);
|
||||
[x[0] * D50[0] / self.paper_xyz[0], x[1] * D50[1] / self.paper_xyz[1], x[2] * D50[2] / self.paper_xyz[2]]
|
||||
}
|
||||
|
||||
pub fn to_lab_rel(&self, cmyk: [f32; 4]) -> Lab {
|
||||
xyz_to_lab(self.to_xyz_rel(cmyk))
|
||||
}
|
||||
pub fn to_lab_abs(&self, cmyk: [f32; 4]) -> Lab {
|
||||
xyz_to_lab(self.to_xyz(cmyk))
|
||||
}
|
||||
|
||||
/// Display sRGB: media-relative (paper shows as white) or absolute (simulate paper colour).
|
||||
pub fn to_srgb(&self, cmyk: [f32; 4], absolute: bool) -> [f32; 3] {
|
||||
xyz_to_srgb(if absolute { self.to_xyz(cmyk) } else { self.to_xyz_rel(cmyk) })
|
||||
}
|
||||
|
||||
/// Lab of the darkest printable black (media-relative).
|
||||
pub fn black_point(&self) -> Lab {
|
||||
xyz_to_lab(self.black_rel)
|
||||
}
|
||||
|
||||
/// Solve CMY for a fixed K so that the media-relative Lab matches `target` (bounded).
|
||||
fn solve_cmy(&self, target: Lab, k: f32, init: [f32; 3]) -> [f32; 3] {
|
||||
let mut x = init.map(|v| v.clamp(0.0, 1.0));
|
||||
let f = |x: [f32; 3]| {
|
||||
let l = self.to_lab_rel([x[0], x[1], x[2], k]);
|
||||
[l.l - target.l, l.a - target.a, l.b - target.b]
|
||||
};
|
||||
let mut r = f(x);
|
||||
let mut err = r.iter().map(|v| v * v).sum::<f32>();
|
||||
let mut lambda = 1e-3f32;
|
||||
for _ in 0..40 {
|
||||
if err < 1e-4 {
|
||||
break;
|
||||
}
|
||||
// Numeric Jacobian (one-sided, pointing into the box).
|
||||
let mut j = [[0.0f32; 3]; 3];
|
||||
for col in 0..3 {
|
||||
let h = if x[col] > 0.5 { -1e-3 } else { 1e-3 };
|
||||
let mut xp = x;
|
||||
xp[col] += h;
|
||||
let rp = f(xp);
|
||||
for row in 0..3 {
|
||||
j[row][col] = (rp[row] - r[row]) / h;
|
||||
}
|
||||
}
|
||||
// Levenberg–Marquardt step: (JᵀJ + λ·diag) δ = −Jᵀr.
|
||||
let mut a = [[0.0f32; 3]; 3];
|
||||
let mut g = [0.0f32; 3];
|
||||
for p in 0..3 {
|
||||
for q in 0..3 {
|
||||
a[p][q] = (0..3).map(|i| j[i][p] * j[i][q]).sum();
|
||||
}
|
||||
g[p] = -(0..3).map(|i| j[i][p] * r[i]).sum::<f32>();
|
||||
}
|
||||
let mut improved = false;
|
||||
for _ in 0..8 {
|
||||
let mut al = a;
|
||||
for (p, row) in al.iter_mut().enumerate() {
|
||||
row[p] += lambda * (a[p][p] + 1e-3);
|
||||
}
|
||||
let Some(d) = solve3(al, g) else {
|
||||
lambda *= 10.0;
|
||||
continue;
|
||||
};
|
||||
let xn = [(x[0] + d[0]).clamp(0.0, 1.0), (x[1] + d[1]).clamp(0.0, 1.0), (x[2] + d[2]).clamp(0.0, 1.0)];
|
||||
let rn = f(xn);
|
||||
let en = rn.iter().map(|v| v * v).sum::<f32>();
|
||||
if en < err {
|
||||
let step = (0..3).map(|i| (xn[i] - x[i]).abs()).fold(0.0, f32::max);
|
||||
x = xn;
|
||||
r = rn;
|
||||
err = en;
|
||||
lambda = (lambda * 0.3).max(1e-7);
|
||||
improved = step > 1e-6;
|
||||
break;
|
||||
}
|
||||
lambda *= 10.0;
|
||||
}
|
||||
if !improved {
|
||||
break;
|
||||
}
|
||||
}
|
||||
x
|
||||
}
|
||||
|
||||
fn initial_cmy(target: Lab, k: f32) -> [f32; 3] {
|
||||
let rgb = super::lab::lab_to_srgb(target);
|
||||
let d = (1.0 - k).max(0.05);
|
||||
rgb.map(|v| ((1.0 - v - k) / d).clamp(0.0, 1.0))
|
||||
}
|
||||
|
||||
fn black_for_grey(&self, grey: f32) -> f32 {
|
||||
let bg = self.params.bg;
|
||||
if grey <= bg.start {
|
||||
return 0.0;
|
||||
}
|
||||
(((grey - bg.start) / (1.0 - bg.start)).clamp(0.0, 1.0).powf(bg.gamma) * bg.max_k).clamp(0.0, 1.0)
|
||||
}
|
||||
|
||||
/// Separate a media-relative Lab (already gamut-mapped) into CMYK with GCR/UCR and the TAC limit.
|
||||
pub fn separate(&self, target: Lab) -> [f32; 4] {
|
||||
if target.l >= 99.99 && target.a.abs() < 0.01 && target.b.abs() < 0.01 {
|
||||
return [0.0; 4];
|
||||
}
|
||||
let cmy0 = self.solve_cmy(target, 0.0, Self::initial_cmy(target, 0.0));
|
||||
let grey = cmy0[0].min(cmy0[1]).min(cmy0[2]);
|
||||
let mut k = self.black_for_grey(grey);
|
||||
// If CMY alone can't get dark enough, black must carry the rest.
|
||||
let l0 = self.to_lab_rel([cmy0[0], cmy0[1], cmy0[2], 0.0]).l;
|
||||
if l0 > target.l + 1.0 {
|
||||
k = k.max(((l0 - target.l) / l0.max(1.0)).clamp(0.0, 1.0));
|
||||
}
|
||||
let mut cmy = if k > 0.0 { self.solve_cmy(target, k, Self::initial_cmy(target, k)) } else { cmy0 };
|
||||
// UCR: trade CMY for K until within the TAC limit.
|
||||
for _ in 0..6 {
|
||||
let sum = cmy.iter().sum::<f32>() + k;
|
||||
if sum <= self.params.tac + 1e-3 || k >= 1.0 {
|
||||
break;
|
||||
}
|
||||
k = (k + (sum - self.params.tac) * 0.5).min(1.0);
|
||||
cmy = self.solve_cmy(target, k, cmy);
|
||||
}
|
||||
let sum = cmy.iter().sum::<f32>() + k;
|
||||
if sum > self.params.tac {
|
||||
let s = ((self.params.tac - k) / cmy.iter().sum::<f32>().max(1e-6)).clamp(0.0, 1.0);
|
||||
cmy = cmy.map(|v| v * s);
|
||||
}
|
||||
[cmy[0], cmy[1], cmy[2], k].map(|v| if v < 1e-4 { 0.0 } else { v.min(1.0) })
|
||||
}
|
||||
|
||||
fn gbd(&self) -> &Gbd {
|
||||
self.gbd.get_or_init(|| {
|
||||
let mut cmax = vec![0.0f32; GBD_L * GBD_H];
|
||||
let mut cusp = vec![(0.0f32, 50.0f32); GBD_H];
|
||||
let n = 16;
|
||||
for ki in 0..=5 {
|
||||
let k = ki as f32 / 5.0;
|
||||
for ci in 0..=n {
|
||||
for mi in 0..=n {
|
||||
for yi in 0..=n {
|
||||
let (c, m, y) = (ci as f32 / n as f32, mi as f32 / n as f32, yi as f32 / n as f32);
|
||||
if c + m + y + k > self.params.tac + 1e-3 {
|
||||
continue;
|
||||
}
|
||||
let lab = self.to_lab_rel([c, m, y, k]);
|
||||
let li = ((lab.l / 2.0).round() as usize).min(GBD_L - 1);
|
||||
let hi = ((lab.hue() / 5.0) as usize).min(GBD_H - 1);
|
||||
let ch = lab.chroma();
|
||||
let cell = &mut cmax[li * GBD_H + hi];
|
||||
*cell = cell.max(ch);
|
||||
if ch > cusp[hi].0 {
|
||||
cusp[hi] = (ch, lab.l);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// Fill holes: take the max over neighbouring hue bins, then interpolate along L.
|
||||
let mut smooth = cmax.clone();
|
||||
for li in 0..GBD_L {
|
||||
for hi in 0..GBD_H {
|
||||
let get = |h: usize| cmax[li * GBD_H + h % GBD_H];
|
||||
smooth[li * GBD_H + hi] = get(hi).max(get(hi + 1)).max(get(hi + GBD_H - 1));
|
||||
}
|
||||
}
|
||||
for hi in 0..GBD_H {
|
||||
let col: Vec<f32> = (0..GBD_L).map(|li| smooth[li * GBD_H + hi]).collect();
|
||||
let known: Vec<usize> = (0..GBD_L).filter(|&li| col[li] > 0.0).collect();
|
||||
for li in 0..GBD_L {
|
||||
if col[li] > 0.0 {
|
||||
continue;
|
||||
}
|
||||
let below = known.iter().rev().find(|&&k| k < li);
|
||||
let above = known.iter().find(|&&k| k > li);
|
||||
smooth[li * GBD_H + hi] = match (below, above) {
|
||||
(Some(&b), Some(&a)) => col[b] + (col[a] - col[b]) * (li - b) as f32 / (a - b) as f32,
|
||||
_ => 0.0,
|
||||
};
|
||||
}
|
||||
}
|
||||
Gbd { cmax: smooth, cusp_l: cusp.into_iter().map(|c| c.1).collect() }
|
||||
})
|
||||
}
|
||||
|
||||
/// Maximum printable chroma at (L, hue), media-relative.
|
||||
pub fn max_chroma(&self, l: f32, hue: f32) -> f32 {
|
||||
let g = self.gbd();
|
||||
let lf = (l.clamp(0.0, 100.0) / 2.0).min((GBD_L - 1) as f32);
|
||||
let hf = hue.rem_euclid(360.0) / 5.0;
|
||||
let (l0, h0) = (lf.floor() as usize, hf.floor() as usize);
|
||||
let (l1, h1) = ((l0 + 1).min(GBD_L - 1), (h0 + 1) % GBD_H);
|
||||
let (tl, th) = (lf - l0 as f32, hf - h0 as f32);
|
||||
let at = |li: usize, hi: usize| g.cmax[li * GBD_H + hi % GBD_H];
|
||||
let a = at(l0, h0) * (1.0 - th) + at(l0, h1) * th;
|
||||
let b = at(l1, h0) * (1.0 - th) + at(l1, h1) * th;
|
||||
a * (1.0 - tl) + b * tl
|
||||
}
|
||||
|
||||
fn cusp_l(&self, hue: f32) -> f32 {
|
||||
self.gbd().cusp_l[((hue.rem_euclid(360.0) / 5.0) as usize).min(GBD_H - 1)]
|
||||
}
|
||||
|
||||
/// Map an absolute source Lab (D50; sRGB white = L 100) to the media-relative target for `intent`.
|
||||
pub fn gamut_map(&self, src: Lab, intent: Intent, bpc: bool) -> Lab {
|
||||
if intent == Intent::AbsoluteColorimetric {
|
||||
// Keep absolute colorimetry: express in media-relative terms without white mapping.
|
||||
let x = lab_to_xyz(src);
|
||||
return xyz_to_lab([x[0] * D50[0] / self.paper_xyz[0], x[1] * D50[1] / self.paper_xyz[1], x[2] * D50[2] / self.paper_xyz[2]]);
|
||||
}
|
||||
let mut lab = src;
|
||||
if bpc || matches!(intent, Intent::Perceptual | Intent::Saturation) {
|
||||
// Black-point compensation in XYZ: source black (0) → printable black.
|
||||
let x = lab_to_xyz(lab);
|
||||
let bp = self.black_rel;
|
||||
let y = [0, 1, 2].map(|i| bp[i] + x[i] * (1.0 - bp[i] / D50[i]));
|
||||
lab = xyz_to_lab(y);
|
||||
}
|
||||
match intent {
|
||||
Intent::Perceptual => {
|
||||
let c = lab.chroma();
|
||||
if c > 1e-3 {
|
||||
// Cusp-directed: very out-of-gamut colours also move towards the lightness
|
||||
// where the press reaches its maximum chroma for this hue.
|
||||
let h = lab.hue();
|
||||
let cmax0 = self.max_chroma(lab.l, h).max(1.0);
|
||||
if c > cmax0 {
|
||||
let w = ((c - cmax0) / c).clamp(0.0, 1.0) * 0.9;
|
||||
lab.l += (self.cusp_l(h) - lab.l) * w;
|
||||
}
|
||||
let cmax = self.max_chroma(lab.l, h).max(1.0);
|
||||
let r = c / cmax;
|
||||
const KNEE: f32 = 0.75;
|
||||
let r2 = if r > KNEE { KNEE + (1.0 - KNEE) * (1.0 - (-(r - KNEE) / (1.0 - KNEE)).exp()) } else { r };
|
||||
lab = Lab::from_lch(lab.l, r2 * cmax * 0.995, lab.hue());
|
||||
}
|
||||
lab
|
||||
}
|
||||
Intent::Saturation => {
|
||||
let (c, h) = (lab.chroma(), lab.hue());
|
||||
if c > 1e-3 {
|
||||
let mut l = lab.l;
|
||||
let cmax = self.max_chroma(l, h);
|
||||
if c > cmax {
|
||||
let cl = self.cusp_l(h);
|
||||
l += (cl - l) * ((c - cmax) / c.max(1.0)).clamp(0.0, 1.0);
|
||||
}
|
||||
let cmax = self.max_chroma(l, h);
|
||||
lab = Lab::from_lch(l, (c * 1.1).min(cmax * 0.995), h);
|
||||
}
|
||||
lab
|
||||
}
|
||||
_ => lab,
|
||||
}
|
||||
}
|
||||
|
||||
/// Convert an absolute Lab to CMYK with `intent`.
|
||||
pub fn from_lab(&self, src: Lab, intent: Intent, bpc: bool) -> [f32; 4] {
|
||||
self.separate(self.gamut_map(src, intent, bpc))
|
||||
}
|
||||
|
||||
pub fn from_srgb(&self, rgb: [f32; 3], intent: Intent, bpc: bool) -> [f32; 4] {
|
||||
self.from_lab(srgb_to_lab(rgb), intent, bpc)
|
||||
}
|
||||
|
||||
/// ΔE2000 between an sRGB colour and its best (relative colorimetric) reproduction.
|
||||
pub fn gamut_error(&self, src: Lab) -> f32 {
|
||||
let target = self.gamut_map(src, Intent::RelativeColorimetric, false);
|
||||
let cmyk = self.separate(target);
|
||||
delta_e2000(self.to_lab_rel(cmyk), target)
|
||||
}
|
||||
}
|
||||
|
||||
fn solve3(a: [[f32; 3]; 3], b: [f32; 3]) -> Option<[f32; 3]> {
|
||||
let det = a[0][0] * (a[1][1] * a[2][2] - a[1][2] * a[2][1]) - a[0][1] * (a[1][0] * a[2][2] - a[1][2] * a[2][0])
|
||||
+ a[0][2] * (a[1][0] * a[2][1] - a[1][1] * a[2][0]);
|
||||
if det.abs() < 1e-12 {
|
||||
return None;
|
||||
}
|
||||
let mut out = [0.0f32; 3];
|
||||
for (i, o) in out.iter_mut().enumerate() {
|
||||
let mut m = a;
|
||||
for r in 0..3 {
|
||||
m[r][i] = b[r];
|
||||
}
|
||||
*o = (m[0][0] * (m[1][1] * m[2][2] - m[1][2] * m[2][1]) - m[0][1] * (m[1][0] * m[2][2] - m[1][2] * m[2][0])
|
||||
+ m[0][2] * (m[1][0] * m[2][1] - m[1][1] * m[2][0]))
|
||||
/ det;
|
||||
}
|
||||
Some(out)
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
//! ICC profiles through `moxcms` (pure Rust): built-in RGB spaces and user-supplied `.icc` files.
|
||||
|
||||
use std::sync::{Arc, OnceLock};
|
||||
|
||||
use moxcms::{ColorProfile, DataColorSpace, Layout, ProfileText, RenderingIntent, TransformF32Executor, TransformOptions};
|
||||
|
||||
use super::{CmsError, Intent, ProfileKind};
|
||||
|
||||
fn moxcms_intent(i: Intent) -> RenderingIntent {
|
||||
match i {
|
||||
Intent::Perceptual => RenderingIntent::Perceptual,
|
||||
Intent::RelativeColorimetric => RenderingIntent::RelativeColorimetric,
|
||||
Intent::Saturation => RenderingIntent::Saturation,
|
||||
Intent::AbsoluteColorimetric => RenderingIntent::AbsoluteColorimetric,
|
||||
}
|
||||
}
|
||||
|
||||
const INTENTS: [Intent; 4] = [Intent::Perceptual, Intent::RelativeColorimetric, Intent::Saturation, Intent::AbsoluteColorimetric];
|
||||
|
||||
fn intent_index(i: Intent) -> usize {
|
||||
INTENTS.iter().position(|x| *x == i).unwrap_or(1)
|
||||
}
|
||||
|
||||
type Xf = Option<Arc<TransformF32Executor>>;
|
||||
|
||||
/// A parsed ICC profile plus lazily-built transforms to/from sRGB.
|
||||
pub struct IccProfile {
|
||||
pub name: String,
|
||||
pub kind: ProfileKind,
|
||||
profile: ColorProfile,
|
||||
srgb: ColorProfile,
|
||||
to_srgb: [OnceLock<Xf>; 4],
|
||||
from_srgb: [OnceLock<Xf>; 4],
|
||||
}
|
||||
|
||||
impl std::fmt::Debug for IccProfile {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.debug_struct("IccProfile").field("name", &self.name).field("kind", &self.kind).finish()
|
||||
}
|
||||
}
|
||||
|
||||
fn text(t: &Option<ProfileText>) -> Option<String> {
|
||||
match t.as_ref()? {
|
||||
ProfileText::PlainString(s) => Some(s.clone()),
|
||||
ProfileText::Localizable(v) => v.iter().find(|l| l.language == "en").or(v.first()).map(|l| l.value.clone()),
|
||||
ProfileText::Description(d) => Some(if d.ascii_string.is_empty() { d.unicode_string.clone() } else { d.ascii_string.clone() }),
|
||||
}
|
||||
.map(|s| s.trim_matches(char::from(0)).trim().to_string())
|
||||
.filter(|s| !s.is_empty())
|
||||
}
|
||||
|
||||
impl IccProfile {
|
||||
pub fn from_profile(name: Option<String>, profile: ColorProfile) -> Result<Self, CmsError> {
|
||||
let kind = match profile.color_space {
|
||||
DataColorSpace::Rgb => ProfileKind::Rgb,
|
||||
DataColorSpace::Cmyk => ProfileKind::Cmyk,
|
||||
DataColorSpace::Gray => ProfileKind::Gray,
|
||||
other => return Err(CmsError::Unsupported(format!("profile colour space {other:?} is not RGB, CMYK or Gray"))),
|
||||
};
|
||||
let name = name.or_else(|| text(&profile.description)).unwrap_or_else(|| format!("Untitled {kind:?} profile"));
|
||||
Ok(Self { name, kind, profile, srgb: ColorProfile::new_srgb(), to_srgb: Default::default(), from_srgb: Default::default() })
|
||||
}
|
||||
|
||||
pub fn from_bytes(name: Option<String>, bytes: &[u8]) -> Result<Self, CmsError> {
|
||||
let p = ColorProfile::new_from_slice(bytes).map_err(|e| CmsError::BadProfile(format!("{e:?}")))?;
|
||||
Self::from_profile(name, p)
|
||||
}
|
||||
|
||||
fn layout(&self) -> Layout {
|
||||
match self.kind {
|
||||
ProfileKind::Cmyk => Layout::Rgba, // 4 channels
|
||||
ProfileKind::Gray => Layout::Gray,
|
||||
ProfileKind::Rgb => Layout::Rgb,
|
||||
}
|
||||
}
|
||||
|
||||
fn channels(&self) -> usize {
|
||||
match self.kind {
|
||||
ProfileKind::Cmyk => 4,
|
||||
ProfileKind::Gray => 1,
|
||||
ProfileKind::Rgb => 3,
|
||||
}
|
||||
}
|
||||
|
||||
fn opts(intent: Intent) -> TransformOptions {
|
||||
TransformOptions { rendering_intent: moxcms_intent(intent), ..Default::default() }
|
||||
}
|
||||
|
||||
fn to_xf(&self, intent: Intent) -> Xf {
|
||||
self.to_srgb[intent_index(intent)]
|
||||
.get_or_init(|| self.profile.create_transform_f32(self.layout(), &self.srgb, Layout::Rgb, Self::opts(intent)).ok())
|
||||
.clone()
|
||||
}
|
||||
|
||||
fn inverse_xf(&self, intent: Intent) -> Xf {
|
||||
self.from_srgb[intent_index(intent)]
|
||||
.get_or_init(|| self.srgb.create_transform_f32(Layout::Rgb, &self.profile, self.layout(), Self::opts(intent)).ok())
|
||||
.clone()
|
||||
}
|
||||
|
||||
/// Device values (RGB / CMYK / Gray, 0..1) → sRGB.
|
||||
pub fn to_srgb(&self, v: &[f32], intent: Intent) -> Option<[f32; 3]> {
|
||||
let xf = self.to_xf(intent)?;
|
||||
let mut src = v.to_vec();
|
||||
src.resize(self.channels(), 0.0);
|
||||
let mut dst = [0.0f32; 3];
|
||||
xf.transform(&src, &mut dst).ok()?;
|
||||
Some(dst.map(|x| x.clamp(0.0, 1.0)))
|
||||
}
|
||||
|
||||
/// sRGB → device values.
|
||||
pub fn from_srgb(&self, rgb: [f32; 3], intent: Intent) -> Option<Vec<f32>> {
|
||||
let xf = self.inverse_xf(intent)?;
|
||||
let mut dst = vec![0.0f32; self.channels()];
|
||||
xf.transform(&rgb, &mut dst).ok()?;
|
||||
Some(dst.into_iter().map(|x| x.clamp(0.0, 1.0)).collect())
|
||||
}
|
||||
|
||||
/// Whether transforms can be built for this profile (checked at registration).
|
||||
pub fn usable(&self) -> bool {
|
||||
self.to_xf(Intent::RelativeColorimetric).is_some() && self.inverse_xf(Intent::RelativeColorimetric).is_some()
|
||||
}
|
||||
}
|
||||
|
||||
/// Built-in RGB working spaces from `moxcms`'s standard primaries.
|
||||
pub fn builtin_rgb(name: &str) -> Option<IccProfile> {
|
||||
let p = match name {
|
||||
super::ADOBE_RGB => ColorProfile::new_adobe_rgb(),
|
||||
super::DISPLAY_P3 => ColorProfile::new_display_p3(),
|
||||
super::PROPHOTO_RGB => ColorProfile::new_pro_photo_rgb(),
|
||||
super::SRGB => ColorProfile::new_srgb(),
|
||||
_ => return None,
|
||||
};
|
||||
IccProfile::from_profile(Some(name.to_string()), p).ok()
|
||||
}
|
||||
@@ -0,0 +1,203 @@
|
||||
//! CIE XYZ / L*a*b* maths (D50, the ICC profile connection space) and colour differences.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// D50 reference white (ICC PCS), Y = 1.
|
||||
pub const D50: [f32; 3] = [0.964_22, 1.0, 0.825_21];
|
||||
|
||||
/// A CIE L*a*b* colour (D50). L is 0..100, a/b roughly -128..127.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Serialize, Deserialize)]
|
||||
pub struct Lab {
|
||||
pub l: f32,
|
||||
pub a: f32,
|
||||
pub b: f32,
|
||||
}
|
||||
|
||||
impl Lab {
|
||||
pub const fn new(l: f32, a: f32, b: f32) -> Self {
|
||||
Self { l, a, b }
|
||||
}
|
||||
/// Chroma (C*ab).
|
||||
pub fn chroma(&self) -> f32 {
|
||||
self.a.hypot(self.b)
|
||||
}
|
||||
/// Hue angle in degrees 0..360.
|
||||
pub fn hue(&self) -> f32 {
|
||||
self.b.atan2(self.a).to_degrees().rem_euclid(360.0)
|
||||
}
|
||||
pub fn from_lch(l: f32, c: f32, h_deg: f32) -> Self {
|
||||
let h = h_deg.to_radians();
|
||||
Self { l, a: c * h.cos(), b: c * h.sin() }
|
||||
}
|
||||
}
|
||||
|
||||
const EPS: f32 = 216.0 / 24389.0;
|
||||
const KAPPA: f32 = 24389.0 / 27.0;
|
||||
|
||||
pub fn xyz_to_lab(xyz: [f32; 3]) -> Lab {
|
||||
let f = |t: f32| if t > EPS { t.cbrt() } else { (KAPPA * t + 16.0) / 116.0 };
|
||||
let fx = f(xyz[0] / D50[0]);
|
||||
let fy = f(xyz[1] / D50[1]);
|
||||
let fz = f(xyz[2] / D50[2]);
|
||||
Lab { l: 116.0 * fy - 16.0, a: 500.0 * (fx - fy), b: 200.0 * (fy - fz) }
|
||||
}
|
||||
|
||||
pub fn lab_to_xyz(lab: Lab) -> [f32; 3] {
|
||||
let fy = (lab.l + 16.0) / 116.0;
|
||||
let fx = fy + lab.a / 500.0;
|
||||
let fz = fy - lab.b / 200.0;
|
||||
let inv = |f: f32| {
|
||||
let f3 = f * f * f;
|
||||
if f3 > EPS { f3 } else { (116.0 * f - 16.0) / KAPPA }
|
||||
};
|
||||
let y = if lab.l > KAPPA * EPS { fy * fy * fy } else { lab.l / KAPPA };
|
||||
[inv(fx) * D50[0], y * D50[1], inv(fz) * D50[2]]
|
||||
}
|
||||
|
||||
/// sRGB transfer: encoded → linear.
|
||||
pub fn srgb_to_linear(v: f32) -> f32 {
|
||||
if v <= 0.040_45 { v / 12.92 } else { ((v + 0.055) / 1.055).powf(2.4) }
|
||||
}
|
||||
/// sRGB transfer: linear → encoded.
|
||||
pub fn linear_to_srgb(v: f32) -> f32 {
|
||||
if v <= 0.003_130_8 { v * 12.92 } else { 1.055 * v.max(0.0).powf(1.0 / 2.4) - 0.055 }
|
||||
}
|
||||
|
||||
#[allow(clippy::excessive_precision)]
|
||||
// ICC sRGB colorants, Bradford-adapted to D50.
|
||||
const SRGB_TO_XYZ: [[f32; 3]; 3] =
|
||||
[[0.436_074_7, 0.385_064_9, 0.143_080_4], [0.222_504_5, 0.716_878_6, 0.060_616_9], [0.013_932_2, 0.097_104_5, 0.714_173_3]];
|
||||
#[allow(clippy::excessive_precision)]
|
||||
const XYZ_TO_SRGB: [[f32; 3]; 3] =
|
||||
[[3.133_856_1, -1.616_866_7, -0.490_614_6], [-0.978_768_4, 1.916_141_5, 0.033_454], [0.071_945_3, -0.228_991_4, 1.405_242_7]];
|
||||
|
||||
fn mul(m: &[[f32; 3]; 3], v: [f32; 3]) -> [f32; 3] {
|
||||
[
|
||||
m[0][0] * v[0] + m[0][1] * v[1] + m[0][2] * v[2],
|
||||
m[1][0] * v[0] + m[1][1] * v[1] + m[1][2] * v[2],
|
||||
m[2][0] * v[0] + m[2][1] * v[1] + m[2][2] * v[2],
|
||||
]
|
||||
}
|
||||
|
||||
/// Encoded sRGB (0..1) → XYZ D50.
|
||||
pub fn srgb_to_xyz(rgb: [f32; 3]) -> [f32; 3] {
|
||||
mul(&SRGB_TO_XYZ, rgb.map(srgb_to_linear))
|
||||
}
|
||||
/// XYZ D50 → encoded sRGB, unclipped.
|
||||
pub fn xyz_to_srgb_unclipped(xyz: [f32; 3]) -> [f32; 3] {
|
||||
mul(&XYZ_TO_SRGB, xyz).map(|v| v.signum() * linear_to_srgb(v.abs()))
|
||||
}
|
||||
/// XYZ D50 → encoded sRGB clipped to 0..1.
|
||||
pub fn xyz_to_srgb(xyz: [f32; 3]) -> [f32; 3] {
|
||||
// Snap float noise so paper white is exactly white.
|
||||
mul(&XYZ_TO_SRGB, xyz).map(|v| {
|
||||
let e = linear_to_srgb(v.clamp(0.0, 1.0));
|
||||
if e > 0.99999 {
|
||||
1.0
|
||||
} else if e < 1e-6 {
|
||||
0.0
|
||||
} else {
|
||||
e
|
||||
}
|
||||
})
|
||||
}
|
||||
pub fn srgb_to_lab(rgb: [f32; 3]) -> Lab {
|
||||
xyz_to_lab(srgb_to_xyz(rgb))
|
||||
}
|
||||
pub fn lab_to_srgb(lab: Lab) -> [f32; 3] {
|
||||
xyz_to_srgb(lab_to_xyz(lab))
|
||||
}
|
||||
/// Linear-light sRGB (for simulations that work on linear RGB).
|
||||
pub fn srgb_linear(rgb: [f32; 3]) -> [f32; 3] {
|
||||
rgb.map(srgb_to_linear)
|
||||
}
|
||||
pub(crate) fn mat_mul(m: &[[f32; 3]; 3], v: [f32; 3]) -> [f32; 3] {
|
||||
mul(m, v)
|
||||
}
|
||||
|
||||
/// CIE76 colour difference.
|
||||
pub fn delta_e76(x: Lab, y: Lab) -> f32 {
|
||||
((x.l - y.l).powi(2) + (x.a - y.a).powi(2) + (x.b - y.b).powi(2)).sqrt()
|
||||
}
|
||||
|
||||
/// CIEDE2000 colour difference (kL = kC = kH = 1).
|
||||
pub fn delta_e2000(x: Lab, y: Lab) -> f32 {
|
||||
let (l1, a1, b1) = (x.l as f64, x.a as f64, x.b as f64);
|
||||
let (l2, a2, b2) = (y.l as f64, y.a as f64, y.b as f64);
|
||||
let c1 = a1.hypot(b1);
|
||||
let c2 = a2.hypot(b2);
|
||||
let cm = (c1 + c2) / 2.0;
|
||||
let g = 0.5 * (1.0 - (cm.powi(7) / (cm.powi(7) + 25f64.powi(7))).sqrt());
|
||||
let a1p = a1 * (1.0 + g);
|
||||
let a2p = a2 * (1.0 + g);
|
||||
let c1p = a1p.hypot(b1);
|
||||
let c2p = a2p.hypot(b2);
|
||||
let hp = |b: f64, a: f64| if b == 0.0 && a == 0.0 { 0.0 } else { b.atan2(a).to_degrees().rem_euclid(360.0) };
|
||||
let h1p = hp(b1, a1p);
|
||||
let h2p = hp(b2, a2p);
|
||||
let dlp = l2 - l1;
|
||||
let dcp = c2p - c1p;
|
||||
let dhp = if c1p * c2p == 0.0 {
|
||||
0.0
|
||||
} else if (h2p - h1p).abs() <= 180.0 {
|
||||
h2p - h1p
|
||||
} else if h2p - h1p > 180.0 {
|
||||
h2p - h1p - 360.0
|
||||
} else {
|
||||
h2p - h1p + 360.0
|
||||
};
|
||||
let dhp_big = 2.0 * (c1p * c2p).sqrt() * (dhp.to_radians() / 2.0).sin();
|
||||
let lpm = (l1 + l2) / 2.0;
|
||||
let cpm = (c1p + c2p) / 2.0;
|
||||
let hpm = if c1p * c2p == 0.0 {
|
||||
h1p + h2p
|
||||
} else if (h1p - h2p).abs() <= 180.0 {
|
||||
(h1p + h2p) / 2.0
|
||||
} else if h1p + h2p < 360.0 {
|
||||
(h1p + h2p + 360.0) / 2.0
|
||||
} else {
|
||||
(h1p + h2p - 360.0) / 2.0
|
||||
};
|
||||
let t = 1.0 - 0.17 * (hpm - 30.0).to_radians().cos() + 0.24 * (2.0 * hpm).to_radians().cos() + 0.32 * (3.0 * hpm + 6.0).to_radians().cos()
|
||||
- 0.20 * (4.0 * hpm - 63.0).to_radians().cos();
|
||||
let dtheta = 30.0 * (-((hpm - 275.0) / 25.0).powi(2)).exp();
|
||||
let rc = 2.0 * (cpm.powi(7) / (cpm.powi(7) + 25f64.powi(7))).sqrt();
|
||||
let sl = 1.0 + 0.015 * (lpm - 50.0).powi(2) / (20.0 + (lpm - 50.0).powi(2)).sqrt();
|
||||
let sc = 1.0 + 0.045 * cpm;
|
||||
let sh = 1.0 + 0.015 * cpm * t;
|
||||
let rt = -(2.0 * dtheta).to_radians().sin() * rc;
|
||||
let v = (dlp / sl).powi(2) + (dcp / sc).powi(2) + (dhp_big / sh).powi(2) + rt * (dcp / sc) * (dhp_big / sh);
|
||||
v.max(0.0).sqrt() as f32
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn white_and_black() {
|
||||
let w = srgb_to_lab([1.0, 1.0, 1.0]);
|
||||
assert!((w.l - 100.0).abs() < 0.05 && w.a.abs() < 0.05 && w.b.abs() < 0.05, "{w:?}");
|
||||
let k = srgb_to_lab([0.0, 0.0, 0.0]);
|
||||
assert!(k.l.abs() < 0.01);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lab_roundtrip() {
|
||||
for rgb in [[0.2, 0.4, 0.6], [1.0, 0.0, 0.0], [0.0, 1.0, 0.0], [0.9, 0.9, 0.1]] {
|
||||
let back = lab_to_srgb(srgb_to_lab(rgb));
|
||||
for i in 0..3 {
|
||||
assert!((back[i] - rgb[i]).abs() < 1e-3, "{rgb:?} → {back:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ciede2000_reference_pairs() {
|
||||
// Sharma, Wu & Dalal (2005) test data, pairs 1 and 7.
|
||||
let d = delta_e2000(Lab::new(50.0, 2.6772, -79.7751), Lab::new(50.0, 0.0, -82.7485));
|
||||
assert!((d - 2.0425).abs() < 1e-3, "{d}");
|
||||
let d = delta_e2000(Lab::new(50.0, 0.0, 0.0), Lab::new(50.0, -1.0, 2.0));
|
||||
assert!((d - 2.3669).abs() < 1e-3, "{d}");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,638 @@
|
||||
//! Colour management: working spaces, RGB ↔ CMYK ↔ Lab conversions with rendering intents,
|
||||
//! gamut checks and soft-proof transforms.
|
||||
//!
|
||||
//! * **RGB working spaces** are ICC matrix/shaper profiles via [`moxcms`] (sRGB, Adobe RGB (1998)
|
||||
//! compatible, Display P3, ProPhoto RGB) or user `.icc` files. Display output is sRGB.
|
||||
//! * **CMYK spaces:** [`GENERIC_CMYK`] is our own documented parametric press model
|
||||
//! ([`generic`]); [`DEVICE_CMYK`] is the old profile-free formula (kept for exact legacy numbers);
|
||||
//! any CMYK `.icc` the user loads goes through `moxcms` (LUT-based profiles).
|
||||
//! * The **active settings** (Edit → Color Settings) are process-wide; [`crate::Color::to_rgb`]
|
||||
//! uses them. Code that needs deterministic numbers builds its own [`Cms`] with [`Cms::new`].
|
||||
|
||||
pub mod generic;
|
||||
pub mod icc;
|
||||
pub mod lab;
|
||||
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::sync::{Arc, Mutex, OnceLock, RwLock};
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
pub use generic::GenericCmyk;
|
||||
pub use icc::IccProfile;
|
||||
pub use lab::{Lab, delta_e76, delta_e2000};
|
||||
|
||||
use crate::Color;
|
||||
|
||||
pub const SRGB: &str = "sRGB IEC61966-2.1";
|
||||
pub const ADOBE_RGB: &str = "Adobe RGB (1998) compatible";
|
||||
pub const DISPLAY_P3: &str = "Display P3";
|
||||
pub const PROPHOTO_RGB: &str = "ProPhoto RGB";
|
||||
/// Our parametric press model (see [`generic`]). Not an Adobe/ECI profile.
|
||||
pub const GENERIC_CMYK: &str = "DesignCraft Generic CMYK (SWOP-like)";
|
||||
/// Profile-free CMYK (`rgb = (1−c)(1−k)` …): uncalibrated, kept for legacy numbers.
|
||||
pub const DEVICE_CMYK: &str = "Device CMYK (uncalibrated)";
|
||||
|
||||
/// ΔE2000 above which a colour counts as out of the CMYK gamut (the gamut warning).
|
||||
pub const GAMUT_THRESHOLD: f32 = 2.0;
|
||||
|
||||
/// ICC rendering intent.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum Intent {
|
||||
Perceptual,
|
||||
#[default]
|
||||
RelativeColorimetric,
|
||||
Saturation,
|
||||
AbsoluteColorimetric,
|
||||
}
|
||||
|
||||
impl Intent {
|
||||
pub const ALL: [Intent; 4] = [Intent::Perceptual, Intent::RelativeColorimetric, Intent::Saturation, Intent::AbsoluteColorimetric];
|
||||
/// Parse `perceptual`, `relative`/`relativeColorimetric`, `saturation`, `absolute`/`absoluteColorimetric`.
|
||||
pub fn parse(s: &str) -> Option<Self> {
|
||||
Some(match s.to_ascii_lowercase().replace([' ', '_', '-'], "").as_str() {
|
||||
"perceptual" => Intent::Perceptual,
|
||||
"relative" | "relativecolorimetric" => Intent::RelativeColorimetric,
|
||||
"saturation" => Intent::Saturation,
|
||||
"absolute" | "absolutecolorimetric" => Intent::AbsoluteColorimetric,
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
pub fn id(&self) -> &'static str {
|
||||
match self {
|
||||
Intent::Perceptual => "perceptual",
|
||||
Intent::RelativeColorimetric => "relative",
|
||||
Intent::Saturation => "saturation",
|
||||
Intent::AbsoluteColorimetric => "absolute",
|
||||
}
|
||||
}
|
||||
pub fn label(&self) -> &'static str {
|
||||
match self {
|
||||
Intent::Perceptual => "Perceptual",
|
||||
Intent::RelativeColorimetric => "Relative Colorimetric",
|
||||
Intent::Saturation => "Saturation",
|
||||
Intent::AbsoluteColorimetric => "Absolute Colorimetric",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum ProfileKind {
|
||||
Rgb,
|
||||
Cmyk,
|
||||
Gray,
|
||||
}
|
||||
|
||||
/// A profile the user can pick.
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub struct ProfileInfo {
|
||||
pub name: String,
|
||||
pub kind: ProfileKind,
|
||||
pub builtin: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, thiserror::Error)]
|
||||
pub enum CmsError {
|
||||
#[error("unknown colour profile: {0}")]
|
||||
UnknownProfile(String),
|
||||
#[error("invalid ICC profile: {0}")]
|
||||
BadProfile(String),
|
||||
#[error("unsupported: {0}")]
|
||||
Unsupported(String),
|
||||
#[error("cannot read profile: {0}")]
|
||||
Io(String),
|
||||
}
|
||||
|
||||
/// Edit → Color Settings.
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ColorSettings {
|
||||
/// RGB working space.
|
||||
pub rgb: String,
|
||||
/// CMYK working space.
|
||||
pub cmyk: String,
|
||||
/// Conversion intent (Convert to CMYK, document colour mode).
|
||||
pub intent: Intent,
|
||||
/// Black-point compensation.
|
||||
pub bpc: bool,
|
||||
}
|
||||
|
||||
impl Default for ColorSettings {
|
||||
fn default() -> Self {
|
||||
Self { rgb: SRGB.into(), cmyk: GENERIC_CMYK.into(), intent: Intent::RelativeColorimetric, bpc: true }
|
||||
}
|
||||
}
|
||||
|
||||
/// Target colour model for [`Cms::convert`].
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum Model {
|
||||
Rgb,
|
||||
Cmyk,
|
||||
Gray,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
enum CmykSpace {
|
||||
Generic(Arc<GenericCmyk>),
|
||||
Device,
|
||||
Icc(Arc<IccProfile>),
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
enum RgbSpace {
|
||||
Srgb,
|
||||
Icc(Arc<IccProfile>),
|
||||
}
|
||||
|
||||
fn generic() -> Arc<GenericCmyk> {
|
||||
static G: OnceLock<Arc<GenericCmyk>> = OnceLock::new();
|
||||
G.get_or_init(|| Arc::new(GenericCmyk::new(generic::swop_like()))).clone()
|
||||
}
|
||||
|
||||
static USER: RwLock<Vec<Arc<IccProfile>>> = RwLock::new(Vec::new());
|
||||
|
||||
fn builtin_rgb_cache(name: &str) -> Option<Arc<IccProfile>> {
|
||||
static CACHE: Mutex<Vec<Arc<IccProfile>>> = Mutex::new(Vec::new());
|
||||
let mut c = CACHE.lock().unwrap_or_else(|e| e.into_inner());
|
||||
if let Some(p) = c.iter().find(|p| p.name == name) {
|
||||
return Some(p.clone());
|
||||
}
|
||||
let p = Arc::new(icc::builtin_rgb(name)?);
|
||||
c.push(p.clone());
|
||||
Some(p)
|
||||
}
|
||||
|
||||
fn user_profile(name: &str) -> Option<Arc<IccProfile>> {
|
||||
USER.read().unwrap_or_else(|e| e.into_inner()).iter().find(|p| p.name == name).cloned()
|
||||
}
|
||||
|
||||
fn cmyk_space(name: &str) -> Result<CmykSpace, CmsError> {
|
||||
match name {
|
||||
GENERIC_CMYK => Ok(CmykSpace::Generic(generic())),
|
||||
DEVICE_CMYK => Ok(CmykSpace::Device),
|
||||
_ => match user_profile(name) {
|
||||
Some(p) if p.kind == ProfileKind::Cmyk => Ok(CmykSpace::Icc(p)),
|
||||
Some(_) => Err(CmsError::Unsupported(format!("{name} is not a CMYK profile"))),
|
||||
None => Err(CmsError::UnknownProfile(name.into())),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
fn rgb_space(name: &str) -> Result<RgbSpace, CmsError> {
|
||||
if name == SRGB {
|
||||
return Ok(RgbSpace::Srgb);
|
||||
}
|
||||
if let Some(p) = builtin_rgb_cache(name) {
|
||||
return Ok(RgbSpace::Icc(p));
|
||||
}
|
||||
match user_profile(name) {
|
||||
Some(p) if p.kind == ProfileKind::Rgb => Ok(RgbSpace::Icc(p)),
|
||||
Some(_) => Err(CmsError::Unsupported(format!("{name} is not an RGB profile"))),
|
||||
None => Err(CmsError::UnknownProfile(name.into())),
|
||||
}
|
||||
}
|
||||
|
||||
/// Every profile available (built-in first, then user-loaded).
|
||||
pub fn profiles() -> Vec<ProfileInfo> {
|
||||
let mut v: Vec<ProfileInfo> = [SRGB, ADOBE_RGB, DISPLAY_P3, PROPHOTO_RGB]
|
||||
.iter()
|
||||
.map(|n| ProfileInfo { name: (*n).into(), kind: ProfileKind::Rgb, builtin: true })
|
||||
.chain([GENERIC_CMYK, DEVICE_CMYK].iter().map(|n| ProfileInfo { name: (*n).into(), kind: ProfileKind::Cmyk, builtin: true }))
|
||||
.collect();
|
||||
for p in USER.read().unwrap_or_else(|e| e.into_inner()).iter() {
|
||||
v.push(ProfileInfo { name: p.name.clone(), kind: p.kind, builtin: false });
|
||||
}
|
||||
v
|
||||
}
|
||||
|
||||
/// Register an ICC profile from bytes (named by its description unless `name` is given).
|
||||
/// Re-registering a name replaces the old profile.
|
||||
pub fn register_icc(bytes: &[u8], name: Option<String>) -> Result<ProfileInfo, CmsError> {
|
||||
let p = IccProfile::from_bytes(name, bytes)?;
|
||||
if p.kind == ProfileKind::Gray {
|
||||
return Err(CmsError::Unsupported("grayscale profiles can't be working spaces yet".into()));
|
||||
}
|
||||
if [SRGB, ADOBE_RGB, DISPLAY_P3, PROPHOTO_RGB, GENERIC_CMYK, DEVICE_CMYK].contains(&p.name.as_str()) {
|
||||
return Err(CmsError::Unsupported(format!("{} clashes with a built-in profile name", p.name)));
|
||||
}
|
||||
if !p.usable() {
|
||||
return Err(CmsError::Unsupported(format!("{}: no usable transform to/from sRGB", p.name)));
|
||||
}
|
||||
let info = ProfileInfo { name: p.name.clone(), kind: p.kind, builtin: false };
|
||||
let mut u = USER.write().unwrap_or_else(|e| e.into_inner());
|
||||
u.retain(|x| x.name != info.name);
|
||||
u.push(Arc::new(p));
|
||||
drop(u);
|
||||
clear_proof_cache();
|
||||
Ok(info)
|
||||
}
|
||||
|
||||
/// Load a `.icc`/`.icm` file from disk (native only).
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub fn load_icc_file(path: &std::path::Path) -> Result<ProfileInfo, CmsError> {
|
||||
let bytes = std::fs::read(path).map_err(|e| CmsError::Io(format!("{}: {e}", path.display())))?;
|
||||
register_icc(&bytes, None)
|
||||
}
|
||||
|
||||
/// A colour-management context: resolved working spaces plus conversion settings.
|
||||
#[derive(Clone)]
|
||||
pub struct Cms {
|
||||
settings: ColorSettings,
|
||||
rgb: RgbSpace,
|
||||
cmyk: CmykSpace,
|
||||
}
|
||||
|
||||
impl std::fmt::Debug for Cms {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.debug_struct("Cms").field("settings", &self.settings).finish()
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for Cms {
|
||||
fn default() -> Self {
|
||||
Self { settings: ColorSettings::default(), rgb: RgbSpace::Srgb, cmyk: CmykSpace::Generic(generic()) }
|
||||
}
|
||||
}
|
||||
|
||||
impl Cms {
|
||||
pub fn new(settings: &ColorSettings) -> Result<Self, CmsError> {
|
||||
Ok(Self { settings: settings.clone(), rgb: rgb_space(&settings.rgb)?, cmyk: cmyk_space(&settings.cmyk)? })
|
||||
}
|
||||
pub fn settings(&self) -> &ColorSettings {
|
||||
&self.settings
|
||||
}
|
||||
pub fn rgb_is_srgb(&self) -> bool {
|
||||
matches!(self.rgb, RgbSpace::Srgb)
|
||||
}
|
||||
|
||||
/// Working RGB → display sRGB.
|
||||
pub fn rgb_to_srgb(&self, rgb: [f32; 3]) -> [f32; 3] {
|
||||
match &self.rgb {
|
||||
RgbSpace::Srgb => rgb,
|
||||
RgbSpace::Icc(p) => p.to_srgb(&rgb, Intent::RelativeColorimetric).unwrap_or(rgb),
|
||||
}
|
||||
}
|
||||
/// Display sRGB → working RGB.
|
||||
pub fn srgb_to_rgb(&self, srgb: [f32; 3]) -> [f32; 3] {
|
||||
match &self.rgb {
|
||||
RgbSpace::Srgb => srgb,
|
||||
RgbSpace::Icc(p) => p.from_srgb(srgb, Intent::RelativeColorimetric).map(|v| [v[0], v[1], v[2]]).unwrap_or(srgb),
|
||||
}
|
||||
}
|
||||
|
||||
fn cmyk_to_srgb_in(space: &CmykSpace, cmyk: [f32; 4], absolute: bool) -> [f32; 3] {
|
||||
let cmyk = cmyk.map(|v| v.clamp(0.0, 1.0));
|
||||
match space {
|
||||
CmykSpace::Generic(g) => g.to_srgb(cmyk, absolute),
|
||||
CmykSpace::Device => naive_cmyk_to_rgb(cmyk),
|
||||
CmykSpace::Icc(p) => {
|
||||
let i = if absolute { Intent::AbsoluteColorimetric } else { Intent::RelativeColorimetric };
|
||||
p.to_srgb(&cmyk, i).unwrap_or_else(|| naive_cmyk_to_rgb(cmyk))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn srgb_to_cmyk_in(space: &CmykSpace, srgb: [f32; 3], intent: Intent, bpc: bool) -> [f32; 4] {
|
||||
match space {
|
||||
CmykSpace::Generic(g) => g.from_srgb(srgb, intent, bpc),
|
||||
CmykSpace::Device => naive_rgb_to_cmyk(srgb),
|
||||
CmykSpace::Icc(p) => p.from_srgb(srgb, intent).map(|v| [v[0], v[1], v[2], v[3]]).unwrap_or_else(|| naive_rgb_to_cmyk(srgb)),
|
||||
}
|
||||
}
|
||||
|
||||
/// CMYK in the working space → display sRGB (`absolute` simulates the paper colour).
|
||||
pub fn cmyk_to_srgb(&self, cmyk: [f32; 4], absolute: bool) -> [f32; 3] {
|
||||
Self::cmyk_to_srgb_in(&self.cmyk, cmyk, absolute)
|
||||
}
|
||||
/// Display sRGB → working CMYK with `intent`.
|
||||
pub fn srgb_to_cmyk(&self, srgb: [f32; 3], intent: Intent) -> [f32; 4] {
|
||||
Self::srgb_to_cmyk_in(&self.cmyk, srgb, intent, self.settings.bpc)
|
||||
}
|
||||
/// Media-relative Lab of a working-space CMYK colour.
|
||||
pub fn cmyk_to_lab(&self, cmyk: [f32; 4]) -> Lab {
|
||||
match &self.cmyk {
|
||||
CmykSpace::Generic(g) => g.to_lab_rel(cmyk.map(|v| v.clamp(0.0, 1.0))),
|
||||
_ => lab::srgb_to_lab(self.cmyk_to_srgb(cmyk, false)),
|
||||
}
|
||||
}
|
||||
/// Lab → working CMYK with `intent`.
|
||||
pub fn lab_to_cmyk(&self, l: Lab, intent: Intent) -> [f32; 4] {
|
||||
match &self.cmyk {
|
||||
CmykSpace::Generic(g) => g.from_lab(l, intent, self.settings.bpc),
|
||||
_ => self.srgb_to_cmyk(lab::lab_to_srgb(l), intent),
|
||||
}
|
||||
}
|
||||
|
||||
/// Display sRGB of any colour.
|
||||
pub fn display_rgb(&self, c: &Color) -> [f32; 3] {
|
||||
match *c {
|
||||
Color::Rgb { r, g, b } => self.rgb_to_srgb([r, g, b]),
|
||||
Color::Cmyk { c, m, y, k } => self.cmyk_to_srgb([c, m, y, k], false),
|
||||
Color::Gray { k } => [1.0 - k.clamp(0.0, 1.0); 3],
|
||||
}
|
||||
}
|
||||
|
||||
/// CIE Lab (D50) of any colour.
|
||||
pub fn lab(&self, c: &Color) -> Lab {
|
||||
match *c {
|
||||
Color::Cmyk { c, m, y, k } => self.cmyk_to_lab([c, m, y, k]),
|
||||
_ => lab::srgb_to_lab(self.display_rgb(c)),
|
||||
}
|
||||
}
|
||||
|
||||
/// Ink values of any colour in the working CMYK space (CMYK passes through; grey is K only).
|
||||
pub fn to_cmyk(&self, c: &Color, intent: Intent) -> [f32; 4] {
|
||||
match *c {
|
||||
Color::Cmyk { c, m, y, k } => [c, m, y, k],
|
||||
Color::Gray { k } => [0.0, 0.0, 0.0, k],
|
||||
Color::Rgb { .. } => self.srgb_to_cmyk(self.display_rgb(c), intent),
|
||||
}
|
||||
}
|
||||
|
||||
/// Convert `c` into `model` (a colour already in `model` is returned unchanged).
|
||||
pub fn convert(&self, c: &Color, model: Model, intent: Intent) -> Color {
|
||||
match (c, model) {
|
||||
(Color::Rgb { .. }, Model::Rgb) | (Color::Cmyk { .. }, Model::Cmyk) | (Color::Gray { .. }, Model::Gray) => *c,
|
||||
(_, Model::Cmyk) => {
|
||||
let [c, m, y, k] = self.to_cmyk(c, intent);
|
||||
Color::Cmyk { c, m, y, k }
|
||||
}
|
||||
(_, Model::Rgb) => {
|
||||
let [r, g, b] = self.srgb_to_rgb(self.display_rgb(c));
|
||||
Color::Rgb { r, g, b }
|
||||
}
|
||||
(_, Model::Gray) => {
|
||||
// Neutral with the same L* (grey ink percentage is 1 − lightness on screen).
|
||||
let l = self.lab(c).l;
|
||||
let v = lab::xyz_to_srgb(lab::lab_to_xyz(Lab::new(l, 0.0, 0.0)))[1];
|
||||
Color::Gray { k: (1.0 - v).clamp(0.0, 1.0) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Colour from Lab (in the working RGB space).
|
||||
pub fn from_lab(&self, l: Lab) -> Color {
|
||||
let [r, g, b] = self.srgb_to_rgb(lab::lab_to_srgb(l));
|
||||
Color::Rgb { r, g, b }
|
||||
}
|
||||
|
||||
/// How far (ΔE2000) the best CMYK reproduction of `c` is from `c` (0 for CMYK/grey colours).
|
||||
pub fn gamut_error(&self, c: &Color) -> f32 {
|
||||
match c {
|
||||
Color::Rgb { .. } => {
|
||||
let srgb = self.display_rgb(c);
|
||||
let src = lab::srgb_to_lab(srgb);
|
||||
match &self.cmyk {
|
||||
CmykSpace::Generic(g) => g.gamut_error(src),
|
||||
CmykSpace::Device => 0.0,
|
||||
CmykSpace::Icc(_) => {
|
||||
let back = self.cmyk_to_srgb(self.srgb_to_cmyk(srgb, Intent::RelativeColorimetric), false);
|
||||
delta_e2000(src, lab::srgb_to_lab(back))
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => 0.0,
|
||||
}
|
||||
}
|
||||
|
||||
/// Gamut warning: can this colour be printed in the working CMYK space?
|
||||
pub fn out_of_gamut(&self, c: &Color) -> bool {
|
||||
self.gamut_error(c) > GAMUT_THRESHOLD
|
||||
}
|
||||
|
||||
/// Soft-proof one display sRGB colour.
|
||||
pub fn proof_srgb(&self, srgb: [f32; 3], proof: &ProofSetup) -> [f32; 3] {
|
||||
let srgb = srgb.map(|v| v.clamp(0.0, 1.0));
|
||||
match &proof.target {
|
||||
ProofTarget::Srgb | ProofTarget::MonitorRgb => srgb,
|
||||
ProofTarget::LegacyMacRgb => srgb.map(|v| lab::linear_to_srgb(v.powf(1.8))),
|
||||
ProofTarget::Protanopia => simulate_cvd(srgb, &PROTANOPIA),
|
||||
ProofTarget::Deuteranopia => simulate_cvd(srgb, &DEUTERANOPIA),
|
||||
ProofTarget::WorkingCmyk => {
|
||||
let cmyk = self.srgb_to_cmyk(srgb, proof.intent);
|
||||
self.cmyk_to_srgb(cmyk, proof.simulate_paper)
|
||||
}
|
||||
ProofTarget::Cmyk(name) => match cmyk_space(name) {
|
||||
Ok(sp) => {
|
||||
let cmyk = Self::srgb_to_cmyk_in(&sp, srgb, proof.intent, self.settings.bpc);
|
||||
Self::cmyk_to_srgb_in(&sp, cmyk, proof.simulate_paper)
|
||||
}
|
||||
Err(_) => srgb,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/// Display sRGB of CMYK inks in the proof's CMYK space (the working space for RGB targets).
|
||||
pub fn proof_cmyk_to_srgb(&self, cmyk: [f32; 4], proof: &ProofSetup) -> [f32; 3] {
|
||||
match &proof.target {
|
||||
ProofTarget::Cmyk(name) => match cmyk_space(name) {
|
||||
Ok(sp) => Self::cmyk_to_srgb_in(&sp, cmyk, proof.simulate_paper),
|
||||
Err(_) => self.cmyk_to_srgb(cmyk, proof.simulate_paper),
|
||||
},
|
||||
_ => self.cmyk_to_srgb(cmyk, proof.simulate_paper),
|
||||
}
|
||||
}
|
||||
|
||||
/// A cached 3-D lookup table implementing [`Cms::proof_srgb`] for 8-bit pixels.
|
||||
pub fn proof_lut(&self, proof: &ProofSetup) -> Arc<ProofLut> {
|
||||
let key = format!("{:?}|{:?}|{:?}|{}", self.settings, proof.target, proof.intent, proof.simulate_paper);
|
||||
let mut cache = PROOF_CACHE.lock().unwrap_or_else(|e| e.into_inner());
|
||||
if let Some((_, l)) = cache.iter().find(|(k, _)| *k == key) {
|
||||
return l.clone();
|
||||
}
|
||||
let lut = Arc::new(ProofLut::build(|rgb| self.proof_srgb(rgb, proof)));
|
||||
if cache.len() >= 8 {
|
||||
cache.remove(0);
|
||||
}
|
||||
cache.push((key, lut.clone()));
|
||||
lut
|
||||
}
|
||||
}
|
||||
|
||||
static PROOF_CACHE: Mutex<Vec<(String, Arc<ProofLut>)>> = Mutex::new(Vec::new());
|
||||
|
||||
fn clear_proof_cache() {
|
||||
PROOF_CACHE.lock().unwrap_or_else(|e| e.into_inner()).clear();
|
||||
}
|
||||
|
||||
pub(crate) fn naive_cmyk_to_rgb([c, m, y, k]: [f32; 4]) -> [f32; 3] {
|
||||
[(1.0 - c) * (1.0 - k), (1.0 - m) * (1.0 - k), (1.0 - y) * (1.0 - k)]
|
||||
}
|
||||
|
||||
pub(crate) fn naive_rgb_to_cmyk([r, g, b]: [f32; 3]) -> [f32; 4] {
|
||||
let k = 1.0 - r.max(g).max(b);
|
||||
if k >= 1.0 {
|
||||
return [0.0, 0.0, 0.0, 1.0];
|
||||
}
|
||||
[(1.0 - r - k) / (1.0 - k), (1.0 - g - k) / (1.0 - k), (1.0 - b - k) / (1.0 - k), k]
|
||||
}
|
||||
|
||||
// Machado, Oliveira & Fernandes (2009) dichromacy simulation matrices (severity 1.0, linear RGB).
|
||||
const PROTANOPIA: [[f32; 3]; 3] = [[0.152_286, 1.052_583, -0.204_868], [0.114_503, 0.786_281, 0.099_216], [-0.003_882, -0.048_116, 1.051_998]];
|
||||
const DEUTERANOPIA: [[f32; 3]; 3] = [[0.367_322, 0.860_646, -0.227_968], [0.280_085, 0.672_501, 0.047_413], [-0.011_820, 0.042_940, 0.968_881]];
|
||||
|
||||
fn simulate_cvd(srgb: [f32; 3], m: &[[f32; 3]; 3]) -> [f32; 3] {
|
||||
lab::mat_mul(m, lab::srgb_linear(srgb)).map(|v| lab::linear_to_srgb(v.clamp(0.0, 1.0)))
|
||||
}
|
||||
|
||||
/// View → Proof Setup target.
|
||||
#[derive(Clone, Debug, Default, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||
#[serde(tag = "kind", content = "profile", rename_all = "camelCase")]
|
||||
pub enum ProofTarget {
|
||||
/// Working CMYK.
|
||||
#[default]
|
||||
WorkingCmyk,
|
||||
/// A specific CMYK profile (Customize…).
|
||||
Cmyk(String),
|
||||
/// Legacy Macintosh RGB (Gamma 1.8).
|
||||
LegacyMacRgb,
|
||||
/// Internet Standard RGB (sRGB).
|
||||
Srgb,
|
||||
/// Monitor RGB (no simulation).
|
||||
MonitorRgb,
|
||||
/// Colour blindness — protanopia type.
|
||||
Protanopia,
|
||||
/// Colour blindness — deuteranopia type.
|
||||
Deuteranopia,
|
||||
}
|
||||
|
||||
impl ProofTarget {
|
||||
pub fn parse(s: &str) -> Option<Self> {
|
||||
if let Some(p) = s.strip_prefix("cmyk:") {
|
||||
return Some(ProofTarget::Cmyk(p.to_string()));
|
||||
}
|
||||
Some(match s {
|
||||
"workingCmyk" | "cmyk" => ProofTarget::WorkingCmyk,
|
||||
"legacyMacRgb" | "macRgb" => ProofTarget::LegacyMacRgb,
|
||||
"srgb" | "sRGB" => ProofTarget::Srgb,
|
||||
"monitorRgb" | "monitor" => ProofTarget::MonitorRgb,
|
||||
"protanopia" => ProofTarget::Protanopia,
|
||||
"deuteranopia" => ProofTarget::Deuteranopia,
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
pub fn id(&self) -> String {
|
||||
match self {
|
||||
ProofTarget::WorkingCmyk => "workingCmyk".into(),
|
||||
ProofTarget::Cmyk(p) => format!("cmyk:{p}"),
|
||||
ProofTarget::LegacyMacRgb => "legacyMacRgb".into(),
|
||||
ProofTarget::Srgb => "srgb".into(),
|
||||
ProofTarget::MonitorRgb => "monitorRgb".into(),
|
||||
ProofTarget::Protanopia => "protanopia".into(),
|
||||
ProofTarget::Deuteranopia => "deuteranopia".into(),
|
||||
}
|
||||
}
|
||||
/// Whether the target simulates a CMYK press (separations make sense).
|
||||
pub fn is_cmyk(&self) -> bool {
|
||||
matches!(self, ProofTarget::WorkingCmyk | ProofTarget::Cmyk(_))
|
||||
}
|
||||
}
|
||||
|
||||
/// View → Proof Setup / Proof Colors / Separations Preview state for rendering.
|
||||
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ProofSetup {
|
||||
pub target: ProofTarget,
|
||||
pub intent: Intent,
|
||||
/// Simulate Paper Color (absolute colorimetric display of the press).
|
||||
#[serde(default)]
|
||||
pub simulate_paper: bool,
|
||||
/// Separations Preview: names of the visible plates ("Cyan", "Magenta", "Yellow", "Black",
|
||||
/// spot swatch names). `None` = normal composite proof.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub separations: Option<Vec<String>>,
|
||||
}
|
||||
|
||||
/// Process plate names in order.
|
||||
pub const PROCESS_PLATES: [&str; 4] = ["Cyan", "Magenta", "Yellow", "Black"];
|
||||
|
||||
const LUT_N: usize = 17;
|
||||
|
||||
/// A 17³ RGB → RGB lookup table with trilinear interpolation.
|
||||
pub struct ProofLut {
|
||||
data: Vec<[f32; 3]>,
|
||||
}
|
||||
|
||||
impl ProofLut {
|
||||
pub fn build(f: impl Fn([f32; 3]) -> [f32; 3]) -> Self {
|
||||
let n = LUT_N;
|
||||
let mut data = Vec::with_capacity(n * n * n);
|
||||
for r in 0..n {
|
||||
for g in 0..n {
|
||||
for b in 0..n {
|
||||
let s = (n - 1) as f32;
|
||||
data.push(f([r as f32 / s, g as f32 / s, b as f32 / s]));
|
||||
}
|
||||
}
|
||||
}
|
||||
Self { data }
|
||||
}
|
||||
pub fn apply(&self, rgb: [f32; 3]) -> [f32; 3] {
|
||||
let n = LUT_N;
|
||||
let s = (n - 1) as f32;
|
||||
let p = rgb.map(|v| v.clamp(0.0, 1.0) * s);
|
||||
let i = p.map(|v| (v.floor() as usize).min(n - 2));
|
||||
let t = [p[0] - i[0] as f32, p[1] - i[1] as f32, p[2] - i[2] as f32];
|
||||
let at = |r: usize, g: usize, b: usize| self.data[(r * n + g) * n + b];
|
||||
let mut out = [0.0f32; 3];
|
||||
for (dr, wr) in [(0, 1.0 - t[0]), (1, t[0])] {
|
||||
for (dg, wg) in [(0, 1.0 - t[1]), (1, t[1])] {
|
||||
for (db, wb) in [(0, 1.0 - t[2]), (1, t[2])] {
|
||||
let w = wr * wg * wb;
|
||||
if w == 0.0 {
|
||||
continue;
|
||||
}
|
||||
let v = at(i[0] + dr, i[1] + dg, i[2] + db);
|
||||
for c in 0..3 {
|
||||
out[c] += w * v[c];
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
pub fn apply8(&self, rgb: [u8; 3]) -> [u8; 3] {
|
||||
self.apply(rgb.map(|v| v as f32 / 255.0)).map(|v| (v.clamp(0.0, 1.0) * 255.0).round() as u8)
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- the active (process-wide) settings ----------
|
||||
|
||||
static ACTIVE: RwLock<Option<Arc<Cms>>> = RwLock::new(None);
|
||||
static RGB_IS_SRGB: AtomicBool = AtomicBool::new(true);
|
||||
static CMYK_IS_DEVICE: AtomicBool = AtomicBool::new(false);
|
||||
|
||||
/// The active colour-management context (Edit → Color Settings).
|
||||
pub fn active() -> Arc<Cms> {
|
||||
if let Some(c) = ACTIVE.read().unwrap_or_else(|e| e.into_inner()).as_ref() {
|
||||
return c.clone();
|
||||
}
|
||||
let mut w = ACTIVE.write().unwrap_or_else(|e| e.into_inner());
|
||||
w.get_or_insert_with(|| Arc::new(Cms::default())).clone()
|
||||
}
|
||||
|
||||
pub fn active_settings() -> ColorSettings {
|
||||
active().settings.clone()
|
||||
}
|
||||
|
||||
/// Replace the active settings (fails, leaving them unchanged, if a profile is unknown).
|
||||
pub fn set_active(settings: &ColorSettings) -> Result<(), CmsError> {
|
||||
let cms = Cms::new(settings)?;
|
||||
RGB_IS_SRGB.store(cms.rgb_is_srgb(), Ordering::Relaxed);
|
||||
CMYK_IS_DEVICE.store(matches!(cms.cmyk, CmykSpace::Device), Ordering::Relaxed);
|
||||
*ACTIVE.write().unwrap_or_else(|e| e.into_inner()) = Some(Arc::new(cms));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Fast path for [`Color::to_rgb`]: RGB colours need no conversion while the working space is sRGB.
|
||||
pub(crate) fn rgb_is_srgb() -> bool {
|
||||
RGB_IS_SRGB.load(Ordering::Relaxed)
|
||||
}
|
||||
pub(crate) fn cmyk_is_device() -> bool {
|
||||
CMYK_IS_DEVICE.load(Ordering::Relaxed)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
@@ -0,0 +1,202 @@
|
||||
use super::*;
|
||||
|
||||
fn cms() -> Cms {
|
||||
Cms::new(&ColorSettings::default()).unwrap()
|
||||
}
|
||||
|
||||
/// Without black-point compensation, relative colorimetric reproduces in-gamut colours exactly.
|
||||
fn cms_no_bpc() -> Cms {
|
||||
Cms::new(&ColorSettings { bpc: false, ..Default::default() }).unwrap()
|
||||
}
|
||||
|
||||
fn de(a: [f32; 3], b: [f32; 3]) -> f32 {
|
||||
delta_e2000(lab::srgb_to_lab(a), lab::srgb_to_lab(b))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn intents_parse_and_name() {
|
||||
for i in Intent::ALL {
|
||||
assert_eq!(Intent::parse(i.id()), Some(i));
|
||||
assert_eq!(Intent::parse(i.label()), Some(i));
|
||||
}
|
||||
assert_eq!(Intent::parse("bogus"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn srgb_via_moxcms_matches_analytic() {
|
||||
// The built-in sRGB ICC profile through moxcms agrees with our analytic sRGB maths.
|
||||
let p = icc::builtin_rgb(SRGB).unwrap();
|
||||
for rgb in [[0.2, 0.5, 0.8], [1.0, 0.0, 0.0], [0.5, 0.5, 0.5]] {
|
||||
let out = p.to_srgb(&rgb, Intent::RelativeColorimetric).unwrap();
|
||||
assert!(de(out, rgb) < 0.5, "{rgb:?} → {out:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn paper_white_and_rich_black() {
|
||||
let c = cms();
|
||||
assert_eq!(c.srgb_to_cmyk([1.0, 1.0, 1.0], Intent::RelativeColorimetric), [0.0; 4]);
|
||||
let k = c.srgb_to_cmyk([0.0, 0.0, 0.0], Intent::RelativeColorimetric);
|
||||
assert!(k[3] > 0.8, "black generation puts most of the black in K: {k:?}");
|
||||
assert!(k.iter().sum::<f32>() <= 3.0 + 1e-3, "TAC limit: {k:?}");
|
||||
assert!(k[0] > 0.3 && k[1] > 0.3 && k[2] > 0.3, "rich black: {k:?}");
|
||||
assert_eq!(c.cmyk_to_srgb([0.0; 4], false), [1.0, 1.0, 1.0]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rgb_cmyk_rgb_roundtrip_in_gamut() {
|
||||
let c = cms_no_bpc();
|
||||
for rgb in [[0.8, 0.6, 0.5], [0.5, 0.5, 0.5], [0.3, 0.45, 0.35], [0.9, 0.85, 0.3], [0.55, 0.4, 0.6], [0.35, 0.55, 0.7], [0.75, 0.75, 0.75]] {
|
||||
let cmyk = c.srgb_to_cmyk(rgb, Intent::RelativeColorimetric);
|
||||
let back = c.cmyk_to_srgb(cmyk, false);
|
||||
assert!(de(rgb, back) < 1.5, "{rgb:?} → {cmyk:?} → {back:?} ΔE {}", de(rgb, back));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cmyk_lab_cmyk_roundtrip() {
|
||||
let c = cms_no_bpc();
|
||||
for cmyk in [[0.2, 0.4, 0.6, 0.1], [0.0, 0.0, 0.0, 0.5], [0.5, 0.1, 0.0, 0.0], [0.1, 0.7, 0.3, 0.2]] {
|
||||
let l = c.cmyk_to_lab(cmyk);
|
||||
// Lab here is media-relative, i.e. what relative colorimetric reproduces exactly.
|
||||
let back = c.lab_to_cmyk(l, Intent::RelativeColorimetric);
|
||||
let l2 = c.cmyk_to_lab(back);
|
||||
assert!(delta_e2000(l, l2) < 1.0, "{cmyk:?} → {l:?} → {back:?} → {l2:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn gcr_puts_grey_into_black() {
|
||||
let k = cms().srgb_to_cmyk([0.4, 0.4, 0.4], Intent::RelativeColorimetric);
|
||||
assert!(k[3] > 0.2, "{k:?}");
|
||||
let spread = k[0].max(k[1]).max(k[2]) - k[0].min(k[1]).min(k[2]);
|
||||
assert!(spread < 0.2, "neutral stays balanced: {k:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn gamut_warning() {
|
||||
let c = cms();
|
||||
assert!(c.out_of_gamut(&Color::rgb(0.0, 0.0, 1.0)), "sRGB blue is outside SWOP-like CMYK");
|
||||
assert!(c.out_of_gamut(&Color::rgb(0.0, 1.0, 0.0)), "sRGB green is outside");
|
||||
assert!(!c.out_of_gamut(&Color::rgb(0.8, 0.6, 0.5)), "skin tone is printable");
|
||||
assert!(!c.out_of_gamut(&Color::rgb(0.5, 0.5, 0.5)));
|
||||
assert!(!c.out_of_gamut(&Color::cmyk(1.0, 0.0, 0.0, 0.0)), "CMYK colours are in gamut by definition");
|
||||
// The uncalibrated device space has no gamut limits.
|
||||
let dev = Cms::new(&ColorSettings { cmyk: DEVICE_CMYK.into(), ..Default::default() }).unwrap();
|
||||
assert!(!dev.out_of_gamut(&Color::rgb(0.0, 0.0, 1.0)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn intents_differ() {
|
||||
let c = cms();
|
||||
let blue = [0.0, 0.0, 1.0];
|
||||
let res: Vec<[f32; 4]> = Intent::ALL.iter().map(|i| c.srgb_to_cmyk(blue, *i)).collect();
|
||||
let dist = |a: [f32; 4], b: [f32; 4]| (0..4).map(|i| (a[i] - b[i]).abs()).sum::<f32>();
|
||||
assert!(dist(res[0], res[1]) > 0.02, "perceptual vs relative: {res:?}");
|
||||
assert!(dist(res[2], res[1]) > 0.02, "saturation vs relative: {res:?}");
|
||||
// Absolute colorimetry keeps the source white brighter than paper → less ink on light greys.
|
||||
let g = [0.88, 0.88, 0.88];
|
||||
let rel = c.srgb_to_cmyk(g, Intent::RelativeColorimetric);
|
||||
let abs = c.srgb_to_cmyk(g, Intent::AbsoluteColorimetric);
|
||||
assert!(rel.iter().sum::<f32>() > abs.iter().sum::<f32>() + 0.02, "{rel:?} vs {abs:?}");
|
||||
// Perceptual compresses in-gamut saturated colours too (relative leaves them alone).
|
||||
let red = lab::srgb_to_lab([0.8, 0.25, 0.25]);
|
||||
let pr = c.cmyk_to_lab(c.srgb_to_cmyk([0.8, 0.25, 0.25], Intent::Perceptual));
|
||||
let rr = c.cmyk_to_lab(c.srgb_to_cmyk([0.8, 0.25, 0.25], Intent::RelativeColorimetric));
|
||||
assert!(delta_e2000(rr, red) < delta_e2000(pr, red), "relative is closer for in-gamut colours");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn simulate_paper_tints_white() {
|
||||
let c = cms();
|
||||
let rel = c.cmyk_to_srgb([0.0; 4], false);
|
||||
let abs = c.cmyk_to_srgb([0.0; 4], true);
|
||||
assert_eq!(rel, [1.0; 3]);
|
||||
assert!(abs[0] < 0.98 && abs[2] < abs[0], "paper is darker and slightly warm: {abs:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn device_cmyk_keeps_legacy_numbers() {
|
||||
let dev = Cms::new(&ColorSettings { cmyk: DEVICE_CMYK.into(), ..Default::default() }).unwrap();
|
||||
assert_eq!(dev.cmyk_to_srgb([1.0, 0.0, 0.0, 0.0], false), [0.0, 1.0, 1.0]);
|
||||
assert_eq!(dev.srgb_to_cmyk([1.0, 0.0, 0.0], Intent::Perceptual), [0.0, 1.0, 1.0, 0.0]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn convert_between_models() {
|
||||
let c = cms();
|
||||
let red = Color::rgb(0.9, 0.2, 0.2);
|
||||
let cmyk = c.convert(&red, Model::Cmyk, Intent::RelativeColorimetric);
|
||||
assert!(matches!(cmyk, Color::Cmyk { .. }));
|
||||
assert_eq!(c.convert(&cmyk, Model::Cmyk, Intent::Perceptual), cmyk, "idempotent");
|
||||
let back = c.convert(&cmyk, Model::Rgb, Intent::RelativeColorimetric);
|
||||
assert!(de(back.to_rgb_uncalibrated(), [0.9, 0.2, 0.2]) < 6.0, "{back:?}");
|
||||
let g = c.convert(&Color::rgb(0.5, 0.5, 0.5), Model::Gray, Intent::RelativeColorimetric);
|
||||
let Color::Gray { k } = g else { panic!() };
|
||||
assert!((k - 0.5).abs() < 0.01, "{k}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wide_gamut_working_space() {
|
||||
let c = Cms::new(&ColorSettings { rgb: ADOBE_RGB.into(), ..Default::default() }).unwrap();
|
||||
assert!(!c.rgb_is_srgb());
|
||||
let grey = c.rgb_to_srgb([0.5, 0.5, 0.5]);
|
||||
assert!(de(grey, [0.5, 0.5, 0.5]) < 1.5, "{grey:?}");
|
||||
// Adobe RGB green is outside sRGB: it clips to (nearly) full sRGB green.
|
||||
let g = c.rgb_to_srgb([0.0, 1.0, 0.0]);
|
||||
assert!(g[1] > 0.95 && g[0] < 0.1, "{g:?}");
|
||||
let back = c.srgb_to_rgb(c.rgb_to_srgb([0.4, 0.5, 0.3]));
|
||||
assert!(de(back, [0.4, 0.5, 0.3]) < 1.0);
|
||||
assert!(matches!(Cms::new(&ColorSettings { rgb: "Nope".into(), ..Default::default() }), Err(CmsError::UnknownProfile(_))));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn proof_lut_matches_direct_proof() {
|
||||
let c = cms();
|
||||
let p = ProofSetup::default();
|
||||
let lut = c.proof_lut(&p);
|
||||
for rgb in [[0.8, 0.6, 0.5], [0.0, 0.0, 1.0], [0.3, 0.9, 0.2]] {
|
||||
let direct = c.proof_srgb(rgb, &p);
|
||||
let via_lut = lut.apply(rgb);
|
||||
assert!(de(direct, via_lut) < 2.5, "{rgb:?}: {direct:?} vs {via_lut:?}");
|
||||
}
|
||||
// Out-of-gamut blue gets visibly duller in the proof.
|
||||
let b = c.proof_srgb([0.0, 0.0, 1.0], &p);
|
||||
assert!(de(b, [0.0, 0.0, 1.0]) > 5.0, "{b:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn colour_blindness_proofs() {
|
||||
let c = cms();
|
||||
let p = ProofSetup { target: ProofTarget::Protanopia, ..Default::default() };
|
||||
let red = c.proof_srgb([0.9, 0.1, 0.1], &p);
|
||||
let green = c.proof_srgb([0.1, 0.6, 0.1], &p);
|
||||
assert!(de(red, green) < de([0.9, 0.1, 0.1], [0.1, 0.6, 0.1]) * 0.5, "red/green confusion");
|
||||
let d = ProofSetup { target: ProofTarget::Deuteranopia, ..Default::default() };
|
||||
assert_ne!(c.proof_srgb([0.9, 0.1, 0.1], &d), [0.9, 0.1, 0.1]);
|
||||
let m = ProofSetup { target: ProofTarget::MonitorRgb, ..Default::default() };
|
||||
assert_eq!(c.proof_srgb([0.2, 0.3, 0.4], &m), [0.2, 0.3, 0.4]);
|
||||
assert_eq!(ProofTarget::parse(&ProofTarget::Cmyk("X".into()).id()), Some(ProofTarget::Cmyk("X".into())));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bad_icc_rejected() {
|
||||
assert!(matches!(register_icc(b"not a profile", None), Err(CmsError::BadProfile(_))));
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
#[test]
|
||||
fn loads_system_cmyk_icc() {
|
||||
let path = std::path::Path::new("/System/Library/ColorSync/Profiles/Generic CMYK Profile.icc");
|
||||
if !path.exists() {
|
||||
return;
|
||||
}
|
||||
let info = load_icc_file(path).unwrap();
|
||||
assert_eq!(info.kind, ProfileKind::Cmyk);
|
||||
assert!(profiles().iter().any(|p| p.name == info.name && !p.builtin));
|
||||
let c = Cms::new(&ColorSettings { cmyk: info.name.clone(), ..Default::default() }).unwrap();
|
||||
let rgb = [0.7, 0.5, 0.4];
|
||||
let back = c.cmyk_to_srgb(c.srgb_to_cmyk(rgb, Intent::RelativeColorimetric), false);
|
||||
assert!(de(rgb, back) < 5.0, "{back:?}");
|
||||
assert_eq!(c.cmyk_to_srgb([0.0; 4], false).map(|v| (v * 255.0).round() as u8), [255, 255, 255]);
|
||||
}
|
||||
@@ -0,0 +1,224 @@
|
||||
//! Gradients: definitions (swatch-able) and their placement on an object.
|
||||
|
||||
use kurbo::{Affine, Point, Rect, Vec2};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::Color;
|
||||
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum GradientKind {
|
||||
#[default]
|
||||
Linear,
|
||||
Radial,
|
||||
/// Freeform gradients (points/lines) — rendered via a mesh approximation.
|
||||
Freeform,
|
||||
}
|
||||
|
||||
impl GradientKind {
|
||||
pub fn label(self) -> &'static str {
|
||||
match self {
|
||||
GradientKind::Linear => "Linear",
|
||||
GradientKind::Radial => "Radial",
|
||||
GradientKind::Freeform => "Freeform",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub struct GradientStop {
|
||||
/// 0..=1 along the gradient.
|
||||
pub offset: f32,
|
||||
pub color: Color,
|
||||
#[serde(default = "one")]
|
||||
pub opacity: f32,
|
||||
/// Midpoint to the next stop, 0.13..=0.87 (InDesign's diamond), default 0.5.
|
||||
#[serde(default = "half")]
|
||||
pub midpoint: f32,
|
||||
}
|
||||
|
||||
fn one() -> f32 {
|
||||
1.0
|
||||
}
|
||||
fn half() -> f32 {
|
||||
0.5
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub struct Gradient {
|
||||
pub kind: GradientKind,
|
||||
pub stops: Vec<GradientStop>,
|
||||
}
|
||||
|
||||
impl Default for Gradient {
|
||||
/// InDesign's default "White, Black" gradient.
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
kind: GradientKind::Linear,
|
||||
stops: vec![
|
||||
GradientStop { offset: 0.0, color: Color::WHITE, opacity: 1.0, midpoint: 0.5 },
|
||||
GradientStop { offset: 1.0, color: Color::BLACK, opacity: 1.0, midpoint: 0.5 },
|
||||
],
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Gradient {
|
||||
/// Colour and opacity at `t` (honours midpoints).
|
||||
pub fn sample(&self, t: f32) -> (Color, f32) {
|
||||
let stops = &self.stops;
|
||||
if stops.is_empty() {
|
||||
return (Color::BLACK, 1.0);
|
||||
}
|
||||
if t <= stops[0].offset {
|
||||
return (stops[0].color, stops[0].opacity);
|
||||
}
|
||||
for w in stops.windows(2) {
|
||||
let (a, b) = (&w[0], &w[1]);
|
||||
if t <= b.offset {
|
||||
let span = (b.offset - a.offset).max(1e-6);
|
||||
let u = (t - a.offset) / span;
|
||||
// Map through the midpoint: u=mid → 0.5.
|
||||
let m = a.midpoint.clamp(0.01, 0.99);
|
||||
let v = if u < m { 0.5 * u / m } else { 0.5 + 0.5 * (u - m) / (1.0 - m) };
|
||||
return (a.color.lerp(&b.color, v), a.opacity + (b.opacity - a.opacity) * v);
|
||||
}
|
||||
}
|
||||
let l = stops.last().unwrap();
|
||||
(l.color, l.opacity)
|
||||
}
|
||||
/// Stops expanded so that midpoints are represented as explicit stops (for renderers without midpoints).
|
||||
pub fn expanded_stops(&self) -> Vec<(f32, Color, f32)> {
|
||||
let mut out = Vec::new();
|
||||
for (i, s) in self.stops.iter().enumerate() {
|
||||
out.push((s.offset, s.color, s.opacity));
|
||||
if let Some(n) = self.stops.get(i + 1)
|
||||
&& (s.midpoint - 0.5).abs() > 1e-3
|
||||
{
|
||||
let t = s.offset + (n.offset - s.offset) * s.midpoint;
|
||||
let (c, o) = self.sample(t);
|
||||
out.push((t, c, o));
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
pub fn reverse(&mut self) {
|
||||
self.stops.reverse();
|
||||
for s in &mut self.stops {
|
||||
s.offset = 1.0 - s.offset;
|
||||
}
|
||||
}
|
||||
pub fn sort(&mut self) {
|
||||
self.stops.sort_by(|a, b| a.offset.total_cmp(&b.offset));
|
||||
}
|
||||
}
|
||||
|
||||
/// Where the gradient sits on an object, in document coordinates.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub struct GradientGeom {
|
||||
pub start: Point,
|
||||
pub end: Point,
|
||||
/// Radial aspect ratio (height / width), 1 = circle.
|
||||
#[serde(default = "one64")]
|
||||
pub aspect: f64,
|
||||
}
|
||||
|
||||
fn one64() -> f64 {
|
||||
1.0
|
||||
}
|
||||
|
||||
impl GradientGeom {
|
||||
/// Default placement for a bounding box: linear spans left→right through the centre at `angle_deg`,
|
||||
/// radial is centred with radius = half the larger dimension.
|
||||
pub fn fit(kind: GradientKind, b: Rect, angle_deg: f64) -> Self {
|
||||
let c = b.center();
|
||||
match kind {
|
||||
GradientKind::Radial => {
|
||||
let r = b.width().max(b.height()) / 2.0;
|
||||
Self { start: c, end: c + Vec2::new(r, 0.0), aspect: 1.0 }
|
||||
}
|
||||
_ => {
|
||||
let a = angle_deg.to_radians();
|
||||
let d = Vec2::new(a.cos(), -a.sin());
|
||||
// Project the box corners onto the direction to cover the whole box.
|
||||
let half = (b.width() * d.x.abs() + b.height() * d.y.abs()) / 2.0;
|
||||
Self { start: c - d * half, end: c + d * half, aspect: 1.0 }
|
||||
}
|
||||
}
|
||||
}
|
||||
pub fn transform(&mut self, a: Affine) {
|
||||
self.start = a * self.start;
|
||||
self.end = a * self.end;
|
||||
}
|
||||
pub fn angle_deg(&self) -> f64 {
|
||||
let v = self.end - self.start;
|
||||
(-v.y).atan2(v.x).to_degrees()
|
||||
}
|
||||
pub fn length(&self) -> f64 {
|
||||
(self.end - self.start).hypot()
|
||||
}
|
||||
}
|
||||
|
||||
/// A gradient applied to a fill or stroke.
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub struct GradientPaint {
|
||||
pub gradient: Gradient,
|
||||
/// None = fit to the object's bounds at `angle` each render (fresh gradients behave like this).
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub geom: Option<GradientGeom>,
|
||||
#[serde(default)]
|
||||
pub angle: f64,
|
||||
/// Name of the gradient swatch, if linked.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub swatch: Option<String>,
|
||||
}
|
||||
|
||||
impl GradientPaint {
|
||||
pub fn new(gradient: Gradient) -> Self {
|
||||
Self { gradient, geom: None, angle: 0.0, swatch: None }
|
||||
}
|
||||
pub fn resolve(&self, bounds: Rect) -> GradientGeom {
|
||||
self.geom.unwrap_or_else(|| GradientGeom::fit(self.gradient.kind, bounds, self.angle))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn sample_endpoints_and_mid() {
|
||||
let g = Gradient::default();
|
||||
assert_eq!(g.sample(0.0).0.to_hex(), "#ffffff");
|
||||
assert_eq!(g.sample(1.0).0.to_hex(), "#000000");
|
||||
let mid = g.sample(0.5).0.to_rgb()[0];
|
||||
assert!((mid - 0.5).abs() < 1e-5);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn midpoint_shifts() {
|
||||
let mut g = Gradient::default();
|
||||
g.stops[0].midpoint = 0.25;
|
||||
// At t = 0.25 we should be half way.
|
||||
assert!((g.sample(0.25).0.to_rgb()[0] - 0.5).abs() < 1e-5);
|
||||
assert_eq!(g.expanded_stops().len(), 3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fit_linear_horizontal() {
|
||||
let g = GradientGeom::fit(GradientKind::Linear, Rect::new(0.0, 0.0, 100.0, 50.0), 0.0);
|
||||
assert_eq!(g.start, Point::new(0.0, 25.0));
|
||||
assert_eq!(g.end, Point::new(100.0, 25.0));
|
||||
assert!((g.angle_deg()).abs() < 1e-9);
|
||||
let v = GradientGeom::fit(GradientKind::Linear, Rect::new(0.0, 0.0, 100.0, 50.0), 90.0);
|
||||
assert!((v.angle_deg() - 90.0).abs() < 1e-9);
|
||||
assert!((v.length() - 50.0).abs() < 1e-9);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reverse_stops() {
|
||||
let mut g = Gradient::default();
|
||||
g.reverse();
|
||||
assert_eq!(g.stops[0].color.to_hex(), "#000000");
|
||||
assert_eq!(g.stops[0].offset, 0.0);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
//! Colour harmony rules (the Color Guide panel).
|
||||
|
||||
use crate::Color;
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum Harmony {
|
||||
Complementary,
|
||||
SplitComplementary,
|
||||
LeftComplement,
|
||||
RightComplement,
|
||||
Analogous,
|
||||
Triad,
|
||||
Tetrad,
|
||||
Square,
|
||||
Shades,
|
||||
}
|
||||
|
||||
impl Harmony {
|
||||
pub const ALL: [Harmony; 9] = [
|
||||
Harmony::Complementary,
|
||||
Harmony::SplitComplementary,
|
||||
Harmony::LeftComplement,
|
||||
Harmony::RightComplement,
|
||||
Harmony::Analogous,
|
||||
Harmony::Triad,
|
||||
Harmony::Tetrad,
|
||||
Harmony::Square,
|
||||
Harmony::Shades,
|
||||
];
|
||||
pub fn label(self) -> &'static str {
|
||||
match self {
|
||||
Harmony::Complementary => "Complementary",
|
||||
Harmony::SplitComplementary => "Split Complementary",
|
||||
Harmony::LeftComplement => "Left Complement",
|
||||
Harmony::RightComplement => "Right Complement",
|
||||
Harmony::Analogous => "Analogous",
|
||||
Harmony::Triad => "Triad",
|
||||
Harmony::Tetrad => "Tetrad",
|
||||
Harmony::Square => "Square",
|
||||
Harmony::Shades => "Shades",
|
||||
}
|
||||
}
|
||||
/// Colours of the harmony, base colour first.
|
||||
pub fn apply(self, base: Color) -> Vec<Color> {
|
||||
let [h, s, v] = base.to_hsb();
|
||||
let hue = |d: f32| Color::from_hsb(h + d, s, v);
|
||||
match self {
|
||||
Harmony::Complementary => vec![base, hue(180.0)],
|
||||
Harmony::SplitComplementary => vec![base, hue(150.0), hue(210.0)],
|
||||
Harmony::LeftComplement => vec![base, hue(150.0)],
|
||||
Harmony::RightComplement => vec![base, hue(210.0)],
|
||||
Harmony::Analogous => vec![hue(-60.0), hue(-30.0), base, hue(30.0), hue(60.0)],
|
||||
Harmony::Triad => vec![base, hue(120.0), hue(240.0)],
|
||||
Harmony::Tetrad => vec![base, hue(60.0), hue(180.0), hue(240.0)],
|
||||
Harmony::Square => vec![base, hue(90.0), hue(180.0), hue(270.0)],
|
||||
Harmony::Shades => (0..5).map(|i| Color::from_hsb(h, s, (v * (1.0 - i as f32 * 0.18)).max(0.0))).collect(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Tints and shades row for the Color Guide variation grid.
|
||||
pub fn variations(base: Color, steps: usize) -> Vec<Color> {
|
||||
let n = steps.max(1) as f32;
|
||||
(0..steps)
|
||||
.map(|i| {
|
||||
let t = (i as f32 + 1.0) / (n + 1.0);
|
||||
if i < steps / 2 { base.lerp(&Color::BLACK, 1.0 - t * 2.0) } else { base.lerp(&Color::WHITE, (t - 0.5) * 2.0) }
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn complementary_red() {
|
||||
let c = Harmony::Complementary.apply(Color::rgb(1.0, 0.0, 0.0));
|
||||
assert_eq!(c[1].to_hex(), "#00ffff");
|
||||
assert_eq!(Harmony::Triad.apply(Color::rgb(1.0, 0.0, 0.0))[1].to_hex(), "#00ff00");
|
||||
assert_eq!(variations(Color::rgb(1.0, 0.0, 0.0), 6).len(), 6);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,287 @@
|
||||
//! Colour, paint and blend-mode types for DesignCraft.
|
||||
//!
|
||||
//! Colours keep the model the user picked them in (RGB, CMYK, Gray, HSB is a UI view of RGB), so
|
||||
//! documents don't drift when converting back and forth. Rendering asks for [`Color::to_rgba`].
|
||||
#![forbid(unsafe_code)]
|
||||
|
||||
pub mod blend;
|
||||
pub mod cms;
|
||||
pub mod gradient;
|
||||
pub mod harmony;
|
||||
pub mod swatch;
|
||||
|
||||
pub use blend::BlendMode;
|
||||
pub use gradient::{Gradient, GradientGeom, GradientKind, GradientPaint, GradientStop};
|
||||
pub use swatch::{ColorType, Swatch, SwatchValue, default_swatches};
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// A colour in its authoring model. Components are 0..=1.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(tag = "model", rename_all = "lowercase")]
|
||||
pub enum Color {
|
||||
Rgb { r: f32, g: f32, b: f32 },
|
||||
Cmyk { c: f32, m: f32, y: f32, k: f32 },
|
||||
Gray { k: f32 },
|
||||
}
|
||||
|
||||
impl Default for Color {
|
||||
fn default() -> Self {
|
||||
Color::BLACK
|
||||
}
|
||||
}
|
||||
|
||||
impl Color {
|
||||
pub const BLACK: Color = Color::Rgb { r: 0.0, g: 0.0, b: 0.0 };
|
||||
pub const WHITE: Color = Color::Rgb { r: 1.0, g: 1.0, b: 1.0 };
|
||||
|
||||
pub fn rgb(r: f32, g: f32, b: f32) -> Self {
|
||||
Color::Rgb { r, g, b }
|
||||
}
|
||||
pub fn rgb8(r: u8, g: u8, b: u8) -> Self {
|
||||
Color::Rgb { r: r as f32 / 255.0, g: g as f32 / 255.0, b: b as f32 / 255.0 }
|
||||
}
|
||||
pub fn cmyk(c: f32, m: f32, y: f32, k: f32) -> Self {
|
||||
Color::Cmyk { c, m, y, k }
|
||||
}
|
||||
pub fn gray(k: f32) -> Self {
|
||||
Color::Gray { k }
|
||||
}
|
||||
|
||||
/// Display (sRGB) colour through the active colour settings ([`cms::active`]): RGB is
|
||||
/// converted from the working RGB space, CMYK through the working CMYK profile (relative
|
||||
/// colorimetric). Grey is ink percentage (0 = white).
|
||||
pub fn to_rgb(&self) -> [f32; 3] {
|
||||
match *self {
|
||||
Color::Rgb { r, g, b } if cms::rgb_is_srgb() => [r, g, b],
|
||||
Color::Cmyk { c, m, y, k } if cms::cmyk_is_device() => cms::naive_cmyk_to_rgb([c, m, y, k]),
|
||||
// InDesign's Gray is ink percentage: 0 = white, 1 = black.
|
||||
Color::Gray { k } => [1.0 - k; 3],
|
||||
_ => cms::active().display_rgb(self),
|
||||
}
|
||||
}
|
||||
/// Profile-free display RGB (`(1−c)(1−k)` …), the pre-colour-management formula.
|
||||
pub fn to_rgb_uncalibrated(&self) -> [f32; 3] {
|
||||
match *self {
|
||||
Color::Rgb { r, g, b } => [r, g, b],
|
||||
Color::Cmyk { c, m, y, k } => cms::naive_cmyk_to_rgb([c, m, y, k]),
|
||||
Color::Gray { k } => [1.0 - k; 3],
|
||||
}
|
||||
}
|
||||
/// CIE Lab (D50) through the active colour settings.
|
||||
pub fn to_lab(&self) -> cms::Lab {
|
||||
cms::active().lab(self)
|
||||
}
|
||||
/// Colour-managed CMYK in the working CMYK space with `intent` (see [`Color::to_cmyk`] for the
|
||||
/// profile-free formula).
|
||||
pub fn to_cmyk_managed(&self, intent: cms::Intent) -> [f32; 4] {
|
||||
cms::active().to_cmyk(self, intent)
|
||||
}
|
||||
/// Gamut warning: true when this colour can't be reproduced in the working CMYK space.
|
||||
pub fn out_of_gamut(&self) -> bool {
|
||||
cms::active().out_of_gamut(self)
|
||||
}
|
||||
pub fn to_rgba8(&self, alpha: f32) -> [u8; 4] {
|
||||
let [r, g, b] = self.to_rgb();
|
||||
let q = |v: f32| (v.clamp(0.0, 1.0) * 255.0).round() as u8;
|
||||
[q(r), q(g), q(b), q(alpha)]
|
||||
}
|
||||
/// Profile-free CMYK (`k = 1 − max(r, g, b)` …), kept for exact legacy numbers; colour-managed
|
||||
/// separations use [`Color::to_cmyk_managed`].
|
||||
pub fn to_cmyk(&self) -> [f32; 4] {
|
||||
match *self {
|
||||
Color::Cmyk { c, m, y, k } => [c, m, y, k],
|
||||
_ => {
|
||||
let [r, g, b] = self.to_rgb();
|
||||
let k = 1.0 - r.max(g).max(b);
|
||||
if k >= 1.0 {
|
||||
return [0.0, 0.0, 0.0, 1.0];
|
||||
}
|
||||
[(1.0 - r - k) / (1.0 - k), (1.0 - g - k) / (1.0 - k), (1.0 - b - k) / (1.0 - k), k]
|
||||
}
|
||||
}
|
||||
}
|
||||
/// HSB with hue in degrees 0..360, saturation and brightness 0..1.
|
||||
pub fn to_hsb(&self) -> [f32; 3] {
|
||||
let [r, g, b] = self.to_rgb();
|
||||
let max = r.max(g).max(b);
|
||||
let min = r.min(g).min(b);
|
||||
let d = max - min;
|
||||
let h = if d <= 0.0 {
|
||||
0.0
|
||||
} else if max == r {
|
||||
60.0 * (((g - b) / d).rem_euclid(6.0))
|
||||
} else if max == g {
|
||||
60.0 * ((b - r) / d + 2.0)
|
||||
} else {
|
||||
60.0 * ((r - g) / d + 4.0)
|
||||
};
|
||||
let s = if max <= 0.0 { 0.0 } else { d / max };
|
||||
[h, s, max]
|
||||
}
|
||||
pub fn from_hsb(h: f32, s: f32, v: f32) -> Self {
|
||||
let h = h.rem_euclid(360.0) / 60.0;
|
||||
let c = v * s;
|
||||
let x = c * (1.0 - (h % 2.0 - 1.0).abs());
|
||||
let (r, g, b) = match h as u32 {
|
||||
0 => (c, x, 0.0),
|
||||
1 => (x, c, 0.0),
|
||||
2 => (0.0, c, x),
|
||||
3 => (0.0, x, c),
|
||||
4 => (x, 0.0, c),
|
||||
_ => (c, 0.0, x),
|
||||
};
|
||||
let m = v - c;
|
||||
Color::Rgb { r: r + m, g: g + m, b: b + m }
|
||||
}
|
||||
/// `#rrggbb` (display RGB).
|
||||
pub fn to_hex(&self) -> String {
|
||||
let [r, g, b, _] = self.to_rgba8(1.0);
|
||||
format!("#{r:02x}{g:02x}{b:02x}")
|
||||
}
|
||||
/// Parse `#rgb`, `#rrggbb` or a few CSS names.
|
||||
pub fn from_hex(s: &str) -> Option<Self> {
|
||||
let s = s.trim();
|
||||
match s.to_ascii_lowercase().as_str() {
|
||||
"black" => return Some(Color::BLACK),
|
||||
"white" => return Some(Color::WHITE),
|
||||
"red" => return Some(Color::rgb(1.0, 0.0, 0.0)),
|
||||
"green" => return Some(Color::rgb8(0, 128, 0)),
|
||||
"blue" => return Some(Color::rgb(0.0, 0.0, 1.0)),
|
||||
_ => {}
|
||||
}
|
||||
let h = s.strip_prefix('#').unwrap_or(s);
|
||||
let p = |i: usize, n: usize| u8::from_str_radix(h.get(i..i + n)?, 16).ok();
|
||||
match h.len() {
|
||||
3 => Some(Color::rgb8(p(0, 1)? * 17, p(1, 1)? * 17, p(2, 1)? * 17)),
|
||||
6 | 8 => Some(Color::rgb8(p(0, 2)?, p(2, 2)?, p(4, 2)?)),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
/// Complement (InDesign's Edit → Edit Colors → Invert is 1 - rgb; this is hue + 180).
|
||||
pub fn complement(&self) -> Self {
|
||||
let [h, s, v] = self.to_hsb();
|
||||
Color::from_hsb(h + 180.0, s, v)
|
||||
}
|
||||
pub fn invert(&self) -> Self {
|
||||
let [r, g, b] = self.to_rgb();
|
||||
Color::rgb(1.0 - r, 1.0 - g, 1.0 - b)
|
||||
}
|
||||
/// Linear interpolation in display RGB.
|
||||
pub fn lerp(&self, other: &Color, t: f32) -> Color {
|
||||
let a = self.to_rgb();
|
||||
let b = other.to_rgb();
|
||||
Color::rgb(a[0] + (b[0] - a[0]) * t, a[1] + (b[1] - a[1]) * t, a[2] + (b[2] - a[2]) * t)
|
||||
}
|
||||
pub fn model_name(&self) -> &'static str {
|
||||
match self {
|
||||
Color::Rgb { .. } => "RGB",
|
||||
Color::Cmyk { .. } => "CMYK",
|
||||
Color::Gray { .. } => "Grayscale",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// What fills or strokes an object.
|
||||
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(tag = "type", rename_all = "lowercase")]
|
||||
pub enum Paint {
|
||||
#[default]
|
||||
None,
|
||||
Solid {
|
||||
color: Color,
|
||||
/// Name of the global swatch this colour is linked to (edits to the swatch update it).
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
swatch: Option<String>,
|
||||
},
|
||||
Gradient(Box<GradientPaint>),
|
||||
Pattern {
|
||||
pattern: String,
|
||||
/// Pattern space → document placement (Transform Patterns edits it; identity = tiles
|
||||
/// anchored at the document origin).
|
||||
#[serde(default, skip_serializing_if = "is_identity")]
|
||||
xf: kurbo::Affine,
|
||||
},
|
||||
}
|
||||
|
||||
fn is_identity(a: &kurbo::Affine) -> bool {
|
||||
*a == kurbo::Affine::IDENTITY
|
||||
}
|
||||
|
||||
impl Paint {
|
||||
pub fn solid(c: Color) -> Self {
|
||||
Paint::Solid { color: c, swatch: None }
|
||||
}
|
||||
pub fn is_none(&self) -> bool {
|
||||
matches!(self, Paint::None)
|
||||
}
|
||||
pub fn color(&self) -> Option<Color> {
|
||||
match self {
|
||||
Paint::Solid { color, .. } => Some(*color),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
pub fn label(&self) -> String {
|
||||
match self {
|
||||
Paint::None => "None".into(),
|
||||
Paint::Solid { color, swatch: Some(n) } => format!("{n} ({})", color.to_hex()),
|
||||
Paint::Solid { color, .. } => color.to_hex(),
|
||||
Paint::Gradient(g) => format!("{} gradient", g.gradient.kind.label()),
|
||||
Paint::Pattern { pattern, .. } => format!("pattern {pattern}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn hex_roundtrip() {
|
||||
let c = Color::from_hex("#ff8000").unwrap();
|
||||
assert_eq!(c.to_hex(), "#ff8000");
|
||||
assert_eq!(Color::from_hex("#fff").unwrap().to_hex(), "#ffffff");
|
||||
assert_eq!(Color::from_hex("zz"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hsb_roundtrip() {
|
||||
for hex in ["#ff0000", "#00ff00", "#0000ff", "#336699", "#ffffff", "#000000", "#c0ffee"] {
|
||||
let c = Color::from_hex(hex).unwrap();
|
||||
let [h, s, v] = c.to_hsb();
|
||||
assert_eq!(Color::from_hsb(h, s, v).to_hex(), hex, "{hex}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cmyk_conversions() {
|
||||
assert_eq!(Color::cmyk(0.0, 0.0, 0.0, 1.0).to_rgb_uncalibrated(), [0.0, 0.0, 0.0]);
|
||||
assert_eq!(Color::cmyk(1.0, 0.0, 0.0, 0.0).to_rgb_uncalibrated(), [0.0, 1.0, 1.0]);
|
||||
// Managed display: paper is white, 100% cyan is a press cyan (not #00ffff).
|
||||
assert_eq!(Color::cmyk(0.0, 0.0, 0.0, 0.0).to_hex(), "#ffffff");
|
||||
let [r, g, b] = Color::cmyk(1.0, 0.0, 0.0, 0.0).to_rgb();
|
||||
assert!(r < 0.2 && g > 0.5 && b > 0.8, "{r} {g} {b}");
|
||||
let k = Color::rgb(1.0, 0.0, 0.0).to_cmyk();
|
||||
assert_eq!(k, [0.0, 1.0, 1.0, 0.0]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn gray_is_ink() {
|
||||
assert_eq!(Color::gray(0.0).to_hex(), "#ffffff");
|
||||
assert_eq!(Color::gray(1.0).to_hex(), "#000000");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn complement_and_invert() {
|
||||
assert_eq!(Color::rgb(1.0, 0.0, 0.0).complement().to_hex(), "#00ffff");
|
||||
assert_eq!(Color::rgb(1.0, 0.0, 0.0).invert().to_hex(), "#00ffff");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn paint_serde() {
|
||||
let p = Paint::solid(Color::rgb8(10, 20, 30));
|
||||
let s = serde_json::to_string(&p).unwrap();
|
||||
assert!(s.contains("\"type\":\"solid\""));
|
||||
assert_eq!(serde_json::from_str::<Paint>(&s).unwrap(), p);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,172 @@
|
||||
//! Document swatches with InDesign semantics: named swatches that page items reference by name,
|
||||
//! the four special swatches (`[None]`, `[Paper]`, `[Black]`, `[Registration]`), process vs spot
|
||||
//! colours, tints of a base swatch and gradient swatches. The default set's composition is ours;
|
||||
//! the CMYK naming convention (`C=100 M=0 Y=0 K=0`) is a plain description of the ink values.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::{Color, Gradient};
|
||||
|
||||
pub const NONE: &str = "[None]";
|
||||
pub const PAPER: &str = "[Paper]";
|
||||
pub const BLACK: &str = "[Black]";
|
||||
pub const REGISTRATION: &str = "[Registration]";
|
||||
|
||||
/// Process colours print as CMYK separations; spot colours on their own plate.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum ColorType {
|
||||
#[default]
|
||||
Process,
|
||||
Spot,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(tag = "kind", rename_all = "camelCase")]
|
||||
pub enum SwatchValue {
|
||||
None,
|
||||
Paper { color: Color },
|
||||
Registration,
|
||||
Color { color: Color, color_type: ColorType },
|
||||
/// A tint (0..=1) of another colour swatch.
|
||||
Tint { base: String, tint: f32 },
|
||||
Gradient { gradient: Gradient },
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub struct Swatch {
|
||||
pub name: String,
|
||||
pub value: SwatchValue,
|
||||
/// Special swatches can't be deleted or renamed.
|
||||
#[serde(default)]
|
||||
pub locked: bool,
|
||||
/// User-named (false = the name is derived from the colour values and tracks edits).
|
||||
#[serde(default)]
|
||||
pub named: bool,
|
||||
}
|
||||
|
||||
impl Swatch {
|
||||
pub fn color(name: &str, c: Color) -> Self {
|
||||
Swatch { name: name.into(), value: SwatchValue::Color { color: c, color_type: ColorType::Process }, locked: false, named: true }
|
||||
}
|
||||
pub fn cmyk(c: f32, m: f32, y: f32, k: f32) -> Self {
|
||||
let col = Color::cmyk(c, m, y, k);
|
||||
Swatch { name: cmyk_name(c, m, y, k), value: SwatchValue::Color { color: col, color_type: ColorType::Process }, locked: false, named: false }
|
||||
}
|
||||
pub fn is_special(&self) -> bool {
|
||||
matches!(self.value, SwatchValue::None | SwatchValue::Paper { .. } | SwatchValue::Registration) || self.name == BLACK
|
||||
}
|
||||
}
|
||||
|
||||
/// `C=100 M=0 Y=0 K=0`.
|
||||
pub fn cmyk_name(c: f32, m: f32, y: f32, k: f32) -> String {
|
||||
let p = |v: f32| (v * 100.0).round() as i32;
|
||||
format!("C={} M={} Y={} K={}", p(c), p(m), p(y), p(k))
|
||||
}
|
||||
|
||||
/// Resolve a swatch name + tint into a display colour. `None` for `[None]` or unknown names.
|
||||
/// Gradients resolve to their first stop (callers that render gradients use [`resolve_gradient`]).
|
||||
pub fn resolve(swatches: &[Swatch], name: &str, tint: f32) -> Option<Color> {
|
||||
resolve_depth(swatches, name, tint, 0)
|
||||
}
|
||||
|
||||
fn resolve_depth(swatches: &[Swatch], name: &str, tint: f32, depth: u8) -> Option<Color> {
|
||||
if depth > 8 {
|
||||
return None;
|
||||
}
|
||||
let s = swatches.iter().find(|s| s.name == name)?;
|
||||
let base = match &s.value {
|
||||
SwatchValue::None => return None,
|
||||
SwatchValue::Paper { color } => return Some(*color),
|
||||
SwatchValue::Registration => Color::cmyk(1.0, 1.0, 1.0, 1.0),
|
||||
SwatchValue::Color { color, .. } => *color,
|
||||
SwatchValue::Tint { base, tint: t } => return resolve_depth(swatches, base, t * tint, depth + 1),
|
||||
SwatchValue::Gradient { gradient } => gradient.stops.first().map(|s| s.color)?,
|
||||
};
|
||||
Some(apply_tint(base, tint))
|
||||
}
|
||||
|
||||
pub fn resolve_gradient<'a>(swatches: &'a [Swatch], name: &str) -> Option<&'a Gradient> {
|
||||
match &swatches.iter().find(|s| s.name == name)?.value {
|
||||
SwatchValue::Gradient { gradient } => Some(gradient),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Tint = ink percentage: CMYK/Gray components scale; RGB mixes towards white.
|
||||
pub fn apply_tint(c: Color, tint: f32) -> Color {
|
||||
let t = tint.clamp(0.0, 1.0);
|
||||
if (t - 1.0).abs() < f32::EPSILON {
|
||||
return c;
|
||||
}
|
||||
match c {
|
||||
Color::Cmyk { c, m, y, k } => Color::cmyk(c * t, m * t, y * t, k * t),
|
||||
Color::Gray { k } => Color::gray(k * t),
|
||||
Color::Rgb { r, g, b } => Color::rgb(1.0 - (1.0 - r) * t, 1.0 - (1.0 - g) * t, 1.0 - (1.0 - b) * t),
|
||||
}
|
||||
}
|
||||
|
||||
/// A new document's swatches, in panel order.
|
||||
pub fn default_swatches() -> Vec<Swatch> {
|
||||
let special = |name: &str, value: SwatchValue| Swatch { name: name.into(), value, locked: true, named: true };
|
||||
let mut v = vec![
|
||||
special(NONE, SwatchValue::None),
|
||||
special(REGISTRATION, SwatchValue::Registration),
|
||||
special(PAPER, SwatchValue::Paper { color: Color::WHITE }),
|
||||
special(BLACK, SwatchValue::Color { color: Color::cmyk(0.0, 0.0, 0.0, 1.0), color_type: ColorType::Process }),
|
||||
];
|
||||
for (c, m, y, k) in [
|
||||
(1.0, 0.0, 0.0, 0.0),
|
||||
(0.0, 1.0, 0.0, 0.0),
|
||||
(0.0, 0.0, 1.0, 0.0),
|
||||
(0.0, 1.0, 1.0, 0.0),
|
||||
(1.0, 0.0, 1.0, 0.0),
|
||||
(1.0, 1.0, 0.0, 0.0),
|
||||
(0.15, 1.0, 1.0, 0.0),
|
||||
(0.75, 0.05, 1.0, 0.0),
|
||||
(1.0, 0.9, 0.1, 0.0),
|
||||
] {
|
||||
v.push(Swatch::cmyk(c, m, y, k));
|
||||
}
|
||||
v
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn defaults_start_with_specials() {
|
||||
let s = default_swatches();
|
||||
assert_eq!(s[0].name, NONE);
|
||||
assert_eq!(s[3].name, BLACK);
|
||||
assert!(s[..4].iter().all(|s| s.locked));
|
||||
assert!(s.iter().any(|s| s.name == "C=100 M=0 Y=0 K=0"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolves_tints_and_specials() {
|
||||
let mut s = default_swatches();
|
||||
s.push(Swatch { name: "Half cyan".into(), value: SwatchValue::Tint { base: "C=100 M=0 Y=0 K=0".into(), tint: 0.5 }, locked: false, named: true });
|
||||
assert_eq!(resolve(&s, NONE, 1.0), None);
|
||||
assert_eq!(resolve(&s, PAPER, 1.0), Some(Color::WHITE));
|
||||
assert_eq!(resolve(&s, BLACK, 0.4), Some(Color::cmyk(0.0, 0.0, 0.0, 0.4)));
|
||||
assert_eq!(resolve(&s, "Half cyan", 0.5), Some(Color::cmyk(0.25, 0.0, 0.0, 0.0)));
|
||||
assert_eq!(resolve(&s, "missing", 1.0), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tint_cycles_terminate() {
|
||||
let s = vec![
|
||||
Swatch { name: "a".into(), value: SwatchValue::Tint { base: "b".into(), tint: 0.5 }, locked: false, named: true },
|
||||
Swatch { name: "b".into(), value: SwatchValue::Tint { base: "a".into(), tint: 0.5 }, locked: false, named: true },
|
||||
];
|
||||
assert_eq!(resolve(&s, "a", 1.0), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rgb_tint_mixes_to_white() {
|
||||
let c = apply_tint(Color::rgb(1.0, 0.0, 0.0), 0.5);
|
||||
assert_eq!(c, Color::rgb(1.0, 0.5, 0.5));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
[package]
|
||||
name = "designcraft-doc"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
description = "DesignCraft document model: spreads, pages, parents, layers, frames, stories, styles, swatches"
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
|
||||
[dependencies]
|
||||
designcraft-geom = { workspace = true }
|
||||
designcraft-color = { workspace = true }
|
||||
serde = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
thiserror = { workspace = true }
|
||||
|
||||
[dev-dependencies]
|
||||
proptest = { workspace = true }
|
||||
@@ -0,0 +1,503 @@
|
||||
//! Text attributes. Every attribute exists in two forms generated by one macro:
|
||||
//! a sparse `…Attrs` (all `Option`, used by styles and local overrides) and a complete `…Props`
|
||||
//! (resolved values used by the composer). Resolution layers sparse attrs over defaults.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
macro_rules! attr_set {
|
||||
(
|
||||
$(#[$m:meta])*
|
||||
$attrs:ident / $props:ident {
|
||||
$( $(#[$fm:meta])* $field:ident : $ty:ty = $default:expr ),* $(,)?
|
||||
}
|
||||
) => {
|
||||
$(#[$m])*
|
||||
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(default, rename_all = "camelCase")]
|
||||
pub struct $attrs {
|
||||
$( $(#[$fm])* #[serde(skip_serializing_if = "Option::is_none")] pub $field: Option<$ty>, )*
|
||||
}
|
||||
|
||||
/// Fully resolved values.
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(default, rename_all = "camelCase")]
|
||||
pub struct $props {
|
||||
$( $(#[$fm])* pub $field: $ty, )*
|
||||
}
|
||||
|
||||
impl Default for $props {
|
||||
fn default() -> Self {
|
||||
Self { $( $field: $default, )* }
|
||||
}
|
||||
}
|
||||
|
||||
impl $attrs {
|
||||
/// Overlay `other` on top of `self` (fields set in `other` win).
|
||||
pub fn merge(&mut self, other: &$attrs) {
|
||||
$( if other.$field.is_some() { self.$field = other.$field.clone(); } )*
|
||||
}
|
||||
pub fn is_empty(&self) -> bool {
|
||||
true $( && self.$field.is_none() )*
|
||||
}
|
||||
/// Number of fields set (for "+" override indicators).
|
||||
pub fn count(&self) -> usize {
|
||||
0 $( + self.$field.is_some() as usize )*
|
||||
}
|
||||
/// Field names that are set (camelCase).
|
||||
pub fn keys(&self) -> Vec<&'static str> {
|
||||
let mut v = Vec::new();
|
||||
$( if self.$field.is_some() { v.push(attr_key(stringify!($field))); } )*
|
||||
v
|
||||
}
|
||||
/// Set one field from JSON by camelCase or snake_case name.
|
||||
pub fn set_json(&mut self, key: &str, value: &serde_json::Value) -> Result<(), String> {
|
||||
let k: String = key.chars().filter(|c| *c != '_').collect::<String>().to_ascii_lowercase();
|
||||
$(
|
||||
if k == stringify!($field).replace('_', "") {
|
||||
if value.is_null() {
|
||||
self.$field = None;
|
||||
} else {
|
||||
self.$field = Some(serde_json::from_value(value.clone()).map_err(|e| format!("{key}: {e}"))?);
|
||||
}
|
||||
return Ok(());
|
||||
}
|
||||
)*
|
||||
Err(format!("unknown attribute `{key}`"))
|
||||
}
|
||||
/// Fields where `self` differs from `base`'s resolved value (used for "clear overrides").
|
||||
pub fn diff_from(&self, base: &$props) -> $attrs {
|
||||
let mut out = $attrs::default();
|
||||
$( if let Some(v) = &self.$field { if *v != base.$field { out.$field = Some(v.clone()); } } )*
|
||||
out
|
||||
}
|
||||
/// Names of every attribute.
|
||||
pub fn all_keys() -> &'static [&'static str] {
|
||||
&[ $( stringify!($field), )* ]
|
||||
}
|
||||
}
|
||||
|
||||
impl $props {
|
||||
pub fn apply(&mut self, a: &$attrs) {
|
||||
$( if let Some(v) = &a.$field { self.$field = v.clone(); } )*
|
||||
}
|
||||
/// The attributes where `self` and `other` differ, with `other`'s values.
|
||||
pub fn delta(&self, other: &$props) -> $attrs {
|
||||
let mut out = $attrs::default();
|
||||
$( if self.$field != other.$field { out.$field = Some(other.$field.clone()); } )*
|
||||
out
|
||||
}
|
||||
/// Keep only values common to all (`None` where they differ) — for mixed selections.
|
||||
pub fn common<'a>(items: impl IntoIterator<Item = &'a $props>) -> $attrs {
|
||||
let mut it = items.into_iter();
|
||||
let Some(first) = it.next() else { return $attrs::default() };
|
||||
let mut out = $attrs { $( $field: Some(first.$field.clone()), )* };
|
||||
for p in it {
|
||||
$( if out.$field.as_ref().is_some_and(|v| *v != p.$field) { out.$field = None; } )*
|
||||
}
|
||||
out
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
fn attr_key(snake: &'static str) -> &'static str {
|
||||
snake
|
||||
}
|
||||
|
||||
// ---------- value types ----------
|
||||
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase", tag = "kind", content = "value")]
|
||||
pub enum Leading {
|
||||
/// Auto leading (percentage of the largest size on the line, Justification → Auto Leading).
|
||||
#[default]
|
||||
Auto,
|
||||
Points(f64),
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase", tag = "kind", content = "value")]
|
||||
pub enum Kerning {
|
||||
#[default]
|
||||
Metrics,
|
||||
Optical,
|
||||
/// Zero ("0" in the kerning menu).
|
||||
None,
|
||||
/// Manual pair kerning in 1/1000 em (applies after this character).
|
||||
Manual(f64),
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum Capitalization {
|
||||
#[default]
|
||||
Normal,
|
||||
AllCaps,
|
||||
SmallCaps,
|
||||
OpenTypeAllSmallCaps,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum Position {
|
||||
#[default]
|
||||
Normal,
|
||||
Superscript,
|
||||
Subscript,
|
||||
OtSuperscript,
|
||||
OtSubscript,
|
||||
OtNumerator,
|
||||
OtDenominator,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum Align {
|
||||
#[default]
|
||||
Left,
|
||||
Center,
|
||||
Right,
|
||||
LeftJustified,
|
||||
CenterJustified,
|
||||
RightJustified,
|
||||
FullyJustified,
|
||||
TowardsSpine,
|
||||
AwayFromSpine,
|
||||
}
|
||||
|
||||
impl Align {
|
||||
pub fn is_justified(self) -> bool {
|
||||
matches!(self, Align::LeftJustified | Align::CenterJustified | Align::RightJustified | Align::FullyJustified)
|
||||
}
|
||||
pub const ALL: [Align; 9] = [
|
||||
Align::Left,
|
||||
Align::Center,
|
||||
Align::Right,
|
||||
Align::LeftJustified,
|
||||
Align::CenterJustified,
|
||||
Align::RightJustified,
|
||||
Align::FullyJustified,
|
||||
Align::TowardsSpine,
|
||||
Align::AwayFromSpine,
|
||||
];
|
||||
pub fn label(self) -> &'static str {
|
||||
match self {
|
||||
Align::Left => "Align left",
|
||||
Align::Center => "Align center",
|
||||
Align::Right => "Align right",
|
||||
Align::LeftJustified => "Justify with last line aligned left",
|
||||
Align::CenterJustified => "Justify with last line aligned center",
|
||||
Align::RightJustified => "Justify with last line aligned right",
|
||||
Align::FullyJustified => "Justify all lines",
|
||||
Align::TowardsSpine => "Align towards spine",
|
||||
Align::AwayFromSpine => "Align away from spine",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum Composer {
|
||||
/// Total-fit (Knuth–Plass) over the whole paragraph.
|
||||
#[default]
|
||||
Paragraph,
|
||||
/// Greedy, line by line.
|
||||
SingleLine,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum GridAlign {
|
||||
#[default]
|
||||
None,
|
||||
AllLines,
|
||||
FirstLineOnly,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum StartParagraph {
|
||||
#[default]
|
||||
Anywhere,
|
||||
NextColumn,
|
||||
NextFrame,
|
||||
NextPage,
|
||||
NextOddPage,
|
||||
NextEvenPage,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase", tag = "kind", content = "columns")]
|
||||
pub enum SpanColumns {
|
||||
#[default]
|
||||
Single,
|
||||
/// Span `n` columns (0 = all).
|
||||
Span(u32),
|
||||
/// Split into `n` sub-columns.
|
||||
Split(u32),
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum TabAlign {
|
||||
#[default]
|
||||
Left,
|
||||
Center,
|
||||
Right,
|
||||
/// Align on a character (default `.`).
|
||||
Char,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct TabStop {
|
||||
pub position: f64,
|
||||
#[serde(default)]
|
||||
pub align: TabAlign,
|
||||
#[serde(default, skip_serializing_if = "String::is_empty")]
|
||||
pub leader: String,
|
||||
#[serde(default, skip_serializing_if = "String::is_empty")]
|
||||
pub align_on: String,
|
||||
}
|
||||
|
||||
/// A paragraph rule (Rule Above / Rule Below).
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(default, rename_all = "camelCase")]
|
||||
pub struct Rule {
|
||||
pub on: bool,
|
||||
pub weight: f64,
|
||||
pub color: String,
|
||||
pub tint: f32,
|
||||
/// Width: column (true) or text (false).
|
||||
pub column_width: bool,
|
||||
pub offset: f64,
|
||||
pub left_indent: f64,
|
||||
pub right_indent: f64,
|
||||
}
|
||||
|
||||
impl Default for Rule {
|
||||
fn default() -> Self {
|
||||
Rule { on: false, weight: 1.0, color: "[Black]".into(), tint: 1.0, column_width: true, offset: 0.0, left_indent: 0.0, right_indent: 0.0 }
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum ListType {
|
||||
#[default]
|
||||
None,
|
||||
Bullets,
|
||||
Numbers,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum NumberStyle {
|
||||
#[default]
|
||||
Arabic,
|
||||
UpperRoman,
|
||||
LowerRoman,
|
||||
UpperLetters,
|
||||
LowerLetters,
|
||||
/// 01, 02, …
|
||||
ArabicLeadingZero,
|
||||
}
|
||||
|
||||
impl NumberStyle {
|
||||
pub fn format(self, n: u32) -> String {
|
||||
match self {
|
||||
NumberStyle::Arabic => n.to_string(),
|
||||
NumberStyle::ArabicLeadingZero => format!("{n:02}"),
|
||||
NumberStyle::UpperRoman => roman(n),
|
||||
NumberStyle::LowerRoman => roman(n).to_lowercase(),
|
||||
NumberStyle::UpperLetters => letters(n),
|
||||
NumberStyle::LowerLetters => letters(n).to_lowercase(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn roman(mut n: u32) -> String {
|
||||
if n == 0 {
|
||||
return "0".into();
|
||||
}
|
||||
let t = [(1000, "M"), (900, "CM"), (500, "D"), (400, "CD"), (100, "C"), (90, "XC"), (50, "L"), (40, "XL"), (10, "X"), (9, "IX"), (5, "V"), (4, "IV"), (1, "I")];
|
||||
let mut s = String::new();
|
||||
for (v, r) in t {
|
||||
while n >= v {
|
||||
s.push_str(r);
|
||||
n -= v;
|
||||
}
|
||||
}
|
||||
s
|
||||
}
|
||||
|
||||
/// A, B, …, Z, AA, BB, … (InDesign's letter numbering repeats the letter).
|
||||
fn letters(n: u32) -> String {
|
||||
if n == 0 {
|
||||
return String::new();
|
||||
}
|
||||
let i = (n - 1) % 26;
|
||||
let reps = (n - 1) / 26 + 1;
|
||||
std::iter::repeat_n(char::from(b'A' + i as u8), reps as usize).collect()
|
||||
}
|
||||
|
||||
attr_set! {
|
||||
/// Character attributes (Character panel / character styles / the character part of paragraph styles).
|
||||
CharAttrs / CharProps {
|
||||
font_family: String = "Source Serif 4".into(),
|
||||
font_style: String = "Regular".into(),
|
||||
/// Point size.
|
||||
size: f64 = 12.0,
|
||||
leading: Leading = Leading::Auto,
|
||||
kerning: Kerning = Kerning::Metrics,
|
||||
/// Tracking in 1/1000 em.
|
||||
tracking: f64 = 0.0,
|
||||
/// Horizontal / vertical scale, 1.0 = 100%.
|
||||
h_scale: f64 = 1.0,
|
||||
v_scale: f64 = 1.0,
|
||||
baseline_shift: f64 = 0.0,
|
||||
/// Skew (false italic), degrees.
|
||||
skew: f64 = 0.0,
|
||||
fill: String = "[Black]".into(),
|
||||
fill_tint: f32 = 1.0,
|
||||
stroke: String = "[None]".into(),
|
||||
stroke_tint: f32 = 1.0,
|
||||
stroke_weight: f64 = 1.0,
|
||||
capitalization: Capitalization = Capitalization::Normal,
|
||||
position: Position = Position::Normal,
|
||||
underline: bool = false,
|
||||
strikethrough: bool = false,
|
||||
ligatures: bool = true,
|
||||
no_break: bool = false,
|
||||
language: String = "English: USA".into(),
|
||||
/// Additional OpenType features, e.g. `["onum", "ss01"]`.
|
||||
otf_features: Vec<String> = Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
attr_set! {
|
||||
/// Paragraph attributes (Paragraph panel / paragraph styles).
|
||||
ParaAttrs / ParaProps {
|
||||
align: Align = Align::Left,
|
||||
left_indent: f64 = 0.0,
|
||||
right_indent: f64 = 0.0,
|
||||
first_line_indent: f64 = 0.0,
|
||||
last_line_indent: f64 = 0.0,
|
||||
space_before: f64 = 0.0,
|
||||
space_after: f64 = 0.0,
|
||||
drop_cap_lines: u32 = 0,
|
||||
drop_cap_chars: u32 = 0,
|
||||
grid_align: GridAlign = GridAlign::None,
|
||||
composer: Composer = Composer::Paragraph,
|
||||
// Hyphenation
|
||||
hyphenate: bool = true,
|
||||
hyph_min_word: u32 = 5,
|
||||
hyph_after_first: u32 = 2,
|
||||
hyph_before_last: u32 = 2,
|
||||
/// Max consecutive hyphenated lines (0 = unlimited).
|
||||
hyph_limit: u32 = 3,
|
||||
hyph_zone: f64 = 36.0,
|
||||
hyph_capitalized: bool = true,
|
||||
hyph_last_word: bool = true,
|
||||
hyph_across_column: bool = true,
|
||||
/// Better spacing (0) ↔ fewer hyphens (1).
|
||||
hyph_weight: f64 = 0.5,
|
||||
// Justification (percent as fractions)
|
||||
word_space_min: f64 = 0.8,
|
||||
word_space_desired: f64 = 1.0,
|
||||
word_space_max: f64 = 1.33,
|
||||
letter_space_min: f64 = 0.0,
|
||||
letter_space_desired: f64 = 0.0,
|
||||
letter_space_max: f64 = 0.0,
|
||||
glyph_scale_min: f64 = 1.0,
|
||||
glyph_scale_desired: f64 = 1.0,
|
||||
glyph_scale_max: f64 = 1.0,
|
||||
auto_leading: f64 = 1.2,
|
||||
single_word_justify: Align = Align::FullyJustified,
|
||||
// Keeps
|
||||
keep_with_next: u32 = 0,
|
||||
keep_lines_together: bool = false,
|
||||
keep_all_lines: bool = true,
|
||||
keep_first: u32 = 2,
|
||||
keep_last: u32 = 2,
|
||||
start_paragraph: StartParagraph = StartParagraph::Anywhere,
|
||||
span_columns: SpanColumns = SpanColumns::Single,
|
||||
rule_above: Rule = Rule::default(),
|
||||
rule_below: Rule = Rule::default(),
|
||||
tabs: Vec<TabStop> = Vec::new(),
|
||||
list_type: ListType = ListType::None,
|
||||
bullet_char: String = "\u{2022}".into(),
|
||||
number_style: NumberStyle = NumberStyle::Arabic,
|
||||
/// Text after the number/bullet (`^t` = tab).
|
||||
list_separator: String = "\t".into(),
|
||||
balance_ragged: bool = false,
|
||||
optical_margin: bool = false,
|
||||
/// Paragraph shading.
|
||||
shading_on: bool = false,
|
||||
shading_color: String = "[Black]".into(),
|
||||
shading_tint: f32 = 0.2,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn merge_and_resolve() {
|
||||
let mut a = CharAttrs { size: Some(10.0), ..Default::default() };
|
||||
a.merge(&CharAttrs { tracking: Some(20.0), size: Some(11.0), ..Default::default() });
|
||||
assert_eq!(a.size, Some(11.0));
|
||||
assert_eq!(a.count(), 2);
|
||||
let mut p = CharProps::default();
|
||||
p.apply(&a);
|
||||
assert_eq!(p.size, 11.0);
|
||||
assert_eq!(p.tracking, 20.0);
|
||||
assert_eq!(p.font_family, "Source Serif 4");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn set_json_by_name() {
|
||||
let mut a = ParaAttrs::default();
|
||||
a.set_json("spaceBefore", &serde_json::json!(6.0)).unwrap();
|
||||
a.set_json("align", &serde_json::json!("center")).unwrap();
|
||||
a.set_json("left_indent", &serde_json::json!(12)).unwrap();
|
||||
assert_eq!(a.space_before, Some(6.0));
|
||||
assert_eq!(a.align, Some(Align::Center));
|
||||
assert_eq!(a.left_indent, Some(12.0));
|
||||
assert!(a.set_json("nope", &serde_json::json!(1)).is_err());
|
||||
a.set_json("align", &serde_json::Value::Null).unwrap();
|
||||
assert_eq!(a.align, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn delta_and_common() {
|
||||
let a = CharProps::default();
|
||||
let mut b = a.clone();
|
||||
b.size = 18.0;
|
||||
let d = a.delta(&b);
|
||||
assert_eq!(d.count(), 1);
|
||||
let c = CharProps::common([&a, &b]);
|
||||
assert_eq!(c.size, None);
|
||||
assert_eq!(c.font_family.as_deref(), Some("Source Serif 4"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn serde_skips_unset() {
|
||||
let a = CharAttrs { size: Some(9.0), ..Default::default() };
|
||||
assert_eq!(serde_json::to_string(&a).unwrap(), r#"{"size":9.0}"#);
|
||||
let p: ParaProps = serde_json::from_str(r#"{"align":"center"}"#).unwrap();
|
||||
assert_eq!(p.align, Align::Center);
|
||||
assert_eq!(p.word_space_max, 1.33);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn numbering_styles() {
|
||||
assert_eq!(NumberStyle::UpperRoman.format(14), "XIV");
|
||||
assert_eq!(NumberStyle::LowerRoman.format(9), "ix");
|
||||
assert_eq!(NumberStyle::UpperLetters.format(1), "A");
|
||||
assert_eq!(NumberStyle::UpperLetters.format(27), "AA");
|
||||
assert_eq!(NumberStyle::LowerLetters.format(28), "bb");
|
||||
assert_eq!(NumberStyle::ArabicLeadingZero.format(3), "03");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
//! Stable identifiers. All ids are unique within a document (one counter).
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
macro_rules! id_type {
|
||||
($name:ident, $prefix:literal) => {
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize, Default)]
|
||||
#[serde(transparent)]
|
||||
pub struct $name(pub u64);
|
||||
impl std::fmt::Display for $name {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
write!(f, concat!($prefix, "{}"), self.0)
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
id_type!(ItemId, "i");
|
||||
id_type!(StoryId, "s");
|
||||
id_type!(SpreadId, "sp");
|
||||
id_type!(PageId, "p");
|
||||
id_type!(LayerId, "l");
|
||||
id_type!(AssetId, "a");
|
||||
@@ -0,0 +1,475 @@
|
||||
//! Stories: the text that flows through a chain of threaded text frames.
|
||||
//!
|
||||
//! A story is one `String` plus formatting:
|
||||
//! - `paras`: one [`ParaFormat`] per paragraph (paragraphs are separated by `\n`; the last
|
||||
//! paragraph has no separator), so `paras.len() == text.matches('\n').count() + 1`;
|
||||
//! - `chars`: character-format runs `(byte_len, CharFormat)` covering exactly `text.len()` bytes.
|
||||
//! Runs are never empty unless the text is empty (then there is exactly one empty run that holds
|
||||
//! the "typing" format).
|
||||
//!
|
||||
//! Special characters: `\t` tab, `\u{2028}` forced line break, [`PAGE_NUMBER`], [`SECTION_MARKER`],
|
||||
//! [`COLUMN_BREAK`], [`FRAME_BREAK`], [`PAGE_BREAK`], `\u{AD}` discretionary hyphen,
|
||||
//! `\u{2011}` non-breaking hyphen, `\u{A0}` non-breaking space, [`INDENT_HERE`], [`RIGHT_INDENT_TAB`].
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::attrs::{CharAttrs, ParaAttrs};
|
||||
use crate::ids::{ItemId, StoryId};
|
||||
|
||||
pub const PAGE_NUMBER: char = '\u{E000}';
|
||||
pub const SECTION_MARKER: char = '\u{E001}';
|
||||
pub const COLUMN_BREAK: char = '\u{E002}';
|
||||
pub const FRAME_BREAK: char = '\u{E003}';
|
||||
pub const PAGE_BREAK: char = '\u{E004}';
|
||||
pub const INDENT_HERE: char = '\u{E005}';
|
||||
pub const RIGHT_INDENT_TAB: char = '\u{E006}';
|
||||
pub const NEXT_PAGE_NUMBER: char = '\u{E007}';
|
||||
pub const PREV_PAGE_NUMBER: char = '\u{E008}';
|
||||
pub const FORCED_LINE_BREAK: char = '\u{2028}';
|
||||
|
||||
pub const BASIC_PARAGRAPH: &str = "[Basic Paragraph]";
|
||||
pub const NO_CHAR_STYLE: &str = "[None]";
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ParaFormat {
|
||||
pub style: String,
|
||||
#[serde(default, skip_serializing_if = "ParaAttrs::is_empty")]
|
||||
pub para: ParaAttrs,
|
||||
/// Paragraph-level character overrides are stored on the char runs; this holds nothing.
|
||||
#[serde(default, skip_serializing_if = "CharAttrs::is_empty")]
|
||||
pub chars: CharAttrs,
|
||||
}
|
||||
|
||||
impl Default for ParaFormat {
|
||||
fn default() -> Self {
|
||||
ParaFormat { style: BASIC_PARAGRAPH.into(), para: ParaAttrs::default(), chars: CharAttrs::default() }
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct CharFormat {
|
||||
pub style: String,
|
||||
#[serde(default, skip_serializing_if = "CharAttrs::is_empty")]
|
||||
pub over: CharAttrs,
|
||||
}
|
||||
|
||||
impl Default for CharFormat {
|
||||
fn default() -> Self {
|
||||
CharFormat { style: NO_CHAR_STYLE.into(), over: CharAttrs::default() }
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct CharRun {
|
||||
pub len: usize,
|
||||
#[serde(flatten)]
|
||||
pub format: CharFormat,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct Story {
|
||||
pub id: StoryId,
|
||||
pub text: String,
|
||||
pub paras: Vec<ParaFormat>,
|
||||
pub chars: Vec<CharRun>,
|
||||
/// Text frames in thread order.
|
||||
pub frames: Vec<ItemId>,
|
||||
/// Bumped on every edit (composition cache key).
|
||||
#[serde(default)]
|
||||
pub rev: u64,
|
||||
}
|
||||
|
||||
impl Story {
|
||||
pub fn new(id: StoryId) -> Self {
|
||||
Story { id, text: String::new(), paras: vec![ParaFormat::default()], chars: vec![CharRun { len: 0, format: CharFormat::default() }], frames: vec![], rev: 0 }
|
||||
}
|
||||
|
||||
pub fn with_text(id: StoryId, text: &str, para: ParaFormat) -> Self {
|
||||
let mut s = Story::new(id);
|
||||
s.paras[0] = para;
|
||||
s.insert(0, text);
|
||||
s
|
||||
}
|
||||
|
||||
pub fn len(&self) -> usize {
|
||||
self.text.len()
|
||||
}
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.text.is_empty()
|
||||
}
|
||||
|
||||
/// Byte ranges of paragraphs (without the trailing `\n`).
|
||||
pub fn para_ranges(&self) -> Vec<std::ops::Range<usize>> {
|
||||
let mut v = Vec::with_capacity(self.paras.len());
|
||||
let mut start = 0;
|
||||
for (i, c) in self.text.char_indices() {
|
||||
if c == '\n' {
|
||||
v.push(start..i);
|
||||
start = i + 1;
|
||||
}
|
||||
}
|
||||
v.push(start..self.text.len());
|
||||
v
|
||||
}
|
||||
|
||||
/// Index of the paragraph containing byte `pos`.
|
||||
pub fn para_at(&self, pos: usize) -> usize {
|
||||
self.text[..pos.min(self.text.len())].bytes().filter(|b| *b == b'\n').count()
|
||||
}
|
||||
|
||||
/// Character format at byte `pos` (the run containing it; at a boundary, the preceding run —
|
||||
/// the format new typing takes).
|
||||
pub fn char_format_at(&self, pos: usize) -> &CharFormat {
|
||||
let mut start = 0;
|
||||
for r in &self.chars {
|
||||
let end = start + r.len;
|
||||
if pos < end || (pos == end && pos > start) {
|
||||
return &r.format;
|
||||
}
|
||||
start = end;
|
||||
}
|
||||
&self.chars.last().expect("at least one run").format
|
||||
}
|
||||
|
||||
/// Iterate `(range, format)` of character runs.
|
||||
pub fn runs(&self) -> impl Iterator<Item = (std::ops::Range<usize>, &CharFormat)> {
|
||||
let mut start = 0;
|
||||
self.chars.iter().map(move |r| {
|
||||
let s = start;
|
||||
start += r.len;
|
||||
(s..start, &r.format)
|
||||
})
|
||||
}
|
||||
|
||||
/// Insert `text` at byte `pos` using the format at that position.
|
||||
pub fn insert(&mut self, pos: usize, text: &str) {
|
||||
let fmt = self.char_format_at(pos).clone();
|
||||
self.insert_with(pos, text, fmt);
|
||||
}
|
||||
|
||||
/// Insert with an explicit character format.
|
||||
pub fn insert_with(&mut self, pos: usize, text: &str, fmt: CharFormat) {
|
||||
if text.is_empty() {
|
||||
return;
|
||||
}
|
||||
let pos = floor_char_boundary(&self.text, pos);
|
||||
// Paragraph formats: each new '\n' splits the current paragraph; the new paragraphs copy it.
|
||||
let pi = self.para_at(pos);
|
||||
let newlines = text.bytes().filter(|b| *b == b'\n').count();
|
||||
if newlines > 0 {
|
||||
let f = self.paras[pi].clone();
|
||||
for _ in 0..newlines {
|
||||
self.paras.insert(pi + 1, f.clone());
|
||||
}
|
||||
}
|
||||
self.text.insert_str(pos, text);
|
||||
// Character runs.
|
||||
self.splice_runs(pos, 0, text.len(), fmt);
|
||||
self.normalize();
|
||||
self.rev += 1;
|
||||
}
|
||||
|
||||
/// Delete the byte range (clamped to char boundaries). Merged paragraphs keep the first one's format.
|
||||
pub fn delete(&mut self, range: std::ops::Range<usize>) {
|
||||
let a = floor_char_boundary(&self.text, range.start.min(self.text.len()));
|
||||
let b = floor_char_boundary(&self.text, range.end.min(self.text.len())).max(a);
|
||||
if a == b {
|
||||
return;
|
||||
}
|
||||
let pi = self.para_at(a);
|
||||
let removed = self.text[a..b].bytes().filter(|c| *c == b'\n').count();
|
||||
self.paras.drain(pi + 1..pi + 1 + removed);
|
||||
self.text.replace_range(a..b, "");
|
||||
self.remove_run_bytes(a, b - a);
|
||||
self.normalize();
|
||||
self.rev += 1;
|
||||
}
|
||||
|
||||
/// Replace a range with text (delete + insert using the format at the start of the range).
|
||||
pub fn replace(&mut self, range: std::ops::Range<usize>, text: &str) {
|
||||
let fmt = if range.start < range.end { self.format_after(range.start).clone() } else { self.char_format_at(range.start).clone() };
|
||||
self.delete(range.clone());
|
||||
self.insert_with(range.start, text, fmt);
|
||||
}
|
||||
|
||||
/// Format of the character *starting* at `pos`.
|
||||
pub fn format_after(&self, pos: usize) -> &CharFormat {
|
||||
let mut start = 0;
|
||||
for r in &self.chars {
|
||||
if pos < start + r.len {
|
||||
return &r.format;
|
||||
}
|
||||
start += r.len;
|
||||
}
|
||||
&self.chars.last().expect("run").format
|
||||
}
|
||||
|
||||
/// Apply `f` to the character format of every run intersecting `range` (splitting runs at the
|
||||
/// range boundaries). With an empty range nothing changes (callers set typing formats instead).
|
||||
pub fn format_chars(&mut self, range: std::ops::Range<usize>, mut f: impl FnMut(&mut CharFormat)) {
|
||||
let (a, b) = (range.start.min(self.text.len()), range.end.min(self.text.len()));
|
||||
if a >= b {
|
||||
return;
|
||||
}
|
||||
self.split_at(a);
|
||||
self.split_at(b);
|
||||
let mut start = 0;
|
||||
for r in &mut self.chars {
|
||||
let end = start + r.len;
|
||||
if start >= a && end <= b && r.len > 0 {
|
||||
f(&mut r.format);
|
||||
}
|
||||
start = end;
|
||||
}
|
||||
self.normalize();
|
||||
self.rev += 1;
|
||||
}
|
||||
|
||||
/// Apply `f` to every paragraph intersecting `range` (an empty range = the paragraph at it).
|
||||
pub fn format_paras(&mut self, range: std::ops::Range<usize>, mut f: impl FnMut(&mut ParaFormat)) {
|
||||
let a = self.para_at(range.start);
|
||||
let b = self.para_at(range.end.max(range.start));
|
||||
for p in &mut self.paras[a..=b] {
|
||||
f(p);
|
||||
}
|
||||
self.rev += 1;
|
||||
}
|
||||
|
||||
/// Plain text of a range.
|
||||
pub fn slice(&self, range: std::ops::Range<usize>) -> &str {
|
||||
let a = floor_char_boundary(&self.text, range.start.min(self.text.len()));
|
||||
let b = floor_char_boundary(&self.text, range.end.min(self.text.len())).max(a);
|
||||
&self.text[a..b]
|
||||
}
|
||||
|
||||
/// Check invariants (tests and debug assertions).
|
||||
pub fn check(&self) -> Result<(), String> {
|
||||
let n = self.text.matches('\n').count() + 1;
|
||||
if self.paras.len() != n {
|
||||
return Err(format!("story {}: {} paragraph formats for {} paragraphs", self.id.0, self.paras.len(), n));
|
||||
}
|
||||
let total: usize = self.chars.iter().map(|r| r.len).sum();
|
||||
if total != self.text.len() {
|
||||
return Err(format!("story {}: runs cover {total} bytes, text has {}", self.id.0, self.text.len()));
|
||||
}
|
||||
if self.chars.is_empty() {
|
||||
return Err("no char runs".into());
|
||||
}
|
||||
if !self.text.is_empty() && self.chars.iter().any(|r| r.len == 0) {
|
||||
return Err("empty run in non-empty story".into());
|
||||
}
|
||||
let mut pos = 0;
|
||||
for r in &self.chars {
|
||||
pos += r.len;
|
||||
if !self.text.is_char_boundary(pos) {
|
||||
return Err(format!("run boundary {pos} splits a character"));
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// ---------- run helpers ----------
|
||||
|
||||
fn split_at(&mut self, pos: usize) {
|
||||
let mut start = 0;
|
||||
for i in 0..self.chars.len() {
|
||||
let end = start + self.chars[i].len;
|
||||
if pos > start && pos < end {
|
||||
let tail = CharRun { len: end - pos, format: self.chars[i].format.clone() };
|
||||
self.chars[i].len = pos - start;
|
||||
self.chars.insert(i + 1, tail);
|
||||
return;
|
||||
}
|
||||
start = end;
|
||||
}
|
||||
}
|
||||
|
||||
fn splice_runs(&mut self, pos: usize, _del: usize, ins: usize, fmt: CharFormat) {
|
||||
if self.chars.len() == 1 && self.chars[0].len == 0 {
|
||||
self.chars[0] = CharRun { len: ins, format: fmt };
|
||||
return;
|
||||
}
|
||||
self.split_at(pos);
|
||||
// Find insertion index: after the run that ends at pos.
|
||||
let mut start = 0;
|
||||
let mut idx = self.chars.len();
|
||||
for (i, r) in self.chars.iter().enumerate() {
|
||||
if start == pos {
|
||||
idx = i;
|
||||
break;
|
||||
}
|
||||
start += r.len;
|
||||
}
|
||||
// Extend the neighbouring run if it has the same format.
|
||||
if idx > 0 && self.chars[idx - 1].format == fmt {
|
||||
self.chars[idx - 1].len += ins;
|
||||
} else if idx < self.chars.len() && self.chars[idx].format == fmt {
|
||||
self.chars[idx].len += ins;
|
||||
} else {
|
||||
self.chars.insert(idx, CharRun { len: ins, format: fmt });
|
||||
}
|
||||
}
|
||||
|
||||
fn remove_run_bytes(&mut self, pos: usize, len: usize) {
|
||||
let typing = self.char_format_at(pos).clone();
|
||||
let end = pos + len;
|
||||
let mut start = 0;
|
||||
for r in &mut self.chars {
|
||||
let (rs, re) = (start, start + r.len);
|
||||
start = re;
|
||||
let ov = re.min(end).saturating_sub(rs.max(pos));
|
||||
r.len -= ov;
|
||||
}
|
||||
self.chars.retain(|r| r.len > 0);
|
||||
if self.chars.is_empty() {
|
||||
self.chars.push(CharRun { len: 0, format: typing });
|
||||
}
|
||||
}
|
||||
|
||||
fn normalize(&mut self) {
|
||||
let mut out: Vec<CharRun> = Vec::with_capacity(self.chars.len());
|
||||
for r in self.chars.drain(..) {
|
||||
if r.len == 0 && !out.is_empty() {
|
||||
continue;
|
||||
}
|
||||
match out.last_mut() {
|
||||
Some(last) if last.format == r.format => last.len += r.len,
|
||||
Some(last) if last.len == 0 => *last = r,
|
||||
_ => out.push(r),
|
||||
}
|
||||
}
|
||||
if out.is_empty() {
|
||||
out.push(CharRun { len: 0, format: CharFormat::default() });
|
||||
}
|
||||
self.chars = out;
|
||||
}
|
||||
}
|
||||
|
||||
pub fn floor_char_boundary(s: &str, mut i: usize) -> usize {
|
||||
i = i.min(s.len());
|
||||
while !s.is_char_boundary(i) {
|
||||
i -= 1;
|
||||
}
|
||||
i
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn st(t: &str) -> Story {
|
||||
Story::with_text(StoryId(1), t, ParaFormat::default())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn paragraphs_track_newlines() {
|
||||
let mut s = st("one\ntwo");
|
||||
assert_eq!(s.paras.len(), 2);
|
||||
s.insert(3, "\nmid");
|
||||
assert_eq!(s.text, "one\nmid\ntwo");
|
||||
assert_eq!(s.paras.len(), 3);
|
||||
s.delete(3..8);
|
||||
assert_eq!(s.text, "onetwo");
|
||||
assert_eq!(s.paras.len(), 1);
|
||||
s.check().unwrap();
|
||||
assert_eq!(s.para_ranges(), vec![0..6]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn char_formats_split_and_merge() {
|
||||
let mut s = st("hello world");
|
||||
s.format_chars(0..5, |f| f.over.size = Some(20.0));
|
||||
assert_eq!(s.chars.len(), 2);
|
||||
s.check().unwrap();
|
||||
// Typing at the end of the bold run continues it.
|
||||
s.insert(5, "!");
|
||||
assert_eq!(s.chars[0].len, 6);
|
||||
assert_eq!(s.text, "hello! world");
|
||||
s.format_chars(0..12, |f| f.over.size = Some(20.0));
|
||||
assert_eq!(s.chars.len(), 1);
|
||||
s.check().unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn delete_everything_keeps_typing_format() {
|
||||
let mut s = st("abc");
|
||||
s.format_chars(0..3, |f| f.over.tracking = Some(50.0));
|
||||
s.delete(0..3);
|
||||
assert!(s.is_empty());
|
||||
assert_eq!(s.chars.len(), 1);
|
||||
assert_eq!(s.chars[0].format.over.tracking, Some(50.0));
|
||||
s.check().unwrap();
|
||||
s.insert(0, "x");
|
||||
assert_eq!(s.chars[0].format.over.tracking, Some(50.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unicode_boundaries_are_respected() {
|
||||
let mut s = st("héllo");
|
||||
s.delete(1..2); // inside 'é' (2 bytes): clamps
|
||||
s.check().unwrap();
|
||||
s.format_chars(0..3, |f| f.over.size = Some(9.0));
|
||||
s.check().unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn replace_and_format_paras() {
|
||||
let mut s = st("a\nb\nc");
|
||||
s.format_paras(2..2, |p| p.style = "Head".into());
|
||||
assert_eq!(s.paras[1].style, "Head");
|
||||
s.replace(0..1, "AAA");
|
||||
assert_eq!(s.text, "AAA\nb\nc");
|
||||
s.check().unwrap();
|
||||
assert_eq!(s.para_at(4), 1);
|
||||
}
|
||||
|
||||
mod prop {
|
||||
use super::*;
|
||||
use proptest::prelude::*;
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
enum Op {
|
||||
Ins(usize, String),
|
||||
Del(usize, usize),
|
||||
Fmt(usize, usize, u8),
|
||||
}
|
||||
|
||||
fn op() -> impl Strategy<Value = Op> {
|
||||
prop_oneof![
|
||||
(0usize..64, "[a-zé\n ]{0,6}").prop_map(|(p, t)| Op::Ins(p, t)),
|
||||
(0usize..64, 0usize..10).prop_map(|(p, n)| Op::Del(p, n)),
|
||||
(0usize..64, 0usize..10, 0u8..3).prop_map(|(p, n, k)| Op::Fmt(p, n, k)),
|
||||
]
|
||||
}
|
||||
|
||||
proptest! {
|
||||
#[test]
|
||||
fn invariants_hold(ops in proptest::collection::vec(op(), 0..40)) {
|
||||
let mut s = st("seed text\nline two");
|
||||
let mut model = s.text.clone();
|
||||
for o in ops {
|
||||
match o {
|
||||
Op::Ins(p, t) => {
|
||||
let p = floor_char_boundary(&s.text, p);
|
||||
s.insert(p, &t);
|
||||
model.insert_str(p, &t);
|
||||
}
|
||||
Op::Del(p, n) => {
|
||||
let a = floor_char_boundary(&s.text, p);
|
||||
let b = floor_char_boundary(&s.text, a + n).max(a);
|
||||
s.delete(a..b);
|
||||
model.replace_range(a..b, "");
|
||||
}
|
||||
Op::Fmt(p, n, k) => s.format_chars(p..p + n, |f| f.over.size = Some(8.0 + k as f64)),
|
||||
}
|
||||
prop_assert_eq!(&s.text, &model);
|
||||
prop_assert!(s.check().is_ok(), "{:?}", s.check());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,261 @@
|
||||
//! Paragraph, character and object styles with based-on inheritance.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::attrs::{CharAttrs, CharProps, ParaAttrs, ParaProps};
|
||||
use crate::item::{Fill, Stroke, TextFrameOptions};
|
||||
use crate::story::{BASIC_PARAGRAPH, CharFormat, NO_CHAR_STYLE, ParaFormat};
|
||||
|
||||
pub const NO_PARA_STYLE: &str = "[No Paragraph Style]";
|
||||
pub const BASIC_GRAPHICS_FRAME: &str = "[Basic Graphics Frame]";
|
||||
pub const BASIC_TEXT_FRAME: &str = "[Basic Text Frame]";
|
||||
pub const NO_OBJECT_STYLE: &str = "[None]";
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ParagraphStyle {
|
||||
/// Unique name; groups are expressed as `Group/Name`.
|
||||
pub name: String,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub based_on: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub next_style: Option<String>,
|
||||
#[serde(default)]
|
||||
pub para: ParaAttrs,
|
||||
#[serde(default)]
|
||||
pub chars: CharAttrs,
|
||||
#[serde(default, skip_serializing_if = "String::is_empty")]
|
||||
pub shortcut: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct CharacterStyle {
|
||||
pub name: String,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub based_on: Option<String>,
|
||||
#[serde(default)]
|
||||
pub chars: CharAttrs,
|
||||
#[serde(default, skip_serializing_if = "String::is_empty")]
|
||||
pub shortcut: String,
|
||||
}
|
||||
|
||||
/// Object style: each attribute group is optional ("not included in style" when `None`).
|
||||
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase", default)]
|
||||
pub struct ObjectStyle {
|
||||
pub name: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub based_on: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub fill: Option<Fill>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub stroke: Option<Stroke>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub paragraph_style: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub text_frame: Option<TextFrameOptions>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct Styles {
|
||||
pub paragraph: Vec<ParagraphStyle>,
|
||||
pub character: Vec<CharacterStyle>,
|
||||
pub object: Vec<ObjectStyle>,
|
||||
/// Default styles for new text/frames (the style selected with nothing selected).
|
||||
pub default_paragraph: String,
|
||||
pub default_character: String,
|
||||
pub default_text_frame: String,
|
||||
pub default_graphic_frame: String,
|
||||
}
|
||||
|
||||
impl Default for Styles {
|
||||
fn default() -> Self {
|
||||
Styles {
|
||||
paragraph: vec![
|
||||
ParagraphStyle {
|
||||
name: NO_PARA_STYLE.into(),
|
||||
based_on: None,
|
||||
next_style: None,
|
||||
para: ParaAttrs::default(),
|
||||
chars: CharAttrs::default(),
|
||||
shortcut: String::new(),
|
||||
},
|
||||
ParagraphStyle {
|
||||
name: BASIC_PARAGRAPH.into(),
|
||||
based_on: None,
|
||||
next_style: Some(BASIC_PARAGRAPH.into()),
|
||||
para: ParaAttrs::default(),
|
||||
chars: CharAttrs::default(),
|
||||
shortcut: String::new(),
|
||||
},
|
||||
],
|
||||
character: vec![CharacterStyle { name: NO_CHAR_STYLE.into(), based_on: None, chars: CharAttrs::default(), shortcut: String::new() }],
|
||||
object: vec![
|
||||
ObjectStyle { name: NO_OBJECT_STYLE.into(), ..Default::default() },
|
||||
ObjectStyle { name: BASIC_GRAPHICS_FRAME.into(), stroke: Some(Stroke::default()), ..Default::default() },
|
||||
ObjectStyle {
|
||||
name: BASIC_TEXT_FRAME.into(),
|
||||
fill: Some(Fill::none()),
|
||||
stroke: Some(Stroke::none()),
|
||||
paragraph_style: Some(BASIC_PARAGRAPH.into()),
|
||||
..Default::default()
|
||||
},
|
||||
],
|
||||
default_paragraph: BASIC_PARAGRAPH.into(),
|
||||
default_character: NO_CHAR_STYLE.into(),
|
||||
default_text_frame: BASIC_TEXT_FRAME.into(),
|
||||
default_graphic_frame: BASIC_GRAPHICS_FRAME.into(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const MAX_DEPTH: usize = 32;
|
||||
|
||||
impl Styles {
|
||||
pub fn para(&self, name: &str) -> Option<&ParagraphStyle> {
|
||||
self.paragraph.iter().find(|s| s.name == name)
|
||||
}
|
||||
pub fn para_mut(&mut self, name: &str) -> Option<&mut ParagraphStyle> {
|
||||
self.paragraph.iter_mut().find(|s| s.name == name)
|
||||
}
|
||||
pub fn char_style(&self, name: &str) -> Option<&CharacterStyle> {
|
||||
self.character.iter().find(|s| s.name == name)
|
||||
}
|
||||
pub fn char_style_mut(&mut self, name: &str) -> Option<&mut CharacterStyle> {
|
||||
self.character.iter_mut().find(|s| s.name == name)
|
||||
}
|
||||
pub fn object_style(&self, name: &str) -> Option<&ObjectStyle> {
|
||||
self.object.iter().find(|s| s.name == name)
|
||||
}
|
||||
|
||||
/// The paragraph style chain from root to `name` (cycle-safe).
|
||||
fn para_chain(&self, name: &str) -> Vec<&ParagraphStyle> {
|
||||
let mut chain = Vec::new();
|
||||
let mut cur = self.para(name);
|
||||
while let Some(s) = cur {
|
||||
if chain.len() >= MAX_DEPTH || chain.iter().any(|c: &&ParagraphStyle| c.name == s.name) {
|
||||
break;
|
||||
}
|
||||
chain.push(s);
|
||||
cur = s.based_on.as_deref().and_then(|b| self.para(b));
|
||||
}
|
||||
chain.reverse();
|
||||
chain
|
||||
}
|
||||
|
||||
fn char_chain(&self, name: &str) -> Vec<&CharacterStyle> {
|
||||
let mut chain = Vec::new();
|
||||
let mut cur = self.char_style(name);
|
||||
while let Some(s) = cur {
|
||||
if chain.len() >= MAX_DEPTH || chain.iter().any(|c: &&CharacterStyle| c.name == s.name) {
|
||||
break;
|
||||
}
|
||||
chain.push(s);
|
||||
cur = s.based_on.as_deref().and_then(|b| self.char_style(b));
|
||||
}
|
||||
chain.reverse();
|
||||
chain
|
||||
}
|
||||
|
||||
/// Resolved paragraph attributes and the paragraph's base character attributes.
|
||||
pub fn resolve_para(&self, p: &ParaFormat) -> (ParaProps, CharProps) {
|
||||
let (mut pp, mut cp) = (ParaProps::default(), CharProps::default());
|
||||
for s in self.para_chain(&p.style) {
|
||||
pp.apply(&s.para);
|
||||
cp.apply(&s.chars);
|
||||
}
|
||||
pp.apply(&p.para);
|
||||
cp.apply(&p.chars);
|
||||
(pp, cp)
|
||||
}
|
||||
|
||||
/// Paragraph-style-only resolution (no local overrides).
|
||||
pub fn resolve_para_style(&self, name: &str) -> (ParaProps, CharProps) {
|
||||
self.resolve_para(&ParaFormat { style: name.into(), ..Default::default() })
|
||||
}
|
||||
|
||||
/// Character attributes for a run: paragraph base ← character style chain ← local overrides.
|
||||
pub fn resolve_char(&self, para_chars: &CharProps, f: &CharFormat) -> CharProps {
|
||||
let mut cp = para_chars.clone();
|
||||
for s in self.char_chain(&f.style) {
|
||||
cp.apply(&s.chars);
|
||||
}
|
||||
cp.apply(&f.over);
|
||||
cp
|
||||
}
|
||||
|
||||
/// Would making `name` based on `parent` create a cycle?
|
||||
pub fn para_based_on_cycles(&self, name: &str, parent: &str) -> bool {
|
||||
name == parent || self.para_chain(parent).iter().any(|s| s.name == name)
|
||||
}
|
||||
pub fn char_based_on_cycles(&self, name: &str, parent: &str) -> bool {
|
||||
name == parent || self.char_chain(parent).iter().any(|s| s.name == name)
|
||||
}
|
||||
|
||||
/// A free name `base`, `base 2`, `base 3`…
|
||||
pub fn unique_name(existing: impl Fn(&str) -> bool, base: &str) -> String {
|
||||
if !existing(base) {
|
||||
return base.to_string();
|
||||
}
|
||||
(2..).map(|i| format!("{base} {i}")).find(|n| !existing(n)).expect("infinite")
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn based_on_chain_resolves_in_order() {
|
||||
let mut st = Styles::default();
|
||||
st.paragraph.push(ParagraphStyle {
|
||||
name: "Body".into(),
|
||||
based_on: Some(BASIC_PARAGRAPH.into()),
|
||||
next_style: None,
|
||||
para: ParaAttrs { space_after: Some(6.0), ..Default::default() },
|
||||
chars: CharAttrs { size: Some(10.0), ..Default::default() },
|
||||
shortcut: String::new(),
|
||||
});
|
||||
st.paragraph.push(ParagraphStyle {
|
||||
name: "Body First".into(),
|
||||
based_on: Some("Body".into()),
|
||||
next_style: Some("Body".into()),
|
||||
para: ParaAttrs { first_line_indent: Some(0.0), ..Default::default() },
|
||||
chars: CharAttrs { size: Some(11.0), ..Default::default() },
|
||||
shortcut: String::new(),
|
||||
});
|
||||
let (pp, cp) = st.resolve_para_style("Body First");
|
||||
assert_eq!(pp.space_after, 6.0);
|
||||
assert_eq!(cp.size, 11.0);
|
||||
let f = CharFormat { style: NO_CHAR_STYLE.into(), over: CharAttrs { tracking: Some(10.0), ..Default::default() } };
|
||||
let r = st.resolve_char(&cp, &f);
|
||||
assert_eq!((r.size, r.tracking), (11.0, 10.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cycles_are_detected_and_safe() {
|
||||
let mut st = Styles::default();
|
||||
for (n, b) in [("A", "B"), ("B", "A")] {
|
||||
st.paragraph.push(ParagraphStyle {
|
||||
name: n.into(),
|
||||
based_on: Some(b.into()),
|
||||
next_style: None,
|
||||
para: ParaAttrs::default(),
|
||||
chars: CharAttrs::default(),
|
||||
shortcut: String::new(),
|
||||
});
|
||||
}
|
||||
let _ = st.resolve_para_style("A"); // terminates
|
||||
assert!(st.para_based_on_cycles("A", "B"));
|
||||
assert!(!st.para_based_on_cycles(BASIC_PARAGRAPH, NO_PARA_STYLE));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unique_names() {
|
||||
let names = ["Body", "Body 2"];
|
||||
assert_eq!(Styles::unique_name(|n| names.contains(&n), "Body"), "Body 3");
|
||||
assert_eq!(Styles::unique_name(|n| names.contains(&n), "Head"), "Head");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
[package]
|
||||
name = "designcraft-geom"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
description = "DesignCraft geometry: anchor paths, shapes, units and measurements, hit testing"
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
|
||||
[dependencies]
|
||||
kurbo = { workspace = true }
|
||||
serde = { workspace = true }
|
||||
thiserror = { workspace = true }
|
||||
|
||||
[dev-dependencies]
|
||||
proptest = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
@@ -0,0 +1,195 @@
|
||||
//! Live corner options (Object → Corner Options): rounded, inverse rounded, inset, bevel, fancy.
|
||||
//!
|
||||
//! Corners are applied at render time to the straight-segment corners of a closed path, so the
|
||||
//! frame keeps its simple editable geometry (as in InDesign).
|
||||
|
||||
use kurbo::{BezPath, Point, Vec2};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::path::{PathData, SubPath};
|
||||
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum CornerShape {
|
||||
#[default]
|
||||
None,
|
||||
Rounded,
|
||||
InverseRounded,
|
||||
Inset,
|
||||
Bevel,
|
||||
Fancy,
|
||||
}
|
||||
|
||||
impl CornerShape {
|
||||
pub const ALL: [CornerShape; 6] =
|
||||
[CornerShape::None, CornerShape::Fancy, CornerShape::Bevel, CornerShape::Inset, CornerShape::InverseRounded, CornerShape::Rounded];
|
||||
pub fn label(self) -> &'static str {
|
||||
match self {
|
||||
CornerShape::None => "None",
|
||||
CornerShape::Rounded => "Rounded",
|
||||
CornerShape::InverseRounded => "Inverse Rounded",
|
||||
CornerShape::Inset => "Inset",
|
||||
CornerShape::Bevel => "Bevel",
|
||||
CornerShape::Fancy => "Fancy",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// One corner's setting.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Serialize, Deserialize)]
|
||||
pub struct Corner {
|
||||
pub shape: CornerShape,
|
||||
pub size: f64,
|
||||
}
|
||||
|
||||
impl Corner {
|
||||
pub fn is_none(&self) -> bool {
|
||||
self.shape == CornerShape::None || self.size <= 0.0
|
||||
}
|
||||
}
|
||||
|
||||
/// Per-corner options in path order (for rectangles: top-left, top-right, bottom-right, bottom-left).
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Serialize, Deserialize)]
|
||||
pub struct CornerOptions {
|
||||
pub corners: [Corner; 4],
|
||||
}
|
||||
|
||||
impl CornerOptions {
|
||||
pub fn uniform(shape: CornerShape, size: f64) -> Self {
|
||||
Self { corners: [Corner { shape, size }; 4] }
|
||||
}
|
||||
pub fn is_none(&self) -> bool {
|
||||
self.corners.iter().all(Corner::is_none)
|
||||
}
|
||||
}
|
||||
|
||||
/// Apply corner options to `path`. Corners are applied to anchors joining two straight segments of
|
||||
/// closed subpaths; other anchors are kept. Anchor `i` of every subpath uses `opts.corners[i % 4]`.
|
||||
pub fn apply(path: &PathData, opts: &CornerOptions) -> BezPath {
|
||||
if opts.is_none() {
|
||||
return path.to_bezpath();
|
||||
}
|
||||
let mut out = BezPath::new();
|
||||
for sp in &path.subpaths {
|
||||
apply_subpath(sp, opts, &mut out);
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn apply_subpath(sp: &SubPath, opts: &CornerOptions, out: &mut BezPath) {
|
||||
let n = sp.anchors.len();
|
||||
let straight_corner = |i: usize| {
|
||||
let a = &sp.anchors[i];
|
||||
sp.closed && !a.has_in() && !a.has_out()
|
||||
};
|
||||
if !sp.closed || n < 3 || !(0..n).any(straight_corner) {
|
||||
sp.to_bezpath_into(out);
|
||||
return;
|
||||
}
|
||||
let pts: Vec<Point> = sp.anchors.iter().map(|a| a.p).collect();
|
||||
let mut first = true;
|
||||
for i in 0..n {
|
||||
let c = opts.corners[i % 4];
|
||||
let p = pts[i];
|
||||
let prev = pts[(i + n - 1) % n];
|
||||
let next = pts[(i + 1) % n];
|
||||
if c.is_none() || !straight_corner(i) || !straight_corner((i + n - 1) % n) && sp.anchors[(i + n - 1) % n].has_out() {
|
||||
if first {
|
||||
out.move_to(p);
|
||||
first = false;
|
||||
} else {
|
||||
out.line_to(p);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
let din = prev - p;
|
||||
let dout = next - p;
|
||||
let (lin, lout) = (din.hypot(), dout.hypot());
|
||||
if lin < 1e-9 || lout < 1e-9 {
|
||||
continue;
|
||||
}
|
||||
let s = c.size.min(lin / 2.0).min(lout / 2.0);
|
||||
let ui = din / lin;
|
||||
let uo = dout / lout;
|
||||
let a = p + ui * s; // entry point on incoming edge
|
||||
let b = p + uo * s; // exit point on outgoing edge
|
||||
if first {
|
||||
out.move_to(a);
|
||||
first = false;
|
||||
} else {
|
||||
out.line_to(a);
|
||||
}
|
||||
corner_shape(out, c.shape, p, a, b, ui, uo, s);
|
||||
}
|
||||
out.close_path();
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn corner_shape(out: &mut BezPath, shape: CornerShape, p: Point, a: Point, b: Point, ui: Vec2, uo: Vec2, s: f64) {
|
||||
const K: f64 = crate::shapes::KAPPA;
|
||||
match shape {
|
||||
CornerShape::None => out.line_to(b),
|
||||
CornerShape::Rounded => out.curve_to(a - ui * (s * K), b - uo * (s * K), b),
|
||||
// Quarter arc centred on the corner point.
|
||||
CornerShape::InverseRounded => out.curve_to(a + uo * (s * K), b + ui * (s * K), b),
|
||||
CornerShape::Inset => {
|
||||
let m = p + ui * s + uo * s;
|
||||
out.line_to(m);
|
||||
out.line_to(b);
|
||||
}
|
||||
CornerShape::Bevel => out.line_to(b),
|
||||
CornerShape::Fancy => {
|
||||
// A decorative notch: step in, small inverse arc, step out.
|
||||
let q = s / 3.0;
|
||||
let a1 = p + ui * (2.0 * q);
|
||||
let m = p + ui * (2.0 * q) + uo * (2.0 * q);
|
||||
let b1 = p + uo * (2.0 * q);
|
||||
out.line_to(a1 + uo * q);
|
||||
out.curve_to(a1 + uo * (q + q * K), m - ui * (q * (1.0 - K)), m);
|
||||
out.curve_to(m - uo * (q * (1.0 - K)), b1 + ui * (q + q * K), b1 + ui * q);
|
||||
out.line_to(b);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::shapes::rectangle;
|
||||
use kurbo::{Rect, Shape};
|
||||
|
||||
#[test]
|
||||
fn none_is_identity() {
|
||||
let r = rectangle(Rect::new(0.0, 0.0, 100.0, 50.0));
|
||||
assert_eq!(apply(&r, &CornerOptions::default()).bounding_box(), Rect::new(0.0, 0.0, 100.0, 50.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_shape_stays_inside_bounds_and_reduces_area() {
|
||||
let rect = Rect::new(0.0, 0.0, 100.0, 60.0);
|
||||
let r = rectangle(rect);
|
||||
for shape in CornerShape::ALL.into_iter().skip(1) {
|
||||
let bp = apply(&r, &CornerOptions::uniform(shape, 12.0));
|
||||
let bb = bp.bounding_box();
|
||||
assert!(bb.x0 >= -1e-6 && bb.y0 >= -1e-6 && bb.x1 <= 100.0 + 1e-6 && bb.y1 <= 60.0 + 1e-6, "{shape:?} {bb:?}");
|
||||
let area = bp.area().abs();
|
||||
assert!(area < 6000.0 && area > 5000.0, "{shape:?} area {area}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rounded_matches_circle_area() {
|
||||
let r = rectangle(Rect::new(0.0, 0.0, 20.0, 20.0));
|
||||
let bp = apply(&r, &CornerOptions::uniform(CornerShape::Rounded, 10.0));
|
||||
let a = bp.area().abs();
|
||||
assert!((a - std::f64::consts::PI * 100.0).abs() < 1.0, "{a}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn size_is_clamped_to_half_edge() {
|
||||
let r = rectangle(Rect::new(0.0, 0.0, 10.0, 10.0));
|
||||
let bp = apply(&r, &CornerOptions::uniform(CornerShape::Bevel, 100.0));
|
||||
// Bevel at half edges gives a diamond: area 50.
|
||||
assert!((bp.area().abs() - 50.0).abs() < 1e-6);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
//! Hit testing primitives.
|
||||
|
||||
use kurbo::{BezPath, ParamCurveNearest, Point, Rect, Shape};
|
||||
|
||||
use crate::path::{FillRule, PathData};
|
||||
|
||||
/// Is `p` inside the filled area of `path` under `rule`? Open subpaths are implicitly closed (as when filled).
|
||||
pub fn fill_contains(path: &BezPath, rule: FillRule, p: Point) -> bool {
|
||||
let w = path.winding(p);
|
||||
match rule {
|
||||
FillRule::NonZero => w != 0,
|
||||
FillRule::EvenOdd => w % 2 != 0,
|
||||
}
|
||||
}
|
||||
|
||||
/// Distance from `p` to the nearest point on the path outline.
|
||||
pub fn distance_to_outline(path: &BezPath, p: Point) -> f64 {
|
||||
path.segments().map(|s| s.nearest(p, 1e-6).distance_sq).fold(f64::INFINITY, f64::min).sqrt()
|
||||
}
|
||||
|
||||
/// Is `p` within `tol` of the path's stroke of width `width`?
|
||||
pub fn stroke_contains(path: &BezPath, width: f64, tol: f64, p: Point) -> bool {
|
||||
let quick = path.bounding_box().inflate(width / 2.0 + tol, width / 2.0 + tol);
|
||||
quick.contains(p) && distance_to_outline(path, p) <= width / 2.0 + tol
|
||||
}
|
||||
|
||||
/// Does the path intersect (or lie inside) the rect? Used for marquee selection.
|
||||
pub fn intersects_rect(path: &PathData, r: Rect) -> bool {
|
||||
let Some(b) = path.bounds() else { return false };
|
||||
if b.intersect(r).area() <= 0.0 && !(b.width() == 0.0 || b.height() == 0.0) {
|
||||
return false;
|
||||
}
|
||||
// Any anchor inside?
|
||||
if path.anchors().any(|(_, _, a)| r.contains(a.p)) {
|
||||
return true;
|
||||
}
|
||||
// Any segment crossing the rect edges?
|
||||
let edges = [
|
||||
kurbo::Line::new((r.x0, r.y0), (r.x1, r.y0)),
|
||||
kurbo::Line::new((r.x1, r.y0), (r.x1, r.y1)),
|
||||
kurbo::Line::new((r.x1, r.y1), (r.x0, r.y1)),
|
||||
kurbo::Line::new((r.x0, r.y1), (r.x0, r.y0)),
|
||||
];
|
||||
let bp = path.to_bezpath();
|
||||
for seg in bp.segments() {
|
||||
for e in &edges {
|
||||
if !seg.intersect_line(*e).is_empty() {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
// Rect entirely inside a closed shape counts as touching it.
|
||||
path.is_closed() && fill_contains(&bp, FillRule::NonZero, r.center())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::shapes;
|
||||
|
||||
#[test]
|
||||
fn fill_and_stroke() {
|
||||
let p = shapes::rectangle(Rect::new(0.0, 0.0, 10.0, 10.0)).to_bezpath();
|
||||
assert!(fill_contains(&p, FillRule::NonZero, Point::new(5.0, 5.0)));
|
||||
assert!(!fill_contains(&p, FillRule::NonZero, Point::new(15.0, 5.0)));
|
||||
assert!(stroke_contains(&p, 2.0, 0.0, Point::new(10.5, 5.0)));
|
||||
assert!(!stroke_contains(&p, 2.0, 0.0, Point::new(12.0, 5.0)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn evenodd_hole() {
|
||||
let mut outer = shapes::rectangle(Rect::new(0.0, 0.0, 10.0, 10.0));
|
||||
outer.subpaths.extend(shapes::rectangle(Rect::new(3.0, 3.0, 7.0, 7.0)).subpaths);
|
||||
let bp = outer.to_bezpath();
|
||||
assert!(!fill_contains(&bp, FillRule::EvenOdd, Point::new(5.0, 5.0)));
|
||||
assert!(fill_contains(&bp, FillRule::NonZero, Point::new(5.0, 5.0)));
|
||||
assert!(fill_contains(&bp, FillRule::EvenOdd, Point::new(1.0, 5.0)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn marquee() {
|
||||
let p = shapes::ellipse(Rect::new(0.0, 0.0, 10.0, 10.0));
|
||||
assert!(intersects_rect(&p, Rect::new(-1.0, 4.0, 1.0, 6.0)));
|
||||
assert!(!intersects_rect(&p, Rect::new(20.0, 20.0, 30.0, 30.0)));
|
||||
// corner region of the bbox that misses the ellipse outline
|
||||
assert!(!intersects_rect(&p, Rect::new(0.0, 0.0, 0.5, 0.5)));
|
||||
// fully inside
|
||||
assert!(intersects_rect(&p, Rect::new(4.0, 4.0, 6.0, 6.0)));
|
||||
// crossing a line
|
||||
let l = shapes::line(Point::new(0.0, 0.0), Point::new(10.0, 10.0));
|
||||
assert!(intersects_rect(&l, Rect::new(4.0, 0.0, 6.0, 10.0)));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
//! DesignCraft geometry kernel.
|
||||
//!
|
||||
//! The editable path model is anchor based (like every InDesign tool thinks about paths):
|
||||
//! a [`PathData`] is a list of [`SubPath`]s, each a list of [`Anchor`]s with absolute in/out
|
||||
//! handle positions. Rendering and algorithms convert to [`kurbo::BezPath`].
|
||||
//!
|
||||
//! Coordinates are document points (1/72 in), y pointing down.
|
||||
#![forbid(unsafe_code)]
|
||||
|
||||
pub mod corners;
|
||||
pub mod hit;
|
||||
pub mod path;
|
||||
pub mod shapes;
|
||||
pub mod snap;
|
||||
pub mod units;
|
||||
|
||||
pub use kurbo;
|
||||
pub use kurbo::{Affine, BezPath, CubicBez, Line, ParamCurve, PathEl, PathSeg, Point, Rect, Shape, Size, Vec2};
|
||||
pub use path::{Anchor, AnchorKind, FillRule, PathData, SubPath};
|
||||
pub use units::{Unit, format_measure, parse_measure};
|
||||
|
||||
/// Tolerance used when comparing handle positions to anchor positions.
|
||||
pub const EPS: f64 = 1e-9;
|
||||
|
||||
/// Union of two optional rects.
|
||||
pub fn union_opt(a: Option<Rect>, b: Option<Rect>) -> Option<Rect> {
|
||||
match (a, b) {
|
||||
(Some(a), Some(b)) => Some(a.union(b)),
|
||||
(a, None) => a,
|
||||
(None, b) => b,
|
||||
}
|
||||
}
|
||||
|
||||
/// Normalize an angle in degrees to (-180, 180].
|
||||
pub fn normalize_deg(mut a: f64) -> f64 {
|
||||
a %= 360.0;
|
||||
if a <= -180.0 {
|
||||
a += 360.0;
|
||||
} else if a > 180.0 {
|
||||
a -= 360.0;
|
||||
}
|
||||
a
|
||||
}
|
||||
|
||||
/// Snap a vector to the nearest multiple of `step_deg` (used for Shift-constrained drags).
|
||||
pub fn constrain_angle(v: Vec2, step_deg: f64) -> Vec2 {
|
||||
let len = v.hypot();
|
||||
if len < EPS {
|
||||
return v;
|
||||
}
|
||||
let step = step_deg.to_radians();
|
||||
let a = (v.y.atan2(v.x) / step).round() * step;
|
||||
Vec2::new(a.cos() * len, a.sin() * len)
|
||||
}
|
||||
|
||||
/// Rect from two corner points in any order.
|
||||
pub fn rect_from_points(a: Point, b: Point) -> Rect {
|
||||
Rect::from_points(a, b)
|
||||
}
|
||||
|
||||
/// The 9 reference points of a rect (InDesign's reference-point locator), row-major from top-left.
|
||||
pub fn reference_point(r: Rect, index: usize) -> Point {
|
||||
let xs = [r.x0, r.center().x, r.x1];
|
||||
let ys = [r.y0, r.center().y, r.y1];
|
||||
Point::new(xs[index % 3], ys[(index / 3).min(2)])
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn angle_normalization() {
|
||||
assert_eq!(normalize_deg(190.0), -170.0);
|
||||
assert_eq!(normalize_deg(-190.0), 170.0);
|
||||
assert_eq!(normalize_deg(180.0), 180.0);
|
||||
assert_eq!(normalize_deg(720.0), 0.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn constrain_45() {
|
||||
let v = constrain_angle(Vec2::new(10.0, 1.0), 45.0);
|
||||
assert!((v.y).abs() < 1e-9);
|
||||
let v = constrain_angle(Vec2::new(10.0, 9.0), 45.0);
|
||||
assert!((v.x - v.y).abs() < 1e-9);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reference_points() {
|
||||
let r = Rect::new(0.0, 0.0, 10.0, 20.0);
|
||||
assert_eq!(reference_point(r, 0), Point::new(0.0, 0.0));
|
||||
assert_eq!(reference_point(r, 4), Point::new(5.0, 10.0));
|
||||
assert_eq!(reference_point(r, 8), Point::new(10.0, 20.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn union_options() {
|
||||
let a = Rect::new(0.0, 0.0, 1.0, 1.0);
|
||||
let b = Rect::new(2.0, 2.0, 3.0, 3.0);
|
||||
assert_eq!(union_opt(Some(a), Some(b)), Some(Rect::new(0.0, 0.0, 3.0, 3.0)));
|
||||
assert_eq!(union_opt(None, Some(b)), Some(b));
|
||||
assert_eq!(union_opt(None, None), None);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,529 @@
|
||||
//! The anchor-based editable path model.
|
||||
|
||||
use kurbo::{Affine, BezPath, CubicBez, ParamCurve, ParamCurveNearest, PathEl, Point, Rect, Shape};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::EPS;
|
||||
|
||||
/// Fill rule for paths and compound paths.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||
pub enum FillRule {
|
||||
/// InDesign's default for compound paths created from overlapping shapes.
|
||||
#[default]
|
||||
NonZero,
|
||||
EvenOdd,
|
||||
}
|
||||
|
||||
/// How an anchor's handles behave when edited.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||
pub enum AnchorKind {
|
||||
/// Handles move independently (or there are none).
|
||||
#[default]
|
||||
Corner,
|
||||
/// Handles stay collinear (dragging one rotates the other).
|
||||
Smooth,
|
||||
}
|
||||
|
||||
/// One anchor point with absolute handle positions. A handle equal to `p` means "no handle".
|
||||
/// Serialized without handles that equal `p` and without the default `kind` (smaller files).
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(from = "AnchorRepr", into = "AnchorRepr")]
|
||||
pub struct Anchor {
|
||||
pub p: Point,
|
||||
#[serde(rename = "in")]
|
||||
pub h_in: Point,
|
||||
#[serde(rename = "out")]
|
||||
pub h_out: Point,
|
||||
#[serde(default)]
|
||||
pub kind: AnchorKind,
|
||||
}
|
||||
|
||||
/// Wire form of [`Anchor`]: points as `[x, y]` (format v2; `{"x", "y"}` maps from v1 files are
|
||||
/// still read), and absent handles mean "no handle" (equal to `p`).
|
||||
#[derive(Serialize, Deserialize)]
|
||||
struct AnchorRepr {
|
||||
p: WirePoint,
|
||||
#[serde(rename = "in", default, skip_serializing_if = "Option::is_none")]
|
||||
h_in: Option<WirePoint>,
|
||||
#[serde(rename = "out", default, skip_serializing_if = "Option::is_none")]
|
||||
h_out: Option<WirePoint>,
|
||||
#[serde(default, skip_serializing_if = "is_corner")]
|
||||
kind: AnchorKind,
|
||||
}
|
||||
|
||||
fn is_corner(k: &AnchorKind) -> bool {
|
||||
*k == AnchorKind::Corner
|
||||
}
|
||||
|
||||
impl From<AnchorRepr> for Anchor {
|
||||
fn from(r: AnchorRepr) -> Self {
|
||||
let p = r.p.0;
|
||||
Self { p, h_in: r.h_in.map_or(p, |h| h.0), h_out: r.h_out.map_or(p, |h| h.0), kind: r.kind }
|
||||
}
|
||||
}
|
||||
|
||||
impl From<Anchor> for AnchorRepr {
|
||||
fn from(a: Anchor) -> Self {
|
||||
let handle = |h: Point| (h != a.p).then_some(WirePoint(h));
|
||||
Self { p: WirePoint(a.p), h_in: handle(a.h_in), h_out: handle(a.h_out), kind: a.kind }
|
||||
}
|
||||
}
|
||||
|
||||
/// A point written as `[x, y]`, read from `[x, y]` or `{"x": …, "y": …}`.
|
||||
struct WirePoint(Point);
|
||||
|
||||
impl Serialize for WirePoint {
|
||||
fn serialize<S: serde::Serializer>(&self, s: S) -> Result<S::Ok, S::Error> {
|
||||
[self.0.x, self.0.y].serialize(s)
|
||||
}
|
||||
}
|
||||
|
||||
impl<'de> Deserialize<'de> for WirePoint {
|
||||
fn deserialize<D: serde::Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
|
||||
struct V;
|
||||
impl<'de> serde::de::Visitor<'de> for V {
|
||||
type Value = WirePoint;
|
||||
fn expecting(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
|
||||
f.write_str("a point as [x, y] or {\"x\": x, \"y\": y}")
|
||||
}
|
||||
fn visit_seq<A: serde::de::SeqAccess<'de>>(self, mut a: A) -> Result<WirePoint, A::Error> {
|
||||
let x = a.next_element()?.ok_or_else(|| serde::de::Error::invalid_length(0, &self))?;
|
||||
let y = a.next_element()?.ok_or_else(|| serde::de::Error::invalid_length(1, &self))?;
|
||||
Ok(WirePoint(Point::new(x, y)))
|
||||
}
|
||||
fn visit_map<A: serde::de::MapAccess<'de>>(self, mut a: A) -> Result<WirePoint, A::Error> {
|
||||
let (mut x, mut y) = (None, None);
|
||||
while let Some(k) = a.next_key::<std::borrow::Cow<'de, str>>()? {
|
||||
match k.as_ref() {
|
||||
"x" => x = Some(a.next_value()?),
|
||||
"y" => y = Some(a.next_value()?),
|
||||
_ => {
|
||||
a.next_value::<serde::de::IgnoredAny>()?;
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(WirePoint(Point::new(
|
||||
x.ok_or_else(|| serde::de::Error::missing_field("x"))?,
|
||||
y.ok_or_else(|| serde::de::Error::missing_field("y"))?,
|
||||
)))
|
||||
}
|
||||
}
|
||||
d.deserialize_any(V)
|
||||
}
|
||||
}
|
||||
|
||||
impl Anchor {
|
||||
/// A corner anchor without handles.
|
||||
pub fn corner(p: Point) -> Self {
|
||||
Self { p, h_in: p, h_out: p, kind: AnchorKind::Corner }
|
||||
}
|
||||
/// A smooth anchor with a symmetric out handle `out` (the in handle is mirrored).
|
||||
pub fn smooth(p: Point, out: Point) -> Self {
|
||||
Self { p, h_in: p - (out - p), h_out: out, kind: AnchorKind::Smooth }
|
||||
}
|
||||
pub fn with_handles(p: Point, h_in: Point, h_out: Point) -> Self {
|
||||
let kind =
|
||||
if is_collinear(p, h_in, h_out) && h_in.distance(p) > EPS && h_out.distance(p) > EPS { AnchorKind::Smooth } else { AnchorKind::Corner };
|
||||
Self { p, h_in, h_out, kind }
|
||||
}
|
||||
pub fn has_in(&self) -> bool {
|
||||
self.h_in.distance(self.p) > EPS
|
||||
}
|
||||
pub fn has_out(&self) -> bool {
|
||||
self.h_out.distance(self.p) > EPS
|
||||
}
|
||||
pub fn transform(&self, a: Affine) -> Self {
|
||||
Self { p: a * self.p, h_in: a * self.h_in, h_out: a * self.h_out, kind: self.kind }
|
||||
}
|
||||
pub fn translate(&mut self, d: kurbo::Vec2) {
|
||||
self.p += d;
|
||||
self.h_in += d;
|
||||
self.h_out += d;
|
||||
}
|
||||
/// Swap handles (used when reversing path direction).
|
||||
pub fn reversed(&self) -> Self {
|
||||
Self { p: self.p, h_in: self.h_out, h_out: self.h_in, kind: self.kind }
|
||||
}
|
||||
/// Remove both handles (convert to a sharp corner).
|
||||
pub fn retract(&mut self) {
|
||||
self.h_in = self.p;
|
||||
self.h_out = self.p;
|
||||
self.kind = AnchorKind::Corner;
|
||||
}
|
||||
}
|
||||
|
||||
fn is_collinear(p: Point, a: Point, b: Point) -> bool {
|
||||
let va = a - p;
|
||||
let vb = b - p;
|
||||
let cross = va.cross(vb);
|
||||
cross.abs() <= 1e-6 * va.hypot().max(1.0) * vb.hypot().max(1.0) && va.dot(vb) < 0.0
|
||||
}
|
||||
|
||||
/// An open or closed run of anchors.
|
||||
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
|
||||
pub struct SubPath {
|
||||
pub anchors: Vec<Anchor>,
|
||||
#[serde(default)]
|
||||
pub closed: bool,
|
||||
}
|
||||
|
||||
impl SubPath {
|
||||
pub fn new(anchors: Vec<Anchor>, closed: bool) -> Self {
|
||||
Self { anchors, closed }
|
||||
}
|
||||
/// Polyline from points.
|
||||
pub fn polyline(points: &[Point], closed: bool) -> Self {
|
||||
Self { anchors: points.iter().map(|&p| Anchor::corner(p)).collect(), closed }
|
||||
}
|
||||
/// Number of segments (a closed path has one more, back to the start).
|
||||
pub fn segment_count(&self) -> usize {
|
||||
let n = self.anchors.len();
|
||||
if n < 2 {
|
||||
0
|
||||
} else if self.closed {
|
||||
n
|
||||
} else {
|
||||
n - 1
|
||||
}
|
||||
}
|
||||
/// Segment `i` as a cubic (lines are cubics with handles on the anchors).
|
||||
pub fn segment(&self, i: usize) -> CubicBez {
|
||||
let n = self.anchors.len();
|
||||
let a = &self.anchors[i % n];
|
||||
let b = &self.anchors[(i + 1) % n];
|
||||
CubicBez::new(a.p, a.h_out, b.h_in, b.p)
|
||||
}
|
||||
/// True if segment `i` is a straight line.
|
||||
pub fn segment_is_line(&self, i: usize) -> bool {
|
||||
let n = self.anchors.len();
|
||||
let a = &self.anchors[i % n];
|
||||
let b = &self.anchors[(i + 1) % n];
|
||||
!a.has_out() && !b.has_in()
|
||||
}
|
||||
pub fn to_bezpath_into(&self, out: &mut BezPath) {
|
||||
let Some(first) = self.anchors.first() else { return };
|
||||
out.move_to(first.p);
|
||||
for i in 0..self.segment_count() {
|
||||
let c = self.segment(i);
|
||||
if self.segment_is_line(i) {
|
||||
out.line_to(c.p3);
|
||||
} else {
|
||||
out.curve_to(c.p1, c.p2, c.p3);
|
||||
}
|
||||
}
|
||||
if self.closed {
|
||||
out.close_path();
|
||||
}
|
||||
}
|
||||
pub fn reverse(&mut self) {
|
||||
self.anchors.reverse();
|
||||
for a in &mut self.anchors {
|
||||
*a = a.reversed();
|
||||
}
|
||||
}
|
||||
/// Split segment `seg` at parameter `t`, inserting a new anchor. Returns the new anchor index.
|
||||
pub fn insert_anchor(&mut self, seg: usize, t: f64) -> usize {
|
||||
let n = self.anchors.len();
|
||||
let c = self.segment(seg);
|
||||
let line = self.segment_is_line(seg);
|
||||
let (l, r) = (c.subsegment(0.0..t), c.subsegment(t..1.0));
|
||||
let i0 = seg % n;
|
||||
let i1 = (seg + 1) % n;
|
||||
let mid = if line {
|
||||
Anchor::corner(l.p3)
|
||||
} else {
|
||||
self.anchors[i0].h_out = l.p1;
|
||||
self.anchors[i1].h_in = r.p2;
|
||||
Anchor { p: l.p3, h_in: l.p2, h_out: r.p1, kind: AnchorKind::Smooth }
|
||||
};
|
||||
self.anchors.insert(seg + 1, mid);
|
||||
seg + 1
|
||||
}
|
||||
/// Signed area via the shoelace formula on the Bézier path (positive = clockwise in y-down).
|
||||
pub fn area(&self) -> f64 {
|
||||
let mut bp = BezPath::new();
|
||||
self.to_bezpath_into(&mut bp);
|
||||
bp.area()
|
||||
}
|
||||
}
|
||||
|
||||
/// A (possibly multi-subpath) path.
|
||||
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
|
||||
pub struct PathData {
|
||||
pub subpaths: Vec<SubPath>,
|
||||
}
|
||||
|
||||
impl PathData {
|
||||
pub fn new(subpaths: Vec<SubPath>) -> Self {
|
||||
Self { subpaths }
|
||||
}
|
||||
pub fn single(sp: SubPath) -> Self {
|
||||
Self { subpaths: vec![sp] }
|
||||
}
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.subpaths.iter().all(|s| s.anchors.is_empty())
|
||||
}
|
||||
pub fn anchor_count(&self) -> usize {
|
||||
self.subpaths.iter().map(|s| s.anchors.len()).sum()
|
||||
}
|
||||
pub fn to_bezpath(&self) -> BezPath {
|
||||
let mut bp = BezPath::new();
|
||||
for sp in &self.subpaths {
|
||||
sp.to_bezpath_into(&mut bp);
|
||||
}
|
||||
bp
|
||||
}
|
||||
/// Convert a kurbo path to anchors. Quadratics are elevated to cubics.
|
||||
pub fn from_bezpath(bp: &BezPath) -> Self {
|
||||
let mut subpaths: Vec<SubPath> = Vec::new();
|
||||
let mut cur: Option<SubPath> = None;
|
||||
let mut last = Point::ZERO;
|
||||
for el in bp.elements() {
|
||||
match *el {
|
||||
PathEl::MoveTo(p) => {
|
||||
if let Some(sp) = cur.take()
|
||||
&& !sp.anchors.is_empty()
|
||||
{
|
||||
subpaths.push(sp);
|
||||
}
|
||||
cur = Some(SubPath { anchors: vec![Anchor::corner(p)], closed: false });
|
||||
last = p;
|
||||
}
|
||||
PathEl::LineTo(p) => {
|
||||
let sp = cur.get_or_insert_with(|| SubPath { anchors: vec![Anchor::corner(last)], closed: false });
|
||||
sp.anchors.push(Anchor::corner(p));
|
||||
last = p;
|
||||
}
|
||||
PathEl::QuadTo(q, p) => {
|
||||
let c1 = last + (q - last) * (2.0 / 3.0);
|
||||
let c2 = p + (q - p) * (2.0 / 3.0);
|
||||
push_curve(&mut cur, last, c1, c2, p);
|
||||
last = p;
|
||||
}
|
||||
PathEl::CurveTo(c1, c2, p) => {
|
||||
push_curve(&mut cur, last, c1, c2, p);
|
||||
last = p;
|
||||
}
|
||||
PathEl::ClosePath => {
|
||||
if let Some(mut sp) = cur.take() {
|
||||
// Merge a closing anchor that duplicates the start.
|
||||
if sp.anchors.len() > 1 {
|
||||
let first = sp.anchors[0].p;
|
||||
let lastp = sp.anchors.last().unwrap().p;
|
||||
if first.distance(lastp) < 1e-7 {
|
||||
let l = sp.anchors.pop().unwrap();
|
||||
sp.anchors[0].h_in = l.h_in;
|
||||
}
|
||||
}
|
||||
sp.closed = true;
|
||||
for a in &mut sp.anchors {
|
||||
*a = Anchor::with_handles(a.p, a.h_in, a.h_out);
|
||||
}
|
||||
last = sp.anchors.first().map(|a| a.p).unwrap_or(last);
|
||||
subpaths.push(sp);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if let Some(mut sp) = cur
|
||||
&& !sp.anchors.is_empty()
|
||||
{
|
||||
for a in &mut sp.anchors {
|
||||
*a = Anchor::with_handles(a.p, a.h_in, a.h_out);
|
||||
}
|
||||
subpaths.push(sp);
|
||||
}
|
||||
Self { subpaths }
|
||||
}
|
||||
/// Tight geometric bounds (curve extrema, not control points).
|
||||
pub fn bounds(&self) -> Option<Rect> {
|
||||
if self.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let bp = self.to_bezpath();
|
||||
if self.anchor_count() == 1 {
|
||||
let p = self.subpaths.iter().find_map(|s| s.anchors.first()).unwrap().p;
|
||||
return Some(Rect::from_points(p, p));
|
||||
}
|
||||
Some(bp.bounding_box())
|
||||
}
|
||||
/// Bounds including control handles (used for quick culling).
|
||||
pub fn control_bounds(&self) -> Option<Rect> {
|
||||
let mut it = self.subpaths.iter().flat_map(|s| s.anchors.iter()).flat_map(|a| [a.p, a.h_in, a.h_out]);
|
||||
let first = it.next()?;
|
||||
Some(it.fold(Rect::from_points(first, first), |r, p| r.union_pt(p)))
|
||||
}
|
||||
pub fn transform(&mut self, a: Affine) {
|
||||
for sp in &mut self.subpaths {
|
||||
for an in &mut sp.anchors {
|
||||
*an = an.transform(a);
|
||||
}
|
||||
}
|
||||
}
|
||||
pub fn transformed(&self, a: Affine) -> Self {
|
||||
let mut c = self.clone();
|
||||
c.transform(a);
|
||||
c
|
||||
}
|
||||
pub fn is_closed(&self) -> bool {
|
||||
!self.subpaths.is_empty() && self.subpaths.iter().all(|s| s.closed)
|
||||
}
|
||||
/// Nearest point on any segment: (subpath, segment, t, point, distance).
|
||||
pub fn nearest(&self, p: Point) -> Option<(usize, usize, f64, Point, f64)> {
|
||||
let mut best: Option<(usize, usize, f64, Point, f64)> = None;
|
||||
for (si, sp) in self.subpaths.iter().enumerate() {
|
||||
for seg in 0..sp.segment_count() {
|
||||
let c = sp.segment(seg);
|
||||
let n = c.nearest(p, 1e-6);
|
||||
let q = c.eval(n.t);
|
||||
let d = n.distance_sq.sqrt();
|
||||
if best.is_none_or(|b| d < b.4) {
|
||||
best = Some((si, seg, n.t, q, d));
|
||||
}
|
||||
}
|
||||
}
|
||||
best
|
||||
}
|
||||
/// Iterate (subpath index, anchor index, anchor).
|
||||
pub fn anchors(&self) -> impl Iterator<Item = (usize, usize, &Anchor)> {
|
||||
self.subpaths.iter().enumerate().flat_map(|(si, s)| s.anchors.iter().enumerate().map(move |(ai, a)| (si, ai, a)))
|
||||
}
|
||||
pub fn anchor_mut(&mut self, si: usize, ai: usize) -> Option<&mut Anchor> {
|
||||
self.subpaths.get_mut(si)?.anchors.get_mut(ai)
|
||||
}
|
||||
pub fn reverse(&mut self) {
|
||||
for sp in &mut self.subpaths {
|
||||
sp.reverse();
|
||||
}
|
||||
}
|
||||
/// Total length of all segments.
|
||||
pub fn length(&self) -> f64 {
|
||||
self.to_bezpath().segments().map(|s| kurbo::ParamCurveArclen::arclen(&s, 1e-6)).sum()
|
||||
}
|
||||
}
|
||||
|
||||
fn push_curve(cur: &mut Option<SubPath>, last: Point, c1: Point, c2: Point, p: Point) {
|
||||
let sp = cur.get_or_insert_with(|| SubPath { anchors: vec![Anchor::corner(last)], closed: false });
|
||||
if let Some(a) = sp.anchors.last_mut() {
|
||||
a.h_out = c1;
|
||||
}
|
||||
sp.anchors.push(Anchor { p, h_in: c2, h_out: p, kind: AnchorKind::Corner });
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn square() -> PathData {
|
||||
PathData::single(SubPath::polyline(&[Point::new(0.0, 0.0), Point::new(10.0, 0.0), Point::new(10.0, 10.0), Point::new(0.0, 10.0)], true))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn anchors_serialize_compactly_and_read_v1_maps() {
|
||||
let corner = Anchor::corner(Point::new(1.0, 2.0));
|
||||
assert_eq!(serde_json::to_string(&corner).unwrap(), r#"{"p":[1.0,2.0]}"#);
|
||||
let smooth = Anchor { p: Point::new(1.0, 2.0), h_in: Point::new(0.0, 2.0), h_out: Point::new(2.0, 2.0), kind: AnchorKind::Smooth };
|
||||
let back: Anchor = serde_json::from_str(&serde_json::to_string(&smooth).unwrap()).unwrap();
|
||||
assert_eq!(back, smooth);
|
||||
let v1 = r#"{"p":{"x":1.0,"y":2.0},"in":{"x":0.0,"y":2.0},"out":{"x":2.0,"y":2.0},"kind":"Smooth"}"#;
|
||||
assert_eq!(serde_json::from_str::<Anchor>(v1).unwrap(), smooth);
|
||||
let v1_corner = r#"{"p":{"x":1.0,"y":2.0},"in":{"x":1.0,"y":2.0},"out":{"x":1.0,"y":2.0},"kind":"Corner"}"#;
|
||||
assert_eq!(serde_json::from_str::<Anchor>(v1_corner).unwrap(), corner);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn square_bounds_and_area() {
|
||||
let p = square();
|
||||
assert_eq!(p.bounds(), Some(Rect::new(0.0, 0.0, 10.0, 10.0)));
|
||||
assert!((p.subpaths[0].area().abs() - 100.0).abs() < 1e-9);
|
||||
assert_eq!(p.subpaths[0].segment_count(), 4);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bezpath_roundtrip_polyline() {
|
||||
let p = square();
|
||||
let bp = p.to_bezpath();
|
||||
let back = PathData::from_bezpath(&bp);
|
||||
assert_eq!(back.subpaths.len(), 1);
|
||||
assert_eq!(back.subpaths[0].anchors.len(), 4);
|
||||
assert!(back.subpaths[0].closed);
|
||||
assert_eq!(back.bounds(), p.bounds());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bezpath_roundtrip_curves() {
|
||||
let c = kurbo::Circle::new((50.0, 50.0), 20.0);
|
||||
let bp = c.to_path(1e-3);
|
||||
let pd = PathData::from_bezpath(&bp);
|
||||
assert_eq!(pd.subpaths.len(), 1);
|
||||
assert!(pd.subpaths[0].closed);
|
||||
let b = pd.bounds().unwrap();
|
||||
assert!((b.width() - 40.0).abs() < 1e-3);
|
||||
// All anchors of a circle are smooth.
|
||||
assert!(pd.subpaths[0].anchors.iter().all(|a| a.kind == AnchorKind::Smooth));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn insert_anchor_on_line_keeps_shape() {
|
||||
let mut p = square();
|
||||
let before = p.bounds();
|
||||
let idx = p.subpaths[0].insert_anchor(0, 0.5);
|
||||
assert_eq!(idx, 1);
|
||||
assert_eq!(p.subpaths[0].anchors[1].p, Point::new(5.0, 0.0));
|
||||
assert_eq!(p.bounds(), before);
|
||||
assert_eq!(p.anchor_count(), 5);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn insert_anchor_on_curve_keeps_shape() {
|
||||
let bp = kurbo::Circle::new((0.0, 0.0), 10.0).to_path(1e-3);
|
||||
let mut pd = PathData::from_bezpath(&bp);
|
||||
let area0 = pd.subpaths[0].area();
|
||||
pd.subpaths[0].insert_anchor(1, 0.3);
|
||||
assert!((pd.subpaths[0].area() - area0).abs() < 1e-6);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nearest_on_edge() {
|
||||
let p = square();
|
||||
let (_, seg, _, q, d) = p.nearest(Point::new(5.0, -3.0)).unwrap();
|
||||
assert_eq!(seg, 0);
|
||||
assert!((q.x - 5.0).abs() < 1e-6 && q.y.abs() < 1e-6);
|
||||
assert!((d - 3.0).abs() < 1e-6);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reverse_flips_area_sign() {
|
||||
let mut p = square();
|
||||
let a = p.subpaths[0].area();
|
||||
p.reverse();
|
||||
assert!((p.subpaths[0].area() + a).abs() < 1e-9);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn transform_moves_bounds() {
|
||||
let p = square().transformed(Affine::translate((5.0, 5.0)));
|
||||
assert_eq!(p.bounds(), Some(Rect::new(5.0, 5.0, 15.0, 15.0)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn smooth_detection() {
|
||||
let a = Anchor::with_handles(Point::new(0.0, 0.0), Point::new(-1.0, 0.0), Point::new(2.0, 0.0));
|
||||
assert_eq!(a.kind, AnchorKind::Smooth);
|
||||
let c = Anchor::with_handles(Point::new(0.0, 0.0), Point::new(-1.0, 0.0), Point::new(0.0, 2.0));
|
||||
assert_eq!(c.kind, AnchorKind::Corner);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn length_of_square() {
|
||||
assert!((square().length() - 40.0).abs() < 1e-6);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn serde_roundtrip() {
|
||||
let p = square();
|
||||
let s = serde_json::to_string(&p).unwrap();
|
||||
let back: PathData = serde_json::from_str(&s).unwrap();
|
||||
assert_eq!(p, back);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,246 @@
|
||||
//! Shape generators matching InDesign's anchor layouts (so later edits behave the same).
|
||||
|
||||
use std::f64::consts::{PI, TAU};
|
||||
|
||||
use kurbo::{Point, Rect, Vec2};
|
||||
|
||||
use crate::path::{Anchor, AnchorKind, PathData, SubPath};
|
||||
|
||||
/// Circle-approximation handle ratio for a quarter arc.
|
||||
pub const KAPPA: f64 = 0.552_284_749_830_793_6;
|
||||
|
||||
/// Rectangle with 4 corner anchors, clockwise from the top-left.
|
||||
pub fn rectangle(r: Rect) -> PathData {
|
||||
let r = r.abs();
|
||||
PathData::single(SubPath::polyline(&[Point::new(r.x0, r.y0), Point::new(r.x1, r.y0), Point::new(r.x1, r.y1), Point::new(r.x0, r.y1)], true))
|
||||
}
|
||||
|
||||
/// Rounded rectangle (8 anchors). The radius is clamped to half the shorter side.
|
||||
pub fn rounded_rectangle(r: Rect, radius: f64) -> PathData {
|
||||
let r = r.abs();
|
||||
let rad = radius.max(0.0).min(r.width() / 2.0).min(r.height() / 2.0);
|
||||
if rad <= 1e-9 {
|
||||
return rectangle(r);
|
||||
}
|
||||
let k = rad * KAPPA;
|
||||
let (x0, y0, x1, y1) = (r.x0, r.y0, r.x1, r.y1);
|
||||
let a = |p: (f64, f64), hin: (f64, f64), hout: (f64, f64)| Anchor { p: p.into(), h_in: hin.into(), h_out: hout.into(), kind: AnchorKind::Corner };
|
||||
let anchors = vec![
|
||||
a((x0 + rad, y0), (x0 + rad - k, y0), (x0 + rad, y0)),
|
||||
a((x1 - rad, y0), (x1 - rad, y0), (x1 - rad + k, y0)),
|
||||
a((x1, y0 + rad), (x1, y0 + rad - k), (x1, y0 + rad)),
|
||||
a((x1, y1 - rad), (x1, y1 - rad), (x1, y1 - rad + k)),
|
||||
a((x1 - rad, y1), (x1 - rad + k, y1), (x1 - rad, y1)),
|
||||
a((x0 + rad, y1), (x0 + rad, y1), (x0 + rad - k, y1)),
|
||||
a((x0, y1 - rad), (x0, y1 - rad + k), (x0, y1 - rad)),
|
||||
a((x0, y0 + rad), (x0, y0 + rad), (x0, y0 + rad - k)),
|
||||
];
|
||||
PathData::single(SubPath::new(anchors, true))
|
||||
}
|
||||
|
||||
/// Ellipse inscribed in `r` with 4 smooth anchors (left, top, right, bottom).
|
||||
pub fn ellipse(r: Rect) -> PathData {
|
||||
let r = r.abs();
|
||||
let c = r.center();
|
||||
let rx = r.width() / 2.0;
|
||||
let ry = r.height() / 2.0;
|
||||
let kx = rx * KAPPA;
|
||||
let ky = ry * KAPPA;
|
||||
let s = |p: Point, hin: Vec2| Anchor { p, h_in: p + hin, h_out: p - hin, kind: AnchorKind::Smooth };
|
||||
let anchors = vec![
|
||||
s(Point::new(c.x - rx, c.y), Vec2::new(0.0, ky)),
|
||||
s(Point::new(c.x, c.y - ry), Vec2::new(-kx, 0.0)),
|
||||
s(Point::new(c.x + rx, c.y), Vec2::new(0.0, -ky)),
|
||||
s(Point::new(c.x, c.y + ry), Vec2::new(kx, 0.0)),
|
||||
];
|
||||
PathData::single(SubPath::new(anchors, true))
|
||||
}
|
||||
|
||||
/// Regular polygon with `sides` (≥ 3), first vertex straight up from the centre.
|
||||
pub fn polygon(center: Point, radius: f64, sides: u32, rotation_deg: f64) -> PathData {
|
||||
let n = sides.max(3);
|
||||
let rot = rotation_deg.to_radians();
|
||||
let pts: Vec<Point> = (0..n)
|
||||
.map(|i| {
|
||||
let a = -PI / 2.0 + rot + TAU * i as f64 / n as f64;
|
||||
Point::new(center.x + radius * a.cos(), center.y + radius * a.sin())
|
||||
})
|
||||
.collect();
|
||||
PathData::single(SubPath::polyline(&pts, true))
|
||||
}
|
||||
|
||||
/// Star with `points` tips (≥ 2), outer radius `r1`, inner radius `r2`, first tip up.
|
||||
pub fn star(center: Point, r1: f64, r2: f64, points: u32, rotation_deg: f64) -> PathData {
|
||||
let n = points.max(2) * 2;
|
||||
let rot = rotation_deg.to_radians();
|
||||
let pts: Vec<Point> = (0..n)
|
||||
.map(|i| {
|
||||
let r = if i % 2 == 0 { r1 } else { r2 };
|
||||
let a = -PI / 2.0 + rot + TAU * i as f64 / n as f64;
|
||||
Point::new(center.x + r * a.cos(), center.y + r * a.sin())
|
||||
})
|
||||
.collect();
|
||||
PathData::single(SubPath::polyline(&pts, true))
|
||||
}
|
||||
|
||||
/// Inner radius of a "straight-armed" star (InDesign's Alt-drag behaviour): shoulders aligned.
|
||||
pub fn straight_star_inner_radius(r1: f64, points: u32) -> f64 {
|
||||
let n = points.max(3) as f64;
|
||||
r1 * (PI / n * 2.0).cos() / (PI / n).cos()
|
||||
}
|
||||
|
||||
/// Open straight line.
|
||||
pub fn line(a: Point, b: Point) -> PathData {
|
||||
PathData::single(SubPath::polyline(&[a, b], false))
|
||||
}
|
||||
|
||||
/// Quarter-ellipse arc from `a` to `b` (Arc tool default: concave, open, x-axis base).
|
||||
pub fn arc(a: Point, b: Point, slope: f64, closed: bool) -> PathData {
|
||||
// Corner of the arc's bounding box is at (a.x, b.y): the arc bows away from it.
|
||||
let k = KAPPA * (1.0 + slope.clamp(-1.0, 1.0) * 0.0);
|
||||
let corner = Point::new(b.x, a.y);
|
||||
let h1 = a + (corner - a) * k;
|
||||
let h2 = b + (corner - b) * k;
|
||||
let mut anchors =
|
||||
vec![Anchor { p: a, h_in: a, h_out: h1, kind: AnchorKind::Corner }, Anchor { p: b, h_in: h2, h_out: b, kind: AnchorKind::Corner }];
|
||||
if closed {
|
||||
let o = Point::new(a.x, b.y);
|
||||
anchors.push(Anchor::corner(o));
|
||||
}
|
||||
PathData::single(SubPath::new(anchors, closed))
|
||||
}
|
||||
|
||||
/// Archimedean-style spiral approximating InDesign's Spiral tool (decay-based, `segments` quarter turns).
|
||||
pub fn spiral(center: Point, radius: f64, decay_percent: f64, segments: u32, clockwise: bool) -> PathData {
|
||||
let decay = (decay_percent / 100.0).clamp(0.05, 0.9999);
|
||||
let dir = if clockwise { 1.0 } else { -1.0 };
|
||||
let mut anchors = Vec::new();
|
||||
let mut r = radius;
|
||||
for i in 0..=segments {
|
||||
let a = dir * (i as f64) * PI / 2.0;
|
||||
let p = Point::new(center.x + r * a.cos(), center.y + r * a.sin());
|
||||
// Tangent direction for a quarter arc of radius r.
|
||||
let t = Vec2::new(-a.sin(), a.cos()) * dir;
|
||||
let r_prev = r / decay;
|
||||
let r_next = r * decay;
|
||||
let h_in = p - t * (KAPPA * (r + r_prev) / 2.0);
|
||||
let h_out = p + t * (KAPPA * (r + r_next) / 2.0);
|
||||
anchors.push(Anchor { p, h_in: if i == 0 { p } else { h_in }, h_out: if i == segments { p } else { h_out }, kind: AnchorKind::Smooth });
|
||||
r *= decay;
|
||||
}
|
||||
// InDesign spirals wind inward from the outer point; reverse so the path starts at the centre end.
|
||||
let mut sp = SubPath::new(anchors, false);
|
||||
sp.reverse();
|
||||
PathData::single(sp)
|
||||
}
|
||||
|
||||
/// Rectangular grid as separate open lines (plus an optional frame rectangle).
|
||||
pub fn rectangular_grid(r: Rect, h_dividers: u32, v_dividers: u32, frame: bool) -> Vec<PathData> {
|
||||
let r = r.abs();
|
||||
let mut out = Vec::new();
|
||||
for i in 1..=h_dividers {
|
||||
let y = r.y0 + r.height() * i as f64 / (h_dividers + 1) as f64;
|
||||
out.push(line(Point::new(r.x0, y), Point::new(r.x1, y)));
|
||||
}
|
||||
for i in 1..=v_dividers {
|
||||
let x = r.x0 + r.width() * i as f64 / (v_dividers + 1) as f64;
|
||||
out.push(line(Point::new(x, r.y0), Point::new(x, r.y1)));
|
||||
}
|
||||
if frame {
|
||||
out.push(rectangle(r));
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Polar grid: concentric ellipses plus radial dividers.
|
||||
pub fn polar_grid(r: Rect, concentric: u32, radial: u32) -> Vec<PathData> {
|
||||
let r = r.abs();
|
||||
let c = r.center();
|
||||
let mut out = Vec::new();
|
||||
for i in 1..=concentric + 1 {
|
||||
let f = i as f64 / (concentric + 1) as f64;
|
||||
out.push(ellipse(Rect::from_center_size(c, (r.width() * f, r.height() * f))));
|
||||
}
|
||||
for i in 0..radial.max(1) {
|
||||
let a = -PI / 2.0 + TAU * i as f64 / radial.max(1) as f64;
|
||||
out.push(line(c, Point::new(c.x + r.width() / 2.0 * a.cos(), c.y + r.height() / 2.0 * a.sin())));
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn rect_is_4_corners_clockwise() {
|
||||
let p = rectangle(Rect::new(0.0, 0.0, 100.0, 50.0));
|
||||
assert_eq!(p.anchor_count(), 4);
|
||||
assert!(p.subpaths[0].area() > 0.0);
|
||||
assert_eq!(p.bounds(), Some(Rect::new(0.0, 0.0, 100.0, 50.0)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rect_normalizes_negative() {
|
||||
let p = rectangle(Rect::new(10.0, 10.0, 0.0, 0.0));
|
||||
assert_eq!(p.bounds(), Some(Rect::new(0.0, 0.0, 10.0, 10.0)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ellipse_bounds_and_area() {
|
||||
let p = ellipse(Rect::new(0.0, 0.0, 100.0, 60.0));
|
||||
let b = p.bounds().unwrap();
|
||||
assert!((b.width() - 100.0).abs() < 1e-6 && (b.height() - 60.0).abs() < 1e-6);
|
||||
let area = p.subpaths[0].area().abs();
|
||||
assert!((area - PI * 50.0 * 30.0).abs() / area < 0.001);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rounded_rect_clamps() {
|
||||
let p = rounded_rectangle(Rect::new(0.0, 0.0, 20.0, 10.0), 100.0);
|
||||
assert_eq!(p.anchor_count(), 8);
|
||||
assert_eq!(p.bounds(), Some(Rect::new(0.0, 0.0, 20.0, 10.0)));
|
||||
assert_eq!(rounded_rectangle(Rect::new(0.0, 0.0, 20.0, 10.0), 0.0).anchor_count(), 4);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn polygon_first_vertex_up() {
|
||||
let p = polygon(Point::new(0.0, 0.0), 10.0, 6, 0.0);
|
||||
assert_eq!(p.anchor_count(), 6);
|
||||
let a = p.subpaths[0].anchors[0].p;
|
||||
assert!(a.x.abs() < 1e-9 && (a.y + 10.0).abs() < 1e-9);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn star_alternates_radii() {
|
||||
let p = star(Point::ZERO, 10.0, 5.0, 5, 0.0);
|
||||
assert_eq!(p.anchor_count(), 10);
|
||||
let d: Vec<f64> = p.subpaths[0].anchors.iter().map(|a| a.p.to_vec2().hypot()).collect();
|
||||
assert!((d[0] - 10.0).abs() < 1e-9 && (d[1] - 5.0).abs() < 1e-9);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn straight_star_five_points() {
|
||||
let r2 = straight_star_inner_radius(100.0, 5);
|
||||
assert!((r2 - 38.196_601).abs() < 1e-3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn spiral_has_segments() {
|
||||
let p = spiral(Point::ZERO, 100.0, 80.0, 10, true);
|
||||
assert_eq!(p.anchor_count(), 11);
|
||||
assert!(!p.subpaths[0].closed);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn grids() {
|
||||
assert_eq!(rectangular_grid(Rect::new(0.0, 0.0, 10.0, 10.0), 5, 5, true).len(), 11);
|
||||
assert_eq!(polar_grid(Rect::new(0.0, 0.0, 10.0, 10.0), 5, 5).len(), 11);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn arc_open_and_closed() {
|
||||
assert_eq!(arc(Point::ZERO, Point::new(10.0, 10.0), 0.0, false).anchor_count(), 2);
|
||||
assert!(arc(Point::ZERO, Point::new(10.0, 10.0), 0.0, true).subpaths[0].closed);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
//! Snapping math shared by tools and smart guides.
|
||||
|
||||
use kurbo::Point;
|
||||
|
||||
/// Snap a value to a grid with the given spacing and origin.
|
||||
pub fn snap_to_grid(v: f64, spacing: f64, origin: f64) -> f64 {
|
||||
if spacing <= 0.0 {
|
||||
return v;
|
||||
}
|
||||
((v - origin) / spacing).round() * spacing + origin
|
||||
}
|
||||
|
||||
pub fn snap_point_to_grid(p: Point, spacing: f64) -> Point {
|
||||
Point::new(snap_to_grid(p.x, spacing, 0.0), snap_to_grid(p.y, spacing, 0.0))
|
||||
}
|
||||
|
||||
/// A candidate alignment line for smart guides.
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub enum Axis {
|
||||
X,
|
||||
Y,
|
||||
}
|
||||
|
||||
/// Find the closest target within `tol` to `v`. Returns (target, delta).
|
||||
pub fn closest(v: f64, targets: impl IntoIterator<Item = f64>, tol: f64) -> Option<(f64, f64)> {
|
||||
targets.into_iter().map(|t| (t, t - v)).filter(|(_, d)| d.abs() <= tol).min_by(|a, b| a.1.abs().total_cmp(&b.1.abs()))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn grid() {
|
||||
assert_eq!(snap_to_grid(13.0, 10.0, 0.0), 10.0);
|
||||
assert_eq!(snap_to_grid(16.0, 10.0, 0.0), 20.0);
|
||||
assert_eq!(snap_to_grid(16.0, 10.0, 3.0), 13.0);
|
||||
assert_eq!(snap_to_grid(16.0, 0.0, 0.0), 16.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn closest_target() {
|
||||
assert_eq!(closest(10.0, [0.0, 11.0, 9.5], 2.0), Some((9.5, -0.5)));
|
||||
assert_eq!(closest(10.0, [0.0, 20.0], 2.0), None);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,370 @@
|
||||
//! Measurement units and InDesign-style measurement fields.
|
||||
//!
|
||||
//! Every length in the document model is in **points** (1/72 in). Fields in the UI accept any unit
|
||||
//! and simple arithmetic, the way InDesign's do: `12`, `1p6` (1 pica 6 points), `p6`, `12mm`,
|
||||
//! `3in`, `2c` (2 ciceros), `1c4` (1 cicero 4 didot points), `10px`, `18Q`, `4ag`, `24pt+2mm`,
|
||||
//! `100/3`, `50%` (relative to a base value). A bare number takes the field's default unit.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// Points per unit.
|
||||
pub const PT_PER_INCH: f64 = 72.0;
|
||||
pub const PT_PER_PICA: f64 = 12.0;
|
||||
pub const PT_PER_MM: f64 = 72.0 / 25.4;
|
||||
pub const PT_PER_CM: f64 = 72.0 / 2.54;
|
||||
/// One didot point is 0.376065 mm; a cicero is 12 didot points.
|
||||
pub const PT_PER_DIDOT: f64 = 0.376_065 * PT_PER_MM;
|
||||
pub const PT_PER_CICERO: f64 = 12.0 * PT_PER_DIDOT;
|
||||
/// Agate (newspaper column measure): 1/14 in.
|
||||
pub const PT_PER_AGATE: f64 = 72.0 / 14.0;
|
||||
/// Q (Japanese type size) and Ha (Japanese leading): 0.25 mm.
|
||||
pub const PT_PER_Q: f64 = 0.25 * PT_PER_MM;
|
||||
|
||||
/// A measurement unit (Preferences → Units & Increments).
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum Unit {
|
||||
#[default]
|
||||
Points,
|
||||
Picas,
|
||||
Inches,
|
||||
InchesDecimal,
|
||||
Millimeters,
|
||||
Centimeters,
|
||||
Ciceros,
|
||||
Agates,
|
||||
Pixels,
|
||||
Q,
|
||||
Ha,
|
||||
}
|
||||
|
||||
impl Unit {
|
||||
pub const ALL: [Unit; 11] = [
|
||||
Unit::Points,
|
||||
Unit::Picas,
|
||||
Unit::Inches,
|
||||
Unit::InchesDecimal,
|
||||
Unit::Millimeters,
|
||||
Unit::Centimeters,
|
||||
Unit::Ciceros,
|
||||
Unit::Agates,
|
||||
Unit::Pixels,
|
||||
Unit::Q,
|
||||
Unit::Ha,
|
||||
];
|
||||
|
||||
/// Points in one of this unit.
|
||||
pub fn points(self) -> f64 {
|
||||
match self {
|
||||
Unit::Points | Unit::Pixels => 1.0,
|
||||
Unit::Picas => PT_PER_PICA,
|
||||
Unit::Inches | Unit::InchesDecimal => PT_PER_INCH,
|
||||
Unit::Millimeters => PT_PER_MM,
|
||||
Unit::Centimeters => PT_PER_CM,
|
||||
Unit::Ciceros => PT_PER_CICERO,
|
||||
Unit::Agates => PT_PER_AGATE,
|
||||
Unit::Q | Unit::Ha => PT_PER_Q,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn to_pt(self, v: f64) -> f64 {
|
||||
v * self.points()
|
||||
}
|
||||
|
||||
pub fn from_pt(self, pt: f64) -> f64 {
|
||||
pt / self.points()
|
||||
}
|
||||
|
||||
/// Label as shown in Preferences and the ruler context menu.
|
||||
pub fn label(self) -> &'static str {
|
||||
match self {
|
||||
Unit::Points => "Points",
|
||||
Unit::Picas => "Picas",
|
||||
Unit::Inches => "Inches",
|
||||
Unit::InchesDecimal => "Inches Decimal",
|
||||
Unit::Millimeters => "Millimeters",
|
||||
Unit::Centimeters => "Centimeters",
|
||||
Unit::Ciceros => "Ciceros",
|
||||
Unit::Agates => "Agates",
|
||||
Unit::Pixels => "Pixels",
|
||||
Unit::Q => "Q",
|
||||
Unit::Ha => "Ha",
|
||||
}
|
||||
}
|
||||
|
||||
/// Suffix used when formatting (`pt`, `mm`, `in`…). Picas and ciceros use their own notation.
|
||||
pub fn suffix(self) -> &'static str {
|
||||
match self {
|
||||
Unit::Points => " pt",
|
||||
Unit::Picas => "p",
|
||||
Unit::Inches | Unit::InchesDecimal => " in",
|
||||
Unit::Millimeters => " mm",
|
||||
Unit::Centimeters => " cm",
|
||||
Unit::Ciceros => "c",
|
||||
Unit::Agates => " ag",
|
||||
Unit::Pixels => " px",
|
||||
Unit::Q => " Q",
|
||||
Unit::Ha => " H",
|
||||
}
|
||||
}
|
||||
|
||||
/// Ruler tick spacing (major, subdivisions) in points for a given zoom (screen px per pt).
|
||||
pub fn ruler_ticks(self, zoom: f64) -> (f64, u32) {
|
||||
let (base, subs): (f64, &[u32]) = match self {
|
||||
Unit::Inches | Unit::InchesDecimal => (PT_PER_INCH, &[8, 4, 2]),
|
||||
Unit::Picas => (PT_PER_PICA, &[6, 3, 2]),
|
||||
Unit::Ciceros => (PT_PER_CICERO, &[6, 3, 2]),
|
||||
Unit::Millimeters => (10.0 * PT_PER_MM, &[10, 5, 2]),
|
||||
Unit::Centimeters => (PT_PER_CM, &[10, 5, 2]),
|
||||
Unit::Agates => (PT_PER_AGATE * 14.0, &[14, 7, 2]),
|
||||
Unit::Q | Unit::Ha => (40.0 * PT_PER_Q, &[4, 2]),
|
||||
Unit::Points | Unit::Pixels => (72.0, &[8, 4, 2]),
|
||||
};
|
||||
// Choose a major spacing (base × 1, 2, 5, 10…) at least ~60 px apart, or base / 2^n when zoomed in.
|
||||
let mut major = base;
|
||||
while major * zoom < 50.0 {
|
||||
major *= if (major / base).log10().fract().abs() < 1e-9 { 2.0 } else { 2.5 };
|
||||
}
|
||||
while major * zoom > 160.0 && major > base / 64.0 {
|
||||
major /= 2.0;
|
||||
}
|
||||
let px = major * zoom;
|
||||
let sub = subs.iter().copied().find(|s| px / *s as f64 >= 6.0).unwrap_or(1);
|
||||
(major, sub)
|
||||
}
|
||||
}
|
||||
|
||||
/// Error for an unparseable measurement.
|
||||
#[derive(Clone, Debug, PartialEq, thiserror::Error)]
|
||||
#[error("invalid measurement `{0}`")]
|
||||
pub struct MeasureError(pub String);
|
||||
|
||||
/// Parse a measurement field into points. Bare numbers use `default`. Supports `+ - * /`
|
||||
/// (left-to-right with `*`/`/` binding tighter), and `%` (of `percent_base`).
|
||||
pub fn parse_measure(input: &str, default: Unit) -> Result<f64, MeasureError> {
|
||||
parse_measure_rel(input, default, None)
|
||||
}
|
||||
|
||||
/// Like [`parse_measure`] but `%` is resolved against `percent_base` (points).
|
||||
pub fn parse_measure_rel(input: &str, default: Unit, percent_base: Option<f64>) -> Result<f64, MeasureError> {
|
||||
let err = || MeasureError(input.to_string());
|
||||
let s: String = input.chars().filter(|c| !c.is_whitespace()).collect::<String>().to_ascii_lowercase();
|
||||
if s.is_empty() {
|
||||
return Err(err());
|
||||
}
|
||||
// Tokenize into terms separated by + and - (a leading sign belongs to the first term).
|
||||
let mut terms: Vec<(f64, String)> = Vec::new();
|
||||
let mut sign = 1.0;
|
||||
let mut cur = String::new();
|
||||
for (i, ch) in s.char_indices() {
|
||||
if (ch == '+' || ch == '-') && i > 0 && !cur.is_empty() && !cur.ends_with(['*', '/']) {
|
||||
terms.push((sign, std::mem::take(&mut cur)));
|
||||
sign = if ch == '-' { -1.0 } else { 1.0 };
|
||||
} else if (ch == '+' || ch == '-') && cur.is_empty() {
|
||||
sign *= if ch == '-' { -1.0 } else { 1.0 };
|
||||
} else {
|
||||
cur.push(ch);
|
||||
}
|
||||
}
|
||||
if cur.is_empty() {
|
||||
return Err(err());
|
||||
}
|
||||
terms.push((sign, cur));
|
||||
let mut total = 0.0;
|
||||
for (sign, term) in terms {
|
||||
total += sign * parse_product(&term, default, percent_base).ok_or_else(err)?;
|
||||
}
|
||||
if total.is_finite() { Ok(total) } else { Err(err()) }
|
||||
}
|
||||
|
||||
/// `a*b/c`: the first factor carries the unit; later factors are plain numbers (or measurements,
|
||||
/// in which case only the first operand's unit counts, matching InDesign's arithmetic).
|
||||
fn parse_product(term: &str, default: Unit, percent_base: Option<f64>) -> Option<f64> {
|
||||
let mut val: Option<f64> = None;
|
||||
let mut op = '*';
|
||||
let mut tok = String::new();
|
||||
let flush = |tok: &str, val: Option<f64>, op: char| -> Option<f64> {
|
||||
let x = if val.is_none() { parse_single(tok, default, percent_base)? } else { parse_number_or_measure(tok, default)? };
|
||||
Some(match (val, op) {
|
||||
(None, _) => x,
|
||||
(Some(v), '*') => v * x,
|
||||
(Some(v), _) => {
|
||||
if x == 0.0 {
|
||||
return None;
|
||||
}
|
||||
v / x
|
||||
}
|
||||
})
|
||||
};
|
||||
for ch in term.chars() {
|
||||
if ch == '*' || ch == '/' {
|
||||
val = Some(flush(&tok, val, op)?);
|
||||
tok.clear();
|
||||
op = ch;
|
||||
} else {
|
||||
tok.push(ch);
|
||||
}
|
||||
}
|
||||
flush(&tok, val, op)
|
||||
}
|
||||
|
||||
fn parse_number_or_measure(tok: &str, _default: Unit) -> Option<f64> {
|
||||
// Multipliers/divisors are unitless numbers.
|
||||
tok.parse::<f64>().ok()
|
||||
}
|
||||
|
||||
fn parse_single(tok: &str, default: Unit, percent_base: Option<f64>) -> Option<f64> {
|
||||
if tok.is_empty() {
|
||||
return None;
|
||||
}
|
||||
if let Some(n) = tok.strip_suffix('%') {
|
||||
return Some(n.parse::<f64>().ok()? / 100.0 * percent_base?);
|
||||
}
|
||||
// Picas `1p6`, `p6`, `1p`; ciceros `1c4`, `c4`, `2c`.
|
||||
for (sep, unit_pt, sub_pt) in [('p', PT_PER_PICA, 1.0), ('c', PT_PER_CICERO, PT_PER_DIDOT)] {
|
||||
if let Some(pos) = tok.find(sep) {
|
||||
let (a, b) = (&tok[..pos], &tok[pos + 1..]);
|
||||
let num_ok = |s: &str| s.is_empty() || s.parse::<f64>().is_ok();
|
||||
// Exclude `pt`, `px`, `cm`.
|
||||
if num_ok(a) && num_ok(b) && !(a.is_empty() && b.is_empty()) {
|
||||
let a = if a.is_empty() { 0.0 } else { a.parse::<f64>().ok()? };
|
||||
let b = if b.is_empty() { 0.0 } else { b.parse::<f64>().ok()? };
|
||||
return Some(a * unit_pt + b * sub_pt);
|
||||
}
|
||||
}
|
||||
}
|
||||
let split = tok.find(|c: char| !(c.is_ascii_digit() || c == '.')).unwrap_or(tok.len());
|
||||
let (num, suffix) = tok.split_at(split);
|
||||
let n: f64 = num.parse().ok()?;
|
||||
let unit = match suffix {
|
||||
"" => default,
|
||||
"pt" | "pts" | "point" | "points" => Unit::Points,
|
||||
"px" | "pixel" | "pixels" => Unit::Pixels,
|
||||
"in" | "inch" | "inches" | "\"" | "i" => Unit::Inches,
|
||||
"mm" | "millimeter" | "millimeters" => Unit::Millimeters,
|
||||
"cm" | "centimeter" | "centimeters" => Unit::Centimeters,
|
||||
"ag" | "agate" | "agates" => Unit::Agates,
|
||||
"q" => Unit::Q,
|
||||
"h" | "ha" => Unit::Ha,
|
||||
_ => return None,
|
||||
};
|
||||
Some(unit.to_pt(n))
|
||||
}
|
||||
|
||||
/// Format a value (points) in `unit` the way InDesign fields show it: up to 3 decimals, trailing
|
||||
/// zeros trimmed; picas as `3p6`, ciceros as `2c4`.
|
||||
pub fn format_measure(pt: f64, unit: Unit) -> String {
|
||||
format_measure_prec(pt, unit, 3)
|
||||
}
|
||||
|
||||
pub fn format_measure_prec(pt: f64, unit: Unit, decimals: usize) -> String {
|
||||
let trim = |v: f64| {
|
||||
let s = format!("{v:.decimals$}");
|
||||
let s = if s.contains('.') { s.trim_end_matches('0').trim_end_matches('.').to_string() } else { s };
|
||||
if s == "-0" { "0".to_string() } else { s }
|
||||
};
|
||||
match unit {
|
||||
Unit::Picas | Unit::Ciceros => {
|
||||
let (big, small) = if unit == Unit::Picas { (PT_PER_PICA, 1.0) } else { (PT_PER_CICERO, PT_PER_DIDOT) };
|
||||
let neg = pt < 0.0;
|
||||
let a = pt.abs();
|
||||
let mut whole = (a / big).floor();
|
||||
let mut rest = (a - whole * big) / small;
|
||||
if (rest - 12.0).abs() < 1e-6 || rest > 12.0 - 1e-6 {
|
||||
whole += 1.0;
|
||||
rest = 0.0;
|
||||
}
|
||||
let sep = if unit == Unit::Picas { 'p' } else { 'c' };
|
||||
format!("{}{}{}{}", if neg { "-" } else { "" }, whole as i64, sep, trim(rest))
|
||||
}
|
||||
_ => format!("{}{}", trim(unit.from_pt(pt)), unit.suffix()),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn p(s: &str) -> f64 {
|
||||
parse_measure(s, Unit::Points).unwrap()
|
||||
}
|
||||
fn close(a: f64, b: f64) -> bool {
|
||||
(a - b).abs() < 1e-6
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn plain_and_units() {
|
||||
assert!(close(p("12"), 12.0));
|
||||
assert!(close(p("12pt"), 12.0));
|
||||
assert!(close(p("1in"), 72.0));
|
||||
assert!(close(p("25.4mm"), 72.0));
|
||||
assert!(close(p("2.54 cm"), 72.0));
|
||||
assert!(close(p("14ag"), 72.0));
|
||||
assert!(close(p("4Q"), PT_PER_MM));
|
||||
assert!(close(parse_measure("3", Unit::Millimeters).unwrap(), 3.0 * PT_PER_MM));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn picas_and_ciceros() {
|
||||
assert!(close(p("1p6"), 18.0));
|
||||
assert!(close(p("p6"), 6.0));
|
||||
assert!(close(p("3p"), 36.0));
|
||||
assert!(close(p("1p6.5"), 18.5));
|
||||
assert!(close(p("1c"), PT_PER_CICERO));
|
||||
assert!(close(p("1c4"), PT_PER_CICERO + 4.0 * PT_PER_DIDOT));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn arithmetic() {
|
||||
assert!(close(p("12+6"), 18.0));
|
||||
assert!(close(p("1in-36"), 36.0));
|
||||
assert!(close(p("10*3"), 30.0));
|
||||
assert!(close(p("100/4"), 25.0));
|
||||
assert!(close(p("1p+6pt"), 18.0));
|
||||
assert!(close(p("-6"), -6.0));
|
||||
assert!(close(p("2*3+4"), 10.0));
|
||||
assert!(close(parse_measure_rel("50%", Unit::Points, Some(40.0)).unwrap(), 20.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_garbage() {
|
||||
assert!(parse_measure("", Unit::Points).is_err());
|
||||
assert!(parse_measure("abc", Unit::Points).is_err());
|
||||
assert!(parse_measure("12zz", Unit::Points).is_err());
|
||||
assert!(parse_measure("4/0", Unit::Points).is_err());
|
||||
assert!(parse_measure("50%", Unit::Points).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn formatting() {
|
||||
assert_eq!(format_measure(18.0, Unit::Picas), "1p6");
|
||||
assert_eq!(format_measure(36.0, Unit::Picas), "3p0");
|
||||
assert_eq!(format_measure(12.5, Unit::Points), "12.5 pt");
|
||||
assert_eq!(format_measure(72.0, Unit::Inches), "1 in");
|
||||
assert_eq!(format_measure(72.0, Unit::Millimeters), "25.4 mm");
|
||||
assert_eq!(format_measure(-18.0, Unit::Picas), "-1p6");
|
||||
assert_eq!(format_measure(0.0, Unit::Points), "0 pt");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn format_parse_roundtrip() {
|
||||
for u in Unit::ALL {
|
||||
for v in [0.0, 1.0, 18.0, 72.0, 612.0, 123.456] {
|
||||
let s = format_measure_prec(v, u, 6);
|
||||
let back = parse_measure(&s, u).unwrap_or_else(|e| panic!("{u:?} {s}: {e}"));
|
||||
assert!((back - v).abs() < 1e-3, "{u:?}: {v} -> {s} -> {back}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ruler_ticks_are_reasonable() {
|
||||
for u in Unit::ALL {
|
||||
for z in [0.1, 0.5, 1.0, 2.0, 8.0] {
|
||||
let (major, sub) = u.ruler_ticks(z);
|
||||
assert!(major > 0.0 && sub >= 1, "{u:?} {z}");
|
||||
assert!(major * z >= 20.0, "{u:?} {z} {major}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
max_width = 150
|
||||
use_small_heuristics = "Max"
|
||||
@@ -0,0 +1,9 @@
|
||||
[package]
|
||||
name = "xtask"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
publish = false
|
||||
|
||||
[dependencies]
|
||||
serde_json = { workspace = true }
|
||||
@@ -0,0 +1,81 @@
|
||||
//! `cargo xtask assets`: every non-code asset must be attributed in `ASSETS.md`.
|
||||
//!
|
||||
//! Scans all git-tracked (and untracked, not ignored) files with an asset extension, plus
|
||||
//! everything under `assets/`, `docs/images/` and `examples/`, and fails if a path is not listed
|
||||
//! as `` `path` `` in ASSETS.md. See the asset policy in AGENTS.md.
|
||||
|
||||
use std::path::Path;
|
||||
use std::process::Command;
|
||||
|
||||
const ASSET_EXT: &[&str] = &[
|
||||
"png",
|
||||
"jpg",
|
||||
"jpeg",
|
||||
"gif",
|
||||
"webp",
|
||||
"bmp",
|
||||
"tif",
|
||||
"tiff",
|
||||
"ico",
|
||||
"icns",
|
||||
"svg",
|
||||
"pdf",
|
||||
"ai",
|
||||
"eps",
|
||||
"psd",
|
||||
"icc",
|
||||
"icm",
|
||||
"ttf",
|
||||
"otf",
|
||||
"woff",
|
||||
"woff2",
|
||||
"ase",
|
||||
"aco",
|
||||
"abr",
|
||||
"designcraft",
|
||||
"mp4",
|
||||
"wav",
|
||||
"mp3",
|
||||
];
|
||||
const ASSET_DIRS: &[&str] = &["assets/", "docs/images/", "examples/"];
|
||||
|
||||
pub fn is_asset(path: &str) -> bool {
|
||||
let ext = Path::new(path).extension().and_then(|e| e.to_str()).map(str::to_ascii_lowercase).unwrap_or_default();
|
||||
ASSET_DIRS.iter().any(|d| path.starts_with(d)) || ASSET_EXT.contains(&ext.as_str())
|
||||
}
|
||||
|
||||
/// Paths that need an ASSETS.md entry but lack one.
|
||||
pub fn missing(files: &[String], assets_md: &str) -> Vec<String> {
|
||||
files.iter().filter(|f| is_asset(f) && !assets_md.contains(&format!("`{f}`"))).cloned().collect()
|
||||
}
|
||||
|
||||
pub fn run(root: &Path) -> Result<(), String> {
|
||||
let out = Command::new("git")
|
||||
.current_dir(root)
|
||||
.args(["ls-files", "--cached", "--others", "--exclude-standard"])
|
||||
.output()
|
||||
.map_err(|e| format!("git ls-files: {e}"))?;
|
||||
let files: Vec<String> = String::from_utf8_lossy(&out.stdout).lines().filter(|l| root.join(l).exists()).map(str::to_owned).collect();
|
||||
let md = std::fs::read_to_string(root.join("ASSETS.md")).map_err(|e| format!("ASSETS.md: {e}"))?;
|
||||
let miss = missing(&files, &md);
|
||||
if miss.is_empty() {
|
||||
println!("assets: all {} asset files attributed in ASSETS.md", files.iter().filter(|f| is_asset(f)).count());
|
||||
Ok(())
|
||||
} else {
|
||||
Err(format!("{} asset file(s) lack an ASSETS.md entry (author, source, licence):\n {}", miss.len(), miss.join("\n ")))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn detects_unattributed_assets() {
|
||||
let files = vec!["assets/icons/a.svg".to_string(), "crates/x/src/lib.rs".into(), "docs/images/b.png".into(), "tests/fixture.png".into()];
|
||||
let md = "| `assets/icons/a.svg` | me | here | MIT |";
|
||||
assert_eq!(missing(&files, md), vec!["docs/images/b.png".to_string(), "tests/fixture.png".into()]);
|
||||
assert!(!is_asset("crates/x/src/lib.rs"));
|
||||
assert!(is_asset("assets/fonts/OFL.txt"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,338 @@
|
||||
//! Dependency layering rules (plan/architecture.md §3).
|
||||
//!
|
||||
//! The rule engine works on a small, metadata-independent model so it can be
|
||||
//! unit-tested; `from_metadata` builds that model from `cargo metadata`.
|
||||
|
||||
use serde_json::Value;
|
||||
|
||||
/// Where a workspace crate sits.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum Class {
|
||||
/// Regular layered crate.
|
||||
Layer(u8),
|
||||
/// Layer 0, and additionally may depend on no workspace crate at all.
|
||||
Standalone,
|
||||
/// Test tooling: may depend on anything up to L5 (it sits at L6 for rule
|
||||
/// purposes); other crates may use it only as a dev-dependency.
|
||||
Testkit,
|
||||
/// Binaries and build tooling: exempt from the rules.
|
||||
Exempt,
|
||||
}
|
||||
|
||||
impl Class {
|
||||
fn layer(self) -> Option<u8> {
|
||||
match self {
|
||||
Class::Layer(l) => Some(l),
|
||||
Class::Standalone => Some(0),
|
||||
Class::Testkit => Some(6),
|
||||
Class::Exempt => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The layering table. Names are package names without the `designcraft-`
|
||||
/// prefix.
|
||||
pub const TABLE: &[(&str, Class)] = &[
|
||||
("geom", Class::Layer(0)),
|
||||
("color", Class::Layer(0)),
|
||||
("doc", Class::Layer(1)),
|
||||
("fonts", Class::Layer(1)),
|
||||
("compose", Class::Layer(2)),
|
||||
("images", Class::Layer(2)),
|
||||
("render", Class::Layer(3)),
|
||||
("pdf", Class::Layer(3)),
|
||||
("idml", Class::Layer(3)),
|
||||
("format", Class::Layer(3)),
|
||||
("tools", Class::Layer(4)),
|
||||
("engine", Class::Layer(5)),
|
||||
("ui-egui", Class::Layer(6)),
|
||||
("mcp", Class::Layer(6)),
|
||||
("testkit", Class::Testkit),
|
||||
// L7 apps and tooling
|
||||
("designcraft", Class::Exempt),
|
||||
("cli", Class::Exempt),
|
||||
("web", Class::Exempt),
|
||||
("xtask", Class::Exempt),
|
||||
];
|
||||
|
||||
/// Explicit orderings *within* a layer (earlier may be used by later).
|
||||
/// The L0 foundation is a small chain: `raster` builds on `color` and
|
||||
/// `geom`, which the §3 diagram draws on one line. The GPU backend (`gpu`)
|
||||
/// reuses the CPU reference (`compose`) for LUTs and parity tests.
|
||||
pub const INTRA_LAYER_ORDER: &[&[&str]] = &[&["geom", "color"], &["doc", "fonts"]];
|
||||
|
||||
fn intra_layer_allowed(from: &str, to: &str) -> bool {
|
||||
let (from, to) = (short_name(from), short_name(to));
|
||||
INTRA_LAYER_ORDER.iter().any(|chain| match (chain.iter().position(|n| *n == from), chain.iter().position(|n| *n == to)) {
|
||||
(Some(f), Some(t)) => t < f,
|
||||
_ => false,
|
||||
})
|
||||
}
|
||||
|
||||
/// External crates that constitute a UI toolkit / windowing dependency.
|
||||
/// Entries ending in `*` are prefixes.
|
||||
pub const UI_CRATES: &[&str] = &["egui", "eframe", "winit", "egui_kittest", "rfd", "bevy*"];
|
||||
|
||||
/// First layer allowed to use UI crates.
|
||||
pub const UI_MIN_LAYER: u8 = 6;
|
||||
|
||||
pub fn short_name(pkg: &str) -> &str {
|
||||
pkg.strip_prefix("designcraft-").unwrap_or(pkg)
|
||||
}
|
||||
|
||||
pub fn classify(pkg: &str) -> Option<Class> {
|
||||
let s = short_name(pkg);
|
||||
TABLE.iter().find(|(n, _)| *n == s).map(|(_, c)| *c)
|
||||
}
|
||||
|
||||
fn is_ui_crate(name: &str) -> bool {
|
||||
UI_CRATES.iter().any(|p| match p.strip_suffix('*') {
|
||||
Some(prefix) => name.starts_with(prefix),
|
||||
None => name == *p,
|
||||
})
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum DepKind {
|
||||
Normal,
|
||||
Dev,
|
||||
Build,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Dep {
|
||||
pub name: String,
|
||||
pub kind: DepKind,
|
||||
/// `true` if the dependency is a workspace member.
|
||||
pub workspace: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Crate {
|
||||
pub name: String,
|
||||
pub deps: Vec<Dep>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum Violation {
|
||||
Unregistered { krate: String },
|
||||
Upward { krate: String, dep: String, from: u8, to: u8, kind: DepKind },
|
||||
StandaloneHasWorkspaceDep { krate: String, dep: String },
|
||||
TestkitAsNormalDep { krate: String },
|
||||
UiBelowL6 { krate: String, dep: String, layer: u8 },
|
||||
}
|
||||
|
||||
impl std::fmt::Display for Violation {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
Violation::Unregistered { krate } => {
|
||||
write!(f, "{krate}: unknown workspace crate; register it in xtask/src/layers.rs TABLE (see plan/architecture.md §3)")
|
||||
}
|
||||
Violation::Upward { krate, dep, from, to, kind } => {
|
||||
write!(f, "{krate} (L{from}) -> {dep} (L{to}) [{kind:?}]: may only depend on strictly lower layers")
|
||||
}
|
||||
Violation::StandaloneHasWorkspaceDep { krate, dep } => {
|
||||
write!(f, "{krate}: standalone crate must not depend on workspace crate {dep}")
|
||||
}
|
||||
Violation::TestkitAsNormalDep { krate } => {
|
||||
write!(f, "{krate}: designcraft-testkit may only be a dev-dependency")
|
||||
}
|
||||
Violation::UiBelowL6 { krate, dep, layer } => {
|
||||
write!(f, "{krate} (L{layer}) depends on UI crate `{dep}`; UI toolkits are only allowed in L6+")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Check all rules. Returns violations sorted for stable output.
|
||||
pub fn check(crates: &[Crate]) -> Vec<Violation> {
|
||||
let mut out = Vec::new();
|
||||
for c in crates {
|
||||
let Some(class) = classify(&c.name) else {
|
||||
out.push(Violation::Unregistered { krate: c.name.clone() });
|
||||
continue;
|
||||
};
|
||||
if class == Class::Exempt {
|
||||
continue;
|
||||
}
|
||||
let layer = class.layer().unwrap_or(0);
|
||||
for d in &c.deps {
|
||||
// Self dev-dependencies (e.g. to enable features in tests) are fine.
|
||||
if d.name == c.name {
|
||||
continue;
|
||||
}
|
||||
if d.workspace {
|
||||
if class == Class::Standalone {
|
||||
out.push(Violation::StandaloneHasWorkspaceDep { krate: c.name.clone(), dep: d.name.clone() });
|
||||
continue;
|
||||
}
|
||||
match classify(&d.name) {
|
||||
// Unregistered deps are reported on their own entry.
|
||||
None => {}
|
||||
Some(Class::Testkit) => {
|
||||
if d.kind != DepKind::Dev {
|
||||
out.push(Violation::TestkitAsNormalDep { krate: c.name.clone() });
|
||||
}
|
||||
}
|
||||
Some(dc) => {
|
||||
let to = dc.layer().unwrap_or(u8::MAX);
|
||||
if to >= layer && !(to == layer && intra_layer_allowed(&c.name, &d.name)) {
|
||||
out.push(Violation::Upward {
|
||||
krate: c.name.clone(),
|
||||
dep: d.name.clone(),
|
||||
from: layer,
|
||||
to: if to == u8::MAX { 7 } else { to },
|
||||
kind: d.kind,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if layer < UI_MIN_LAYER && is_ui_crate(&d.name) {
|
||||
out.push(Violation::UiBelowL6 { krate: c.name.clone(), dep: d.name.clone(), layer });
|
||||
}
|
||||
}
|
||||
}
|
||||
out.sort_by_key(|v| v.to_string());
|
||||
out.dedup();
|
||||
out
|
||||
}
|
||||
|
||||
/// Build the model from `cargo metadata --format-version 1 --no-deps`.
|
||||
pub fn from_metadata(meta: &Value) -> Result<Vec<Crate>, String> {
|
||||
let pkgs = meta["packages"].as_array().ok_or("metadata: no packages array")?;
|
||||
let members: Vec<&str> = pkgs.iter().filter_map(|p| p["name"].as_str()).collect();
|
||||
let mut out = Vec::new();
|
||||
for p in pkgs {
|
||||
let name = p["name"].as_str().ok_or("package without name")?.to_owned();
|
||||
let mut deps = Vec::new();
|
||||
for d in p["dependencies"].as_array().into_iter().flatten() {
|
||||
let dname = d["name"].as_str().unwrap_or_default().to_owned();
|
||||
let kind = match d["kind"].as_str() {
|
||||
Some("dev") => DepKind::Dev,
|
||||
Some("build") => DepKind::Build,
|
||||
_ => DepKind::Normal,
|
||||
};
|
||||
let workspace = members.contains(&dname.as_str()) || d["path"].is_string();
|
||||
deps.push(Dep { name: dname, kind, workspace });
|
||||
}
|
||||
out.push(Crate { name, deps });
|
||||
}
|
||||
out.sort_by(|a, b| a.name.cmp(&b.name));
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
pub fn describe(class: Option<Class>) -> String {
|
||||
match class {
|
||||
Some(Class::Layer(l)) => format!("L{l}"),
|
||||
Some(Class::Standalone) => "L0 standalone".into(),
|
||||
Some(Class::Testkit) => "testkit".into(),
|
||||
Some(Class::Exempt) => "exempt".into(),
|
||||
None => "UNREGISTERED".into(),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn c(name: &str, deps: &[(&str, DepKind, bool)]) -> Crate {
|
||||
Crate { name: name.into(), deps: deps.iter().map(|(n, k, w)| Dep { name: (*n).into(), kind: *k, workspace: *w }).collect() }
|
||||
}
|
||||
use DepKind::*;
|
||||
|
||||
#[test]
|
||||
fn clean_downward_graph_passes() {
|
||||
let g = [
|
||||
c("designcraft-geom", &[("kurbo", Normal, false)]),
|
||||
c("designcraft-doc", &[("designcraft-geom", Normal, true)]),
|
||||
c("designcraft-engine", &[("designcraft-doc", Normal, true), ("designcraft-testkit", Dev, true)]),
|
||||
c("designcraft-ui-egui", &[("designcraft-engine", Normal, true), ("egui", Normal, false)]),
|
||||
c("designcraft-cli", &[("designcraft-ui-egui", Normal, true)]),
|
||||
];
|
||||
assert!(check(&g).is_empty(), "{:?}", check(&g));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn upward_dependency_flagged() {
|
||||
let v = check(&[c("designcraft-doc", &[("designcraft-engine", Normal, true)])]);
|
||||
assert!(matches!(v[..], [Violation::Upward { from: 1, to: 5, .. }]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sideways_dependency_flagged() {
|
||||
let v = check(&[c("designcraft-text", &[("designcraft-pathops", Normal, true)])]);
|
||||
assert!(matches!(v[..], [Violation::Upward { from: 2, to: 2, .. }]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn l0_foundation_chain_allowed_one_way() {
|
||||
assert!(check(&[c("designcraft-color", &[("designcraft-geom", Normal, true)])]).is_empty());
|
||||
let v = check(&[c("designcraft-geom", &[("designcraft-color", Normal, true)])]);
|
||||
assert!(matches!(v[..], [Violation::Upward { from: 0, to: 0, .. }]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn self_dev_dependency_ignored() {
|
||||
assert!(check(&[c("designcraft-doc", &[("designcraft-doc", Dev, true)])]).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn upward_dev_dependency_flagged() {
|
||||
let v = check(&[c("designcraft-geom", &[("designcraft-doc", Dev, true)])]);
|
||||
assert!(matches!(v[..], [Violation::Upward { kind: Dev, .. }]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ui_crates_below_l6_flagged() {
|
||||
for dep in ["egui", "eframe", "winit", "egui_kittest", "rfd", "bevy_ecs", "bevy"] {
|
||||
let v = check(&[c("designcraft-engine", &[(dep, Normal, false)])]);
|
||||
assert!(matches!(v[..], [Violation::UiBelowL6 { layer: 5, .. }]), "{dep}");
|
||||
}
|
||||
assert!(check(&[c("designcraft-engine", &[("egui_extras_not", Normal, false)])]).is_empty());
|
||||
assert!(check(&[c("designcraft-mcp", &[("winit", Normal, false)])]).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unregistered_crate_is_error() {
|
||||
let v = check(&[c("designcraft-mystery", &[])]);
|
||||
assert!(matches!(&v[..], [Violation::Unregistered { krate }] if krate == "designcraft-mystery"));
|
||||
assert!(v[0].to_string().contains("register"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn testkit_only_as_dev_dependency() {
|
||||
let v = check(&[c("designcraft-render", &[("designcraft-testkit", Normal, true)])]);
|
||||
assert!(matches!(v[..], [Violation::TestkitAsNormalDep { .. }]));
|
||||
assert!(check(&[c("designcraft-render", &[("designcraft-testkit", Dev, true)])]).is_empty());
|
||||
// testkit itself may use anything up to L5 but not L6 crates.
|
||||
assert!(check(&[c("designcraft-testkit", &[("designcraft-engine", Normal, true)])]).is_empty());
|
||||
assert!(!check(&[c("designcraft-testkit", &[("designcraft-ui-egui", Normal, true)])]).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn apps_and_xtask_exempt() {
|
||||
for app in ["designcraft", "designcraft-cli", "designcraft-web", "xtask"] {
|
||||
assert!(check(&[c(app, &[("egui", Normal, false), ("designcraft-ui-egui", Normal, true)])]).is_empty());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn metadata_parsing() {
|
||||
let meta: Value = serde_json::from_str(
|
||||
r#"{"packages":[
|
||||
{"name":"designcraft-doc","dependencies":[
|
||||
{"name":"designcraft-geom","kind":null,"path":"/x/crates/geom"},
|
||||
{"name":"serde","kind":null},
|
||||
{"name":"proptest","kind":"dev"}]},
|
||||
{"name":"designcraft-geom","dependencies":[]}
|
||||
]}"#,
|
||||
)
|
||||
.unwrap();
|
||||
let g = from_metadata(&meta).unwrap();
|
||||
assert_eq!(g.len(), 2);
|
||||
let doc = g.iter().find(|c| c.name == "designcraft-doc").unwrap();
|
||||
assert!(doc.deps[0].workspace && !doc.deps[1].workspace);
|
||||
assert_eq!(doc.deps[2].kind, Dev);
|
||||
assert!(check(&g).is_empty());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,227 @@
|
||||
//! Workspace tooling: `cargo xtask <command>`.
|
||||
//!
|
||||
//! Pure Rust (std + serde_json). External tools (`cargo`, `curl`, `tar`) are
|
||||
//! invoked through `std::process::Command`.
|
||||
|
||||
mod assets;
|
||||
mod layers;
|
||||
mod stats;
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::{Command, ExitCode};
|
||||
|
||||
const USAGE: &str = "\
|
||||
usage: cargo xtask <command>
|
||||
|
||||
commands:
|
||||
assets check that every icon/image/font/asset is attributed in ASSETS.md
|
||||
layers enforce the crate dependency layering (plan/architecture.md §3)
|
||||
wasm cargo check --target wasm32-unknown-unknown for the wasm-safe crates
|
||||
ci fmt --check, clippy -D warnings, test, assets, layers, wasm (stops at first failure)
|
||||
corpus [--download]
|
||||
show where test corpora live; --download fetches PngSuite into corpus/pngsuite
|
||||
stats [--exact] count tests and lines per crate (--exact: ask the test harness via `-- --list`)
|
||||
";
|
||||
|
||||
fn main() -> ExitCode {
|
||||
let args: Vec<String> = std::env::args().skip(1).collect();
|
||||
let rest: Vec<&str> = args.iter().skip(1).map(String::as_str).collect();
|
||||
let result = match args.first().map(String::as_str) {
|
||||
Some("assets") => assets::run(&root()),
|
||||
Some("layers") => cmd_layers(),
|
||||
Some("wasm") => cmd_wasm(),
|
||||
Some("ci") => cmd_ci(),
|
||||
Some("corpus") => cmd_corpus(rest.contains(&"--download")),
|
||||
Some("stats") => stats::run(&root(), rest.contains(&"--exact")),
|
||||
Some("-h" | "--help" | "help") | None => {
|
||||
print!("{USAGE}");
|
||||
Ok(())
|
||||
}
|
||||
Some(other) => Err(format!("unknown command `{other}`\n\n{USAGE}")),
|
||||
};
|
||||
match result {
|
||||
Ok(()) => ExitCode::SUCCESS,
|
||||
Err(e) => {
|
||||
eprintln!("error: {e}");
|
||||
ExitCode::FAILURE
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Workspace root (parent of the xtask crate).
|
||||
pub fn root() -> PathBuf {
|
||||
Path::new(env!("CARGO_MANIFEST_DIR")).parent().expect("xtask has a parent dir").to_path_buf()
|
||||
}
|
||||
|
||||
pub fn cargo() -> Command {
|
||||
let mut c = Command::new(std::env::var("CARGO").unwrap_or_else(|_| "cargo".into()));
|
||||
c.current_dir(root());
|
||||
c
|
||||
}
|
||||
|
||||
fn run(mut cmd: Command, what: &str) -> Result<(), String> {
|
||||
eprintln!("$ {what}");
|
||||
let status = cmd.status().map_err(|e| format!("{what}: failed to spawn: {e}"))?;
|
||||
if status.success() { Ok(()) } else { Err(format!("{what}: exited with {status}")) }
|
||||
}
|
||||
|
||||
pub fn metadata() -> Result<serde_json::Value, String> {
|
||||
let out = cargo().args(["metadata", "--format-version", "1", "--no-deps"]).output().map_err(|e| format!("cargo metadata: {e}"))?;
|
||||
if !out.status.success() {
|
||||
return Err(format!("cargo metadata failed:\n{}", String::from_utf8_lossy(&out.stderr)));
|
||||
}
|
||||
serde_json::from_slice(&out.stdout).map_err(|e| format!("cargo metadata: bad JSON: {e}"))
|
||||
}
|
||||
|
||||
fn cmd_layers() -> Result<(), String> {
|
||||
let crates = layers::from_metadata(&metadata()?)?;
|
||||
println!("Dependency layering (plan/architecture.md §3)\n");
|
||||
println!("{:<28} {:<14} workspace deps", "crate", "layer");
|
||||
for c in &crates {
|
||||
let ws: Vec<String> = c
|
||||
.deps
|
||||
.iter()
|
||||
.filter(|d| d.workspace)
|
||||
.map(|d| {
|
||||
let k = match d.kind {
|
||||
layers::DepKind::Normal => "",
|
||||
layers::DepKind::Dev => " (dev)",
|
||||
layers::DepKind::Build => " (build)",
|
||||
};
|
||||
format!("{}{k}", layers::short_name(&d.name))
|
||||
})
|
||||
.collect();
|
||||
println!("{:<28} {:<14} {}", c.name, layers::describe(layers::classify(&c.name)), ws.join(", "));
|
||||
}
|
||||
let violations = layers::check(&crates);
|
||||
println!();
|
||||
if violations.is_empty() {
|
||||
println!("OK: {} crates, no layering violations.", crates.len());
|
||||
Ok(())
|
||||
} else {
|
||||
println!("{} violation(s):", violations.len());
|
||||
for v in &violations {
|
||||
println!(" - {v}");
|
||||
}
|
||||
Err(format!("{} layering violation(s)", violations.len()))
|
||||
}
|
||||
}
|
||||
|
||||
/// Workspace packages that must build for wasm32: all L0–L5 crates plus
|
||||
/// the egui shell.
|
||||
fn wasm_set() -> Result<Vec<String>, String> {
|
||||
let crates = layers::from_metadata(&metadata()?)?;
|
||||
Ok(crates
|
||||
.into_iter()
|
||||
.filter(|c| match layers::classify(&c.name) {
|
||||
Some(layers::Class::Layer(l)) => l <= 5 || layers::short_name(&c.name) == "ui-egui",
|
||||
Some(layers::Class::Standalone) => true,
|
||||
_ => false,
|
||||
})
|
||||
.map(|c| c.name)
|
||||
.collect())
|
||||
}
|
||||
|
||||
fn cmd_wasm() -> Result<(), String> {
|
||||
let set = wasm_set()?;
|
||||
let mut results = Vec::new();
|
||||
for pkg in &set {
|
||||
let mut c = cargo();
|
||||
c.args(["check", "--target", "wasm32-unknown-unknown", "-p", pkg]);
|
||||
let ok = run(c, &format!("cargo check --target wasm32-unknown-unknown -p {pkg}")).is_ok();
|
||||
results.push((pkg.clone(), ok));
|
||||
}
|
||||
println!("\nwasm32-unknown-unknown check:");
|
||||
for (p, ok) in &results {
|
||||
println!(" {:<6} {p}", if *ok { "ok" } else { "FAIL" });
|
||||
}
|
||||
let failed = results.iter().filter(|r| !r.1).count();
|
||||
if failed == 0 { Ok(()) } else { Err(format!("{failed} crate(s) failed the wasm check")) }
|
||||
}
|
||||
|
||||
fn cmd_ci() -> Result<(), String> {
|
||||
type Step = (&'static str, Box<dyn Fn() -> Result<(), String>>);
|
||||
let steps: Vec<Step> = vec![
|
||||
(
|
||||
"fmt",
|
||||
Box::new(|| {
|
||||
let mut c = cargo();
|
||||
c.args(["fmt", "--all", "--", "--check"]);
|
||||
run(c, "cargo fmt --all -- --check")
|
||||
}),
|
||||
),
|
||||
(
|
||||
"clippy",
|
||||
Box::new(|| {
|
||||
let mut c = cargo();
|
||||
c.args(["clippy", "--workspace", "--all-targets", "--", "-D", "warnings"]);
|
||||
run(c, "cargo clippy --workspace --all-targets -- -D warnings")
|
||||
}),
|
||||
),
|
||||
(
|
||||
"test",
|
||||
Box::new(|| {
|
||||
let mut c = cargo();
|
||||
c.args(["test", "--workspace"]);
|
||||
run(c, "cargo test --workspace")
|
||||
}),
|
||||
),
|
||||
("assets", Box::new(|| assets::run(&root()))),
|
||||
("layers", Box::new(cmd_layers)),
|
||||
("wasm", Box::new(cmd_wasm)),
|
||||
];
|
||||
let mut done = Vec::new();
|
||||
for (name, f) in &steps {
|
||||
eprintln!("\n=== ci: {name} ===");
|
||||
if let Err(e) = f() {
|
||||
println!("\nCI summary:");
|
||||
for d in &done {
|
||||
println!(" ok {d}");
|
||||
}
|
||||
println!(" FAIL {name}: {e}");
|
||||
for (n, _) in steps.iter().skip(done.len() + 1) {
|
||||
println!(" skip {n}");
|
||||
}
|
||||
return Err(format!("ci failed at `{name}`"));
|
||||
}
|
||||
done.push(*name);
|
||||
}
|
||||
println!("\nCI summary: all {} steps passed ({})", done.len(), done.join(", "));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
const PNGSUITE_URL: &str = "http://www.schaik.com/pngsuite/PngSuite-2017jul19.tgz";
|
||||
|
||||
fn cmd_corpus(download: bool) -> Result<(), String> {
|
||||
let corpus = root().join("corpus");
|
||||
println!(
|
||||
"Test corpora live under {} (git-ignored, never committed).
|
||||
Tests that use a corpus skip cleanly when it is absent.
|
||||
|
||||
corpus/pngsuite/ PngSuite (public domain) — designcraft-codecs compares every file
|
||||
against the `image` crate. Fetch: cargo xtask corpus --download
|
||||
corpus/psd/ PSD samples from MIT/BSD projects (ag-psd, psd-tools test data).
|
||||
Copy files in manually; licences must be MIT/BSD/CC0.
|
||||
corpus/tiff/ libtiff pics (optional)
|
||||
corpus/exr/ OpenEXR sample images (optional)
|
||||
corpus/raw/ raw.pixls.us samples, CC0 (optional)
|
||||
",
|
||||
corpus.display()
|
||||
);
|
||||
if !download {
|
||||
return Ok(());
|
||||
}
|
||||
let dest = corpus.join("pngsuite");
|
||||
std::fs::create_dir_all(&dest).map_err(|e| format!("create {}: {e}", dest.display()))?;
|
||||
let tgz = corpus.join("PngSuite-2017jul19.tgz");
|
||||
let mut curl = Command::new("curl");
|
||||
curl.args(["-fsSL", "-o"]).arg(&tgz).arg(PNGSUITE_URL);
|
||||
run(curl, &format!("curl {PNGSUITE_URL}"))?;
|
||||
let mut tar = Command::new("tar");
|
||||
tar.arg("-xzf").arg(&tgz).arg("-C").arg(&dest);
|
||||
run(tar, "tar -xzf PngSuite-2017jul19.tgz")?;
|
||||
let _ = std::fs::remove_file(&tgz);
|
||||
let n = std::fs::read_dir(&dest).map(|d| d.flatten().filter(|e| e.path().extension().is_some_and(|x| x == "png")).count()).unwrap_or(0);
|
||||
println!("PngSuite: {n} PNG files in {}", dest.display());
|
||||
if n == 0 { Err("no PNG files extracted".into()) } else { Ok(()) }
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
//! `cargo xtask stats`: tests and lines of Rust per crate.
|
||||
|
||||
use std::path::Path;
|
||||
|
||||
#[derive(Default, Clone, Copy)]
|
||||
struct Counts {
|
||||
files: usize,
|
||||
lines: usize,
|
||||
tests: usize,
|
||||
proptests: usize,
|
||||
}
|
||||
|
||||
fn walk(dir: &Path, c: &mut Counts) {
|
||||
let Ok(rd) = std::fs::read_dir(dir) else {
|
||||
return;
|
||||
};
|
||||
for e in rd.flatten() {
|
||||
let p = e.path();
|
||||
let name = e.file_name();
|
||||
let name = name.to_string_lossy();
|
||||
if p.is_dir() {
|
||||
// Nested fuzz workspaces and build output are not part of the crate.
|
||||
if name != "target" && name != "fuzz" && !name.starts_with('.') {
|
||||
walk(&p, c);
|
||||
}
|
||||
} else if name.ends_with(".rs")
|
||||
&& let Ok(s) = std::fs::read_to_string(&p)
|
||||
{
|
||||
c.files += 1;
|
||||
c.lines += s.lines().count();
|
||||
let (t, pt) = count_tests(&s);
|
||||
c.tests += t;
|
||||
c.proptests += pt;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// `#[test]` attributes and `proptest!` blocks (each block may hold many
|
||||
/// property tests, and macro-generated tests are only counted once — use
|
||||
/// `--exact` for harness-accurate numbers).
|
||||
fn count_tests(src: &str) -> (usize, usize) {
|
||||
let mut tests = 0;
|
||||
let mut props = 0;
|
||||
for line in src.lines() {
|
||||
let t = line.trim_start();
|
||||
if t.starts_with("//") {
|
||||
continue;
|
||||
}
|
||||
tests += t.matches("#[test]").count();
|
||||
if t.starts_with("proptest!") {
|
||||
props += 1;
|
||||
}
|
||||
}
|
||||
(tests, props)
|
||||
}
|
||||
|
||||
fn exact_tests(pkg: &str) -> Option<usize> {
|
||||
let out = crate::cargo().args(["test", "-q", "-p", pkg, "--", "--list"]).output().ok()?;
|
||||
if !out.status.success() {
|
||||
return None;
|
||||
}
|
||||
Some(String::from_utf8_lossy(&out.stdout).lines().filter(|l| l.ends_with(": test")).count())
|
||||
}
|
||||
|
||||
pub fn run(_root: &Path, exact: bool) -> Result<(), String> {
|
||||
let meta = crate::metadata()?;
|
||||
let mut rows = Vec::new();
|
||||
for p in meta["packages"].as_array().into_iter().flatten() {
|
||||
let name = p["name"].as_str().unwrap_or("?").to_owned();
|
||||
let Some(dir) = p["manifest_path"].as_str().and_then(|m| Path::new(m).parent().map(Path::to_path_buf)) else {
|
||||
continue;
|
||||
};
|
||||
let mut c = Counts::default();
|
||||
walk(&dir, &mut c);
|
||||
let ex = if exact { exact_tests(&name) } else { None };
|
||||
rows.push((name, c, ex));
|
||||
}
|
||||
rows.sort_by(|a, b| a.0.cmp(&b.0));
|
||||
|
||||
let exact_col = if exact { format!(" {:>8}", "harness") } else { String::new() };
|
||||
println!("{:<26} {:>6} {:>8} {:>7} {:>10}{exact_col}", "crate", "files", "lines", "#[test]", "proptest!");
|
||||
println!("{}", "-".repeat(62 + if exact { 9 } else { 0 }));
|
||||
let mut total = Counts::default();
|
||||
let mut total_exact = 0;
|
||||
for (name, c, ex) in &rows {
|
||||
let ex_s = match (exact, ex) {
|
||||
(false, _) => String::new(),
|
||||
(true, Some(n)) => {
|
||||
total_exact += n;
|
||||
format!(" {n:>8}")
|
||||
}
|
||||
(true, None) => format!(" {:>8}", "error"),
|
||||
};
|
||||
println!("{name:<26} {:>6} {:>8} {:>7} {:>10}{ex_s}", c.files, c.lines, c.tests, c.proptests);
|
||||
total.files += c.files;
|
||||
total.lines += c.lines;
|
||||
total.tests += c.tests;
|
||||
total.proptests += c.proptests;
|
||||
}
|
||||
println!("{}", "-".repeat(62 + if exact { 9 } else { 0 }));
|
||||
let ex_s = if exact { format!(" {total_exact:>8}") } else { String::new() };
|
||||
println!("{:<26} {:>6} {:>8} {:>7} {:>10}{ex_s}", "TOTAL", total.files, total.lines, total.tests, total.proptests);
|
||||
if !exact {
|
||||
println!("\n(#[test] counts source attributes; macro-generated tests count once. Use --exact for harness counts.)");
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::count_tests;
|
||||
|
||||
#[test]
|
||||
fn counts_attributes_and_proptest_blocks() {
|
||||
let src = "#[test]\nfn a() {}\n// #[test] commented\n #[test]\nfn b() {}\nproptest! {\n}\n";
|
||||
assert_eq!(count_tests(src), (2, 1));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user