first commit

This commit is contained in:
Brandon Thomas
2026-10-01 09:13:29 -04:00
parent 6f2979cd0f
commit e1e1352fff
48 changed files with 6824 additions and 0 deletions
+2
View File
@@ -0,0 +1,2 @@
[alias]
xtask = "run --package xtask --"
+5
View File
@@ -1,2 +1,7 @@
*~
plan/
/target
/dist
/corpus
/log
.DS_Store
+75
View File
@@ -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
View File
@@ -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
View File
@@ -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.
+92
View File
@@ -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.
+93
View File
@@ -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.
+93
View File
@@ -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.
+93
View File
@@ -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.
+19
View File
@@ -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 }
+100
View File
@@ -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));
}
}
}
+453
View File
@@ -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)
}
+135
View File
@@ -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()
}
+203
View File
@@ -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}");
}
}
+638
View File
@@ -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;
+202
View File
@@ -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]);
}
+224
View File
@@ -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);
}
}
+83
View File
@@ -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);
}
}
+287
View File
@@ -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);
}
}
+172
View File
@@ -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));
}
}
+19
View File
@@ -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 }
+503
View File
@@ -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");
}
}
+23
View File
@@ -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");
+475
View File
@@ -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());
}
}
}
}
}
+261
View File
@@ -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");
}
}
+18
View File
@@ -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 }
+195
View File
@@ -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);
}
}
+93
View File
@@ -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)));
}
}
+104
View File
@@ -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);
}
}
+529
View File
@@ -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);
}
}
+246
View File
@@ -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);
}
}
+46
View File
@@ -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);
}
}
+370
View File
@@ -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}");
}
}
}
}
+2
View File
@@ -0,0 +1,2 @@
max_width = 150
use_small_heuristics = "Max"
+9
View File
@@ -0,0 +1,9 @@
[package]
name = "xtask"
version.workspace = true
edition.workspace = true
license.workspace = true
publish = false
[dependencies]
serde_json = { workspace = true }
+81
View File
@@ -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"));
}
}
+338
View File
@@ -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());
}
}
+227
View File
@@ -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(()) }
}
+118
View File
@@ -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));
}
}