diff --git a/.cargo/config.toml b/.cargo/config.toml new file mode 100644 index 00000000..35049cbc --- /dev/null +++ b/.cargo/config.toml @@ -0,0 +1,2 @@ +[alias] +xtask = "run --package xtask --" diff --git a/.gitignore b/.gitignore index 19cdc639..0d78b09d 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,7 @@ *~ plan/ +/target +/dist +/corpus +/log +.DS_Store diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 00000000..cd893757 --- /dev/null +++ b/Cargo.toml @@ -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 diff --git a/LICENSE-APACHE b/LICENSE-APACHE new file mode 100644 index 00000000..4947287f --- /dev/null +++ b/LICENSE-APACHE @@ -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 \ No newline at end of file diff --git a/LICENSE-MIT b/LICENSE-MIT new file mode 100644 index 00000000..7b09fda3 --- /dev/null +++ b/LICENSE-MIT @@ -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. diff --git a/assets/fonts/Inter-Medium.ttf b/assets/fonts/Inter-Medium.ttf new file mode 100644 index 00000000..458cd060 Binary files /dev/null and b/assets/fonts/Inter-Medium.ttf differ diff --git a/assets/fonts/Inter-Regular.ttf b/assets/fonts/Inter-Regular.ttf new file mode 100644 index 00000000..b7aaca8d Binary files /dev/null and b/assets/fonts/Inter-Regular.ttf differ diff --git a/assets/fonts/Inter-SemiBold.ttf b/assets/fonts/Inter-SemiBold.ttf new file mode 100644 index 00000000..47f8ab1d Binary files /dev/null and b/assets/fonts/Inter-SemiBold.ttf differ diff --git a/assets/fonts/JetBrainsMono-Regular.ttf b/assets/fonts/JetBrainsMono-Regular.ttf new file mode 100644 index 00000000..711830ed Binary files /dev/null and b/assets/fonts/JetBrainsMono-Regular.ttf differ diff --git a/assets/fonts/OFL-Inter.txt b/assets/fonts/OFL-Inter.txt new file mode 100644 index 00000000..9b2ca37b --- /dev/null +++ b/assets/fonts/OFL-Inter.txt @@ -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. diff --git a/assets/fonts/OFL-JetBrainsMono.txt b/assets/fonts/OFL-JetBrainsMono.txt new file mode 100644 index 00000000..5ceee002 --- /dev/null +++ b/assets/fonts/OFL-JetBrainsMono.txt @@ -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. diff --git a/assets/fonts/OFL-SourceSans3.txt b/assets/fonts/OFL-SourceSans3.txt new file mode 100644 index 00000000..22c601b8 --- /dev/null +++ b/assets/fonts/OFL-SourceSans3.txt @@ -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. diff --git a/assets/fonts/OFL-SourceSerif4.txt b/assets/fonts/OFL-SourceSerif4.txt new file mode 100644 index 00000000..5871e1f3 --- /dev/null +++ b/assets/fonts/OFL-SourceSerif4.txt @@ -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. diff --git a/assets/fonts/SourceSans3-Bold.ttf b/assets/fonts/SourceSans3-Bold.ttf new file mode 100644 index 00000000..85693ccc Binary files /dev/null and b/assets/fonts/SourceSans3-Bold.ttf differ diff --git a/assets/fonts/SourceSans3-It.ttf b/assets/fonts/SourceSans3-It.ttf new file mode 100644 index 00000000..8e0576f2 Binary files /dev/null and b/assets/fonts/SourceSans3-It.ttf differ diff --git a/assets/fonts/SourceSans3-Regular.ttf b/assets/fonts/SourceSans3-Regular.ttf new file mode 100644 index 00000000..c5dd0b88 Binary files /dev/null and b/assets/fonts/SourceSans3-Regular.ttf differ diff --git a/assets/fonts/SourceSans3-Semibold.ttf b/assets/fonts/SourceSans3-Semibold.ttf new file mode 100644 index 00000000..4aff9a3a Binary files /dev/null and b/assets/fonts/SourceSans3-Semibold.ttf differ diff --git a/assets/fonts/SourceSerif4-Regular.ttf b/assets/fonts/SourceSerif4-Regular.ttf new file mode 100644 index 00000000..964a2bf8 Binary files /dev/null and b/assets/fonts/SourceSerif4-Regular.ttf differ diff --git a/crates/color/Cargo.toml b/crates/color/Cargo.toml new file mode 100644 index 00000000..ca707a0d --- /dev/null +++ b/crates/color/Cargo.toml @@ -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 } diff --git a/crates/color/src/blend.rs b/crates/color/src/blend.rs new file mode 100644 index 00000000..ae19d264 --- /dev/null +++ b/crates/color/src/blend.rs @@ -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 { + 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)); + } + } +} diff --git a/crates/color/src/cms/generic.rs b/crates/color/src/cms/generic.rs new file mode 100644 index 00000000..55caefc8 --- /dev/null +++ b/crates/color/src/cms/generic.rs @@ -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, + /// Lightness of maximum chroma per hue bin. + cusp_l: Vec, +} + +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, +} + +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::(); + 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::(); + } + 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::(); + 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::() + 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::() + k; + if sum > self.params.tac { + let s = ((self.params.tac - k) / cmy.iter().sum::().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 = (0..GBD_L).map(|li| smooth[li * GBD_H + hi]).collect(); + let known: Vec = (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) +} diff --git a/crates/color/src/cms/icc.rs b/crates/color/src/cms/icc.rs new file mode 100644 index 00000000..565ae0b7 --- /dev/null +++ b/crates/color/src/cms/icc.rs @@ -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>; + +/// 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; 4], + from_srgb: [OnceLock; 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) -> Option { + 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, profile: ColorProfile) -> Result { + 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, bytes: &[u8]) -> Result { + 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> { + 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 { + 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() +} diff --git a/crates/color/src/cms/lab.rs b/crates/color/src/cms/lab.rs new file mode 100644 index 00000000..0cbbace7 --- /dev/null +++ b/crates/color/src/cms/lab.rs @@ -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}"); + } +} diff --git a/crates/color/src/cms/mod.rs b/crates/color/src/cms/mod.rs new file mode 100644 index 00000000..7e68b8b0 --- /dev/null +++ b/crates/color/src/cms/mod.rs @@ -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 { + 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), + Device, + Icc(Arc), +} + +#[derive(Clone)] +enum RgbSpace { + Srgb, + Icc(Arc), +} + +fn generic() -> Arc { + static G: OnceLock> = OnceLock::new(); + G.get_or_init(|| Arc::new(GenericCmyk::new(generic::swop_like()))).clone() +} + +static USER: RwLock>> = RwLock::new(Vec::new()); + +fn builtin_rgb_cache(name: &str) -> Option> { + static CACHE: Mutex>> = 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> { + USER.read().unwrap_or_else(|e| e.into_inner()).iter().find(|p| p.name == name).cloned() +} + +fn cmyk_space(name: &str) -> Result { + 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 { + 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 { + let mut v: Vec = [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) -> Result { + 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 { + 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 { + 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 { + 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)>> = 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 { + 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>, +} + +/// 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>> = 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 { + 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; diff --git a/crates/color/src/cms/tests.rs b/crates/color/src/cms/tests.rs new file mode 100644 index 00000000..4e4f1d8b --- /dev/null +++ b/crates/color/src/cms/tests.rs @@ -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::() <= 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::(); + 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::() > abs.iter().sum::() + 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]); +} diff --git a/crates/color/src/gradient.rs b/crates/color/src/gradient.rs new file mode 100644 index 00000000..6e5ffb33 --- /dev/null +++ b/crates/color/src/gradient.rs @@ -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, +} + +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, + #[serde(default)] + pub angle: f64, + /// Name of the gradient swatch, if linked. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub swatch: Option, +} + +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); + } +} diff --git a/crates/color/src/harmony.rs b/crates/color/src/harmony.rs new file mode 100644 index 00000000..01336d20 --- /dev/null +++ b/crates/color/src/harmony.rs @@ -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 { + 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 { + 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); + } +} diff --git a/crates/color/src/lib.rs b/crates/color/src/lib.rs new file mode 100644 index 00000000..ce220012 --- /dev/null +++ b/crates/color/src/lib.rs @@ -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 { + 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, + }, + Gradient(Box), + 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 { + 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::(&s).unwrap(), p); + } +} diff --git a/crates/color/src/swatch.rs b/crates/color/src/swatch.rs new file mode 100644 index 00000000..7ac198cd --- /dev/null +++ b/crates/color/src/swatch.rs @@ -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 { + resolve_depth(swatches, name, tint, 0) +} + +fn resolve_depth(swatches: &[Swatch], name: &str, tint: f32, depth: u8) -> Option { + 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 { + 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)); + } +} diff --git a/crates/doc/Cargo.toml b/crates/doc/Cargo.toml new file mode 100644 index 00000000..ed7430db --- /dev/null +++ b/crates/doc/Cargo.toml @@ -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 } diff --git a/crates/doc/src/attrs.rs b/crates/doc/src/attrs.rs new file mode 100644 index 00000000..4749aa07 --- /dev/null +++ b/crates/doc/src/attrs.rs @@ -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::().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) -> $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 = 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 = 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"); + } +} diff --git a/crates/doc/src/ids.rs b/crates/doc/src/ids.rs new file mode 100644 index 00000000..3dbb94c4 --- /dev/null +++ b/crates/doc/src/ids.rs @@ -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"); diff --git a/crates/doc/src/story.rs b/crates/doc/src/story.rs new file mode 100644 index 00000000..4b2a0297 --- /dev/null +++ b/crates/doc/src/story.rs @@ -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, + pub chars: Vec, + /// Text frames in thread order. + pub frames: Vec, + /// 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> { + 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, &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) { + 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, 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, 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, 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) -> &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 = 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 { + 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()); + } + } + } + } +} diff --git a/crates/doc/src/styles.rs b/crates/doc/src/styles.rs new file mode 100644 index 00000000..21eb4fc8 --- /dev/null +++ b/crates/doc/src/styles.rs @@ -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, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub next_style: Option, + #[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, + #[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, + #[serde(skip_serializing_if = "Option::is_none")] + pub fill: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub stroke: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub paragraph_style: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub text_frame: Option, +} + +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct Styles { + pub paragraph: Vec, + pub character: Vec, + pub object: Vec, + /// 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"); + } +} diff --git a/crates/geom/Cargo.toml b/crates/geom/Cargo.toml new file mode 100644 index 00000000..33a2a54f --- /dev/null +++ b/crates/geom/Cargo.toml @@ -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 } diff --git a/crates/geom/src/corners.rs b/crates/geom/src/corners.rs new file mode 100644 index 00000000..df1fd0fa --- /dev/null +++ b/crates/geom/src/corners.rs @@ -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 = 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); + } +} diff --git a/crates/geom/src/hit.rs b/crates/geom/src/hit.rs new file mode 100644 index 00000000..944ded3e --- /dev/null +++ b/crates/geom/src/hit.rs @@ -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))); + } +} diff --git a/crates/geom/src/lib.rs b/crates/geom/src/lib.rs new file mode 100644 index 00000000..9bdcc6fd --- /dev/null +++ b/crates/geom/src/lib.rs @@ -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, b: Option) -> Option { + 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); + } +} diff --git a/crates/geom/src/path.rs b/crates/geom/src/path.rs new file mode 100644 index 00000000..88b44ac0 --- /dev/null +++ b/crates/geom/src/path.rs @@ -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, + #[serde(rename = "out", default, skip_serializing_if = "Option::is_none")] + h_out: Option, + #[serde(default, skip_serializing_if = "is_corner")] + kind: AnchorKind, +} + +fn is_corner(k: &AnchorKind) -> bool { + *k == AnchorKind::Corner +} + +impl From 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 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(&self, s: S) -> Result { + [self.0.x, self.0.y].serialize(s) + } +} + +impl<'de> Deserialize<'de> for WirePoint { + fn deserialize>(d: D) -> Result { + 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>(self, mut a: A) -> Result { + 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>(self, mut a: A) -> Result { + let (mut x, mut y) = (None, None); + while let Some(k) = a.next_key::>()? { + match k.as_ref() { + "x" => x = Some(a.next_value()?), + "y" => y = Some(a.next_value()?), + _ => { + a.next_value::()?; + } + } + } + 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, + #[serde(default)] + pub closed: bool, +} + +impl SubPath { + pub fn new(anchors: Vec, 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, +} + +impl PathData { + pub fn new(subpaths: Vec) -> 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 = Vec::new(); + let mut cur: Option = 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 { + 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 { + 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 { + 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, 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::(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::(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); + } +} diff --git a/crates/geom/src/shapes.rs b/crates/geom/src/shapes.rs new file mode 100644 index 00000000..a01c0d21 --- /dev/null +++ b/crates/geom/src/shapes.rs @@ -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 = (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 = (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 { + 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 { + 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 = 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); + } +} diff --git a/crates/geom/src/snap.rs b/crates/geom/src/snap.rs new file mode 100644 index 00000000..caa3f569 --- /dev/null +++ b/crates/geom/src/snap.rs @@ -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, 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); + } +} diff --git a/crates/geom/src/units.rs b/crates/geom/src/units.rs new file mode 100644 index 00000000..7b5aae77 --- /dev/null +++ b/crates/geom/src/units.rs @@ -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 { + 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) -> Result { + let err = || MeasureError(input.to_string()); + let s: String = input.chars().filter(|c| !c.is_whitespace()).collect::().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) -> Option { + let mut val: Option = None; + let mut op = '*'; + let mut tok = String::new(); + let flush = |tok: &str, val: Option, op: char| -> Option { + 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 { + // Multipliers/divisors are unitless numbers. + tok.parse::().ok() +} + +fn parse_single(tok: &str, default: Unit, percent_base: Option) -> Option { + if tok.is_empty() { + return None; + } + if let Some(n) = tok.strip_suffix('%') { + return Some(n.parse::().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::().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::().ok()? }; + let b = if b.is_empty() { 0.0 } else { b.parse::().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}"); + } + } + } +} diff --git a/rustfmt.toml b/rustfmt.toml new file mode 100644 index 00000000..3121924d --- /dev/null +++ b/rustfmt.toml @@ -0,0 +1,2 @@ +max_width = 150 +use_small_heuristics = "Max" diff --git a/xtask/Cargo.toml b/xtask/Cargo.toml new file mode 100644 index 00000000..df7dfa87 --- /dev/null +++ b/xtask/Cargo.toml @@ -0,0 +1,9 @@ +[package] +name = "xtask" +version.workspace = true +edition.workspace = true +license.workspace = true +publish = false + +[dependencies] +serde_json = { workspace = true } diff --git a/xtask/src/assets.rs b/xtask/src/assets.rs new file mode 100644 index 00000000..4e2a7053 --- /dev/null +++ b/xtask/src/assets.rs @@ -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 { + 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::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")); + } +} diff --git a/xtask/src/layers.rs b/xtask/src/layers.rs new file mode 100644 index 00000000..623a528b --- /dev/null +++ b/xtask/src/layers.rs @@ -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 { + 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 { + 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, +} + +#[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 { + 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, 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) -> 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()); + } +} diff --git a/xtask/src/main.rs b/xtask/src/main.rs new file mode 100644 index 00000000..3994206f --- /dev/null +++ b/xtask/src/main.rs @@ -0,0 +1,227 @@ +//! Workspace tooling: `cargo xtask `. +//! +//! 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 + +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 = 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 { + 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 = 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, 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 Result<(), String>>); + let steps: Vec = 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(()) } +} diff --git a/xtask/src/stats.rs b/xtask/src/stats.rs new file mode 100644 index 00000000..f40b1300 --- /dev/null +++ b/xtask/src/stats.rs @@ -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 { + 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)); + } +}