M0.1–M0.6: workspace, geom, document model, fonts, layout, render
Clean-room foundations for WordCraft: Arc-shared document model with paragraph/run editing, styles, lists, sections and tables; Word-style font substitution; first-fit line breaking with tabs, lists, alignment, pagination, tables, headers/footers and hit testing; vello_cpu page rasteriser. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
|
@ -1,3 +1,10 @@
|
|||
plan/
|
||||
plans/
|
||||
*~
|
||||
plan/
|
||||
/target
|
||||
/dist
|
||||
/corpus
|
||||
/log
|
||||
.DS_Store
|
||||
/.claude/
|
||||
/target-*/
|
||||
/craft-fonts/
|
||||
|
|
|
|||
|
|
@ -0,0 +1,77 @@
|
|||
[workspace]
|
||||
resolver = "3"
|
||||
members = ["crates/*", "xtask"]
|
||||
default-members = ["crates/*", "xtask"]
|
||||
|
||||
[workspace.package]
|
||||
version = "0.1.0"
|
||||
edition = "2024"
|
||||
license = "MIT OR Apache-2.0"
|
||||
rust-version = "1.90"
|
||||
repository = "https://github.com/storytold/wordcraft"
|
||||
|
||||
[workspace.dependencies]
|
||||
# internal
|
||||
wordcraft-geom = { path = "crates/geom" }
|
||||
wordcraft-doc = { path = "crates/doc" }
|
||||
wordcraft-fonts = { path = "crates/fonts" }
|
||||
wordcraft-layout = { path = "crates/layout" }
|
||||
wordcraft-docx = { path = "crates/docx" }
|
||||
wordcraft-formats = { path = "crates/formats" }
|
||||
wordcraft-render = { path = "crates/render" }
|
||||
wordcraft-pdf = { path = "crates/pdf" }
|
||||
wordcraft-proof = { path = "crates/proof" }
|
||||
wordcraft-engine = { path = "crates/engine" }
|
||||
wordcraft-mcp = { path = "crates/mcp" }
|
||||
wordcraft-ui-egui = { path = "crates/ui-egui" }
|
||||
|
||||
# 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"
|
||||
unicode-bidi = "0.3"
|
||||
unicode-segmentation = "1"
|
||||
unicode-linebreak = "0.1"
|
||||
regex = { version = "1", default-features = false, features = ["std", "unicode", "perf"] }
|
||||
|
||||
# text / rendering / formats
|
||||
skrifa = "0.47"
|
||||
harfrust = "0.13"
|
||||
vello_cpu = "0.2"
|
||||
image = { version = "0.25", default-features = false, features = ["png", "jpeg", "webp", "gif", "bmp"] }
|
||||
krilla = { version = "0.8", features = ["pdf"] }
|
||||
quick-xml = "0.38"
|
||||
zip = { version = "3", default-features = false, features = ["deflate"] }
|
||||
|
||||
# ui
|
||||
egui = "0.36"
|
||||
egui_extras = { version = "0.36", default-features = false }
|
||||
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 = "forbid"
|
||||
|
||||
[workspace.lints.clippy]
|
||||
dbg_macro = "warn"
|
||||
todo = "warn"
|
||||
too_many_arguments = "allow"
|
||||
type_complexity = "allow"
|
||||
|
||||
[profile.dev.package."*"]
|
||||
opt-level = 2
|
||||
|
||||
[profile.dev]
|
||||
opt-level = 1
|
||||
|
||||
[profile.release]
|
||||
opt-level = 3
|
||||
lto = "thin"
|
||||
codegen-units = 16
|
||||
debug = 0
|
||||
|
|
@ -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
|
||||
|
|
@ -0,0 +1,21 @@
|
|||
MIT License
|
||||
|
||||
Copyright (c) 2026 ArtCraft Team and the WordCraft 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.
|
||||
|
|
@ -0,0 +1,92 @@
|
|||
Copyright (c) 2016 The Inter Project Authors (https://github.com/rsms/inter)
|
||||
|
||||
This Font Software is licensed under the SIL Open Font License, Version 1.1.
|
||||
This license is copied below, and is also available with a FAQ at:
|
||||
http://scripts.sil.org/OFL
|
||||
|
||||
-----------------------------------------------------------
|
||||
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
|
||||
-----------------------------------------------------------
|
||||
|
||||
PREAMBLE
|
||||
The goals of the Open Font License (OFL) are to stimulate worldwide
|
||||
development of collaborative font projects, to support the font creation
|
||||
efforts of academic and linguistic communities, and to provide a free and
|
||||
open framework in which fonts may be shared and improved in partnership
|
||||
with others.
|
||||
|
||||
The OFL allows the licensed fonts to be used, studied, modified and
|
||||
redistributed freely as long as they are not sold by themselves. The
|
||||
fonts, including any derivative works, can be bundled, embedded,
|
||||
redistributed and/or sold with any software provided that any reserved
|
||||
names are not used by derivative works. The fonts and derivatives,
|
||||
however, cannot be released under any other type of license. The
|
||||
requirement for fonts to remain under this license does not apply
|
||||
to any document created using the fonts or their derivatives.
|
||||
|
||||
DEFINITIONS
|
||||
"Font Software" refers to the set of files released by the Copyright
|
||||
Holder(s) under this license and clearly marked as such. This may
|
||||
include source files, build scripts and documentation.
|
||||
|
||||
"Reserved Font Name" refers to any names specified as such after the
|
||||
copyright statement(s).
|
||||
|
||||
"Original Version" refers to the collection of Font Software components as
|
||||
distributed by the Copyright Holder(s).
|
||||
|
||||
"Modified Version" refers to any derivative made by adding to, deleting,
|
||||
or substituting -- in part or in whole -- any of the components of the
|
||||
Original Version, by changing formats or by porting the Font Software to a
|
||||
new environment.
|
||||
|
||||
"Author" refers to any designer, engineer, programmer, technical
|
||||
writer or other person who contributed to the Font Software.
|
||||
|
||||
PERMISSION AND CONDITIONS
|
||||
Permission is hereby granted, free of charge, to any person obtaining
|
||||
a copy of the Font Software, to use, study, copy, merge, embed, modify,
|
||||
redistribute, and sell modified and unmodified copies of the Font
|
||||
Software, subject to the following conditions:
|
||||
|
||||
1) Neither the Font Software nor any of its individual components,
|
||||
in Original or Modified Versions, may be sold by itself.
|
||||
|
||||
2) Original or Modified Versions of the Font Software may be bundled,
|
||||
redistributed and/or sold with any software, provided that each copy
|
||||
contains the above copyright notice and this license. These can be
|
||||
included either as stand-alone text files, human-readable headers or
|
||||
in the appropriate machine-readable metadata fields within text or
|
||||
binary files as long as those fields can be easily viewed by the user.
|
||||
|
||||
3) No Modified Version of the Font Software may use the Reserved Font
|
||||
Name(s) unless explicit written permission is granted by the corresponding
|
||||
Copyright Holder. This restriction only applies to the primary font name as
|
||||
presented to the users.
|
||||
|
||||
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
|
||||
Software shall not be used to promote, endorse or advertise any
|
||||
Modified Version, except to acknowledge the contribution(s) of the
|
||||
Copyright Holder(s) and the Author(s) or with their explicit written
|
||||
permission.
|
||||
|
||||
5) The Font Software, modified or unmodified, in part or in whole,
|
||||
must be distributed entirely under this license, and must not be
|
||||
distributed under any other license. The requirement for fonts to
|
||||
remain under this license does not apply to any document created
|
||||
using the Font Software.
|
||||
|
||||
TERMINATION
|
||||
This license becomes null and void if any of the above conditions are
|
||||
not met.
|
||||
|
||||
DISCLAIMER
|
||||
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
||||
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
|
||||
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
|
||||
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
|
||||
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
|
||||
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
|
||||
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
||||
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
|
||||
OTHER DEALINGS IN THE FONT SOFTWARE.
|
||||
|
|
@ -0,0 +1,93 @@
|
|||
Copyright 2020 The JetBrains Mono Project Authors (https://github.com/JetBrains/JetBrainsMono)
|
||||
|
||||
This Font Software is licensed under the SIL Open Font License, Version 1.1.
|
||||
This license is copied below, and is also available with a FAQ at:
|
||||
https://openfontlicense.org
|
||||
|
||||
|
||||
-----------------------------------------------------------
|
||||
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
|
||||
-----------------------------------------------------------
|
||||
|
||||
PREAMBLE
|
||||
The goals of the Open Font License (OFL) are to stimulate worldwide
|
||||
development of collaborative font projects, to support the font creation
|
||||
efforts of academic and linguistic communities, and to provide a free and
|
||||
open framework in which fonts may be shared and improved in partnership
|
||||
with others.
|
||||
|
||||
The OFL allows the licensed fonts to be used, studied, modified and
|
||||
redistributed freely as long as they are not sold by themselves. The
|
||||
fonts, including any derivative works, can be bundled, embedded,
|
||||
redistributed and/or sold with any software provided that any reserved
|
||||
names are not used by derivative works. The fonts and derivatives,
|
||||
however, cannot be released under any other type of license. The
|
||||
requirement for fonts to remain under this license does not apply
|
||||
to any document created using the fonts or their derivatives.
|
||||
|
||||
DEFINITIONS
|
||||
"Font Software" refers to the set of files released by the Copyright
|
||||
Holder(s) under this license and clearly marked as such. This may
|
||||
include source files, build scripts and documentation.
|
||||
|
||||
"Reserved Font Name" refers to any names specified as such after the
|
||||
copyright statement(s).
|
||||
|
||||
"Original Version" refers to the collection of Font Software components as
|
||||
distributed by the Copyright Holder(s).
|
||||
|
||||
"Modified Version" refers to any derivative made by adding to, deleting,
|
||||
or substituting -- in part or in whole -- any of the components of the
|
||||
Original Version, by changing formats or by porting the Font Software to a
|
||||
new environment.
|
||||
|
||||
"Author" refers to any designer, engineer, programmer, technical
|
||||
writer or other person who contributed to the Font Software.
|
||||
|
||||
PERMISSION & CONDITIONS
|
||||
Permission is hereby granted, free of charge, to any person obtaining
|
||||
a copy of the Font Software, to use, study, copy, merge, embed, modify,
|
||||
redistribute, and sell modified and unmodified copies of the Font
|
||||
Software, subject to the following conditions:
|
||||
|
||||
1) Neither the Font Software nor any of its individual components,
|
||||
in Original or Modified Versions, may be sold by itself.
|
||||
|
||||
2) Original or Modified Versions of the Font Software may be bundled,
|
||||
redistributed and/or sold with any software, provided that each copy
|
||||
contains the above copyright notice and this license. These can be
|
||||
included either as stand-alone text files, human-readable headers or
|
||||
in the appropriate machine-readable metadata fields within text or
|
||||
binary files as long as those fields can be easily viewed by the user.
|
||||
|
||||
3) No Modified Version of the Font Software may use the Reserved Font
|
||||
Name(s) unless explicit written permission is granted by the corresponding
|
||||
Copyright Holder. This restriction only applies to the primary font name as
|
||||
presented to the users.
|
||||
|
||||
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
|
||||
Software shall not be used to promote, endorse or advertise any
|
||||
Modified Version, except to acknowledge the contribution(s) of the
|
||||
Copyright Holder(s) and the Author(s) or with their explicit written
|
||||
permission.
|
||||
|
||||
5) The Font Software, modified or unmodified, in part or in whole,
|
||||
must be distributed entirely under this license, and must not be
|
||||
distributed under any other license. The requirement for fonts to
|
||||
remain under this license does not apply to any document created
|
||||
using the Font Software.
|
||||
|
||||
TERMINATION
|
||||
This license becomes null and void if any of the above conditions are
|
||||
not met.
|
||||
|
||||
DISCLAIMER
|
||||
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
||||
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
|
||||
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
|
||||
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
|
||||
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
|
||||
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
|
||||
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
||||
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
|
||||
OTHER DEALINGS IN THE FONT SOFTWARE.
|
||||
|
|
@ -0,0 +1,93 @@
|
|||
Copyright 2010-2024 Adobe (http://www.adobe.com/), with Reserved Font Name 'Source'. All Rights Reserved. Source is a trademark of Adobe in the United States and/or other countries.
|
||||
|
||||
This Font Software is licensed under the SIL Open Font License, Version 1.1.
|
||||
|
||||
This license is copied below, and is also available with a FAQ at: http://scripts.sil.org/OFL
|
||||
|
||||
|
||||
-----------------------------------------------------------
|
||||
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
|
||||
-----------------------------------------------------------
|
||||
|
||||
PREAMBLE
|
||||
The goals of the Open Font License (OFL) are to stimulate worldwide
|
||||
development of collaborative font projects, to support the font creation
|
||||
efforts of academic and linguistic communities, and to provide a free and
|
||||
open framework in which fonts may be shared and improved in partnership
|
||||
with others.
|
||||
|
||||
The OFL allows the licensed fonts to be used, studied, modified and
|
||||
redistributed freely as long as they are not sold by themselves. The
|
||||
fonts, including any derivative works, can be bundled, embedded,
|
||||
redistributed and/or sold with any software provided that any reserved
|
||||
names are not used by derivative works. The fonts and derivatives,
|
||||
however, cannot be released under any other type of license. The
|
||||
requirement for fonts to remain under this license does not apply
|
||||
to any document created using the fonts or their derivatives.
|
||||
|
||||
DEFINITIONS
|
||||
"Font Software" refers to the set of files released by the Copyright
|
||||
Holder(s) under this license and clearly marked as such. This may
|
||||
include source files, build scripts and documentation.
|
||||
|
||||
"Reserved Font Name" refers to any names specified as such after the
|
||||
copyright statement(s).
|
||||
|
||||
"Original Version" refers to the collection of Font Software components as
|
||||
distributed by the Copyright Holder(s).
|
||||
|
||||
"Modified Version" refers to any derivative made by adding to, deleting,
|
||||
or substituting -- in part or in whole -- any of the components of the
|
||||
Original Version, by changing formats or by porting the Font Software to a
|
||||
new environment.
|
||||
|
||||
"Author" refers to any designer, engineer, programmer, technical
|
||||
writer or other person who contributed to the Font Software.
|
||||
|
||||
PERMISSION & CONDITIONS
|
||||
Permission is hereby granted, free of charge, to any person obtaining
|
||||
a copy of the Font Software, to use, study, copy, merge, embed, modify,
|
||||
redistribute, and sell modified and unmodified copies of the Font
|
||||
Software, subject to the following conditions:
|
||||
|
||||
1) Neither the Font Software nor any of its individual components,
|
||||
in Original or Modified Versions, may be sold by itself.
|
||||
|
||||
2) Original or Modified Versions of the Font Software may be bundled,
|
||||
redistributed and/or sold with any software, provided that each copy
|
||||
contains the above copyright notice and this license. These can be
|
||||
included either as stand-alone text files, human-readable headers or
|
||||
in the appropriate machine-readable metadata fields within text or
|
||||
binary files as long as those fields can be easily viewed by the user.
|
||||
|
||||
3) No Modified Version of the Font Software may use the Reserved Font
|
||||
Name(s) unless explicit written permission is granted by the corresponding
|
||||
Copyright Holder. This restriction only applies to the primary font name as
|
||||
presented to the users.
|
||||
|
||||
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
|
||||
Software shall not be used to promote, endorse or advertise any
|
||||
Modified Version, except to acknowledge the contribution(s) of the
|
||||
Copyright Holder(s) and the Author(s) or with their explicit written
|
||||
permission.
|
||||
|
||||
5) The Font Software, modified or unmodified, in part or in whole,
|
||||
must be distributed entirely under this license, and must not be
|
||||
distributed under any other license. The requirement for fonts to
|
||||
remain under this license does not apply to any document created
|
||||
using the Font Software.
|
||||
|
||||
TERMINATION
|
||||
This license becomes null and void if any of the above conditions are
|
||||
not met.
|
||||
|
||||
DISCLAIMER
|
||||
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
||||
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
|
||||
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
|
||||
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
|
||||
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
|
||||
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
|
||||
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
||||
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
|
||||
OTHER DEALINGS IN THE FONT SOFTWARE.
|
||||
|
|
@ -0,0 +1,93 @@
|
|||
Copyright 2014 - 2023 Adobe (http://www.adobe.com/), with Reserved Font Name ‘Source’. All Rights Reserved. Source is a trademark of Adobe in the United States and/or other countries.
|
||||
|
||||
This Font Software is licensed under the SIL Open Font License, Version 1.1.
|
||||
|
||||
This license is copied below, and is also available with a FAQ at: http://scripts.sil.org/OFL
|
||||
|
||||
|
||||
-----------------------------------------------------------
|
||||
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
|
||||
-----------------------------------------------------------
|
||||
|
||||
PREAMBLE
|
||||
The goals of the Open Font License (OFL) are to stimulate worldwide
|
||||
development of collaborative font projects, to support the font creation
|
||||
efforts of academic and linguistic communities, and to provide a free and
|
||||
open framework in which fonts may be shared and improved in partnership
|
||||
with others.
|
||||
|
||||
The OFL allows the licensed fonts to be used, studied, modified and
|
||||
redistributed freely as long as they are not sold by themselves. The
|
||||
fonts, including any derivative works, can be bundled, embedded,
|
||||
redistributed and/or sold with any software provided that any reserved
|
||||
names are not used by derivative works. The fonts and derivatives,
|
||||
however, cannot be released under any other type of license. The
|
||||
requirement for fonts to remain under this license does not apply
|
||||
to any document created using the fonts or their derivatives.
|
||||
|
||||
DEFINITIONS
|
||||
"Font Software" refers to the set of files released by the Copyright
|
||||
Holder(s) under this license and clearly marked as such. This may
|
||||
include source files, build scripts and documentation.
|
||||
|
||||
"Reserved Font Name" refers to any names specified as such after the
|
||||
copyright statement(s).
|
||||
|
||||
"Original Version" refers to the collection of Font Software components as
|
||||
distributed by the Copyright Holder(s).
|
||||
|
||||
"Modified Version" refers to any derivative made by adding to, deleting,
|
||||
or substituting -- in part or in whole -- any of the components of the
|
||||
Original Version, by changing formats or by porting the Font Software to a
|
||||
new environment.
|
||||
|
||||
"Author" refers to any designer, engineer, programmer, technical
|
||||
writer or other person who contributed to the Font Software.
|
||||
|
||||
PERMISSION & CONDITIONS
|
||||
Permission is hereby granted, free of charge, to any person obtaining
|
||||
a copy of the Font Software, to use, study, copy, merge, embed, modify,
|
||||
redistribute, and sell modified and unmodified copies of the Font
|
||||
Software, subject to the following conditions:
|
||||
|
||||
1) Neither the Font Software nor any of its individual components,
|
||||
in Original or Modified Versions, may be sold by itself.
|
||||
|
||||
2) Original or Modified Versions of the Font Software may be bundled,
|
||||
redistributed and/or sold with any software, provided that each copy
|
||||
contains the above copyright notice and this license. These can be
|
||||
included either as stand-alone text files, human-readable headers or
|
||||
in the appropriate machine-readable metadata fields within text or
|
||||
binary files as long as those fields can be easily viewed by the user.
|
||||
|
||||
3) No Modified Version of the Font Software may use the Reserved Font
|
||||
Name(s) unless explicit written permission is granted by the corresponding
|
||||
Copyright Holder. This restriction only applies to the primary font name as
|
||||
presented to the users.
|
||||
|
||||
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
|
||||
Software shall not be used to promote, endorse or advertise any
|
||||
Modified Version, except to acknowledge the contribution(s) of the
|
||||
Copyright Holder(s) and the Author(s) or with their explicit written
|
||||
permission.
|
||||
|
||||
5) The Font Software, modified or unmodified, in part or in whole,
|
||||
must be distributed entirely under this license, and must not be
|
||||
distributed under any other license. The requirement for fonts to
|
||||
remain under this license does not apply to any document created
|
||||
using the Font Software.
|
||||
|
||||
TERMINATION
|
||||
This license becomes null and void if any of the above conditions are
|
||||
not met.
|
||||
|
||||
DISCLAIMER
|
||||
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
||||
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
|
||||
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
|
||||
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
|
||||
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
|
||||
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
|
||||
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
||||
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
|
||||
OTHER DEALINGS IN THE FONT SOFTWARE.
|
||||
|
|
@ -0,0 +1,4 @@
|
|||
allow-unwrap-in-tests = true
|
||||
allow-expect-in-tests = true
|
||||
allow-panic-in-tests = true
|
||||
allow-indexing-slicing-in-tests = true
|
||||
|
|
@ -0,0 +1,19 @@
|
|||
[package]
|
||||
name = "wordcraft-doc"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
description = "WordCraft document model: stories, paragraphs, runs, tables, sections, styles, numbering, fields, notes, comments, revisions; edit primitives"
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
|
||||
[dependencies]
|
||||
wordcraft-geom = { workspace = true }
|
||||
serde = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
thiserror = { workspace = true }
|
||||
unicode-segmentation = { workspace = true }
|
||||
|
||||
[dev-dependencies]
|
||||
proptest = { workspace = true }
|
||||
|
|
@ -0,0 +1,390 @@
|
|||
//! Range editing on a [`Document`]: insert text, split paragraphs, delete ranges across
|
||||
//! paragraphs and tables, apply character/paragraph formatting, copy a range to a fragment and
|
||||
//! paste a fragment.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::para::InlineObject;
|
||||
use crate::props::{CharProps, ParaProps};
|
||||
use crate::{Block, Blocks, DocError, Document, Paragraph, Path, Pos, Result, para_block};
|
||||
|
||||
/// A copied piece of a document: blocks, where the first and last paragraphs may be partial.
|
||||
#[derive(Clone, Debug, PartialEq, Default, Serialize, Deserialize)]
|
||||
pub struct Fragment {
|
||||
pub blocks: Vec<Block>,
|
||||
}
|
||||
|
||||
impl Fragment {
|
||||
pub fn plain_text(&self) -> String {
|
||||
let mut d = Document::new();
|
||||
d.body = self.blocks.iter().cloned().map(Arc::new).collect();
|
||||
d.plain_text(crate::StoryRef::Body)
|
||||
}
|
||||
pub fn from_text(s: &str) -> Fragment {
|
||||
let lines: Vec<&str> = s.split('\n').collect();
|
||||
Fragment { blocks: lines.iter().map(|l| Block::Para(Paragraph::with_text(l.trim_end_matches('\r'), CharProps::default()))).collect() }
|
||||
}
|
||||
}
|
||||
|
||||
fn order(a: &Pos, b: &Pos) -> (Pos, Pos) {
|
||||
if a <= b { (a.clone(), b.clone()) } else { (b.clone(), a.clone()) }
|
||||
}
|
||||
|
||||
impl Document {
|
||||
/// Insert plain text at `pos` (`\n` inside `s` is a manual line break; use
|
||||
/// [`Document::split_paragraph`] for paragraph breaks). Returns the position after it.
|
||||
pub fn insert_text(&mut self, pos: &Pos, s: &str, props: &CharProps) -> Result<Pos> {
|
||||
let p = self.para_mut(pos.story, &pos.path)?;
|
||||
let n = p.insert_text(pos.off, s, props)?;
|
||||
Ok(Pos { off: pos.off + n, ..pos.clone() })
|
||||
}
|
||||
|
||||
/// Insert an inline object at `pos`; returns the position after it.
|
||||
pub fn insert_object(&mut self, pos: &Pos, obj: InlineObject, props: &CharProps) -> Result<Pos> {
|
||||
let p = self.para_mut(pos.story, &pos.path)?;
|
||||
p.insert_object(pos.off, obj, props)?;
|
||||
Ok(Pos { off: pos.off + crate::para::OBJ.len_utf8(), ..pos.clone() })
|
||||
}
|
||||
|
||||
/// Split the paragraph at `pos`; returns the start of the new (second) paragraph.
|
||||
pub fn split_paragraph(&mut self, pos: &Pos) -> Result<Pos> {
|
||||
let i = pos.path.last();
|
||||
let tail = self.para_mut(pos.story, &pos.path)?.split_off(pos.off)?;
|
||||
let bl = self.container_mut(pos.story, &pos.path)?;
|
||||
bl.insert((i + 1).min(bl.len()), para_block(tail));
|
||||
Ok(Pos { story: pos.story, path: pos.path.with_last(i + 1), off: 0 })
|
||||
}
|
||||
|
||||
/// Insert a block before the block at `path` (same container).
|
||||
pub fn insert_block(&mut self, story: crate::StoryRef, path: &Path, block: Block) -> Result<()> {
|
||||
let i = path.last();
|
||||
let bl = self.container_mut(story, path)?;
|
||||
if i > bl.len() {
|
||||
return Err(DocError::BadPath(path.to_string()));
|
||||
}
|
||||
bl.insert(i, Arc::new(block));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Remove the block at `path` (keeps at least one paragraph in the container).
|
||||
pub fn remove_block(&mut self, story: crate::StoryRef, path: &Path) -> Result<Block> {
|
||||
let i = path.last();
|
||||
let bl = self.container_mut(story, path)?;
|
||||
if i >= bl.len() {
|
||||
return Err(DocError::BadPath(path.to_string()));
|
||||
}
|
||||
let b = bl.remove(i);
|
||||
if bl.is_empty() || matches!(bl.last().map(|b| &**b), Some(Block::Table(_))) {
|
||||
bl.push(para_block(Paragraph::new()));
|
||||
}
|
||||
Ok(Arc::try_unwrap(b).unwrap_or_else(|a| (*a).clone()))
|
||||
}
|
||||
|
||||
/// Paragraph paths between `a` and `b` inclusive (same story), document order.
|
||||
pub fn paths_between(&self, a: &Pos, b: &Pos) -> Vec<Path> {
|
||||
let (a, b) = order(a, b);
|
||||
if a.story != b.story {
|
||||
return Vec::new();
|
||||
}
|
||||
self.para_paths(a.story).into_iter().filter(|p| *p >= a.path && *p <= b.path).collect()
|
||||
}
|
||||
|
||||
/// Delete `a..b`. Returns the position where the deletion collapsed to.
|
||||
pub fn delete_range(&mut self, a: &Pos, b: &Pos) -> Result<Pos> {
|
||||
let (a, b) = order(a, b);
|
||||
if a == b {
|
||||
return Ok(a);
|
||||
}
|
||||
if a.story != b.story {
|
||||
return Err(DocError::Invalid("range spans stories".into()));
|
||||
}
|
||||
if a.path == b.path {
|
||||
self.para_mut(a.story, &a.path)?.delete(a.off, b.off)?;
|
||||
return Ok(a);
|
||||
}
|
||||
if a.path.parent() == b.path.parent() {
|
||||
// Same container: trim the ends, drop the blocks between, join.
|
||||
let (ia, ib) = (a.path.last(), b.path.last());
|
||||
{
|
||||
let pa = self.para_mut(a.story, &a.path)?;
|
||||
let end = pa.len();
|
||||
pa.delete(a.off, end)?;
|
||||
}
|
||||
let mut tail = {
|
||||
let pb = self.para_mut(b.story, &b.path)?;
|
||||
pb.delete(0, b.off)?;
|
||||
pb.clone()
|
||||
};
|
||||
let bl = self.container_mut(a.story, &a.path)?;
|
||||
if ib < bl.len() && ia < ib {
|
||||
bl.drain(ia + 1..=ib);
|
||||
}
|
||||
tail.props = ParaProps::default();
|
||||
let pa = self.para_mut(a.story, &a.path)?;
|
||||
let keep_props = pa.props.clone();
|
||||
pa.append(tail);
|
||||
pa.props = keep_props;
|
||||
return Ok(a);
|
||||
}
|
||||
// Different containers (range enters or leaves a table): clear text in every paragraph
|
||||
// inside; remove whole tables that are entirely covered when at the same level as `a`.
|
||||
let paths = self.paths_between(&a, &b);
|
||||
for p in paths.iter().rev() {
|
||||
let (from, to) = {
|
||||
let Some(para) = self.para(a.story, p) else { continue };
|
||||
let from = if *p == a.path { a.off } else { 0 };
|
||||
let to = if *p == b.path { b.off } else { para.len() };
|
||||
(from, to)
|
||||
};
|
||||
self.para_mut(a.story, p)?.delete(from, to)?;
|
||||
}
|
||||
// Drop top-level tables strictly between a and b at a's level.
|
||||
if a.path.depth() == 0 && b.path.depth() > 0 {
|
||||
let first_b = b.path.0.first().copied().unwrap_or(0) as usize;
|
||||
let ia = a.path.last();
|
||||
let bl = self.container_mut(a.story, &a.path)?;
|
||||
let mut i = first_b;
|
||||
while i > ia + 1 {
|
||||
i -= 1;
|
||||
if matches!(bl.get(i).map(|b| &**b), Some(Block::Table(_))) {
|
||||
bl.remove(i);
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(a)
|
||||
}
|
||||
|
||||
/// Apply `f` to the character formatting of `a..b`. A collapsed range changes nothing (the
|
||||
/// engine keeps "pending" formatting for the caret instead).
|
||||
pub fn format_range(&mut self, a: &Pos, b: &Pos, f: &dyn Fn(&mut CharProps)) -> Result<()> {
|
||||
let (a, b) = order(a, b);
|
||||
for p in self.paths_between(&a, &b) {
|
||||
let para = self.para_mut(a.story, &p)?;
|
||||
let from = if p == a.path { a.off } else { 0 };
|
||||
let to = if p == b.path { b.off } else { para.len() };
|
||||
para.format(from, to, f)?;
|
||||
// A fully selected paragraph also gets the mark formatted (Word does).
|
||||
if to == para.len() && (from == 0 || p != a.path) {
|
||||
f(&mut para.mark);
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Apply `f` to the paragraph properties of every paragraph touched by `a..b`.
|
||||
pub fn format_paragraphs(&mut self, a: &Pos, b: &Pos, f: &dyn Fn(&mut ParaProps)) -> Result<()> {
|
||||
for p in self.paths_between(a, b) {
|
||||
let para = self.para_mut(a.story, &p)?;
|
||||
f(&mut para.props);
|
||||
para.touch();
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Copy `a..b` into a fragment.
|
||||
pub fn copy_range(&self, a: &Pos, b: &Pos) -> Fragment {
|
||||
let (a, b) = order(a, b);
|
||||
let mut out = Vec::new();
|
||||
if a.story != b.story {
|
||||
return Fragment::default();
|
||||
}
|
||||
if a.path.parent() == b.path.parent() {
|
||||
let Some(bl) = self.container(a.story, &a.path) else { return Fragment::default() };
|
||||
for i in a.path.last()..=b.path.last() {
|
||||
let Some(blk) = bl.get(i) else { break };
|
||||
match &**blk {
|
||||
Block::Para(p) => {
|
||||
let mut p = p.clone();
|
||||
let to = if i == b.path.last() { b.off } else { p.len() };
|
||||
let from = if i == a.path.last() { a.off } else { 0 };
|
||||
let _ = p.delete(to.min(p.len()), p.len());
|
||||
let _ = p.delete(0, from.min(p.len()));
|
||||
if i == b.path.last() && to < self.para(a.story, &b.path).map(Paragraph::len).unwrap_or(0) {
|
||||
p.section = None;
|
||||
}
|
||||
out.push(Block::Para(p));
|
||||
}
|
||||
Block::Table(t) => out.push(Block::Table(t.clone())),
|
||||
}
|
||||
}
|
||||
return Fragment { blocks: out };
|
||||
}
|
||||
for p in self.paths_between(&a, &b) {
|
||||
if let Some(para) = self.para(a.story, &p) {
|
||||
let mut q = para.clone();
|
||||
let to = if p == b.path { b.off } else { q.len() };
|
||||
let from = if p == a.path { a.off } else { 0 };
|
||||
let _ = q.delete(to.min(q.len()), q.len());
|
||||
let _ = q.delete(0, from.min(q.len()));
|
||||
q.section = None;
|
||||
out.push(Block::Para(q));
|
||||
}
|
||||
}
|
||||
Fragment { blocks: out }
|
||||
}
|
||||
|
||||
/// Insert a fragment at `pos`; returns the position after the pasted content.
|
||||
pub fn insert_fragment(&mut self, pos: &Pos, frag: &Fragment) -> Result<Pos> {
|
||||
let mut blocks = frag.blocks.clone();
|
||||
if blocks.is_empty() {
|
||||
return Ok(pos.clone());
|
||||
}
|
||||
// Single paragraph: inline insert keeping runs.
|
||||
if blocks.len() == 1
|
||||
&& let Some(Block::Para(src)) = blocks.first()
|
||||
{
|
||||
let src = src.clone();
|
||||
let target = self.para_mut(pos.story, &pos.path)?;
|
||||
let tail = target.split_off(pos.off)?;
|
||||
let added = src.len();
|
||||
let keep = target.props.clone();
|
||||
let sect = tail.section.clone();
|
||||
let mut src = src;
|
||||
src.section = None;
|
||||
target.append(src);
|
||||
target.append(tail);
|
||||
target.props = keep;
|
||||
target.section = sect;
|
||||
return Ok(Pos { off: pos.off + added, ..pos.clone() });
|
||||
}
|
||||
// Multi-block: split, merge first into left part, last into right part.
|
||||
let after = self.split_paragraph(pos)?;
|
||||
let left = pos.path.clone();
|
||||
let first = blocks.remove(0);
|
||||
let last = blocks.pop();
|
||||
let mut idx = left.last() + 1;
|
||||
match first {
|
||||
Block::Para(p) => {
|
||||
let l = self.para_mut(pos.story, &left)?;
|
||||
let keep = l.props.clone();
|
||||
let mut p2 = p;
|
||||
let pprops = p2.props.clone();
|
||||
p2.section = None;
|
||||
l.append(p2);
|
||||
l.props = if keep.is_empty() { pprops } else { keep };
|
||||
}
|
||||
t @ Block::Table(_) => {
|
||||
self.insert_block(pos.story, &left.with_last(idx), t)?;
|
||||
idx += 1;
|
||||
}
|
||||
}
|
||||
for b in blocks {
|
||||
self.insert_block(pos.story, &left.with_last(idx), b)?;
|
||||
idx += 1;
|
||||
}
|
||||
let right = after.path.with_last(idx);
|
||||
match last {
|
||||
Some(Block::Para(p)) => {
|
||||
let r = self.para_mut(pos.story, &right)?;
|
||||
let tail = std::mem::replace(r, p.clone());
|
||||
let off = r.len();
|
||||
let sect = tail.section.clone();
|
||||
r.append(tail);
|
||||
r.section = sect;
|
||||
Ok(Pos { story: pos.story, path: right, off })
|
||||
}
|
||||
Some(t @ Block::Table(_)) => {
|
||||
self.insert_block(pos.story, &right, t)?;
|
||||
Ok(Pos { story: pos.story, path: right.with_last(idx + 1), off: 0 })
|
||||
}
|
||||
None => Ok(Pos { story: pos.story, path: right, off: 0 }),
|
||||
}
|
||||
}
|
||||
|
||||
/// Replace every block of a story.
|
||||
pub fn set_story(&mut self, story: crate::StoryRef, blocks: Blocks) -> Result<()> {
|
||||
let s = self.story_mut(story)?;
|
||||
*s = blocks;
|
||||
if s.is_empty() {
|
||||
s.push(para_block(Paragraph::new()));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::{StoryRef, Table};
|
||||
|
||||
fn doc(lines: &[&str]) -> Document {
|
||||
Document::from_text(&lines.join("\n"))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn split_and_join() {
|
||||
let mut d = doc(&["Hello world"]);
|
||||
let p = d.split_paragraph(&Pos::body(0, 5)).unwrap();
|
||||
assert_eq!(p, Pos::body(1, 0));
|
||||
assert_eq!(d.plain_text(StoryRef::Body), "Hello\n world");
|
||||
let at = d.delete_range(&Pos::body(0, 5), &Pos::body(1, 0)).unwrap();
|
||||
assert_eq!(at, Pos::body(0, 5));
|
||||
assert_eq!(d.plain_text(StoryRef::Body), "Hello world");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn delete_multi() {
|
||||
let mut d = doc(&["one", "two", "three"]);
|
||||
d.delete_range(&Pos::body(2, 2), &Pos::body(0, 1)).unwrap();
|
||||
assert_eq!(d.plain_text(StoryRef::Body), "oree");
|
||||
assert_eq!(d.body.len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn format_across() {
|
||||
let mut d = doc(&["abc", "def"]);
|
||||
d.format_range(&Pos::body(0, 1), &Pos::body(1, 2), &|c| c.bold = Some(true)).unwrap();
|
||||
let p0 = d.para(StoryRef::Body, &Path::top(0)).unwrap();
|
||||
assert_eq!(p0.props_of_char(0).bold, None);
|
||||
assert_eq!(p0.props_of_char(1).bold, Some(true));
|
||||
let p1 = d.para(StoryRef::Body, &Path::top(1)).unwrap();
|
||||
assert_eq!(p1.props_of_char(1).bold, Some(true));
|
||||
assert_eq!(p1.props_of_char(2).bold, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn copy_paste_round_trip() {
|
||||
let mut d = doc(&["alpha", "beta", "gamma"]);
|
||||
let f = d.copy_range(&Pos::body(0, 2), &Pos::body(2, 3));
|
||||
assert_eq!(f.plain_text(), "pha\nbeta\ngam");
|
||||
let end = d.insert_fragment(&Pos::body(2, 5), &f).unwrap();
|
||||
assert_eq!(d.plain_text(StoryRef::Body), "alpha\nbeta\ngammapha\nbeta\ngam");
|
||||
assert_eq!(end, Pos::body(4, 3));
|
||||
let one = Fragment::from_text("XY");
|
||||
let e = d.insert_fragment(&Pos::body(0, 0), &one).unwrap();
|
||||
assert_eq!(e, Pos::body(0, 2));
|
||||
assert!(d.plain_text(StoryRef::Body).starts_with("XYalpha"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tables_in_paths() {
|
||||
let mut d = doc(&["before", "after"]);
|
||||
d.insert_block(StoryRef::Body, &Path::top(1), Block::Table(Table::new(2, 2, 200.0))).unwrap();
|
||||
let paths = d.para_paths(StoryRef::Body);
|
||||
assert_eq!(paths.len(), 6);
|
||||
assert_eq!(paths[1], Path(vec![1, 0, 0, 0]));
|
||||
let cell = Pos { story: StoryRef::Body, path: Path(vec![1, 0, 1, 0]), off: 0 };
|
||||
let e = d.insert_text(&cell, "x", &CharProps::default()).unwrap();
|
||||
assert_eq!(e.off, 1);
|
||||
assert_eq!(d.next_para(StoryRef::Body, &Path(vec![1, 0, 1, 0])), Some(Path(vec![1, 1, 0, 0])));
|
||||
assert!(d.plain_text(StoryRef::Body).contains("\tx"));
|
||||
// Delete from before the table into a cell: text cleared, table kept (partially covered).
|
||||
d.delete_range(&Pos::body(0, 2), &cell).unwrap();
|
||||
assert_eq!(d.para(StoryRef::Body, &Path::top(0)).unwrap().text, "be");
|
||||
assert!(d.container(StoryRef::Body, &Path(vec![1, 0, 0, 0])).is_some());
|
||||
assert_eq!(Path(vec![1, 0, 1, 0]).cell(), Some((Path::top(1), 0, 1)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bad_positions_error() {
|
||||
let mut d = doc(&["é"]);
|
||||
assert!(d.insert_text(&Pos::body(0, 1), "x", &CharProps::default()).is_err());
|
||||
assert!(d.insert_text(&Pos::body(5, 0), "x", &CharProps::default()).is_err());
|
||||
assert!(d.split_paragraph(&Pos::body(0, 99)).is_err());
|
||||
let c = d.clamp(&Pos::body(9, 9));
|
||||
assert_eq!(c, Pos::body(0, 2));
|
||||
assert!(d.delete_range(&Pos { story: StoryRef::Part(3), path: Path::top(0), off: 0 }, &Pos::body(0, 0)).is_err());
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,651 @@
|
|||
//! WordCraft document model.
|
||||
//!
|
||||
//! A [`Document`] holds the main story (`body`) and secondary stories (`parts`: headers,
|
||||
//! footers, notes, comments, text boxes). A story is a list of [`Block`]s: paragraphs and tables;
|
||||
//! table cells hold block lists of their own. Blocks are `Arc`-shared, so cloning a document is
|
||||
//! cheap (undo snapshots, handing a frozen copy to the renderer) and edits copy only what they
|
||||
//! touch (`Arc::make_mut`).
|
||||
//!
|
||||
//! Positions ([`Pos`]) name a story, a [`Path`] to a paragraph and a byte offset in it. Paths
|
||||
//! compare in document order.
|
||||
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
|
||||
|
||||
pub mod edit;
|
||||
pub mod numbering;
|
||||
pub mod para;
|
||||
pub mod props;
|
||||
pub mod resolve;
|
||||
pub mod section;
|
||||
pub mod styles;
|
||||
pub mod table;
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
use std::sync::Arc;
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
pub use numbering::{ListKind, Numbering};
|
||||
pub use para::{InlineObject, Paragraph, Run};
|
||||
pub use props::{Align, CharProps, ParaProps, Rgb, TextColor};
|
||||
pub use section::SectionProps;
|
||||
pub use styles::{Style, StyleKind, StyleSheet};
|
||||
pub use table::{Cell, Row, Table};
|
||||
|
||||
#[derive(Debug, thiserror::Error, Clone, PartialEq)]
|
||||
pub enum DocError {
|
||||
#[error("offset {0} is not a character boundary in the paragraph")]
|
||||
BadOffset(usize),
|
||||
#[error("no paragraph at {0}")]
|
||||
BadPath(String),
|
||||
#[error("no story {0:?}")]
|
||||
BadStory(StoryRef),
|
||||
#[error("{0}")]
|
||||
Invalid(String),
|
||||
}
|
||||
|
||||
pub type Result<T> = std::result::Result<T, DocError>;
|
||||
|
||||
/// A block in a story.
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(tag = "kind", rename_all = "camelCase")]
|
||||
pub enum Block {
|
||||
Para(Paragraph),
|
||||
Table(Table),
|
||||
}
|
||||
|
||||
impl Block {
|
||||
pub fn as_para(&self) -> Option<&Paragraph> {
|
||||
match self {
|
||||
Block::Para(p) => Some(p),
|
||||
Block::Table(_) => None,
|
||||
}
|
||||
}
|
||||
pub fn as_table(&self) -> Option<&Table> {
|
||||
match self {
|
||||
Block::Table(t) => Some(t),
|
||||
Block::Para(_) => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub type Blocks = Vec<Arc<Block>>;
|
||||
|
||||
pub fn para_block(p: Paragraph) -> Arc<Block> {
|
||||
Arc::new(Block::Para(p))
|
||||
}
|
||||
|
||||
/// Which story a position is in.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize, Default)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum StoryRef {
|
||||
#[default]
|
||||
Body,
|
||||
Part(u32),
|
||||
}
|
||||
|
||||
/// Path to a paragraph: `[block]` in a story, `[block, row, cell, block]` inside a table cell,
|
||||
/// three more indexes per nesting level. Lexicographic order = document order.
|
||||
#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize, Default)]
|
||||
pub struct Path(pub Vec<u32>);
|
||||
|
||||
impl Path {
|
||||
pub fn top(i: usize) -> Path {
|
||||
Path(vec![i as u32])
|
||||
}
|
||||
/// The block index within its container.
|
||||
pub fn last(&self) -> usize {
|
||||
self.0.last().copied().unwrap_or(0) as usize
|
||||
}
|
||||
pub fn with_last(&self, i: usize) -> Path {
|
||||
let mut p = self.clone();
|
||||
if let Some(l) = p.0.last_mut() {
|
||||
*l = i as u32;
|
||||
}
|
||||
p
|
||||
}
|
||||
/// The container prefix (everything but the last index).
|
||||
pub fn parent(&self) -> &[u32] {
|
||||
self.0.split_last().map(|(_, r)| r).unwrap_or(&[])
|
||||
}
|
||||
/// Nesting depth (0 = top-level story block).
|
||||
pub fn depth(&self) -> usize {
|
||||
self.0.len().saturating_sub(1) / 3
|
||||
}
|
||||
/// The enclosing table cell (table block path, row, cell) if any.
|
||||
pub fn cell(&self) -> Option<(Path, usize, usize)> {
|
||||
if self.0.len() < 4 {
|
||||
return None;
|
||||
}
|
||||
let n = self.0.len();
|
||||
let t = Path(self.0.get(..n - 3)?.to_vec());
|
||||
Some((t, *self.0.get(n - 3)? as usize, *self.0.get(n - 2)? as usize))
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Display for Path {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
let s: Vec<String> = self.0.iter().map(|i| i.to_string()).collect();
|
||||
write!(f, "{}", s.join("/"))
|
||||
}
|
||||
}
|
||||
|
||||
/// A position in a document.
|
||||
#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize, Default)]
|
||||
pub struct Pos {
|
||||
pub story: StoryRef,
|
||||
pub path: Path,
|
||||
pub off: usize,
|
||||
}
|
||||
|
||||
impl Pos {
|
||||
pub fn new(story: StoryRef, path: Path, off: usize) -> Pos {
|
||||
Pos { story, path, off }
|
||||
}
|
||||
pub fn body(block: usize, off: usize) -> Pos {
|
||||
Pos { story: StoryRef::Body, path: Path::top(block), off }
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum PartKind {
|
||||
#[default]
|
||||
Header,
|
||||
Footer,
|
||||
Footnote,
|
||||
Endnote,
|
||||
Comment,
|
||||
TextBox,
|
||||
}
|
||||
|
||||
/// A secondary story.
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Default)]
|
||||
pub struct Part {
|
||||
pub kind: PartKind,
|
||||
pub blocks: Blocks,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Default)]
|
||||
#[serde(default, rename_all = "camelCase")]
|
||||
pub struct Comment {
|
||||
pub author: String,
|
||||
pub initials: String,
|
||||
/// ISO 8601.
|
||||
pub date: String,
|
||||
/// Reply to this comment id.
|
||||
pub parent: Option<u32>,
|
||||
pub resolved: bool,
|
||||
/// `Document::parts` id of the comment text.
|
||||
pub part: u32,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum RevisionKind {
|
||||
#[default]
|
||||
Insert,
|
||||
Delete,
|
||||
Format,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Default)]
|
||||
#[serde(default)]
|
||||
pub struct Revision {
|
||||
pub kind: RevisionKind,
|
||||
pub author: String,
|
||||
pub date: String,
|
||||
}
|
||||
|
||||
/// Document properties (File › Info).
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Default)]
|
||||
#[serde(default, rename_all = "camelCase")]
|
||||
pub struct CoreProps {
|
||||
pub title: String,
|
||||
pub subject: String,
|
||||
pub creator: String,
|
||||
pub keywords: String,
|
||||
pub description: String,
|
||||
pub category: String,
|
||||
pub last_modified_by: String,
|
||||
pub created: String,
|
||||
pub modified: String,
|
||||
pub revision: u32,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(default, rename_all = "camelCase")]
|
||||
pub struct Settings {
|
||||
pub track_changes: bool,
|
||||
/// Default tab stop interval, points.
|
||||
pub default_tab: f32,
|
||||
pub even_odd_headers: bool,
|
||||
pub mirror_margins: bool,
|
||||
pub auto_hyphenation: bool,
|
||||
pub page_color: Option<Rgb>,
|
||||
pub watermark: Option<Watermark>,
|
||||
/// Theme fonts (major = headings, minor = body).
|
||||
pub major_font: String,
|
||||
pub minor_font: String,
|
||||
/// Theme colours: dk1 lt1 dk2 lt2 accent1..6 hlink folHlink.
|
||||
pub theme_colors: Vec<Rgb>,
|
||||
pub theme_name: String,
|
||||
pub footnote_format: section::NumFormat,
|
||||
pub endnote_format: section::NumFormat,
|
||||
pub protection: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(default)]
|
||||
pub struct Watermark {
|
||||
pub text: String,
|
||||
pub font: String,
|
||||
pub color: Rgb,
|
||||
pub diagonal: bool,
|
||||
pub semitransparent: bool,
|
||||
}
|
||||
|
||||
impl Default for Watermark {
|
||||
fn default() -> Self {
|
||||
Watermark { text: "DRAFT".into(), font: styles::BODY_FONT.into(), color: Rgb(0xC0, 0xC0, 0xC0), diagonal: true, semitransparent: true }
|
||||
}
|
||||
}
|
||||
|
||||
/// Our default theme colours.
|
||||
pub const THEME_COLORS: [Rgb; 12] = [
|
||||
Rgb(0x00, 0x00, 0x00),
|
||||
Rgb(0xFF, 0xFF, 0xFF),
|
||||
Rgb(0x0E, 0x28, 0x41),
|
||||
Rgb(0xE8, 0xE8, 0xE8),
|
||||
Rgb(0x15, 0x60, 0x82),
|
||||
Rgb(0xE9, 0x71, 0x32),
|
||||
Rgb(0x19, 0x6B, 0x24),
|
||||
Rgb(0x0F, 0x9E, 0xD5),
|
||||
Rgb(0xA0, 0x2B, 0x93),
|
||||
Rgb(0x4E, 0xA7, 0x2E),
|
||||
Rgb(0x46, 0x78, 0x86),
|
||||
Rgb(0x96, 0x60, 0x7D),
|
||||
];
|
||||
|
||||
impl Default for Settings {
|
||||
fn default() -> Self {
|
||||
Settings {
|
||||
track_changes: false,
|
||||
default_tab: 36.0,
|
||||
even_odd_headers: false,
|
||||
mirror_margins: false,
|
||||
auto_hyphenation: false,
|
||||
page_color: None,
|
||||
watermark: None,
|
||||
major_font: styles::HEADING_FONT.into(),
|
||||
minor_font: styles::BODY_FONT.into(),
|
||||
theme_colors: THEME_COLORS.to_vec(),
|
||||
theme_name: "Craft".into(),
|
||||
footnote_format: section::NumFormat::Decimal,
|
||||
endnote_format: section::NumFormat::LowerRoman,
|
||||
protection: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A document.
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(default, rename_all = "camelCase")]
|
||||
pub struct Document {
|
||||
pub body: Blocks,
|
||||
/// Properties of the last section (OOXML's body-level `w:sectPr`).
|
||||
pub last_section: SectionProps,
|
||||
pub parts: BTreeMap<u32, Part>,
|
||||
pub styles: StyleSheet,
|
||||
pub numbering: Numbering,
|
||||
pub comments: BTreeMap<u32, Comment>,
|
||||
pub revisions: Vec<Revision>,
|
||||
pub settings: Settings,
|
||||
pub core: CoreProps,
|
||||
/// Embedded media (images) by key.
|
||||
#[serde(skip)]
|
||||
pub media: BTreeMap<String, Arc<Vec<u8>>>,
|
||||
/// Source-format parts we keep verbatim for round-trip (e.g. custom XML), by part name.
|
||||
#[serde(skip)]
|
||||
pub passthrough: BTreeMap<String, Arc<Vec<u8>>>,
|
||||
}
|
||||
|
||||
impl Default for Document {
|
||||
fn default() -> Self {
|
||||
Document::new()
|
||||
}
|
||||
}
|
||||
|
||||
impl Document {
|
||||
/// A blank document: one empty Normal paragraph, Letter, built-in styles.
|
||||
pub fn new() -> Self {
|
||||
Document {
|
||||
body: vec![para_block(Paragraph::new())],
|
||||
last_section: SectionProps::default(),
|
||||
parts: BTreeMap::new(),
|
||||
styles: StyleSheet::builtin(),
|
||||
numbering: Numbering::default(),
|
||||
comments: BTreeMap::new(),
|
||||
revisions: Vec::new(),
|
||||
settings: Settings::default(),
|
||||
core: CoreProps::default(),
|
||||
media: BTreeMap::new(),
|
||||
passthrough: BTreeMap::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// A document from plain text, one paragraph per line.
|
||||
pub fn from_text(text: &str) -> Self {
|
||||
let mut d = Document::new();
|
||||
d.body = text.split('\n').map(|l| para_block(Paragraph::with_text(l.trim_end_matches('\r'), CharProps::default()))).collect();
|
||||
d.ensure_nonempty();
|
||||
d
|
||||
}
|
||||
|
||||
/// Every story must hold at least one paragraph, and every cell too.
|
||||
pub fn ensure_nonempty(&mut self) {
|
||||
fn fix(bl: &mut Blocks) {
|
||||
if !bl.iter().any(|b| matches!(**b, Block::Para(_))) || matches!(bl.last().map(|b| &**b), Some(Block::Table(_))) {
|
||||
bl.push(para_block(Paragraph::new()));
|
||||
}
|
||||
for b in bl.iter_mut() {
|
||||
if let Block::Table(t) = &**b {
|
||||
let needs = t.rows.iter().any(|r| r.cells.iter().any(|c| c.blocks.is_empty() || matches!(c.blocks.last().map(|b| &**b), Some(Block::Table(_)))));
|
||||
if needs && let Block::Table(t) = Arc::make_mut(b) {
|
||||
for r in &mut t.rows {
|
||||
for c in &mut r.cells {
|
||||
fix_cell(&mut c.blocks);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
fn fix_cell(bl: &mut Blocks) {
|
||||
if bl.is_empty() || matches!(bl.last().map(|b| &**b), Some(Block::Table(_))) {
|
||||
bl.push(para_block(Paragraph::new()));
|
||||
}
|
||||
}
|
||||
fix(&mut self.body);
|
||||
for p in self.parts.values_mut() {
|
||||
if p.blocks.is_empty() {
|
||||
p.blocks.push(para_block(Paragraph::new()));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn story(&self, s: StoryRef) -> Option<&Blocks> {
|
||||
match s {
|
||||
StoryRef::Body => Some(&self.body),
|
||||
StoryRef::Part(id) => self.parts.get(&id).map(|p| &p.blocks),
|
||||
}
|
||||
}
|
||||
pub fn story_mut(&mut self, s: StoryRef) -> Result<&mut Blocks> {
|
||||
match s {
|
||||
StoryRef::Body => Ok(&mut self.body),
|
||||
StoryRef::Part(id) => self.parts.get_mut(&id).map(|p| &mut p.blocks).ok_or(DocError::BadStory(s)),
|
||||
}
|
||||
}
|
||||
|
||||
/// The block list that contains the block at `path`.
|
||||
pub fn container(&self, s: StoryRef, path: &Path) -> Option<&Blocks> {
|
||||
let mut bl = self.story(s)?;
|
||||
let parent = path.parent();
|
||||
if parent.len() % 3 != 0 {
|
||||
return None;
|
||||
}
|
||||
for ch in parent.chunks(3) {
|
||||
let [b, r, c] = ch else { return None };
|
||||
let Block::Table(t) = &**bl.get(*b as usize)? else { return None };
|
||||
bl = &t.rows.get(*r as usize)?.cells.get(*c as usize)?.blocks;
|
||||
}
|
||||
Some(bl)
|
||||
}
|
||||
|
||||
pub fn container_mut(&mut self, s: StoryRef, path: &Path) -> Result<&mut Blocks> {
|
||||
let bad = || DocError::BadPath(path.to_string());
|
||||
let parent: Vec<u32> = path.parent().to_vec();
|
||||
if parent.len() % 3 != 0 {
|
||||
return Err(bad());
|
||||
}
|
||||
let mut bl = self.story_mut(s)?;
|
||||
for ch in parent.chunks(3) {
|
||||
let [b, r, c] = ch else { return Err(bad()) };
|
||||
let blk = bl.get_mut(*b as usize).ok_or_else(bad)?;
|
||||
let Block::Table(t) = Arc::make_mut(blk) else { return Err(bad()) };
|
||||
bl = &mut t.rows.get_mut(*r as usize).ok_or_else(bad)?.cells.get_mut(*c as usize).ok_or_else(bad)?.blocks;
|
||||
}
|
||||
Ok(bl)
|
||||
}
|
||||
|
||||
pub fn block(&self, s: StoryRef, path: &Path) -> Option<&Block> {
|
||||
self.container(s, path)?.get(path.last()).map(|b| &**b)
|
||||
}
|
||||
|
||||
pub fn para(&self, s: StoryRef, path: &Path) -> Option<&Paragraph> {
|
||||
self.block(s, path)?.as_para()
|
||||
}
|
||||
pub fn para_at(&self, p: &Pos) -> Option<&Paragraph> {
|
||||
self.para(p.story, &p.path)
|
||||
}
|
||||
|
||||
pub fn para_mut(&mut self, s: StoryRef, path: &Path) -> Result<&mut Paragraph> {
|
||||
let i = path.last();
|
||||
let bl = self.container_mut(s, path)?;
|
||||
match bl.get_mut(i).map(Arc::make_mut) {
|
||||
Some(Block::Para(p)) => Ok(p),
|
||||
_ => Err(DocError::BadPath(path.to_string())),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn table(&self, s: StoryRef, path: &Path) -> Option<&Table> {
|
||||
self.block(s, path)?.as_table()
|
||||
}
|
||||
pub fn table_mut(&mut self, s: StoryRef, path: &Path) -> Result<&mut Table> {
|
||||
let i = path.last();
|
||||
let bl = self.container_mut(s, path)?;
|
||||
match bl.get_mut(i).map(Arc::make_mut) {
|
||||
Some(Block::Table(t)) => Ok(t),
|
||||
_ => Err(DocError::BadPath(path.to_string())),
|
||||
}
|
||||
}
|
||||
|
||||
/// Paths of every paragraph in a story, in document order (tables descended, depth-limited).
|
||||
pub fn para_paths(&self, s: StoryRef) -> Vec<Path> {
|
||||
let mut out = Vec::new();
|
||||
if let Some(bl) = self.story(s) {
|
||||
walk(bl, &mut Vec::new(), &mut out, 0);
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// First and last positions of a story.
|
||||
pub fn start_of(&self, s: StoryRef) -> Pos {
|
||||
let path = self.para_paths(s).into_iter().next().unwrap_or_else(|| Path::top(0));
|
||||
Pos { story: s, path, off: 0 }
|
||||
}
|
||||
pub fn end_of(&self, s: StoryRef) -> Pos {
|
||||
let path = self.para_paths(s).pop().unwrap_or_else(|| Path::top(0));
|
||||
let off = self.para(s, &path).map(|p| p.len()).unwrap_or(0);
|
||||
Pos { story: s, path, off }
|
||||
}
|
||||
|
||||
/// The next / previous paragraph path in document order.
|
||||
pub fn next_para(&self, s: StoryRef, path: &Path) -> Option<Path> {
|
||||
let all = self.para_paths(s);
|
||||
let i = all.binary_search(path).ok()?;
|
||||
all.get(i + 1).cloned()
|
||||
}
|
||||
pub fn prev_para(&self, s: StoryRef, path: &Path) -> Option<Path> {
|
||||
let all = self.para_paths(s);
|
||||
let i = all.binary_search(path).ok()?;
|
||||
i.checked_sub(1).and_then(|j| all.get(j).cloned())
|
||||
}
|
||||
|
||||
/// Clamp a position into the document (valid path, char boundary).
|
||||
pub fn clamp(&self, p: &Pos) -> Pos {
|
||||
if let Some(para) = self.para_at(p) {
|
||||
return Pos { story: p.story, path: p.path.clone(), off: para.clamp(p.off) };
|
||||
}
|
||||
let story = if self.story(p.story).is_some() { p.story } else { StoryRef::Body };
|
||||
let all = self.para_paths(story);
|
||||
let path = all.iter().rev().find(|q| **q <= p.path).or(all.first()).cloned().unwrap_or_else(|| Path::top(0));
|
||||
let off = self.para(story, &path).map(|q| q.clamp(p.off)).unwrap_or(0);
|
||||
Pos { story, path, off }
|
||||
}
|
||||
|
||||
/// The whole text of a story, paragraphs separated by `\n` (tables: cells by `\t`).
|
||||
pub fn plain_text(&self, s: StoryRef) -> String {
|
||||
let mut out = String::new();
|
||||
if let Some(bl) = self.story(s) {
|
||||
text_of(bl, &mut out, 0);
|
||||
}
|
||||
if out.ends_with('\n') {
|
||||
out.pop();
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Section properties in effect for each top-level body block (index → section number), and
|
||||
/// the list of sections.
|
||||
pub fn sections(&self) -> Vec<(usize, &SectionProps)> {
|
||||
let mut v = Vec::new();
|
||||
for (i, b) in self.body.iter().enumerate() {
|
||||
if let Block::Para(p) = &**b
|
||||
&& let Some(s) = &p.section
|
||||
{
|
||||
v.push((i, &**s));
|
||||
}
|
||||
}
|
||||
v.push((self.body.len().saturating_sub(1), &self.last_section));
|
||||
v
|
||||
}
|
||||
|
||||
/// The section that contains top-level body block `block` (index into `sections()`).
|
||||
pub fn section_index_of(&self, block: usize) -> usize {
|
||||
self.sections().iter().position(|(end, _)| block <= *end).unwrap_or(0)
|
||||
}
|
||||
|
||||
/// Mutable section properties for the section containing body block `block`.
|
||||
pub fn section_mut(&mut self, block: usize) -> &mut SectionProps {
|
||||
let ends: Vec<usize> = self.sections().iter().map(|(e, _)| *e).collect();
|
||||
let idx = ends.iter().position(|e| block <= *e).unwrap_or(ends.len().saturating_sub(1));
|
||||
if idx + 1 < ends.len()
|
||||
&& let Some(e) = ends.get(idx)
|
||||
&& let Some(Block::Para(p)) = self.body.get_mut(*e).map(Arc::make_mut)
|
||||
&& let Some(s) = p.section.as_mut()
|
||||
{
|
||||
return s;
|
||||
}
|
||||
&mut self.last_section
|
||||
}
|
||||
|
||||
/// Allocate a new part id and insert the part.
|
||||
pub fn add_part(&mut self, kind: PartKind, blocks: Blocks) -> u32 {
|
||||
let id = self.parts.keys().next_back().map(|k| k + 1).unwrap_or(1);
|
||||
let mut part = Part { kind, blocks };
|
||||
if part.blocks.is_empty() {
|
||||
part.blocks.push(para_block(Paragraph::new()));
|
||||
}
|
||||
self.parts.insert(id, part);
|
||||
id
|
||||
}
|
||||
|
||||
/// Count words like Word's status bar (whitespace-separated tokens containing a letter or digit).
|
||||
pub fn word_count(&self) -> usize {
|
||||
count_words(&self.plain_text(StoryRef::Body))
|
||||
}
|
||||
|
||||
/// Number of paragraphs in the body (all depths).
|
||||
pub fn paragraph_count(&self) -> usize {
|
||||
self.para_paths(StoryRef::Body).len()
|
||||
}
|
||||
|
||||
/// Add a media item; returns its key (deduplicated by content).
|
||||
pub fn add_media(&mut self, bytes: Vec<u8>, ext: &str) -> String {
|
||||
if let Some((k, _)) = self.media.iter().find(|(_, v)| v.as_slice() == bytes.as_slice()) {
|
||||
return k.clone();
|
||||
}
|
||||
let n = self.media.len() + 1;
|
||||
let mut key = format!("image{n}.{ext}");
|
||||
let mut i = n;
|
||||
while self.media.contains_key(&key) {
|
||||
i += 1;
|
||||
key = format!("image{i}.{ext}");
|
||||
}
|
||||
self.media.insert(key.clone(), Arc::new(bytes));
|
||||
key
|
||||
}
|
||||
|
||||
/// Bookmark names in the body, in order.
|
||||
pub fn bookmarks(&self) -> Vec<(String, Pos)> {
|
||||
let mut out = Vec::new();
|
||||
for path in self.para_paths(StoryRef::Body) {
|
||||
if let Some(p) = self.para(StoryRef::Body, &path) {
|
||||
for off in p.object_offsets() {
|
||||
if let Some(InlineObject::BookmarkStart { name }) = p.object_at(off) {
|
||||
out.push((name.clone(), Pos { story: StoryRef::Body, path: path.clone(), off }));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
}
|
||||
|
||||
/// Words in `s` (tokens separated by whitespace that contain at least one alphanumeric char).
|
||||
pub fn count_words(s: &str) -> usize {
|
||||
s.split(|c: char| c.is_whitespace() || c == para::OBJ).filter(|w| w.chars().any(char::is_alphanumeric)).count()
|
||||
}
|
||||
|
||||
fn walk(bl: &Blocks, prefix: &mut Vec<u32>, out: &mut Vec<Path>, depth: usize) {
|
||||
if depth > 16 {
|
||||
return;
|
||||
}
|
||||
for (i, b) in bl.iter().enumerate() {
|
||||
prefix.push(i as u32);
|
||||
match &**b {
|
||||
Block::Para(_) => out.push(Path(prefix.clone())),
|
||||
Block::Table(t) => {
|
||||
for (r, row) in t.rows.iter().enumerate() {
|
||||
for (c, cell) in row.cells.iter().enumerate() {
|
||||
prefix.push(r as u32);
|
||||
prefix.push(c as u32);
|
||||
walk(&cell.blocks, prefix, out, depth + 1);
|
||||
prefix.pop();
|
||||
prefix.pop();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
prefix.pop();
|
||||
}
|
||||
}
|
||||
|
||||
fn text_of(bl: &Blocks, out: &mut String, depth: usize) {
|
||||
if depth > 16 {
|
||||
return;
|
||||
}
|
||||
for b in bl {
|
||||
match &**b {
|
||||
Block::Para(p) => {
|
||||
out.push_str(&p.plain_text());
|
||||
out.push('\n');
|
||||
}
|
||||
Block::Table(t) => {
|
||||
for row in &t.rows {
|
||||
let mut cells = Vec::new();
|
||||
for c in &row.cells {
|
||||
let mut s = String::new();
|
||||
text_of(&c.blocks, &mut s, depth + 1);
|
||||
cells.push(s.trim_end_matches('\n').replace('\n', " "));
|
||||
}
|
||||
out.push_str(&cells.join("\t"));
|
||||
out.push('\n');
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
|
|
@ -0,0 +1,311 @@
|
|||
//! Lists: abstract numbering definitions (9 levels each), numbering instances, and the counter
|
||||
//! state used to compute list labels.
|
||||
|
||||
use std::collections::HashMap;
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::props::{Align, CharProps};
|
||||
use crate::section::NumFormat;
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum LevelSuffix {
|
||||
#[default]
|
||||
Tab,
|
||||
Space,
|
||||
Nothing,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(default, rename_all = "camelCase")]
|
||||
pub struct Level {
|
||||
pub start: u32,
|
||||
pub format: NumFormat,
|
||||
/// Label template: `%1.`, `%1.%2.`, or the bullet character.
|
||||
pub text: String,
|
||||
pub align: Align,
|
||||
/// Left indent of the text, points.
|
||||
pub indent: f32,
|
||||
/// Hanging indent (label sits this far left of `indent`), points.
|
||||
pub hanging: f32,
|
||||
pub suffix: LevelSuffix,
|
||||
/// Label formatting (bullet font etc.).
|
||||
pub chr: CharProps,
|
||||
/// Restart after a higher level (true = after any higher level, the default).
|
||||
pub restart: bool,
|
||||
/// Show all levels as decimal (legal numbering).
|
||||
pub legal: bool,
|
||||
/// Paragraph style linked to this level (e.g. Heading 1 for outline numbering).
|
||||
pub style: Option<String>,
|
||||
}
|
||||
|
||||
impl Default for Level {
|
||||
fn default() -> Self {
|
||||
Level {
|
||||
start: 1,
|
||||
format: NumFormat::Decimal,
|
||||
text: "%1.".into(),
|
||||
align: Align::Left,
|
||||
indent: 36.0,
|
||||
hanging: 18.0,
|
||||
suffix: LevelSuffix::Tab,
|
||||
chr: CharProps::default(),
|
||||
restart: true,
|
||||
legal: false,
|
||||
style: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Default)]
|
||||
#[serde(default)]
|
||||
pub struct AbstractNum {
|
||||
pub id: u32,
|
||||
pub name: Option<String>,
|
||||
pub levels: Vec<Level>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Default)]
|
||||
#[serde(default, rename_all = "camelCase")]
|
||||
pub struct Num {
|
||||
pub id: u32,
|
||||
pub abstract_id: u32,
|
||||
/// Per-level start overrides (level, start).
|
||||
pub start_overrides: Vec<(u8, u32)>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Default)]
|
||||
#[serde(default)]
|
||||
pub struct Numbering {
|
||||
pub abstracts: Vec<AbstractNum>,
|
||||
pub nums: Vec<Num>,
|
||||
}
|
||||
|
||||
/// Built-in list kinds for the Bullets / Numbering / Multilevel galleries.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum ListKind {
|
||||
/// • ○ ▪ repeating.
|
||||
Bullet,
|
||||
/// A specific bullet character.
|
||||
BulletChar(char),
|
||||
/// 1. a. i.
|
||||
Numbered,
|
||||
/// 1) a) i)
|
||||
NumberedParen,
|
||||
/// I. A. 1.
|
||||
Outline,
|
||||
/// A. B. C.
|
||||
UpperLetter,
|
||||
/// a) b) c)
|
||||
LowerLetter,
|
||||
/// i. ii. iii.
|
||||
LowerRoman,
|
||||
/// 1. 1.1. 1.1.1.
|
||||
Legal,
|
||||
}
|
||||
|
||||
impl ListKind {
|
||||
pub fn parse(s: &str) -> Option<ListKind> {
|
||||
Some(match s {
|
||||
"bullet" => ListKind::Bullet,
|
||||
"numbered" | "number" => ListKind::Numbered,
|
||||
"numberedParen" => ListKind::NumberedParen,
|
||||
"outline" => ListKind::Outline,
|
||||
"upperLetter" => ListKind::UpperLetter,
|
||||
"lowerLetter" => ListKind::LowerLetter,
|
||||
"lowerRoman" => ListKind::LowerRoman,
|
||||
"legal" | "multilevel" => ListKind::Legal,
|
||||
s if s.chars().count() == 1 => ListKind::BulletChar(s.chars().next()?),
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
pub fn is_bullet(self) -> bool {
|
||||
matches!(self, ListKind::Bullet | ListKind::BulletChar(_))
|
||||
}
|
||||
}
|
||||
|
||||
fn bullet_level(i: usize, c: char) -> Level {
|
||||
Level {
|
||||
format: NumFormat::Bullet,
|
||||
text: c.to_string(),
|
||||
indent: 36.0 * (i as f32 + 1.0),
|
||||
hanging: 18.0,
|
||||
chr: CharProps { font: Some(if c == '•' || c == '▪' || c == '○' { "Arial".into() } else { "Segoe UI Symbol".into() }), ..CharProps::default() },
|
||||
..Level::default()
|
||||
}
|
||||
}
|
||||
|
||||
/// Nine levels for a list kind.
|
||||
pub fn levels_for(kind: ListKind) -> Vec<Level> {
|
||||
(0..9)
|
||||
.map(|i| {
|
||||
let ind = 36.0 * (i as f32 + 1.0);
|
||||
let cyc = |a: NumFormat, b: NumFormat, c: NumFormat| match i % 3 {
|
||||
0 => a,
|
||||
1 => b,
|
||||
_ => c,
|
||||
};
|
||||
let lvl = |format: NumFormat, text: String| Level { format, text, indent: ind, hanging: 18.0, ..Level::default() };
|
||||
let n = i + 1;
|
||||
match kind {
|
||||
ListKind::Bullet => bullet_level(i, ['•', '○', '▪'][i % 3]),
|
||||
ListKind::BulletChar(c) => bullet_level(i, if i == 0 { c } else { ['•', '○', '▪'][i % 3] }),
|
||||
ListKind::Numbered => lvl(cyc(NumFormat::Decimal, NumFormat::LowerLetter, NumFormat::LowerRoman), format!("%{n}.")),
|
||||
ListKind::NumberedParen => lvl(cyc(NumFormat::Decimal, NumFormat::LowerLetter, NumFormat::LowerRoman), format!("%{n})")),
|
||||
ListKind::UpperLetter => lvl(cyc(NumFormat::UpperLetter, NumFormat::LowerLetter, NumFormat::LowerRoman), format!("%{n}.")),
|
||||
ListKind::LowerLetter => lvl(cyc(NumFormat::LowerLetter, NumFormat::LowerRoman, NumFormat::Decimal), format!("%{n})")),
|
||||
ListKind::LowerRoman => lvl(cyc(NumFormat::LowerRoman, NumFormat::LowerLetter, NumFormat::Decimal), format!("%{n}.")),
|
||||
ListKind::Outline => {
|
||||
let f = [NumFormat::UpperRoman, NumFormat::UpperLetter, NumFormat::Decimal, NumFormat::LowerLetter, NumFormat::LowerRoman]
|
||||
.get(i)
|
||||
.copied()
|
||||
.unwrap_or(NumFormat::Decimal);
|
||||
lvl(f, format!("%{n}."))
|
||||
}
|
||||
ListKind::Legal => {
|
||||
let text: String = (1..=n).map(|k| format!("%{k}.")).collect();
|
||||
Level { format: NumFormat::Decimal, text, indent: 18.0 + 25.0 * i as f32, hanging: 18.0 + 7.0 * i as f32, ..Level::default() }
|
||||
}
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
impl Numbering {
|
||||
pub fn num(&self, id: u32) -> Option<&Num> {
|
||||
self.nums.iter().find(|n| n.id == id)
|
||||
}
|
||||
pub fn abstract_of(&self, num: u32) -> Option<&AbstractNum> {
|
||||
let n = self.num(num)?;
|
||||
self.abstracts.iter().find(|a| a.id == n.abstract_id)
|
||||
}
|
||||
pub fn level(&self, num: u32, level: u8) -> Option<&Level> {
|
||||
self.abstract_of(num)?.levels.get(level as usize)
|
||||
}
|
||||
/// Create a new list of `kind`; returns the `num` id.
|
||||
pub fn add_list(&mut self, kind: ListKind) -> u32 {
|
||||
let aid = self.abstracts.iter().map(|a| a.id + 1).max().unwrap_or(0);
|
||||
self.abstracts.push(AbstractNum { id: aid, name: None, levels: levels_for(kind) });
|
||||
let nid = self.nums.iter().map(|n| n.id + 1).max().unwrap_or(1).max(1);
|
||||
self.nums.push(Num { id: nid, abstract_id: aid, start_overrides: Vec::new() });
|
||||
nid
|
||||
}
|
||||
/// A new `num` that restarts numbering of an existing list.
|
||||
pub fn restart(&mut self, num: u32) -> Option<u32> {
|
||||
let a = self.num(num)?.abstract_id;
|
||||
let nid = self.nums.iter().map(|n| n.id + 1).max().unwrap_or(1).max(1);
|
||||
let starts = self.abstracts.iter().find(|x| x.id == a).map(|x| x.levels.iter().enumerate().map(|(i, l)| (i as u8, l.start)).collect());
|
||||
self.nums.push(Num { id: nid, abstract_id: a, start_overrides: starts.unwrap_or_default() });
|
||||
Some(nid)
|
||||
}
|
||||
/// Find an existing list whose first level matches `kind`, so consecutive bullet clicks reuse it.
|
||||
pub fn find_kind(&self, kind: ListKind) -> Option<u32> {
|
||||
let want = levels_for(kind);
|
||||
let first = want.first()?;
|
||||
self.nums.iter().find(|n| self.abstract_of(n.id).and_then(|a| a.levels.first()).is_some_and(|l| l.format == first.format && l.text == first.text)).map(|n| n.id)
|
||||
}
|
||||
}
|
||||
|
||||
/// Running list counters while walking a document in order.
|
||||
#[derive(Default, Debug, Clone)]
|
||||
pub struct Counters {
|
||||
/// Per abstract list: current value per level.
|
||||
state: HashMap<u32, [u32; 9]>,
|
||||
/// Which nums have started (to apply start overrides once).
|
||||
seen: HashMap<u32, bool>,
|
||||
}
|
||||
|
||||
impl Counters {
|
||||
/// Advance the counter for `num`/`level` and return the label (`"1."`, `"a)"`, `"•"`).
|
||||
pub fn next_label(&mut self, numbering: &Numbering, num: u32, level: u8) -> Option<(String, Level)> {
|
||||
let n = numbering.num(num)?;
|
||||
let abs = numbering.abstract_of(num)?;
|
||||
let lv = (level as usize).min(8);
|
||||
let def = abs.levels.get(lv)?.clone();
|
||||
let st = self.state.entry(abs.id).or_insert([0; 9]);
|
||||
if !self.seen.contains_key(&num) {
|
||||
self.seen.insert(num, true);
|
||||
for (l, start) in &n.start_overrides {
|
||||
if let Some(s) = st.get_mut(*l as usize) {
|
||||
*s = start.saturating_sub(1);
|
||||
}
|
||||
}
|
||||
}
|
||||
if let Some(v) = st.get_mut(lv) {
|
||||
*v = if *v == 0 { def.start.max(if def.start == 0 { 0 } else { 1 }) } else { v.saturating_add(1) };
|
||||
if *v == 0 {
|
||||
*v = def.start;
|
||||
}
|
||||
}
|
||||
for deeper in st.iter_mut().skip(lv + 1) {
|
||||
*deeper = 0;
|
||||
}
|
||||
let vals = *st;
|
||||
if def.format == NumFormat::Bullet {
|
||||
return Some((def.text.clone(), def));
|
||||
}
|
||||
let mut label = String::new();
|
||||
let mut chars = def.text.chars().peekable();
|
||||
while let Some(c) = chars.next() {
|
||||
if c == '%'
|
||||
&& let Some(d) = chars.peek().and_then(|d| d.to_digit(10))
|
||||
{
|
||||
chars.next();
|
||||
let k = (d as usize).saturating_sub(1).min(8);
|
||||
let val = vals.get(k).copied().unwrap_or(0);
|
||||
let val = if val == 0 { abs.levels.get(k).map(|l| l.start).unwrap_or(1) } else { val };
|
||||
let fmt = if def.legal && k != lv { NumFormat::Decimal } else { abs.levels.get(k).map(|l| l.format).unwrap_or(NumFormat::Decimal) };
|
||||
label.push_str(&fmt.format(val));
|
||||
} else {
|
||||
label.push(c);
|
||||
}
|
||||
}
|
||||
Some((label, def))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn numbered_labels() {
|
||||
let mut n = Numbering::default();
|
||||
let id = n.add_list(ListKind::Numbered);
|
||||
let mut c = Counters::default();
|
||||
assert_eq!(c.next_label(&n, id, 0).unwrap().0, "1.");
|
||||
assert_eq!(c.next_label(&n, id, 0).unwrap().0, "2.");
|
||||
assert_eq!(c.next_label(&n, id, 1).unwrap().0, "a.");
|
||||
assert_eq!(c.next_label(&n, id, 1).unwrap().0, "b.");
|
||||
assert_eq!(c.next_label(&n, id, 0).unwrap().0, "3.");
|
||||
assert_eq!(c.next_label(&n, id, 1).unwrap().0, "a.");
|
||||
assert!(c.next_label(&n, 999, 0).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn legal_labels() {
|
||||
let mut n = Numbering::default();
|
||||
let id = n.add_list(ListKind::Legal);
|
||||
let mut c = Counters::default();
|
||||
assert_eq!(c.next_label(&n, id, 0).unwrap().0, "1.");
|
||||
assert_eq!(c.next_label(&n, id, 1).unwrap().0, "1.1.");
|
||||
assert_eq!(c.next_label(&n, id, 2).unwrap().0, "1.1.1.");
|
||||
assert_eq!(c.next_label(&n, id, 1).unwrap().0, "1.2.");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn restart_and_bullets() {
|
||||
let mut n = Numbering::default();
|
||||
let id = n.add_list(ListKind::Numbered);
|
||||
let mut c = Counters::default();
|
||||
c.next_label(&n, id, 0);
|
||||
c.next_label(&n, id, 0);
|
||||
let r = n.restart(id).unwrap();
|
||||
assert_eq!(c.next_label(&n, r, 0).unwrap().0, "1.");
|
||||
let b = n.add_list(ListKind::Bullet);
|
||||
assert_eq!(c.next_label(&n, b, 0).unwrap().0, "•");
|
||||
assert_eq!(n.find_kind(ListKind::Bullet), Some(b));
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,805 @@
|
|||
//! Paragraphs: text with formatting runs and inline objects.
|
||||
//!
|
||||
//! A paragraph's text is one `String`. Formatting is a list of [`Run`]s whose byte lengths sum to
|
||||
//! the text length. Inline objects (pictures, fields, note references, bookmarks, comment
|
||||
//! anchors…) sit in the text as U+FFFC; the k-th U+FFFC is `objects[k]`. Special characters:
|
||||
//! `\t` tab, `\n` manual line break, U+000C page break, U+000E column break.
|
||||
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::props::{CharProps, ParaProps};
|
||||
use crate::section::SectionProps;
|
||||
use crate::{DocError, Result};
|
||||
|
||||
/// Object replacement character: marks an inline object in paragraph text.
|
||||
pub const OBJ: char = '\u{FFFC}';
|
||||
/// Manual line break (Shift+Enter).
|
||||
pub const LINE_BREAK: char = '\n';
|
||||
/// Page break (Ctrl+Enter).
|
||||
pub const PAGE_BREAK: char = '\u{000C}';
|
||||
/// Column break (Ctrl+Shift+Enter).
|
||||
pub const COLUMN_BREAK: char = '\u{000E}';
|
||||
/// Optional hyphen (Ctrl+-).
|
||||
pub const SOFT_HYPHEN: char = '\u{00AD}';
|
||||
/// Nonbreaking hyphen (Ctrl+Shift+-).
|
||||
pub const NB_HYPHEN: char = '\u{2011}';
|
||||
/// Nonbreaking space (Ctrl+Shift+Space).
|
||||
pub const NBSP: char = '\u{00A0}';
|
||||
|
||||
static REV: AtomicU64 = AtomicU64::new(1);
|
||||
|
||||
/// A fresh revision number (process-unique), used by layout caches to detect edits.
|
||||
pub fn next_rev() -> u64 {
|
||||
REV.fetch_add(1, Ordering::Relaxed)
|
||||
}
|
||||
|
||||
/// A formatting run: `len` bytes of text sharing `props`.
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub struct Run {
|
||||
pub len: usize,
|
||||
#[serde(default, skip_serializing_if = "CharProps::is_empty")]
|
||||
pub props: CharProps,
|
||||
}
|
||||
|
||||
/// How a floating object is placed.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum Wrap {
|
||||
/// In line with text (not floating).
|
||||
#[default]
|
||||
Inline,
|
||||
Square,
|
||||
Tight,
|
||||
Through,
|
||||
TopAndBottom,
|
||||
BehindText,
|
||||
InFrontOfText,
|
||||
}
|
||||
|
||||
/// What a floating object's offset is relative to.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum Anchor {
|
||||
#[default]
|
||||
Column,
|
||||
Margin,
|
||||
Page,
|
||||
Paragraph,
|
||||
}
|
||||
|
||||
/// Floating placement (ignored when `wrap` is `Inline`).
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize, Default)]
|
||||
#[serde(default, rename_all = "camelCase")]
|
||||
pub struct Float {
|
||||
pub wrap: Wrap,
|
||||
pub h_rel: Anchor,
|
||||
pub v_rel: Anchor,
|
||||
/// Offsets, points.
|
||||
pub x: f32,
|
||||
pub y: f32,
|
||||
/// Distance from surrounding text (points).
|
||||
pub dist: f32,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum ShapeKind {
|
||||
#[default]
|
||||
Rectangle,
|
||||
RoundedRectangle,
|
||||
Ellipse,
|
||||
Triangle,
|
||||
Diamond,
|
||||
Line,
|
||||
Arrow,
|
||||
Star,
|
||||
Heart,
|
||||
/// A text box (rectangle with a text story).
|
||||
TextBox,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum NoteKind {
|
||||
#[default]
|
||||
Footnote,
|
||||
Endnote,
|
||||
}
|
||||
|
||||
/// An inline object anchored at a U+FFFC in the paragraph text.
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(tag = "type", rename_all = "camelCase")]
|
||||
pub enum InlineObject {
|
||||
Image {
|
||||
/// Key in `Document::media`.
|
||||
media: String,
|
||||
/// Display size, points.
|
||||
w: f32,
|
||||
h: f32,
|
||||
#[serde(default)]
|
||||
alt: String,
|
||||
#[serde(default)]
|
||||
float: Float,
|
||||
/// Crop (left, top, right, bottom) fractions 0..1.
|
||||
#[serde(default)]
|
||||
crop: [f32; 4],
|
||||
},
|
||||
Shape {
|
||||
kind: ShapeKind,
|
||||
w: f32,
|
||||
h: f32,
|
||||
fill: Option<crate::props::Rgb>,
|
||||
stroke: Option<crate::props::Rgb>,
|
||||
#[serde(default)]
|
||||
stroke_width: f32,
|
||||
#[serde(default)]
|
||||
float: Float,
|
||||
/// Text box content: `Document::parts` id.
|
||||
#[serde(default)]
|
||||
story: Option<u32>,
|
||||
},
|
||||
/// A field: `instr` is the field code (`PAGE`, `NUMPAGES`, `DATE \@ "M/d/yyyy"`, `TOC \o "1-3"`…);
|
||||
/// `result` the cached display text.
|
||||
Field {
|
||||
instr: String,
|
||||
#[serde(default)]
|
||||
result: String,
|
||||
#[serde(default)]
|
||||
locked: bool,
|
||||
},
|
||||
NoteRef {
|
||||
kind: NoteKind,
|
||||
/// `Document::parts` id of the note's story.
|
||||
id: u32,
|
||||
/// Custom mark (empty = automatic number).
|
||||
#[serde(default)]
|
||||
custom: String,
|
||||
},
|
||||
BookmarkStart {
|
||||
name: String,
|
||||
},
|
||||
BookmarkEnd {
|
||||
name: String,
|
||||
},
|
||||
CommentStart {
|
||||
id: u32,
|
||||
},
|
||||
CommentEnd {
|
||||
id: u32,
|
||||
},
|
||||
/// An equation in linear format (`x=(-b±√(b^2-4ac))/2a`).
|
||||
Equation {
|
||||
linear: String,
|
||||
#[serde(default)]
|
||||
display: bool,
|
||||
},
|
||||
/// Something we don't model, kept for round-trip (raw XML of the source format).
|
||||
Opaque {
|
||||
format: String,
|
||||
xml: String,
|
||||
#[serde(default)]
|
||||
text: String,
|
||||
},
|
||||
}
|
||||
|
||||
impl InlineObject {
|
||||
/// Zero-width markers don't take part in layout.
|
||||
pub fn is_marker(&self) -> bool {
|
||||
matches!(
|
||||
self,
|
||||
InlineObject::BookmarkStart { .. } | InlineObject::BookmarkEnd { .. } | InlineObject::CommentStart { .. } | InlineObject::CommentEnd { .. }
|
||||
)
|
||||
}
|
||||
pub fn is_floating(&self) -> bool {
|
||||
match self {
|
||||
InlineObject::Image { float, .. } | InlineObject::Shape { float, .. } => float.wrap != Wrap::Inline,
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
/// The text this object contributes to plain-text extraction.
|
||||
pub fn plain_text(&self) -> &str {
|
||||
match self {
|
||||
InlineObject::Field { result, .. } => result,
|
||||
InlineObject::Equation { linear, .. } => linear,
|
||||
InlineObject::Opaque { text, .. } => text,
|
||||
_ => "",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A paragraph.
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
pub struct Paragraph {
|
||||
pub text: String,
|
||||
pub runs: Vec<Run>,
|
||||
#[serde(default, skip_serializing_if = "ParaProps::is_empty")]
|
||||
pub props: ParaProps,
|
||||
/// Formatting of the paragraph mark (used when typing into an empty paragraph).
|
||||
#[serde(default, skip_serializing_if = "CharProps::is_empty")]
|
||||
pub mark: CharProps,
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub objects: Vec<InlineObject>,
|
||||
/// Set when this paragraph ends a section (OOXML keeps `w:sectPr` on the last paragraph).
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub section: Option<Box<SectionProps>>,
|
||||
/// Change counter for caches (not part of equality).
|
||||
#[serde(skip, default = "next_rev")]
|
||||
pub rev: u64,
|
||||
}
|
||||
|
||||
impl PartialEq for Paragraph {
|
||||
fn eq(&self, o: &Self) -> bool {
|
||||
self.text == o.text && self.runs == o.runs && self.props == o.props && self.mark == o.mark && self.objects == o.objects && self.section == o.section
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for Paragraph {
|
||||
fn default() -> Self {
|
||||
Paragraph::new()
|
||||
}
|
||||
}
|
||||
|
||||
impl Paragraph {
|
||||
pub fn new() -> Self {
|
||||
Paragraph { text: String::new(), runs: Vec::new(), props: ParaProps::default(), mark: CharProps::default(), objects: Vec::new(), section: None, rev: next_rev() }
|
||||
}
|
||||
/// A paragraph of plain text in one run.
|
||||
pub fn with_text(text: &str, props: CharProps) -> Self {
|
||||
let mut p = Paragraph::new();
|
||||
let clean: String = text.chars().filter(|c| *c != OBJ).collect();
|
||||
if !clean.is_empty() {
|
||||
p.runs.push(Run { len: clean.len(), props: props.clone() });
|
||||
}
|
||||
p.text = clean;
|
||||
p.mark = props;
|
||||
p
|
||||
}
|
||||
pub fn styled(mut self, style: &str) -> Self {
|
||||
self.props.style = Some(style.to_string());
|
||||
self
|
||||
}
|
||||
pub fn touch(&mut self) {
|
||||
self.rev = next_rev();
|
||||
}
|
||||
pub fn len(&self) -> usize {
|
||||
self.text.len()
|
||||
}
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.text.is_empty()
|
||||
}
|
||||
/// The text with objects removed or replaced by their text (fields → result).
|
||||
pub fn plain_text(&self) -> String {
|
||||
let mut out = String::with_capacity(self.text.len());
|
||||
let mut k = 0;
|
||||
for c in self.text.chars() {
|
||||
if c == OBJ {
|
||||
if let Some(o) = self.objects.get(k) {
|
||||
out.push_str(o.plain_text());
|
||||
}
|
||||
k += 1;
|
||||
} else {
|
||||
out.push(c);
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn check(&self, off: usize) -> Result<()> {
|
||||
if off > self.text.len() || !self.text.is_char_boundary(off) {
|
||||
return Err(DocError::BadOffset(off));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Clamp `off` to the text and back to a char boundary.
|
||||
pub fn clamp(&self, off: usize) -> usize {
|
||||
let mut o = off.min(self.text.len());
|
||||
while o > 0 && !self.text.is_char_boundary(o) {
|
||||
o -= 1;
|
||||
}
|
||||
o
|
||||
}
|
||||
|
||||
/// Index of the run containing byte `off` and the offset where that run starts. At a run
|
||||
/// boundary the earlier run wins (typing continues the formatting to the left).
|
||||
fn run_at(&self, off: usize) -> Option<(usize, usize)> {
|
||||
let mut start = 0;
|
||||
for (i, r) in self.runs.iter().enumerate() {
|
||||
let end = start + r.len;
|
||||
if off < end || (off == end && off > start) {
|
||||
return Some((i, start));
|
||||
}
|
||||
start = end;
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Formatting in effect at `off` (what typing there would get): the run to the left, or the
|
||||
/// first run at offset 0, or the paragraph mark when empty.
|
||||
pub fn props_at(&self, off: usize) -> &CharProps {
|
||||
if off == 0 {
|
||||
return self.runs.first().map(|r| &r.props).unwrap_or(&self.mark);
|
||||
}
|
||||
self.run_at(off).and_then(|(i, _)| self.runs.get(i)).map(|r| &r.props).unwrap_or(&self.mark)
|
||||
}
|
||||
|
||||
/// Formatting of the character starting at `off`.
|
||||
pub fn props_of_char(&self, off: usize) -> &CharProps {
|
||||
let mut start = 0;
|
||||
for r in &self.runs {
|
||||
if off < start + r.len {
|
||||
return &r.props;
|
||||
}
|
||||
start += r.len;
|
||||
}
|
||||
&self.mark
|
||||
}
|
||||
|
||||
/// Runs as (byte range, props).
|
||||
pub fn run_ranges(&self) -> impl Iterator<Item = (std::ops::Range<usize>, &CharProps)> {
|
||||
let mut s = 0;
|
||||
self.runs.iter().map(move |r| {
|
||||
let a = s;
|
||||
s += r.len;
|
||||
(a..s, &r.props)
|
||||
})
|
||||
}
|
||||
|
||||
/// Number of objects in `text[..off]`.
|
||||
fn objects_before(&self, off: usize) -> usize {
|
||||
self.text.get(..off).map(|t| t.chars().filter(|c| *c == OBJ).count()).unwrap_or(0)
|
||||
}
|
||||
|
||||
/// The object at byte `off` (which must hold U+FFFC).
|
||||
pub fn object_at(&self, off: usize) -> Option<&InlineObject> {
|
||||
if !self.text.get(off..)?.starts_with(OBJ) {
|
||||
return None;
|
||||
}
|
||||
self.objects.get(self.objects_before(off))
|
||||
}
|
||||
pub fn object_at_mut(&mut self, off: usize) -> Option<&mut InlineObject> {
|
||||
if !self.text.get(off..)?.starts_with(OBJ) {
|
||||
return None;
|
||||
}
|
||||
let k = self.objects_before(off);
|
||||
self.objects.get_mut(k)
|
||||
}
|
||||
/// Byte offsets of all objects, in order.
|
||||
pub fn object_offsets(&self) -> Vec<usize> {
|
||||
self.text.char_indices().filter(|(_, c)| *c == OBJ).map(|(i, _)| i).collect()
|
||||
}
|
||||
|
||||
/// Insert plain text (U+FFFC is dropped) at `off` with `props`. Returns the inserted length.
|
||||
pub fn insert_text(&mut self, off: usize, s: &str, props: &CharProps) -> Result<usize> {
|
||||
self.check(off)?;
|
||||
let clean: std::borrow::Cow<str> = if s.contains(OBJ) { s.chars().filter(|c| *c != OBJ).collect::<String>().into() } else { s.into() };
|
||||
if clean.is_empty() {
|
||||
return Ok(0);
|
||||
}
|
||||
self.text.insert_str(off, &clean);
|
||||
self.insert_run(off, clean.len(), props.clone());
|
||||
self.touch();
|
||||
Ok(clean.len())
|
||||
}
|
||||
|
||||
/// Insert an object at `off`.
|
||||
pub fn insert_object(&mut self, off: usize, obj: InlineObject, props: &CharProps) -> Result<()> {
|
||||
self.check(off)?;
|
||||
let k = self.objects_before(off);
|
||||
self.text.insert(off, OBJ);
|
||||
self.objects.insert(k.min(self.objects.len()), obj);
|
||||
self.insert_run(off, OBJ.len_utf8(), props.clone());
|
||||
self.touch();
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn insert_run(&mut self, off: usize, len: usize, props: CharProps) {
|
||||
// Split the run at `off`, insert, merge neighbours.
|
||||
let mut start = 0;
|
||||
let mut idx = self.runs.len();
|
||||
for (i, r) in self.runs.iter().enumerate() {
|
||||
if off <= start {
|
||||
idx = i;
|
||||
break;
|
||||
}
|
||||
if off < start + r.len {
|
||||
// split
|
||||
let left = off - start;
|
||||
let right = r.len - left;
|
||||
let p = r.props.clone();
|
||||
if let Some(r) = self.runs.get_mut(i) {
|
||||
r.len = left;
|
||||
}
|
||||
self.runs.insert(i + 1, Run { len: right, props: p });
|
||||
idx = i + 1;
|
||||
break;
|
||||
}
|
||||
start += r.len;
|
||||
}
|
||||
self.runs.insert(idx.min(self.runs.len()), Run { len, props });
|
||||
self.normalize();
|
||||
}
|
||||
|
||||
/// Merge adjacent equal runs and drop empty ones.
|
||||
pub fn normalize(&mut self) {
|
||||
let mut out: Vec<Run> = Vec::with_capacity(self.runs.len());
|
||||
for r in self.runs.drain(..) {
|
||||
if r.len == 0 {
|
||||
continue;
|
||||
}
|
||||
match out.last_mut() {
|
||||
Some(l) if l.props == r.props => l.len += r.len,
|
||||
_ => out.push(r),
|
||||
}
|
||||
}
|
||||
self.runs = out;
|
||||
// Repair a run list that doesn't cover the text exactly (never expected, but files lie).
|
||||
let total: usize = self.runs.iter().map(|r| r.len).sum();
|
||||
if total != self.text.len() {
|
||||
if total < self.text.len() {
|
||||
let p = self.runs.last().map(|r| r.props.clone()).unwrap_or_else(|| self.mark.clone());
|
||||
self.runs.push(Run { len: self.text.len() - total, props: p });
|
||||
} else {
|
||||
let mut keep = self.text.len();
|
||||
for r in &mut self.runs {
|
||||
r.len = r.len.min(keep);
|
||||
keep -= r.len;
|
||||
}
|
||||
self.runs.retain(|r| r.len > 0);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Delete `a..b` (bytes). Objects inside are removed.
|
||||
pub fn delete(&mut self, a: usize, b: usize) -> Result<()> {
|
||||
let (a, b) = (a.min(b), a.max(b));
|
||||
self.check(a)?;
|
||||
self.check(b)?;
|
||||
if a == b {
|
||||
return Ok(());
|
||||
}
|
||||
if self.runs.is_empty() || a == 0 {
|
||||
// Keep the formatting of what's deleted for the mark when emptying.
|
||||
if b == self.text.len() {
|
||||
self.mark = self.props_of_char(a).clone();
|
||||
}
|
||||
}
|
||||
let k0 = self.objects_before(a);
|
||||
let k1 = self.objects_before(b);
|
||||
if k1 > k0 && k1 <= self.objects.len() {
|
||||
self.objects.drain(k0..k1);
|
||||
}
|
||||
self.text.replace_range(a..b, "");
|
||||
let mut start = 0;
|
||||
for r in &mut self.runs {
|
||||
let (rs, re) = (start, start + r.len);
|
||||
start = re;
|
||||
let ov = re.min(b).saturating_sub(rs.max(a));
|
||||
r.len -= ov.min(r.len);
|
||||
}
|
||||
self.normalize();
|
||||
self.touch();
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Apply `f` to the formatting of `a..b`.
|
||||
pub fn format(&mut self, a: usize, b: usize, f: &dyn Fn(&mut CharProps)) -> Result<()> {
|
||||
let (a, b) = (a.min(b), a.max(b));
|
||||
self.check(a)?;
|
||||
self.check(b)?;
|
||||
if a == b {
|
||||
return Ok(());
|
||||
}
|
||||
let mut out = Vec::with_capacity(self.runs.len() + 2);
|
||||
let mut start = 0;
|
||||
for r in self.runs.drain(..) {
|
||||
let (rs, re) = (start, start + r.len);
|
||||
start = re;
|
||||
let cut = [rs, a.clamp(rs, re), b.clamp(rs, re), re];
|
||||
for w in cut.windows(2) {
|
||||
let (x, y) = (w[0], w[1]);
|
||||
if y <= x {
|
||||
continue;
|
||||
}
|
||||
let mut p = r.props.clone();
|
||||
if x >= a && y <= b {
|
||||
f(&mut p);
|
||||
}
|
||||
out.push(Run { len: y - x, props: p });
|
||||
}
|
||||
}
|
||||
self.runs = out;
|
||||
self.normalize();
|
||||
self.touch();
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Split at `off`: `self` keeps `..off`, the returned paragraph gets `off..` with the same
|
||||
/// paragraph properties (the section break, if any, moves to the second half).
|
||||
pub fn split_off(&mut self, off: usize) -> Result<Paragraph> {
|
||||
self.check(off)?;
|
||||
let k = self.objects_before(off);
|
||||
let tail_text = self.text.split_off(off);
|
||||
let tail_objs = if k <= self.objects.len() { self.objects.split_off(k) } else { Vec::new() };
|
||||
let mut tail_runs = Vec::new();
|
||||
let mut start = 0;
|
||||
let mut keep = Vec::new();
|
||||
for r in self.runs.drain(..) {
|
||||
let (rs, re) = (start, start + r.len);
|
||||
start = re;
|
||||
if re <= off {
|
||||
keep.push(r);
|
||||
} else if rs >= off {
|
||||
tail_runs.push(r);
|
||||
} else {
|
||||
keep.push(Run { len: off - rs, props: r.props.clone() });
|
||||
tail_runs.push(Run { len: re - off, props: r.props });
|
||||
}
|
||||
}
|
||||
self.runs = keep;
|
||||
let carry = self.props_at(off).clone();
|
||||
let mut tail = Paragraph {
|
||||
text: tail_text,
|
||||
runs: tail_runs,
|
||||
props: self.props.clone(),
|
||||
mark: if self.text.is_empty() && off == 0 { self.mark.clone() } else { carry },
|
||||
objects: tail_objs,
|
||||
section: self.section.take(),
|
||||
rev: next_rev(),
|
||||
};
|
||||
if tail.text.is_empty() {
|
||||
tail.mark = self.props_at(off).clone();
|
||||
}
|
||||
self.normalize();
|
||||
tail.normalize();
|
||||
self.touch();
|
||||
Ok(tail)
|
||||
}
|
||||
|
||||
/// Append `other`'s content (its paragraph properties are dropped; its section break kept).
|
||||
pub fn append(&mut self, other: Paragraph) {
|
||||
let Paragraph { text, runs, objects, section, .. } = other;
|
||||
self.text.push_str(&text);
|
||||
self.runs.extend(runs);
|
||||
self.objects.extend(objects);
|
||||
if section.is_some() {
|
||||
self.section = section;
|
||||
}
|
||||
self.normalize();
|
||||
self.touch();
|
||||
}
|
||||
|
||||
/// Previous grapheme boundary before `off` (0 at start).
|
||||
pub fn prev_boundary(&self, off: usize) -> usize {
|
||||
use unicode_segmentation::GraphemeCursor;
|
||||
let off = self.clamp(off);
|
||||
let mut c = GraphemeCursor::new(off, self.text.len(), true);
|
||||
c.prev_boundary(&self.text, 0).ok().flatten().unwrap_or(0)
|
||||
}
|
||||
/// Next grapheme boundary after `off` (len at end).
|
||||
pub fn next_boundary(&self, off: usize) -> usize {
|
||||
use unicode_segmentation::GraphemeCursor;
|
||||
let off = self.clamp(off);
|
||||
let mut c = GraphemeCursor::new(off, self.text.len(), true);
|
||||
c.next_boundary(&self.text, 0).ok().flatten().unwrap_or(self.text.len())
|
||||
}
|
||||
/// Start of the word at or before `off` (Ctrl+Left).
|
||||
pub fn word_start(&self, off: usize) -> usize {
|
||||
let off = self.clamp(off);
|
||||
let Some(before) = self.text.get(..off) else { return 0 };
|
||||
let mut idx = off;
|
||||
let mut seen_word = false;
|
||||
for (i, c) in before.char_indices().rev() {
|
||||
let w = c.is_alphanumeric() || c == '_' || c == '\'';
|
||||
if w {
|
||||
seen_word = true;
|
||||
idx = i;
|
||||
} else if seen_word {
|
||||
break;
|
||||
} else {
|
||||
idx = i;
|
||||
}
|
||||
}
|
||||
idx
|
||||
}
|
||||
/// End of the word after `off`, including trailing spaces (Ctrl+Right, like Word).
|
||||
pub fn word_end(&self, off: usize) -> usize {
|
||||
let off = self.clamp(off);
|
||||
let Some(after) = self.text.get(off..) else { return self.text.len() };
|
||||
let mut it = after.char_indices().peekable();
|
||||
let mut end = self.text.len();
|
||||
let first_word = after.chars().next().is_some_and(|c| c.is_alphanumeric() || c == '_');
|
||||
let mut in_space = false;
|
||||
while let Some((i, c)) = it.next() {
|
||||
let w = c.is_alphanumeric() || c == '_' || c == '\'';
|
||||
if c == ' ' || c == NBSP {
|
||||
in_space = true;
|
||||
continue;
|
||||
}
|
||||
if in_space || (first_word && !w) || (!first_word && i > 0) {
|
||||
end = off + i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
end
|
||||
}
|
||||
/// The word around `off` (double-click): (start, end) without trailing space.
|
||||
pub fn word_at(&self, off: usize) -> (usize, usize) {
|
||||
let off = self.clamp(off);
|
||||
let is_w = |c: char| c.is_alphanumeric() || c == '_' || c == '\'';
|
||||
let mut a = off;
|
||||
for (i, c) in self.text.get(..off).unwrap_or("").char_indices().rev() {
|
||||
if !is_w(c) {
|
||||
break;
|
||||
}
|
||||
a = i;
|
||||
}
|
||||
let mut b = off;
|
||||
for (i, c) in self.text.get(off..).unwrap_or("").char_indices() {
|
||||
if !is_w(c) {
|
||||
if i == 0 && a == off {
|
||||
b = off + c.len_utf8();
|
||||
}
|
||||
break;
|
||||
}
|
||||
b = off + i + c.len_utf8();
|
||||
}
|
||||
// Word also selects the trailing spaces.
|
||||
let mut e = b;
|
||||
for (i, c) in self.text.get(b..).unwrap_or("").char_indices() {
|
||||
if c != ' ' {
|
||||
break;
|
||||
}
|
||||
e = b + i + 1;
|
||||
}
|
||||
(a, e)
|
||||
}
|
||||
/// Sentence around `off` (Ctrl+click): ends after `.`, `!`, `?` and following spaces.
|
||||
pub fn sentence_at(&self, off: usize) -> (usize, usize) {
|
||||
let off = self.clamp(off);
|
||||
let t = &self.text;
|
||||
let mut a = 0;
|
||||
let mut prev_end = false;
|
||||
for (i, c) in t.char_indices() {
|
||||
if i >= off {
|
||||
break;
|
||||
}
|
||||
if prev_end && c != ' ' {
|
||||
a = i;
|
||||
}
|
||||
if matches!(c, '.' | '!' | '?') {
|
||||
prev_end = true;
|
||||
} else if c != ' ' {
|
||||
prev_end = false;
|
||||
}
|
||||
}
|
||||
let mut b = t.len();
|
||||
let mut ended = false;
|
||||
for (i, c) in t.get(off..).unwrap_or("").char_indices() {
|
||||
if ended && c != ' ' {
|
||||
b = off + i;
|
||||
break;
|
||||
}
|
||||
if matches!(c, '.' | '!' | '?') {
|
||||
ended = true;
|
||||
}
|
||||
}
|
||||
(a, b)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn bold() -> CharProps {
|
||||
CharProps { bold: Some(true), ..Default::default() }
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn insert_and_runs() {
|
||||
let mut p = Paragraph::with_text("Hello world", CharProps::default());
|
||||
p.insert_text(5, ",", &CharProps::default()).unwrap();
|
||||
assert_eq!(p.text, "Hello, world");
|
||||
assert_eq!(p.runs.len(), 1);
|
||||
p.insert_text(0, "Oh ", &bold()).unwrap();
|
||||
assert_eq!(p.runs.len(), 2);
|
||||
assert_eq!(p.runs[0].len, 3);
|
||||
assert!(p.insert_text(100, "x", &bold()).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn insert_rejects_mid_char() {
|
||||
let mut p = Paragraph::with_text("é", CharProps::default());
|
||||
assert!(p.insert_text(1, "x", &bold()).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn format_splits_runs() {
|
||||
let mut p = Paragraph::with_text("abcdef", CharProps::default());
|
||||
p.format(2, 4, &|c| c.bold = Some(true)).unwrap();
|
||||
assert_eq!(p.runs.iter().map(|r| r.len).collect::<Vec<_>>(), vec![2, 2, 2]);
|
||||
assert_eq!(p.props_of_char(2).bold, Some(true));
|
||||
p.format(0, 6, &|c| c.bold = Some(true)).unwrap();
|
||||
assert_eq!(p.runs.len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn delete_merges() {
|
||||
let mut p = Paragraph::with_text("abcdef", CharProps::default());
|
||||
p.format(2, 4, &|c| c.bold = Some(true)).unwrap();
|
||||
p.delete(2, 4).unwrap();
|
||||
assert_eq!(p.text, "abef");
|
||||
assert_eq!(p.runs.len(), 1);
|
||||
p.delete(0, 4).unwrap();
|
||||
assert!(p.runs.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn objects_track_text() {
|
||||
let mut p = Paragraph::with_text("ab", CharProps::default());
|
||||
p.insert_object(1, InlineObject::Field { instr: "PAGE".into(), result: "1".into(), locked: false }, &CharProps::default()).unwrap();
|
||||
p.insert_object(0, InlineObject::BookmarkStart { name: "x".into() }, &CharProps::default()).unwrap();
|
||||
assert_eq!(p.objects.len(), 2);
|
||||
assert!(matches!(p.object_at(0), Some(InlineObject::BookmarkStart { .. })));
|
||||
let off = p.object_offsets()[1];
|
||||
assert!(matches!(p.object_at(off), Some(InlineObject::Field { .. })));
|
||||
assert_eq!(p.plain_text(), "a1b");
|
||||
p.delete(0, 3).unwrap();
|
||||
assert_eq!(p.objects.len(), 1);
|
||||
let tail = p.split_off(p.object_offsets()[0]).unwrap();
|
||||
assert!(p.objects.is_empty());
|
||||
assert_eq!(tail.objects.len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn split_and_append() {
|
||||
let mut p = Paragraph::with_text("Hello world", bold());
|
||||
let t = p.split_off(5).unwrap();
|
||||
assert_eq!(p.text, "Hello");
|
||||
assert_eq!(t.text, " world");
|
||||
assert_eq!(t.runs[0].props.bold, Some(true));
|
||||
p.append(t);
|
||||
assert_eq!(p.text, "Hello world");
|
||||
assert_eq!(p.runs.len(), 1);
|
||||
let t = p.split_off(p.len()).unwrap();
|
||||
assert!(t.is_empty());
|
||||
assert_eq!(t.mark.bold, Some(true));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn words() {
|
||||
let p = Paragraph::with_text("The quick brown fox.", CharProps::default());
|
||||
assert_eq!(p.word_end(0), 4);
|
||||
assert_eq!(p.word_end(4), 11);
|
||||
assert_eq!(p.word_start(10), 4);
|
||||
assert_eq!(p.word_start(11), 4);
|
||||
assert_eq!(p.word_at(5), (4, 11));
|
||||
assert_eq!(p.sentence_at(3), (0, 21));
|
||||
assert_eq!(p.next_boundary(0), 1);
|
||||
assert_eq!(p.prev_boundary(0), 0);
|
||||
}
|
||||
|
||||
proptest::proptest! {
|
||||
#[test]
|
||||
fn edits_keep_runs_consistent(ops in proptest::collection::vec((0u8..4, 0usize..40, 0usize..40, "[a-zé ]{0,5}"), 0..40)) {
|
||||
let mut p = Paragraph::with_text("seed text", CharProps::default());
|
||||
for (k, a, b, s) in ops {
|
||||
let a = p.clamp(a);
|
||||
let b = p.clamp(b);
|
||||
let _ = match k {
|
||||
0 => p.insert_text(a, &s, &bold()).map(|_| ()),
|
||||
1 => p.delete(a, b),
|
||||
2 => p.format(a, b, &|c| c.italic = Some(true)),
|
||||
_ => p.split_off(a).map(|t| p.append(t)),
|
||||
};
|
||||
let total: usize = p.runs.iter().map(|r| r.len).sum();
|
||||
proptest::prop_assert_eq!(total, p.text.len());
|
||||
proptest::prop_assert!(p.runs.iter().all(|r| r.len > 0));
|
||||
proptest::prop_assert_eq!(p.text.matches(OBJ).count(), p.objects.len());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,713 @@
|
|||
//! Character, paragraph, table and cell properties.
|
||||
//!
|
||||
//! Every field is an `Option`: `None` means "inherit" (from the style chain, then document
|
||||
//! defaults). [`CharProps::overlay`] and friends apply a patch on top of a base.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// An sRGB colour.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
|
||||
pub struct Rgb(pub u8, pub u8, pub u8);
|
||||
|
||||
impl Rgb {
|
||||
pub const BLACK: Rgb = Rgb(0, 0, 0);
|
||||
pub const WHITE: Rgb = Rgb(255, 255, 255);
|
||||
/// `RRGGBB` (no `#`).
|
||||
pub fn hex(self) -> String {
|
||||
format!("{:02X}{:02X}{:02X}", self.0, self.1, self.2)
|
||||
}
|
||||
/// Parse `RRGGBB` or `#RRGGBB`.
|
||||
pub fn parse(s: &str) -> Option<Rgb> {
|
||||
let s = s.trim().trim_start_matches('#');
|
||||
if s.len() != 6 || !s.is_ascii() {
|
||||
return None;
|
||||
}
|
||||
let p = |i: usize| s.get(i..i + 2).and_then(|h| u8::from_str_radix(h, 16).ok());
|
||||
Some(Rgb(p(0)?, p(2)?, p(4)?))
|
||||
}
|
||||
pub fn luma(self) -> f32 {
|
||||
0.299 * self.0 as f32 + 0.587 * self.1 as f32 + 0.114 * self.2 as f32
|
||||
}
|
||||
}
|
||||
|
||||
/// A text colour: automatic (black on light backgrounds, white on dark) or a fixed colour.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
|
||||
pub enum TextColor {
|
||||
#[default]
|
||||
Auto,
|
||||
Rgb(Rgb),
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum Underline {
|
||||
#[default]
|
||||
None,
|
||||
Single,
|
||||
Words,
|
||||
Double,
|
||||
Thick,
|
||||
Dotted,
|
||||
Dash,
|
||||
DotDash,
|
||||
DotDotDash,
|
||||
Wave,
|
||||
DoubleWave,
|
||||
}
|
||||
|
||||
impl Underline {
|
||||
pub const ALL: [Underline; 11] = [
|
||||
Underline::None,
|
||||
Underline::Single,
|
||||
Underline::Words,
|
||||
Underline::Double,
|
||||
Underline::Thick,
|
||||
Underline::Dotted,
|
||||
Underline::Dash,
|
||||
Underline::DotDash,
|
||||
Underline::DotDotDash,
|
||||
Underline::Wave,
|
||||
Underline::DoubleWave,
|
||||
];
|
||||
/// The OOXML `w:u/@w:val` name.
|
||||
pub fn ooxml(self) -> &'static str {
|
||||
match self {
|
||||
Underline::None => "none",
|
||||
Underline::Single => "single",
|
||||
Underline::Words => "words",
|
||||
Underline::Double => "double",
|
||||
Underline::Thick => "thick",
|
||||
Underline::Dotted => "dotted",
|
||||
Underline::Dash => "dash",
|
||||
Underline::DotDash => "dotDash",
|
||||
Underline::DotDotDash => "dotDotDash",
|
||||
Underline::Wave => "wave",
|
||||
Underline::DoubleWave => "wavyDouble",
|
||||
}
|
||||
}
|
||||
pub fn from_ooxml(s: &str) -> Underline {
|
||||
Underline::ALL.iter().copied().find(|u| u.ooxml() == s).unwrap_or(match s {
|
||||
"thickDash" | "dashLong" | "dashedHeavy" | "dashLongHeavy" => Underline::Dash,
|
||||
"dottedHeavy" => Underline::Dotted,
|
||||
"wavyHeavy" => Underline::Wave,
|
||||
"dashDotHeavy" => Underline::DotDash,
|
||||
"dashDotDotHeavy" => Underline::DotDotDash,
|
||||
_ => Underline::Single,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum VertAlign {
|
||||
#[default]
|
||||
Baseline,
|
||||
Superscript,
|
||||
Subscript,
|
||||
}
|
||||
|
||||
/// Word's 16 highlighter colours (plus none).
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum Highlight {
|
||||
#[default]
|
||||
None,
|
||||
Yellow,
|
||||
BrightGreen,
|
||||
Turquoise,
|
||||
Pink,
|
||||
Blue,
|
||||
Red,
|
||||
DarkBlue,
|
||||
Teal,
|
||||
Green,
|
||||
Violet,
|
||||
DarkRed,
|
||||
DarkYellow,
|
||||
Gray50,
|
||||
Gray25,
|
||||
Black,
|
||||
White,
|
||||
}
|
||||
|
||||
impl Highlight {
|
||||
pub const ALL: [Highlight; 17] = [
|
||||
Highlight::None,
|
||||
Highlight::Yellow,
|
||||
Highlight::BrightGreen,
|
||||
Highlight::Turquoise,
|
||||
Highlight::Pink,
|
||||
Highlight::Blue,
|
||||
Highlight::Red,
|
||||
Highlight::DarkBlue,
|
||||
Highlight::Teal,
|
||||
Highlight::Green,
|
||||
Highlight::Violet,
|
||||
Highlight::DarkRed,
|
||||
Highlight::DarkYellow,
|
||||
Highlight::Gray50,
|
||||
Highlight::Gray25,
|
||||
Highlight::Black,
|
||||
Highlight::White,
|
||||
];
|
||||
pub fn rgb(self) -> Option<Rgb> {
|
||||
Some(match self {
|
||||
Highlight::None => return None,
|
||||
Highlight::Yellow => Rgb(255, 255, 0),
|
||||
Highlight::BrightGreen => Rgb(0, 255, 0),
|
||||
Highlight::Turquoise => Rgb(0, 255, 255),
|
||||
Highlight::Pink => Rgb(255, 0, 255),
|
||||
Highlight::Blue => Rgb(0, 0, 255),
|
||||
Highlight::Red => Rgb(255, 0, 0),
|
||||
Highlight::DarkBlue => Rgb(0, 0, 128),
|
||||
Highlight::Teal => Rgb(0, 128, 128),
|
||||
Highlight::Green => Rgb(0, 128, 0),
|
||||
Highlight::Violet => Rgb(128, 0, 128),
|
||||
Highlight::DarkRed => Rgb(128, 0, 0),
|
||||
Highlight::DarkYellow => Rgb(128, 128, 0),
|
||||
Highlight::Gray50 => Rgb(128, 128, 128),
|
||||
Highlight::Gray25 => Rgb(192, 192, 192),
|
||||
Highlight::Black => Rgb(0, 0, 0),
|
||||
Highlight::White => Rgb(255, 255, 255),
|
||||
})
|
||||
}
|
||||
/// OOXML `w:highlight/@w:val`.
|
||||
pub fn ooxml(self) -> &'static str {
|
||||
match self {
|
||||
Highlight::None => "none",
|
||||
Highlight::Yellow => "yellow",
|
||||
Highlight::BrightGreen => "green",
|
||||
Highlight::Turquoise => "cyan",
|
||||
Highlight::Pink => "magenta",
|
||||
Highlight::Blue => "blue",
|
||||
Highlight::Red => "red",
|
||||
Highlight::DarkBlue => "darkBlue",
|
||||
Highlight::Teal => "darkCyan",
|
||||
Highlight::Green => "darkGreen",
|
||||
Highlight::Violet => "darkMagenta",
|
||||
Highlight::DarkRed => "darkRed",
|
||||
Highlight::DarkYellow => "darkYellow",
|
||||
Highlight::Gray50 => "darkGray",
|
||||
Highlight::Gray25 => "lightGray",
|
||||
Highlight::Black => "black",
|
||||
Highlight::White => "white",
|
||||
}
|
||||
}
|
||||
pub fn from_ooxml(s: &str) -> Highlight {
|
||||
Highlight::ALL.iter().copied().find(|h| h.ooxml() == s).unwrap_or(Highlight::None)
|
||||
}
|
||||
pub fn name(self) -> &'static str {
|
||||
match self {
|
||||
Highlight::None => "No Color",
|
||||
Highlight::Yellow => "Yellow",
|
||||
Highlight::BrightGreen => "Bright Green",
|
||||
Highlight::Turquoise => "Turquoise",
|
||||
Highlight::Pink => "Pink",
|
||||
Highlight::Blue => "Blue",
|
||||
Highlight::Red => "Red",
|
||||
Highlight::DarkBlue => "Dark Blue",
|
||||
Highlight::Teal => "Teal",
|
||||
Highlight::Green => "Green",
|
||||
Highlight::Violet => "Violet",
|
||||
Highlight::DarkRed => "Dark Red",
|
||||
Highlight::DarkYellow => "Dark Yellow",
|
||||
Highlight::Gray50 => "Gray-50%",
|
||||
Highlight::Gray25 => "Gray-25%",
|
||||
Highlight::Black => "Black",
|
||||
Highlight::White => "White",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Character formatting (direct or in a style). `None` = inherit.
|
||||
#[derive(Clone, Debug, PartialEq, Default, Serialize, Deserialize)]
|
||||
#[serde(default, rename_all = "camelCase")]
|
||||
pub struct CharProps {
|
||||
/// Character style id.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub style: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub font: Option<String>,
|
||||
/// Points.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub size: Option<f32>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub bold: Option<bool>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub italic: Option<bool>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub underline: Option<Underline>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub underline_color: Option<Rgb>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub strike: Option<bool>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub double_strike: Option<bool>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub color: Option<TextColor>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub highlight: Option<Highlight>,
|
||||
/// Character shading (background fill).
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub shading: Option<Rgb>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub vert_align: Option<VertAlign>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub caps: Option<bool>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub small_caps: Option<bool>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub hidden: Option<bool>,
|
||||
/// Character spacing (expanded/condensed), points.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub spacing: Option<f32>,
|
||||
/// Horizontal scale, percent (100 = normal).
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub scale: Option<f32>,
|
||||
/// Raised (+) / lowered (−) position, points.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub position: Option<f32>,
|
||||
/// Kerning for fonts at or above this size (points); 0 = off.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub kern: Option<f32>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub outline: Option<bool>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub shadow: Option<bool>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub emboss: Option<bool>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub engrave: Option<bool>,
|
||||
/// BCP 47 language tag (proofing).
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub lang: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub no_proof: Option<bool>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub rtl: Option<bool>,
|
||||
/// Hyperlink target: a URL, or `#bookmark` for an internal link.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub link: Option<String>,
|
||||
/// Tracked insertion: index into `Document::revisions`.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub ins: Option<u32>,
|
||||
/// Tracked deletion: index into `Document::revisions`.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub del: Option<u32>,
|
||||
}
|
||||
|
||||
macro_rules! overlay_fields {
|
||||
($dst:ident, $src:ident; $($f:ident),*) => {
|
||||
$( if $src.$f.is_some() { $dst.$f = $src.$f.clone(); } )*
|
||||
};
|
||||
}
|
||||
|
||||
impl CharProps {
|
||||
/// Apply every `Some` field of `patch` on top of `self`.
|
||||
pub fn overlay(&mut self, patch: &CharProps) {
|
||||
overlay_fields!(self, patch; style, font, size, bold, italic, underline, underline_color, strike, double_strike, color, highlight,
|
||||
shading, vert_align, caps, small_caps, hidden, spacing, scale, position, kern, outline, shadow, emboss, engrave, lang, no_proof, rtl,
|
||||
link, ins, del);
|
||||
}
|
||||
pub fn overlaid(mut self, patch: &CharProps) -> CharProps {
|
||||
self.overlay(patch);
|
||||
self
|
||||
}
|
||||
pub fn is_empty(&self) -> bool {
|
||||
*self == CharProps::default()
|
||||
}
|
||||
/// Clear direct formatting but keep what isn't "formatting" (links, revisions, character style
|
||||
/// is cleared too, as Word's Clear Formatting does).
|
||||
pub fn cleared(&self) -> CharProps {
|
||||
CharProps { link: self.link.clone(), ins: self.ins, del: self.del, ..Default::default() }
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum Align {
|
||||
#[default]
|
||||
Left,
|
||||
Center,
|
||||
Right,
|
||||
Justify,
|
||||
Distribute,
|
||||
}
|
||||
|
||||
/// Line spacing rule.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase", tag = "rule", content = "value")]
|
||||
pub enum LineSpacing {
|
||||
/// Multiple of single spacing (1.0 = single, 1.15, 1.5, 2.0 = double).
|
||||
Multiple(f32),
|
||||
/// At least this many points.
|
||||
AtLeast(f32),
|
||||
/// Exactly this many points.
|
||||
Exactly(f32),
|
||||
}
|
||||
|
||||
impl Default for LineSpacing {
|
||||
fn default() -> Self {
|
||||
LineSpacing::Multiple(1.0)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum TabAlign {
|
||||
#[default]
|
||||
Left,
|
||||
Center,
|
||||
Right,
|
||||
Decimal,
|
||||
Bar,
|
||||
/// Clears an inherited stop at this position.
|
||||
Clear,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum TabLeader {
|
||||
#[default]
|
||||
None,
|
||||
Dot,
|
||||
Hyphen,
|
||||
Underscore,
|
||||
MiddleDot,
|
||||
}
|
||||
|
||||
impl TabLeader {
|
||||
pub fn char(self) -> Option<char> {
|
||||
match self {
|
||||
TabLeader::None => None,
|
||||
TabLeader::Dot => Some('.'),
|
||||
TabLeader::Hyphen => Some('-'),
|
||||
TabLeader::Underscore => Some('_'),
|
||||
TabLeader::MiddleDot => Some('·'),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub struct TabStop {
|
||||
/// Points from the paragraph's left indent origin (the text column's left edge).
|
||||
pub pos: f32,
|
||||
#[serde(default)]
|
||||
pub align: TabAlign,
|
||||
#[serde(default)]
|
||||
pub leader: TabLeader,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum BorderStyle {
|
||||
#[default]
|
||||
None,
|
||||
Single,
|
||||
Double,
|
||||
Dotted,
|
||||
Dashed,
|
||||
Thick,
|
||||
Triple,
|
||||
DotDash,
|
||||
Wave,
|
||||
}
|
||||
|
||||
impl BorderStyle {
|
||||
pub fn ooxml(self) -> &'static str {
|
||||
match self {
|
||||
BorderStyle::None => "nil",
|
||||
BorderStyle::Single => "single",
|
||||
BorderStyle::Double => "double",
|
||||
BorderStyle::Dotted => "dotted",
|
||||
BorderStyle::Dashed => "dashed",
|
||||
BorderStyle::Thick => "thick",
|
||||
BorderStyle::Triple => "triple",
|
||||
BorderStyle::DotDash => "dotDash",
|
||||
BorderStyle::Wave => "wave",
|
||||
}
|
||||
}
|
||||
pub fn from_ooxml(s: &str) -> BorderStyle {
|
||||
match s {
|
||||
"nil" | "none" => BorderStyle::None,
|
||||
"double" => BorderStyle::Double,
|
||||
"dotted" => BorderStyle::Dotted,
|
||||
"dashed" | "dashSmallGap" => BorderStyle::Dashed,
|
||||
"thick" => BorderStyle::Thick,
|
||||
"triple" => BorderStyle::Triple,
|
||||
"dotDash" | "dotDotDash" => BorderStyle::DotDash,
|
||||
"wave" | "doubleWave" => BorderStyle::Wave,
|
||||
_ => BorderStyle::Single,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// One border line.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize, Default)]
|
||||
#[serde(default)]
|
||||
pub struct Border {
|
||||
pub style: BorderStyle,
|
||||
/// Line width, points.
|
||||
pub width: f32,
|
||||
pub color: Option<Rgb>,
|
||||
/// Distance from the text, points.
|
||||
pub space: f32,
|
||||
}
|
||||
|
||||
impl Border {
|
||||
pub fn single(width: f32) -> Border {
|
||||
Border { style: BorderStyle::Single, width, color: None, space: 0.0 }
|
||||
}
|
||||
pub fn is_visible(&self) -> bool {
|
||||
self.style != BorderStyle::None && self.width > 0.0
|
||||
}
|
||||
}
|
||||
|
||||
/// Borders on the four sides (plus inner borders for tables and runs of bordered paragraphs).
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize, Default)]
|
||||
#[serde(default)]
|
||||
pub struct Borders {
|
||||
pub top: Option<Border>,
|
||||
pub left: Option<Border>,
|
||||
pub bottom: Option<Border>,
|
||||
pub right: Option<Border>,
|
||||
/// Between paragraphs / inside horizontal (tables).
|
||||
pub between: Option<Border>,
|
||||
/// Inside vertical (tables).
|
||||
pub inside_v: Option<Border>,
|
||||
}
|
||||
|
||||
impl Borders {
|
||||
pub fn all(b: Border) -> Borders {
|
||||
Borders { top: Some(b), left: Some(b), bottom: Some(b), right: Some(b), between: Some(b), inside_v: Some(b) }
|
||||
}
|
||||
pub fn box_(b: Border) -> Borders {
|
||||
Borders { top: Some(b), left: Some(b), bottom: Some(b), right: Some(b), between: None, inside_v: None }
|
||||
}
|
||||
pub fn any_visible(&self) -> bool {
|
||||
[self.top, self.left, self.bottom, self.right, self.between, self.inside_v].iter().flatten().any(Border::is_visible)
|
||||
}
|
||||
}
|
||||
|
||||
/// A reference to a numbering definition and level.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||
pub struct NumRef {
|
||||
/// `Numbering::nums` id; 0 removes numbering inherited from a style.
|
||||
pub num: u32,
|
||||
pub level: u8,
|
||||
}
|
||||
|
||||
/// Paragraph formatting. `None` = inherit.
|
||||
#[derive(Clone, Debug, PartialEq, Default, Serialize, Deserialize)]
|
||||
#[serde(default, rename_all = "camelCase")]
|
||||
pub struct ParaProps {
|
||||
/// Paragraph style id.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub style: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub align: Option<Align>,
|
||||
/// Left indent, points.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub indent_left: Option<f32>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub indent_right: Option<f32>,
|
||||
/// First-line indent, points; negative = hanging.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub indent_first: Option<f32>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub space_before: Option<f32>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub space_after: Option<f32>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub line_spacing: Option<LineSpacing>,
|
||||
/// Ignore space before/after between paragraphs of the same style.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub contextual_spacing: Option<bool>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub keep_next: Option<bool>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub keep_lines: Option<bool>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub page_break_before: Option<bool>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub widow_control: Option<bool>,
|
||||
/// 0..=8 = Level 1..9; 9 = body text.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub outline_level: Option<u8>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub numbering: Option<NumRef>,
|
||||
/// Tab stops (replaces the inherited list; `TabAlign::Clear` stops remove inherited ones).
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub tabs: Option<Vec<TabStop>>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub shading: Option<Rgb>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub borders: Option<Borders>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub suppress_hyphens: Option<bool>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub suppress_line_numbers: Option<bool>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub bidi: Option<bool>,
|
||||
/// Drop cap: lines to drop (0 = none) — applies to the first character(s).
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub drop_cap: Option<u8>,
|
||||
}
|
||||
|
||||
impl ParaProps {
|
||||
pub fn overlay(&mut self, patch: &ParaProps) {
|
||||
overlay_fields!(self, patch; style, align, indent_left, indent_right, indent_first, space_before, space_after, line_spacing,
|
||||
contextual_spacing, keep_next, keep_lines, page_break_before, widow_control, outline_level, numbering, tabs, shading, borders,
|
||||
suppress_hyphens, suppress_line_numbers, bidi, drop_cap);
|
||||
}
|
||||
pub fn overlaid(mut self, patch: &ParaProps) -> ParaProps {
|
||||
self.overlay(patch);
|
||||
self
|
||||
}
|
||||
pub fn is_empty(&self) -> bool {
|
||||
*self == ParaProps::default()
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum VAlign {
|
||||
#[default]
|
||||
Top,
|
||||
Center,
|
||||
Bottom,
|
||||
}
|
||||
|
||||
/// Table-wide properties.
|
||||
#[derive(Clone, Debug, PartialEq, Default, Serialize, Deserialize)]
|
||||
#[serde(default, rename_all = "camelCase")]
|
||||
pub struct TableProps {
|
||||
pub style: Option<String>,
|
||||
/// Preferred width: points (`Some`) or automatic (`None`).
|
||||
pub width: Option<f32>,
|
||||
/// Preferred width as a percentage of the text column (overrides `width`).
|
||||
pub width_pct: Option<f32>,
|
||||
pub align: Option<Align>,
|
||||
/// Indent from the left margin, points.
|
||||
pub indent: Option<f32>,
|
||||
pub borders: Option<Borders>,
|
||||
/// Default cell margins (top, left, bottom, right), points.
|
||||
pub cell_margins: Option<[f32; 4]>,
|
||||
/// Fixed column widths (no auto-fit).
|
||||
pub fixed: bool,
|
||||
/// Table style options ("look"): header row, total row, banded rows, first column, last
|
||||
/// column, banded columns.
|
||||
pub look: TableLook,
|
||||
pub shading: Option<Rgb>,
|
||||
/// Alternative text.
|
||||
pub caption: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(default, rename_all = "camelCase")]
|
||||
pub struct TableLook {
|
||||
pub header_row: bool,
|
||||
pub total_row: bool,
|
||||
pub banded_rows: bool,
|
||||
pub first_column: bool,
|
||||
pub last_column: bool,
|
||||
pub banded_columns: bool,
|
||||
}
|
||||
|
||||
impl Default for TableLook {
|
||||
fn default() -> Self {
|
||||
TableLook { header_row: true, total_row: false, banded_rows: true, first_column: true, last_column: false, banded_columns: false }
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum HeightRule {
|
||||
#[default]
|
||||
Auto,
|
||||
AtLeast,
|
||||
Exact,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Default, Serialize, Deserialize)]
|
||||
#[serde(default, rename_all = "camelCase")]
|
||||
pub struct RowProps {
|
||||
/// Points (meaning depends on `height_rule`).
|
||||
pub height: Option<f32>,
|
||||
pub height_rule: HeightRule,
|
||||
/// Repeat as a header row at the top of each page.
|
||||
pub header: bool,
|
||||
/// Don't let the row break across pages.
|
||||
pub cant_split: bool,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum VMerge {
|
||||
#[default]
|
||||
None,
|
||||
/// First cell of a vertically merged range.
|
||||
Restart,
|
||||
/// Continues the merge from the cell above.
|
||||
Continue,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Default, Serialize, Deserialize)]
|
||||
#[serde(default, rename_all = "camelCase")]
|
||||
pub struct CellProps {
|
||||
/// Preferred width, points.
|
||||
pub width: Option<f32>,
|
||||
/// Number of grid columns spanned (1 = no horizontal merge).
|
||||
pub span: u32,
|
||||
pub vmerge: VMerge,
|
||||
pub shading: Option<Rgb>,
|
||||
pub borders: Option<Borders>,
|
||||
pub valign: VAlign,
|
||||
/// Cell margins override (top, left, bottom, right), points.
|
||||
pub margins: Option<[f32; 4]>,
|
||||
/// Text direction: false = horizontal, true = rotated (top-to-bottom).
|
||||
pub vertical_text: bool,
|
||||
pub no_wrap: bool,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn rgb_hex() {
|
||||
assert_eq!(Rgb(255, 0, 16).hex(), "FF0010");
|
||||
assert_eq!(Rgb::parse("#FF0010"), Some(Rgb(255, 0, 16)));
|
||||
assert_eq!(Rgb::parse("zzzzzz"), None);
|
||||
assert_eq!(Rgb::parse("é1234"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn overlay_keeps_unset() {
|
||||
let base = CharProps { bold: Some(true), size: Some(12.0), ..Default::default() };
|
||||
let patch = CharProps { size: Some(14.0), italic: Some(true), ..Default::default() };
|
||||
let r = base.overlaid(&patch);
|
||||
assert_eq!(r.bold, Some(true));
|
||||
assert_eq!(r.size, Some(14.0));
|
||||
assert_eq!(r.italic, Some(true));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ooxml_names_round_trip() {
|
||||
for u in Underline::ALL {
|
||||
assert_eq!(Underline::from_ooxml(u.ooxml()), u);
|
||||
}
|
||||
for h in Highlight::ALL {
|
||||
assert_eq!(Highlight::from_ooxml(h.ooxml()), h);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn props_serde_skip_none() {
|
||||
let p = CharProps { bold: Some(true), ..Default::default() };
|
||||
let s = serde_json::to_string(&p).unwrap();
|
||||
assert_eq!(s, r#"{"bold":true}"#);
|
||||
let back: CharProps = serde_json::from_str(&s).unwrap();
|
||||
assert_eq!(back, p);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,255 @@
|
|||
//! Style resolution: document defaults → paragraph style chain → character style chain → direct
|
||||
//! formatting, into concrete values for layout.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::props::{Align, Borders, CharProps, Highlight, LineSpacing, NumRef, ParaProps, Rgb, TabAlign, TabStop, TextColor, Underline, VertAlign};
|
||||
use crate::styles::{StyleKind, StyleSheet};
|
||||
|
||||
/// Concrete character formatting.
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ResolvedChar {
|
||||
pub font: String,
|
||||
pub size: f32,
|
||||
pub bold: bool,
|
||||
pub italic: bool,
|
||||
pub underline: Underline,
|
||||
pub underline_color: Option<Rgb>,
|
||||
pub strike: bool,
|
||||
pub double_strike: bool,
|
||||
pub color: TextColor,
|
||||
pub highlight: Option<Rgb>,
|
||||
pub shading: Option<Rgb>,
|
||||
pub vert_align: VertAlign,
|
||||
pub caps: bool,
|
||||
pub small_caps: bool,
|
||||
pub hidden: bool,
|
||||
pub spacing: f32,
|
||||
pub scale: f32,
|
||||
pub position: f32,
|
||||
pub outline: bool,
|
||||
pub shadow: bool,
|
||||
pub emboss: bool,
|
||||
pub engrave: bool,
|
||||
pub link: Option<String>,
|
||||
pub ins: Option<u32>,
|
||||
pub del: Option<u32>,
|
||||
pub no_proof: bool,
|
||||
pub lang: Option<String>,
|
||||
}
|
||||
|
||||
impl ResolvedChar {
|
||||
fn from(c: &CharProps) -> ResolvedChar {
|
||||
ResolvedChar {
|
||||
font: c.font.clone().unwrap_or_else(|| crate::styles::BODY_FONT.to_string()),
|
||||
size: c.size.unwrap_or(11.0).clamp(1.0, 1638.0),
|
||||
bold: c.bold.unwrap_or(false),
|
||||
italic: c.italic.unwrap_or(false),
|
||||
underline: c.underline.unwrap_or_default(),
|
||||
underline_color: c.underline_color,
|
||||
strike: c.strike.unwrap_or(false),
|
||||
double_strike: c.double_strike.unwrap_or(false),
|
||||
color: c.color.unwrap_or_default(),
|
||||
highlight: c.highlight.and_then(Highlight::rgb),
|
||||
shading: c.shading,
|
||||
vert_align: c.vert_align.unwrap_or_default(),
|
||||
caps: c.caps.unwrap_or(false),
|
||||
small_caps: c.small_caps.unwrap_or(false),
|
||||
hidden: c.hidden.unwrap_or(false),
|
||||
spacing: c.spacing.unwrap_or(0.0).clamp(-100.0, 100.0),
|
||||
scale: c.scale.unwrap_or(100.0).clamp(1.0, 600.0),
|
||||
position: c.position.unwrap_or(0.0).clamp(-800.0, 800.0),
|
||||
outline: c.outline.unwrap_or(false),
|
||||
shadow: c.shadow.unwrap_or(false),
|
||||
emboss: c.emboss.unwrap_or(false),
|
||||
engrave: c.engrave.unwrap_or(false),
|
||||
link: c.link.clone(),
|
||||
ins: c.ins,
|
||||
del: c.del,
|
||||
no_proof: c.no_proof.unwrap_or(false),
|
||||
lang: c.lang.clone(),
|
||||
}
|
||||
}
|
||||
/// The size glyphs are drawn at (super/subscript shrink to ~2/3).
|
||||
pub fn draw_size(&self) -> f32 {
|
||||
if self.vert_align == VertAlign::Baseline { self.size } else { self.size * 0.65 }
|
||||
}
|
||||
/// Baseline shift, points (positive = up).
|
||||
pub fn baseline_shift(&self) -> f32 {
|
||||
self.position
|
||||
+ match self.vert_align {
|
||||
VertAlign::Baseline => 0.0,
|
||||
VertAlign::Superscript => self.size * 0.33,
|
||||
VertAlign::Subscript => -self.size * 0.14,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Concrete paragraph formatting.
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ResolvedPara {
|
||||
pub style: String,
|
||||
pub align: Align,
|
||||
pub indent_left: f32,
|
||||
pub indent_right: f32,
|
||||
pub indent_first: f32,
|
||||
pub space_before: f32,
|
||||
pub space_after: f32,
|
||||
pub line_spacing: LineSpacing,
|
||||
pub contextual_spacing: bool,
|
||||
pub keep_next: bool,
|
||||
pub keep_lines: bool,
|
||||
pub page_break_before: bool,
|
||||
pub widow_control: bool,
|
||||
pub outline_level: Option<u8>,
|
||||
pub numbering: Option<NumRef>,
|
||||
pub tabs: Vec<TabStop>,
|
||||
pub shading: Option<Rgb>,
|
||||
pub borders: Option<Borders>,
|
||||
pub bidi: bool,
|
||||
pub drop_cap: u8,
|
||||
pub suppress_hyphens: bool,
|
||||
}
|
||||
|
||||
const MAX_INDENT: f32 = 1584.0; // 22"
|
||||
|
||||
impl ResolvedPara {
|
||||
fn from(p: &ParaProps, style: &str) -> ResolvedPara {
|
||||
let clampi = |v: Option<f32>| v.unwrap_or(0.0).clamp(-MAX_INDENT, MAX_INDENT);
|
||||
let mut tabs: Vec<TabStop> = p.tabs.clone().unwrap_or_default().into_iter().filter(|t| t.align != TabAlign::Clear && t.pos.is_finite()).collect();
|
||||
tabs.sort_by(|a, b| a.pos.total_cmp(&b.pos));
|
||||
ResolvedPara {
|
||||
style: style.to_string(),
|
||||
align: p.align.unwrap_or_default(),
|
||||
indent_left: clampi(p.indent_left),
|
||||
indent_right: clampi(p.indent_right),
|
||||
indent_first: clampi(p.indent_first),
|
||||
space_before: p.space_before.unwrap_or(0.0).clamp(0.0, 1584.0),
|
||||
space_after: p.space_after.unwrap_or(0.0).clamp(0.0, 1584.0),
|
||||
line_spacing: match p.line_spacing.unwrap_or_default() {
|
||||
LineSpacing::Multiple(m) => LineSpacing::Multiple(if m.is_finite() { m.clamp(0.06, 132.0) } else { 1.0 }),
|
||||
LineSpacing::AtLeast(v) => LineSpacing::AtLeast(if v.is_finite() { v.clamp(0.0, 1584.0) } else { 0.0 }),
|
||||
LineSpacing::Exactly(v) => LineSpacing::Exactly(if v.is_finite() { v.clamp(0.7, 1584.0) } else { 12.0 }),
|
||||
},
|
||||
contextual_spacing: p.contextual_spacing.unwrap_or(false),
|
||||
keep_next: p.keep_next.unwrap_or(false),
|
||||
keep_lines: p.keep_lines.unwrap_or(false),
|
||||
page_break_before: p.page_break_before.unwrap_or(false),
|
||||
widow_control: p.widow_control.unwrap_or(true),
|
||||
outline_level: p.outline_level.filter(|l| *l < 9),
|
||||
numbering: p.numbering.filter(|n| n.num != 0),
|
||||
tabs,
|
||||
shading: p.shading,
|
||||
borders: p.borders.filter(Borders::any_visible),
|
||||
bidi: p.bidi.unwrap_or(false),
|
||||
drop_cap: p.drop_cap.unwrap_or(0).min(10),
|
||||
suppress_hyphens: p.suppress_hyphens.unwrap_or(false),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl StyleSheet {
|
||||
/// Paragraph properties after defaults and the style chain (without direct formatting).
|
||||
pub fn para_style_props(&self, style: &str) -> (ParaProps, CharProps) {
|
||||
let mut pp = self.default_para.clone();
|
||||
let mut cp = self.default_chr.clone();
|
||||
for s in self.chain(style) {
|
||||
if s.kind == StyleKind::Paragraph {
|
||||
let mut sp = s.para.clone();
|
||||
// Tabs accumulate along the chain (with Clear removing inherited stops).
|
||||
if let (Some(base), Some(add)) = (pp.tabs.clone(), sp.tabs.clone()) {
|
||||
let mut merged: Vec<TabStop> = base.into_iter().filter(|t| !add.iter().any(|a| (a.pos - t.pos).abs() < 0.5)).collect();
|
||||
merged.extend(add);
|
||||
sp.tabs = Some(merged);
|
||||
}
|
||||
pp.overlay(&sp);
|
||||
cp.overlay(&s.chr);
|
||||
}
|
||||
}
|
||||
pp.style = None;
|
||||
(pp, cp)
|
||||
}
|
||||
|
||||
/// Resolve a paragraph's properties.
|
||||
pub fn resolve_para(&self, p: &ParaProps) -> ResolvedPara {
|
||||
let style = p.style.clone().unwrap_or_else(|| "Normal".into());
|
||||
let (mut base, _) = self.para_style_props(&style);
|
||||
let mut direct = p.clone();
|
||||
if let (Some(b), Some(d)) = (base.tabs.clone(), direct.tabs.clone()) {
|
||||
let mut merged: Vec<TabStop> = b.into_iter().filter(|t| !d.iter().any(|a| (a.pos - t.pos).abs() < 0.5)).collect();
|
||||
merged.extend(d);
|
||||
direct.tabs = Some(merged);
|
||||
}
|
||||
base.overlay(&direct);
|
||||
ResolvedPara::from(&base, &style)
|
||||
}
|
||||
|
||||
/// Resolve character formatting for a run in a paragraph of style `para_style`.
|
||||
pub fn resolve_char(&self, para_style: Option<&str>, c: &CharProps) -> ResolvedChar {
|
||||
let (_, mut cp) = self.para_style_props(para_style.unwrap_or("Normal"));
|
||||
if let Some(cs) = c.style.as_deref() {
|
||||
for s in self.chain(cs) {
|
||||
if s.kind == StyleKind::Character || s.kind == StyleKind::Paragraph {
|
||||
cp.overlay(&s.chr);
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut direct = c.clone();
|
||||
direct.style = None;
|
||||
cp.overlay(&direct);
|
||||
ResolvedChar::from(&cp)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn heading_resolves() {
|
||||
let s = StyleSheet::builtin();
|
||||
let p = s.resolve_para(&ParaProps { style: Some("Heading1".into()), ..Default::default() });
|
||||
assert!(p.keep_next);
|
||||
assert_eq!(p.outline_level, Some(0));
|
||||
assert_eq!(p.space_after, 4.0);
|
||||
let c = s.resolve_char(Some("Heading1"), &CharProps::default());
|
||||
assert_eq!(c.size, 20.0);
|
||||
assert_eq!(c.font, crate::styles::HEADING_FONT);
|
||||
let c2 = s.resolve_char(Some("Heading1"), &CharProps { size: Some(30.0), ..Default::default() });
|
||||
assert_eq!(c2.size, 30.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn char_style_then_direct() {
|
||||
let s = StyleSheet::builtin();
|
||||
let c = s.resolve_char(None, &CharProps { style: Some("Strong".into()), italic: Some(true), ..Default::default() });
|
||||
assert!(c.bold && c.italic);
|
||||
assert_eq!(c.size, 12.0);
|
||||
let unknown = s.resolve_char(Some("NoSuchStyle"), &CharProps::default());
|
||||
assert_eq!(unknown.size, 12.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hostile_values_clamped() {
|
||||
let s = StyleSheet::builtin();
|
||||
let p = s.resolve_para(&ParaProps { indent_left: Some(f32::INFINITY), line_spacing: Some(LineSpacing::Multiple(f32::NAN)), ..Default::default() });
|
||||
assert_eq!(p.indent_left, MAX_INDENT);
|
||||
assert_eq!(p.line_spacing, LineSpacing::Multiple(1.0));
|
||||
let c = s.resolve_char(None, &CharProps { size: Some(-4.0), ..Default::default() });
|
||||
assert_eq!(c.size, 1.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn header_tabs_inherit() {
|
||||
let s = StyleSheet::builtin();
|
||||
let p = s.resolve_para(&ParaProps {
|
||||
style: Some("Header".into()),
|
||||
tabs: Some(vec![TabStop { pos: 100.0, align: TabAlign::Left, leader: Default::default() }]),
|
||||
..Default::default()
|
||||
});
|
||||
assert_eq!(p.tabs.len(), 3);
|
||||
assert_eq!(p.tabs[0].pos, 100.0);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,407 @@
|
|||
//! Section properties: page setup, columns, headers/footers, page numbering.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::props::{Borders, VAlign};
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum SectionStart {
|
||||
#[default]
|
||||
NextPage,
|
||||
Continuous,
|
||||
EvenPage,
|
||||
OddPage,
|
||||
NextColumn,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum NumFormat {
|
||||
#[default]
|
||||
Decimal,
|
||||
UpperRoman,
|
||||
LowerRoman,
|
||||
UpperLetter,
|
||||
LowerLetter,
|
||||
Ordinal,
|
||||
CardinalText,
|
||||
OrdinalText,
|
||||
DecimalZero,
|
||||
Bullet,
|
||||
None,
|
||||
}
|
||||
|
||||
impl NumFormat {
|
||||
pub fn ooxml(self) -> &'static str {
|
||||
match self {
|
||||
NumFormat::Decimal => "decimal",
|
||||
NumFormat::UpperRoman => "upperRoman",
|
||||
NumFormat::LowerRoman => "lowerRoman",
|
||||
NumFormat::UpperLetter => "upperLetter",
|
||||
NumFormat::LowerLetter => "lowerLetter",
|
||||
NumFormat::Ordinal => "ordinal",
|
||||
NumFormat::CardinalText => "cardinalText",
|
||||
NumFormat::OrdinalText => "ordinalText",
|
||||
NumFormat::DecimalZero => "decimalZero",
|
||||
NumFormat::Bullet => "bullet",
|
||||
NumFormat::None => "none",
|
||||
}
|
||||
}
|
||||
pub fn from_ooxml(s: &str) -> NumFormat {
|
||||
[
|
||||
NumFormat::Decimal,
|
||||
NumFormat::UpperRoman,
|
||||
NumFormat::LowerRoman,
|
||||
NumFormat::UpperLetter,
|
||||
NumFormat::LowerLetter,
|
||||
NumFormat::Ordinal,
|
||||
NumFormat::CardinalText,
|
||||
NumFormat::OrdinalText,
|
||||
NumFormat::DecimalZero,
|
||||
NumFormat::Bullet,
|
||||
NumFormat::None,
|
||||
]
|
||||
.into_iter()
|
||||
.find(|f| f.ooxml() == s)
|
||||
.unwrap_or(NumFormat::Decimal)
|
||||
}
|
||||
/// Format `n` (1-based).
|
||||
pub fn format(self, n: u32) -> String {
|
||||
match self {
|
||||
NumFormat::Decimal => n.to_string(),
|
||||
NumFormat::DecimalZero => format!("{n:02}"),
|
||||
NumFormat::UpperRoman => roman(n).to_uppercase(),
|
||||
NumFormat::LowerRoman => roman(n),
|
||||
NumFormat::UpperLetter => letters(n).to_uppercase(),
|
||||
NumFormat::LowerLetter => letters(n),
|
||||
NumFormat::Ordinal => format!("{n}{}", ordinal_suffix(n)),
|
||||
NumFormat::CardinalText => cardinal_text(n),
|
||||
NumFormat::OrdinalText => ordinal_text(n),
|
||||
NumFormat::Bullet | NumFormat::None => String::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn ordinal_suffix(n: u32) -> &'static str {
|
||||
match (n % 10, n % 100) {
|
||||
(_, 11..=13) => "th",
|
||||
(1, _) => "st",
|
||||
(2, _) => "nd",
|
||||
(3, _) => "rd",
|
||||
_ => "th",
|
||||
}
|
||||
}
|
||||
|
||||
/// Lowercase roman numerals (0 → empty; values above 3999 use repeated `m`).
|
||||
pub fn roman(mut n: u32) -> String {
|
||||
const T: [(u32, &str); 13] =
|
||||
[(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();
|
||||
n = n.min(50_000);
|
||||
for (v, r) in T {
|
||||
while n >= v {
|
||||
s.push_str(r);
|
||||
n -= v;
|
||||
}
|
||||
}
|
||||
s
|
||||
}
|
||||
|
||||
/// Word-style letters: a..z, aa..zz, aaa.. (repeated letter, not base-26).
|
||||
pub fn letters(n: u32) -> String {
|
||||
if n == 0 {
|
||||
return String::new();
|
||||
}
|
||||
let n = n.min(780);
|
||||
let k = (n - 1) / 26 + 1;
|
||||
let c = (b'a' + ((n - 1) % 26) as u8) as char;
|
||||
std::iter::repeat_n(c, k as usize).collect()
|
||||
}
|
||||
|
||||
const ONES: [&str; 20] = [
|
||||
"zero",
|
||||
"one",
|
||||
"two",
|
||||
"three",
|
||||
"four",
|
||||
"five",
|
||||
"six",
|
||||
"seven",
|
||||
"eight",
|
||||
"nine",
|
||||
"ten",
|
||||
"eleven",
|
||||
"twelve",
|
||||
"thirteen",
|
||||
"fourteen",
|
||||
"fifteen",
|
||||
"sixteen",
|
||||
"seventeen",
|
||||
"eighteen",
|
||||
"nineteen",
|
||||
];
|
||||
const TENS: [&str; 10] = ["", "", "twenty", "thirty", "forty", "fifty", "sixty", "seventy", "eighty", "ninety"];
|
||||
|
||||
/// English cardinal words, capitalised first letter (`Twenty-One`).
|
||||
pub fn cardinal_text(n: u32) -> String {
|
||||
fn words(n: u32) -> String {
|
||||
match n {
|
||||
0..=19 => ONES.get(n as usize).copied().unwrap_or("").to_string(),
|
||||
20..=99 => {
|
||||
let t = TENS.get((n / 10) as usize).copied().unwrap_or("");
|
||||
if n % 10 == 0 { t.to_string() } else { format!("{t}-{}", ONES.get((n % 10) as usize).copied().unwrap_or("")) }
|
||||
}
|
||||
100..=999 => {
|
||||
let h = format!("{} hundred", words(n / 100));
|
||||
if n % 100 == 0 { h } else { format!("{h} {}", words(n % 100)) }
|
||||
}
|
||||
_ => {
|
||||
let th = format!("{} thousand", words(n / 1000));
|
||||
if n % 1000 == 0 { th } else { format!("{th} {}", words(n % 1000)) }
|
||||
}
|
||||
}
|
||||
}
|
||||
capitalize(&words(n.min(999_999)))
|
||||
}
|
||||
|
||||
/// English ordinal words (`Twenty-First`).
|
||||
pub fn ordinal_text(n: u32) -> String {
|
||||
let c = cardinal_text(n).to_lowercase();
|
||||
let (head, last) = match c.rfind(['-', ' ']) {
|
||||
Some(i) => (c.get(..=i).unwrap_or(""), c.get(i + 1..).unwrap_or("")),
|
||||
None => ("", c.as_str()),
|
||||
};
|
||||
let last = match last {
|
||||
"one" => "first".to_string(),
|
||||
"two" => "second".to_string(),
|
||||
"three" => "third".to_string(),
|
||||
"five" => "fifth".to_string(),
|
||||
"eight" => "eighth".to_string(),
|
||||
"nine" => "ninth".to_string(),
|
||||
"twelve" => "twelfth".to_string(),
|
||||
l if l.ends_with('y') => format!("{}ieth", l.trim_end_matches('y')),
|
||||
l => format!("{l}th"),
|
||||
};
|
||||
capitalize(&format!("{head}{last}"))
|
||||
}
|
||||
|
||||
fn capitalize(s: &str) -> String {
|
||||
let mut out = String::with_capacity(s.len());
|
||||
let mut up = true;
|
||||
for c in s.chars() {
|
||||
if up {
|
||||
out.extend(c.to_uppercase());
|
||||
} else {
|
||||
out.push(c);
|
||||
}
|
||||
up = c == '-' || c == ' ';
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Header/footer story ids (in `Document::parts`) for a section.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Default, Serialize, Deserialize)]
|
||||
#[serde(default)]
|
||||
pub struct HeaderSet {
|
||||
pub default: Option<u32>,
|
||||
pub first: Option<u32>,
|
||||
pub even: Option<u32>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(default, rename_all = "camelCase")]
|
||||
pub struct Columns {
|
||||
pub count: u32,
|
||||
/// Space between columns, points.
|
||||
pub space: f32,
|
||||
/// Line between columns.
|
||||
pub separator: bool,
|
||||
/// Unequal widths (width, space after) per column; empty = equal.
|
||||
pub widths: Vec<(f32, f32)>,
|
||||
}
|
||||
|
||||
impl Default for Columns {
|
||||
fn default() -> Self {
|
||||
Columns { count: 1, space: 36.0, separator: false, widths: Vec::new() }
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(default, rename_all = "camelCase")]
|
||||
pub struct LineNumbering {
|
||||
pub count_by: u32,
|
||||
pub start: u32,
|
||||
/// Distance from text, points (0 = auto).
|
||||
pub distance: f32,
|
||||
/// Restart each page / section / continuous.
|
||||
pub restart: LineNumberRestart,
|
||||
}
|
||||
|
||||
impl Default for LineNumbering {
|
||||
fn default() -> Self {
|
||||
LineNumbering { count_by: 1, start: 1, distance: 0.0, restart: LineNumberRestart::Page }
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum LineNumberRestart {
|
||||
#[default]
|
||||
Page,
|
||||
Section,
|
||||
Continuous,
|
||||
}
|
||||
|
||||
/// Page setup and section-level settings.
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(default, rename_all = "camelCase")]
|
||||
pub struct SectionProps {
|
||||
pub start: SectionStart,
|
||||
/// Page width/height, points (already swapped for landscape).
|
||||
pub page_w: f32,
|
||||
pub page_h: f32,
|
||||
pub landscape: bool,
|
||||
pub margin_top: f32,
|
||||
pub margin_bottom: f32,
|
||||
pub margin_left: f32,
|
||||
pub margin_right: f32,
|
||||
/// Distance from the page edge to the header / footer.
|
||||
pub header: f32,
|
||||
pub footer: f32,
|
||||
pub gutter: f32,
|
||||
/// Mirror margins (inside/outside) — set from the document settings.
|
||||
pub columns: Columns,
|
||||
pub headers: HeaderSet,
|
||||
pub footers: HeaderSet,
|
||||
/// Different first page header/footer.
|
||||
pub title_page: bool,
|
||||
pub page_num_start: Option<u32>,
|
||||
pub page_num_format: NumFormat,
|
||||
pub line_numbers: Option<LineNumbering>,
|
||||
pub valign: VAlign,
|
||||
pub page_borders: Option<Borders>,
|
||||
/// Text direction / bidi section.
|
||||
pub rtl: bool,
|
||||
}
|
||||
|
||||
impl Default for SectionProps {
|
||||
/// Word's default: US Letter, 1" margins, 0.5" header/footer distance.
|
||||
fn default() -> Self {
|
||||
SectionProps {
|
||||
start: SectionStart::NextPage,
|
||||
page_w: 612.0,
|
||||
page_h: 792.0,
|
||||
landscape: false,
|
||||
margin_top: 72.0,
|
||||
margin_bottom: 72.0,
|
||||
margin_left: 72.0,
|
||||
margin_right: 72.0,
|
||||
header: 36.0,
|
||||
footer: 36.0,
|
||||
gutter: 0.0,
|
||||
columns: Columns::default(),
|
||||
headers: HeaderSet::default(),
|
||||
footers: HeaderSet::default(),
|
||||
title_page: false,
|
||||
page_num_start: None,
|
||||
page_num_format: NumFormat::Decimal,
|
||||
line_numbers: None,
|
||||
valign: VAlign::Top,
|
||||
page_borders: None,
|
||||
rtl: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl SectionProps {
|
||||
/// Width of the text area, points.
|
||||
pub fn text_width(&self) -> f32 {
|
||||
(self.page_w - self.margin_left - self.margin_right - self.gutter).max(18.0)
|
||||
}
|
||||
pub fn text_height(&self) -> f32 {
|
||||
(self.page_h - self.margin_top - self.margin_bottom).max(18.0)
|
||||
}
|
||||
/// Swap to landscape/portrait (keeps margins relative to the paper as Word does: rotates them).
|
||||
pub fn set_landscape(&mut self, on: bool) {
|
||||
if on == self.landscape {
|
||||
return;
|
||||
}
|
||||
self.landscape = on;
|
||||
std::mem::swap(&mut self.page_w, &mut self.page_h);
|
||||
let (t, b, l, r) = (self.margin_top, self.margin_bottom, self.margin_left, self.margin_right);
|
||||
if on {
|
||||
self.margin_top = l;
|
||||
self.margin_bottom = r;
|
||||
self.margin_left = b;
|
||||
self.margin_right = t;
|
||||
} else {
|
||||
self.margin_left = t;
|
||||
self.margin_right = b;
|
||||
self.margin_bottom = l;
|
||||
self.margin_top = r;
|
||||
}
|
||||
}
|
||||
/// Column (x offset from the text area's left, width) for each column.
|
||||
pub fn column_boxes(&self) -> Vec<(f32, f32)> {
|
||||
let tw = self.text_width();
|
||||
let n = self.columns.count.clamp(1, 45) as usize;
|
||||
if !self.columns.widths.is_empty() && self.columns.widths.len() == n {
|
||||
let mut x = 0.0;
|
||||
return self
|
||||
.columns
|
||||
.widths
|
||||
.iter()
|
||||
.map(|(w, sp)| {
|
||||
let b = (x, w.max(18.0));
|
||||
x += w + sp;
|
||||
b
|
||||
})
|
||||
.collect();
|
||||
}
|
||||
let space = self.columns.space.max(0.0);
|
||||
let w = ((tw - space * (n as f32 - 1.0)) / n as f32).max(18.0);
|
||||
(0..n).map(|i| (i as f32 * (w + space), w)).collect()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn number_formats() {
|
||||
assert_eq!(NumFormat::UpperRoman.format(1994), "MCMXCIV");
|
||||
assert_eq!(NumFormat::LowerLetter.format(27), "aa");
|
||||
assert_eq!(NumFormat::LowerLetter.format(0), "");
|
||||
assert_eq!(NumFormat::Ordinal.format(12), "12th");
|
||||
assert_eq!(NumFormat::Ordinal.format(22), "22nd");
|
||||
assert_eq!(NumFormat::CardinalText.format(21), "Twenty-One");
|
||||
assert_eq!(NumFormat::OrdinalText.format(21), "Twenty-First");
|
||||
assert_eq!(NumFormat::OrdinalText.format(20), "Twentieth");
|
||||
assert_eq!(NumFormat::OrdinalText.format(112), "One Hundred Twelfth");
|
||||
assert_eq!(NumFormat::DecimalZero.format(3), "03");
|
||||
assert!(!NumFormat::UpperRoman.format(u32::MAX).is_empty());
|
||||
assert_eq!(NumFormat::LowerLetter.format(u32::MAX).len(), 30);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn landscape_swaps() {
|
||||
let mut s = SectionProps { margin_left: 50.0, ..Default::default() };
|
||||
s.set_landscape(true);
|
||||
assert_eq!((s.page_w, s.page_h), (792.0, 612.0));
|
||||
assert_eq!(s.margin_top, 50.0);
|
||||
s.set_landscape(false);
|
||||
assert_eq!(s.margin_left, 50.0);
|
||||
assert_eq!((s.page_w, s.page_h), (612.0, 792.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn columns_split() {
|
||||
let s = SectionProps { columns: Columns { count: 2, space: 36.0, ..Default::default() }, ..Default::default() };
|
||||
let b = s.column_boxes();
|
||||
assert_eq!(b.len(), 2);
|
||||
assert_eq!(b[0], (0.0, 216.0));
|
||||
assert_eq!(b[1], (252.0, 216.0));
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,478 @@
|
|||
//! Styles: paragraph, character, table and linked styles with `based on` chains, plus the
|
||||
//! built-in style set (our own values in the spirit of a modern word processor's defaults).
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::props::{Align, Border, BorderStyle, Borders, CharProps, LineSpacing, ParaProps, Rgb, TabAlign, TabLeader, TabStop, TextColor, Underline};
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum StyleKind {
|
||||
#[default]
|
||||
Paragraph,
|
||||
Character,
|
||||
Table,
|
||||
Numbering,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Default)]
|
||||
#[serde(default, rename_all = "camelCase")]
|
||||
pub struct Style {
|
||||
/// Stable id (`Heading1`), referenced by `ParaProps::style` / `CharProps::style`.
|
||||
pub id: String,
|
||||
/// Display name (`Heading 1`).
|
||||
pub name: String,
|
||||
pub kind: StyleKind,
|
||||
pub based_on: Option<String>,
|
||||
/// Style for the paragraph after this one (Enter at the end).
|
||||
pub next: Option<String>,
|
||||
/// Linked character/paragraph style id.
|
||||
pub linked: Option<String>,
|
||||
pub para: ParaProps,
|
||||
pub chr: CharProps,
|
||||
/// Gallery order (lower first).
|
||||
pub priority: Option<u32>,
|
||||
/// Shown in the Styles gallery on the Home tab.
|
||||
pub quick: bool,
|
||||
pub hidden: bool,
|
||||
/// Built in (cannot be deleted).
|
||||
pub builtin: bool,
|
||||
/// Table style: conditional formatting for header row, banding etc.
|
||||
pub table: Option<TableStyleParts>,
|
||||
}
|
||||
|
||||
/// Table style conditional formats (shading + character formatting per region).
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Default)]
|
||||
#[serde(default, rename_all = "camelCase")]
|
||||
pub struct TableStyleParts {
|
||||
pub borders: Option<Borders>,
|
||||
pub header_fill: Option<Rgb>,
|
||||
pub header_chr: CharProps,
|
||||
pub band_fill: Option<Rgb>,
|
||||
pub first_col_chr: CharProps,
|
||||
pub total_chr: CharProps,
|
||||
pub total_border_top: Option<Border>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(default, rename_all = "camelCase")]
|
||||
pub struct StyleSheet {
|
||||
/// Document defaults (`w:docDefaults`).
|
||||
pub default_chr: CharProps,
|
||||
pub default_para: ParaProps,
|
||||
pub styles: Vec<Style>,
|
||||
}
|
||||
|
||||
impl Default for StyleSheet {
|
||||
fn default() -> Self {
|
||||
StyleSheet::builtin()
|
||||
}
|
||||
}
|
||||
|
||||
/// The default heading colour.
|
||||
pub const HEADING_BLUE: Rgb = Rgb(0x0F, 0x47, 0x61);
|
||||
/// Default body/heading fonts (theme minor/major). Substituted by layout when not installed.
|
||||
pub const BODY_FONT: &str = "Aptos";
|
||||
pub const HEADING_FONT: &str = "Aptos Display";
|
||||
|
||||
fn chr() -> CharProps {
|
||||
CharProps::default()
|
||||
}
|
||||
fn para() -> ParaProps {
|
||||
ParaProps::default()
|
||||
}
|
||||
fn color(c: Rgb) -> Option<TextColor> {
|
||||
Some(TextColor::Rgb(c))
|
||||
}
|
||||
|
||||
impl StyleSheet {
|
||||
pub fn empty() -> Self {
|
||||
StyleSheet { default_chr: CharProps::default(), default_para: ParaProps::default(), styles: Vec::new() }
|
||||
}
|
||||
|
||||
/// The built-in style set used for new documents.
|
||||
pub fn builtin() -> Self {
|
||||
let mut s = StyleSheet {
|
||||
default_chr: CharProps { font: Some(BODY_FONT.into()), size: Some(12.0), kern: Some(0.0), lang: Some("en-US".into()), ..chr() },
|
||||
default_para: ParaProps {
|
||||
space_after: Some(8.0),
|
||||
space_before: Some(0.0),
|
||||
line_spacing: Some(LineSpacing::Multiple(1.15)),
|
||||
widow_control: Some(true),
|
||||
..para()
|
||||
},
|
||||
styles: Vec::new(),
|
||||
};
|
||||
let mut add = |st: Style| s.styles.push(st);
|
||||
add(Style { id: "Normal".into(), name: "Normal".into(), priority: Some(0), quick: true, ..p_style() });
|
||||
add(Style {
|
||||
id: "DefaultParagraphFont".into(),
|
||||
name: "Default Paragraph Font".into(),
|
||||
kind: StyleKind::Character,
|
||||
priority: Some(1),
|
||||
hidden: true,
|
||||
builtin: true,
|
||||
..Default::default()
|
||||
});
|
||||
add(Style {
|
||||
id: "NoSpacing".into(),
|
||||
name: "No Spacing".into(),
|
||||
based_on: Some("Normal".into()),
|
||||
para: ParaProps { space_after: Some(0.0), line_spacing: Some(LineSpacing::Multiple(1.0)), ..para() },
|
||||
priority: Some(1),
|
||||
quick: true,
|
||||
..p_style()
|
||||
});
|
||||
let heads: [(f32, f32, f32, Option<bool>, Rgb, &str); 9] = [
|
||||
(20.0, 18.0, 4.0, None, HEADING_BLUE, HEADING_FONT),
|
||||
(16.0, 8.0, 4.0, None, HEADING_BLUE, HEADING_FONT),
|
||||
(14.0, 8.0, 4.0, None, HEADING_BLUE, BODY_FONT),
|
||||
(12.0, 4.0, 2.0, Some(true), HEADING_BLUE, BODY_FONT),
|
||||
(12.0, 4.0, 2.0, None, HEADING_BLUE, BODY_FONT),
|
||||
(12.0, 2.0, 0.0, Some(true), Rgb(0x59, 0x59, 0x59), BODY_FONT),
|
||||
(12.0, 2.0, 0.0, None, Rgb(0x59, 0x59, 0x59), BODY_FONT),
|
||||
(12.0, 0.0, 0.0, Some(true), Rgb(0x27, 0x27, 0x27), BODY_FONT),
|
||||
(12.0, 0.0, 0.0, None, Rgb(0x27, 0x27, 0x27), BODY_FONT),
|
||||
];
|
||||
for (i, (size, before, after, italic, c, font)) in heads.into_iter().enumerate() {
|
||||
let n = i + 1;
|
||||
add(Style {
|
||||
id: format!("Heading{n}"),
|
||||
name: format!("Heading {n}"),
|
||||
based_on: Some("Normal".into()),
|
||||
next: Some("Normal".into()),
|
||||
linked: Some(format!("Heading{n}Char")),
|
||||
para: ParaProps {
|
||||
space_before: Some(before),
|
||||
space_after: Some(after),
|
||||
keep_next: Some(true),
|
||||
keep_lines: Some(true),
|
||||
outline_level: Some(i as u8),
|
||||
..para()
|
||||
},
|
||||
chr: CharProps { font: Some(font.into()), size: Some(size), italic, color: color(c), ..chr() },
|
||||
priority: Some(9 + n as u32),
|
||||
quick: n <= 3,
|
||||
..p_style()
|
||||
});
|
||||
}
|
||||
add(Style {
|
||||
id: "Title".into(),
|
||||
name: "Title".into(),
|
||||
based_on: Some("Normal".into()),
|
||||
next: Some("Normal".into()),
|
||||
para: ParaProps { space_after: Some(4.0), contextual_spacing: Some(true), line_spacing: Some(LineSpacing::Multiple(1.0)), ..para() },
|
||||
chr: CharProps { font: Some(HEADING_FONT.into()), size: Some(28.0), spacing: Some(-0.5), kern: Some(14.0), ..chr() },
|
||||
priority: Some(10),
|
||||
quick: true,
|
||||
..p_style()
|
||||
});
|
||||
add(Style {
|
||||
id: "Subtitle".into(),
|
||||
name: "Subtitle".into(),
|
||||
based_on: Some("Normal".into()),
|
||||
next: Some("Normal".into()),
|
||||
chr: CharProps { size: Some(14.0), spacing: Some(0.75), color: color(Rgb(0x59, 0x59, 0x59)), ..chr() },
|
||||
priority: Some(11),
|
||||
quick: true,
|
||||
..p_style()
|
||||
});
|
||||
let c_style = |id: &str, name: &str, prio: u32, c: CharProps| Style {
|
||||
id: id.into(),
|
||||
name: name.into(),
|
||||
kind: StyleKind::Character,
|
||||
based_on: Some("DefaultParagraphFont".into()),
|
||||
chr: c,
|
||||
priority: Some(prio),
|
||||
quick: true,
|
||||
builtin: true,
|
||||
..Default::default()
|
||||
};
|
||||
add(c_style("SubtleEmphasis", "Subtle Emphasis", 19, CharProps { italic: Some(true), color: color(Rgb(0x40, 0x40, 0x40)), ..chr() }));
|
||||
add(c_style("Emphasis", "Emphasis", 20, CharProps { italic: Some(true), ..chr() }));
|
||||
add(c_style("IntenseEmphasis", "Intense Emphasis", 21, CharProps { italic: Some(true), color: color(HEADING_BLUE), ..chr() }));
|
||||
add(c_style("Strong", "Strong", 22, CharProps { bold: Some(true), ..chr() }));
|
||||
add(Style {
|
||||
id: "Quote".into(),
|
||||
name: "Quote".into(),
|
||||
based_on: Some("Normal".into()),
|
||||
next: Some("Normal".into()),
|
||||
para: ParaProps { align: Some(Align::Center), space_before: Some(8.0), ..para() },
|
||||
chr: CharProps { italic: Some(true), color: color(Rgb(0x40, 0x40, 0x40)), ..chr() },
|
||||
priority: Some(29),
|
||||
quick: true,
|
||||
..p_style()
|
||||
});
|
||||
let blue_line = Border { style: BorderStyle::Single, width: 0.5, color: Some(HEADING_BLUE), space: 10.0 };
|
||||
add(Style {
|
||||
id: "IntenseQuote".into(),
|
||||
name: "Intense Quote".into(),
|
||||
based_on: Some("Normal".into()),
|
||||
next: Some("Normal".into()),
|
||||
para: ParaProps {
|
||||
align: Some(Align::Center),
|
||||
space_before: Some(18.0),
|
||||
space_after: Some(18.0),
|
||||
indent_left: Some(43.2),
|
||||
indent_right: Some(43.2),
|
||||
borders: Some(Borders { top: Some(blue_line), bottom: Some(blue_line), ..Default::default() }),
|
||||
..para()
|
||||
},
|
||||
chr: CharProps { italic: Some(true), color: color(HEADING_BLUE), ..chr() },
|
||||
priority: Some(30),
|
||||
quick: true,
|
||||
..p_style()
|
||||
});
|
||||
add(c_style(
|
||||
"SubtleReference",
|
||||
"Subtle Reference",
|
||||
31,
|
||||
CharProps { small_caps: Some(true), color: color(Rgb(0x5A, 0x5A, 0x5A)), ..chr() },
|
||||
));
|
||||
add(c_style(
|
||||
"IntenseReference",
|
||||
"Intense Reference",
|
||||
32,
|
||||
CharProps { bold: Some(true), small_caps: Some(true), spacing: Some(0.25), color: color(HEADING_BLUE), ..chr() },
|
||||
));
|
||||
add(c_style("BookTitle", "Book Title", 33, CharProps { bold: Some(true), italic: Some(true), spacing: Some(0.25), ..chr() }));
|
||||
add(Style {
|
||||
id: "ListParagraph".into(),
|
||||
name: "List Paragraph".into(),
|
||||
based_on: Some("Normal".into()),
|
||||
para: ParaProps { indent_left: Some(36.0), contextual_spacing: Some(true), ..para() },
|
||||
priority: Some(34),
|
||||
quick: true,
|
||||
..p_style()
|
||||
});
|
||||
let mut hl = c_style("Hyperlink", "Hyperlink", 99, CharProps { color: color(Rgb(0x46, 0x78, 0x86)), underline: Some(Underline::Single), ..chr() });
|
||||
hl.quick = false;
|
||||
add(hl);
|
||||
add(Style {
|
||||
id: "Caption".into(),
|
||||
name: "Caption".into(),
|
||||
based_on: Some("Normal".into()),
|
||||
next: Some("Normal".into()),
|
||||
para: ParaProps { space_after: Some(10.0), line_spacing: Some(LineSpacing::Multiple(1.0)), ..para() },
|
||||
chr: CharProps { italic: Some(true), size: Some(9.0), color: color(Rgb(0x0E, 0x28, 0x41)), ..chr() },
|
||||
priority: Some(35),
|
||||
..p_style()
|
||||
});
|
||||
for (id, name) in [("Header", "Header"), ("Footer", "Footer")] {
|
||||
add(Style {
|
||||
id: id.into(),
|
||||
name: name.into(),
|
||||
based_on: Some("Normal".into()),
|
||||
para: ParaProps {
|
||||
space_after: Some(0.0),
|
||||
line_spacing: Some(LineSpacing::Multiple(1.0)),
|
||||
tabs: Some(vec![
|
||||
TabStop { pos: 234.0, align: TabAlign::Center, leader: TabLeader::None },
|
||||
TabStop { pos: 468.0, align: TabAlign::Right, leader: TabLeader::None },
|
||||
]),
|
||||
..para()
|
||||
},
|
||||
priority: Some(99),
|
||||
..p_style()
|
||||
});
|
||||
}
|
||||
add(Style {
|
||||
id: "FootnoteText".into(),
|
||||
name: "Footnote Text".into(),
|
||||
based_on: Some("Normal".into()),
|
||||
para: ParaProps { space_after: Some(0.0), line_spacing: Some(LineSpacing::Multiple(1.0)), ..para() },
|
||||
chr: CharProps { size: Some(10.0), ..chr() },
|
||||
priority: Some(99),
|
||||
..p_style()
|
||||
});
|
||||
let mut fr = c_style("FootnoteReference", "Footnote Reference", 99, CharProps { vert_align: Some(crate::props::VertAlign::Superscript), ..chr() });
|
||||
fr.quick = false;
|
||||
add(fr);
|
||||
add(Style {
|
||||
id: "EndnoteText".into(),
|
||||
name: "Endnote Text".into(),
|
||||
based_on: Some("Normal".into()),
|
||||
para: ParaProps { space_after: Some(0.0), line_spacing: Some(LineSpacing::Multiple(1.0)), ..para() },
|
||||
chr: CharProps { size: Some(10.0), ..chr() },
|
||||
priority: Some(99),
|
||||
..p_style()
|
||||
});
|
||||
let mut er = c_style("EndnoteReference", "Endnote Reference", 99, CharProps { vert_align: Some(crate::props::VertAlign::Superscript), ..chr() });
|
||||
er.quick = false;
|
||||
add(er);
|
||||
add(Style {
|
||||
id: "TOCHeading".into(),
|
||||
name: "TOC Heading".into(),
|
||||
based_on: Some("Heading1".into()),
|
||||
next: Some("Normal".into()),
|
||||
para: ParaProps { outline_level: Some(9), space_before: Some(12.0), ..para() },
|
||||
priority: Some(39),
|
||||
..p_style()
|
||||
});
|
||||
for n in 1..=9u32 {
|
||||
add(Style {
|
||||
id: format!("TOC{n}"),
|
||||
name: format!("TOC {n}"),
|
||||
based_on: Some("Normal".into()),
|
||||
next: Some("Normal".into()),
|
||||
para: ParaProps { space_after: Some(5.0), indent_left: Some(12.0 * (n - 1) as f32), ..para() },
|
||||
priority: Some(39),
|
||||
..p_style()
|
||||
});
|
||||
}
|
||||
add(Style {
|
||||
id: "TableGrid".into(),
|
||||
name: "Table Grid".into(),
|
||||
kind: StyleKind::Table,
|
||||
para: ParaProps { space_after: Some(0.0), line_spacing: Some(LineSpacing::Multiple(1.0)), ..para() },
|
||||
priority: Some(59),
|
||||
builtin: true,
|
||||
table: Some(TableStyleParts { borders: Some(Borders::all(Border::single(0.5))), ..Default::default() }),
|
||||
..Default::default()
|
||||
});
|
||||
s.styles.extend(table_styles());
|
||||
s
|
||||
}
|
||||
|
||||
pub fn get(&self, id: &str) -> Option<&Style> {
|
||||
self.styles.iter().find(|s| s.id == id)
|
||||
}
|
||||
pub fn get_mut(&mut self, id: &str) -> Option<&mut Style> {
|
||||
self.styles.iter_mut().find(|s| s.id == id)
|
||||
}
|
||||
/// Find by display name (case-insensitive) or id.
|
||||
pub fn find(&self, name_or_id: &str) -> Option<&Style> {
|
||||
self.get(name_or_id).or_else(|| self.styles.iter().find(|s| s.name.eq_ignore_ascii_case(name_or_id)))
|
||||
}
|
||||
/// The Quick Styles gallery, in priority order.
|
||||
pub fn gallery(&self) -> Vec<&Style> {
|
||||
let mut v: Vec<&Style> = self.styles.iter().filter(|s| s.quick && !s.hidden && s.kind != StyleKind::Table).collect();
|
||||
v.sort_by_key(|s| (s.priority.unwrap_or(99), s.name.clone()));
|
||||
v
|
||||
}
|
||||
/// The `based_on` chain from the root down to `id` (bounded, cycle-safe).
|
||||
pub fn chain(&self, id: &str) -> Vec<&Style> {
|
||||
let mut out = Vec::new();
|
||||
let mut cur = self.get(id);
|
||||
while let Some(s) = cur {
|
||||
if out.len() >= 32 || out.iter().any(|o: &&Style| o.id == s.id) {
|
||||
break;
|
||||
}
|
||||
out.push(s);
|
||||
cur = s.based_on.as_deref().and_then(|b| self.get(b));
|
||||
}
|
||||
out.reverse();
|
||||
out
|
||||
}
|
||||
/// Add or replace a style.
|
||||
pub fn upsert(&mut self, st: Style) {
|
||||
match self.styles.iter_mut().find(|s| s.id == st.id) {
|
||||
Some(s) => *s = st,
|
||||
None => self.styles.push(st),
|
||||
}
|
||||
}
|
||||
/// An id not used yet, derived from a display name.
|
||||
pub fn new_id(&self, name: &str) -> String {
|
||||
let base: String = name.chars().filter(|c| c.is_alphanumeric()).collect();
|
||||
let base = if base.is_empty() { "Style".to_string() } else { base };
|
||||
if self.get(&base).is_none() {
|
||||
return base;
|
||||
}
|
||||
(1..10_000).map(|i| format!("{base}{i}")).find(|id| self.get(id).is_none()).unwrap_or(base)
|
||||
}
|
||||
}
|
||||
|
||||
fn p_style() -> Style {
|
||||
Style { kind: StyleKind::Paragraph, builtin: true, ..Default::default() }
|
||||
}
|
||||
|
||||
/// A few table styles of our own (grid, plain, light/medium accents).
|
||||
fn table_styles() -> Vec<Style> {
|
||||
let thin = |c: Rgb| Border { style: BorderStyle::Single, width: 0.5, color: Some(c), space: 0.0 };
|
||||
let mk = |id: &str, name: &str, parts: TableStyleParts| Style {
|
||||
id: id.into(),
|
||||
name: name.into(),
|
||||
kind: StyleKind::Table,
|
||||
based_on: Some("TableNormal".into()),
|
||||
para: ParaProps { space_after: Some(0.0), line_spacing: Some(LineSpacing::Multiple(1.0)), ..ParaProps::default() },
|
||||
priority: Some(60),
|
||||
builtin: true,
|
||||
table: Some(parts),
|
||||
..Default::default()
|
||||
};
|
||||
let accents = [
|
||||
("Blue", Rgb(0x15, 0x60, 0x82), Rgb(0xC1, 0xE4, 0xF5)),
|
||||
("Orange", Rgb(0xE9, 0x71, 0x32), Rgb(0xFB, 0xE2, 0xD5)),
|
||||
("Green", Rgb(0x19, 0x6B, 0x24), Rgb(0xC1, 0xF0, 0xC8)),
|
||||
("Purple", Rgb(0x71, 0x2E, 0x8D), Rgb(0xE5, 0xD2, 0xEE)),
|
||||
];
|
||||
let mut v = vec![
|
||||
Style { id: "TableNormal".into(), name: "Normal Table".into(), kind: StyleKind::Table, hidden: true, builtin: true, ..Default::default() },
|
||||
mk("PlainTable1", "Plain Table 1", TableStyleParts {
|
||||
borders: Some(Borders::all(thin(Rgb(0xBF, 0xBF, 0xBF)))),
|
||||
header_chr: CharProps { bold: Some(true), ..CharProps::default() },
|
||||
band_fill: Some(Rgb(0xF2, 0xF2, 0xF2)),
|
||||
first_col_chr: CharProps { bold: Some(true), ..CharProps::default() },
|
||||
..Default::default()
|
||||
}),
|
||||
mk("GridTable1Light", "Grid Table 1 Light", TableStyleParts {
|
||||
borders: Some(Borders::all(thin(Rgb(0x99, 0x99, 0x99)))),
|
||||
header_chr: CharProps { bold: Some(true), ..CharProps::default() },
|
||||
first_col_chr: CharProps { bold: Some(true), ..CharProps::default() },
|
||||
..Default::default()
|
||||
}),
|
||||
];
|
||||
for (name, dark, light) in accents {
|
||||
v.push(mk(&format!("GridTable4Accent{name}"), &format!("Grid Table 4 – {name}"), TableStyleParts {
|
||||
borders: Some(Borders::all(thin(light))),
|
||||
header_fill: Some(dark),
|
||||
header_chr: CharProps { bold: Some(true), color: Some(TextColor::Rgb(Rgb::WHITE)), ..CharProps::default() },
|
||||
band_fill: Some(light),
|
||||
first_col_chr: CharProps { bold: Some(true), ..CharProps::default() },
|
||||
..Default::default()
|
||||
}));
|
||||
v.push(mk(&format!("ListTable3Accent{name}"), &format!("List Table 3 – {name}"), TableStyleParts {
|
||||
borders: Some(Borders::box_(thin(dark))),
|
||||
header_fill: Some(dark),
|
||||
header_chr: CharProps { bold: Some(true), color: Some(TextColor::Rgb(Rgb::WHITE)), ..CharProps::default() },
|
||||
total_border_top: Some(thin(dark)),
|
||||
..Default::default()
|
||||
}));
|
||||
}
|
||||
v
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn builtins_present() {
|
||||
let s = StyleSheet::builtin();
|
||||
for id in ["Normal", "Heading1", "Heading9", "Title", "Strong", "TableGrid", "TOC3", "Hyperlink", "ListParagraph"] {
|
||||
assert!(s.get(id).is_some(), "{id}");
|
||||
}
|
||||
assert_eq!(s.find("heading 2").map(|s| s.id.as_str()), Some("Heading2"));
|
||||
let g = s.gallery();
|
||||
assert_eq!(g.first().map(|s| s.id.as_str()), Some("Normal"));
|
||||
assert!(g.iter().any(|s| s.id == "Heading1"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn chain_is_cycle_safe() {
|
||||
let mut s = StyleSheet::builtin();
|
||||
if let Some(n) = s.get_mut("Normal") {
|
||||
n.based_on = Some("Heading1".into());
|
||||
}
|
||||
let c = s.chain("Heading1");
|
||||
assert!(c.len() <= 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn new_ids_unique() {
|
||||
let s = StyleSheet::builtin();
|
||||
assert_eq!(s.new_id("My Style"), "MyStyle");
|
||||
assert_eq!(s.new_id("Normal"), "Normal1");
|
||||
assert_eq!(s.new_id("!!!"), "Style");
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,340 @@
|
|||
//! Tables: a column grid, rows and cells (cells hold block lists). Merges follow OOXML: a cell
|
||||
//! spans `span` grid columns; vertical merges are `Restart` + `Continue` cells.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::props::{CellProps, RowProps, TableProps, VMerge};
|
||||
use crate::{Blocks, Paragraph, para_block};
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Default)]
|
||||
pub struct Cell {
|
||||
#[serde(default)]
|
||||
pub props: CellProps,
|
||||
pub blocks: Blocks,
|
||||
}
|
||||
|
||||
impl Cell {
|
||||
pub fn empty() -> Cell {
|
||||
Cell { props: CellProps { span: 1, ..Default::default() }, blocks: vec![para_block(Paragraph::new())] }
|
||||
}
|
||||
pub fn with_text(s: &str) -> Cell {
|
||||
Cell { props: CellProps { span: 1, ..Default::default() }, blocks: vec![para_block(Paragraph::with_text(s, Default::default()))] }
|
||||
}
|
||||
pub fn span(&self) -> usize {
|
||||
self.props.span.clamp(1, 63) as usize
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Default)]
|
||||
pub struct Row {
|
||||
#[serde(default)]
|
||||
pub props: RowProps,
|
||||
pub cells: Vec<Cell>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Default)]
|
||||
pub struct Table {
|
||||
#[serde(default)]
|
||||
pub props: TableProps,
|
||||
/// Grid column widths, points.
|
||||
pub grid: Vec<f32>,
|
||||
pub rows: Vec<Row>,
|
||||
}
|
||||
|
||||
/// Hard limits that keep hostile input from exhausting memory.
|
||||
pub const MAX_ROWS: usize = 32_767;
|
||||
pub const MAX_COLS: usize = 63;
|
||||
|
||||
impl Table {
|
||||
/// A `rows × cols` table spanning `width` points, "Table Grid" style.
|
||||
pub fn new(rows: usize, cols: usize, width: f32) -> Table {
|
||||
let rows = rows.clamp(1, MAX_ROWS);
|
||||
let cols = cols.clamp(1, MAX_COLS);
|
||||
let w = (width / cols as f32).max(6.0);
|
||||
Table {
|
||||
props: TableProps { style: Some("TableGrid".into()), ..Default::default() },
|
||||
grid: vec![w; cols],
|
||||
rows: (0..rows)
|
||||
.map(|_| Row {
|
||||
props: RowProps::default(),
|
||||
cells: (0..cols)
|
||||
.map(|_| {
|
||||
let mut c = Cell::empty();
|
||||
c.props.width = Some(w);
|
||||
c
|
||||
})
|
||||
.collect(),
|
||||
})
|
||||
.collect(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn cols(&self) -> usize {
|
||||
self.grid.len().max(self.rows.iter().map(|r| r.cells.iter().map(Cell::span).sum::<usize>()).max().unwrap_or(0))
|
||||
}
|
||||
|
||||
/// Grid column where cell `c` of row `r` starts.
|
||||
pub fn grid_col(&self, r: usize, c: usize) -> usize {
|
||||
self.rows.get(r).map(|row| row.cells.iter().take(c).map(Cell::span).sum()).unwrap_or(0)
|
||||
}
|
||||
|
||||
/// Cell index in row `r` that covers grid column `g`.
|
||||
pub fn cell_at_grid(&self, r: usize, g: usize) -> Option<usize> {
|
||||
let row = self.rows.get(r)?;
|
||||
let mut x = 0;
|
||||
for (i, c) in row.cells.iter().enumerate() {
|
||||
if g < x + c.span() {
|
||||
return Some(i);
|
||||
}
|
||||
x += c.span();
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Insert a row copied (formatting, empty text) from row `like` at index `at`.
|
||||
pub fn insert_row(&mut self, at: usize, like: usize) {
|
||||
let Some(src) = self.rows.get(like.min(self.rows.len().saturating_sub(1))) else { return };
|
||||
if self.rows.len() >= MAX_ROWS {
|
||||
return;
|
||||
}
|
||||
let mut row = Row { props: src.props.clone(), cells: Vec::with_capacity(src.cells.len()) };
|
||||
row.props.header = false;
|
||||
for c in &src.cells {
|
||||
let mut nc = Cell::empty();
|
||||
nc.props = c.props.clone();
|
||||
if nc.props.vmerge == VMerge::Restart {
|
||||
nc.props.vmerge = VMerge::None;
|
||||
}
|
||||
row.cells.push(nc);
|
||||
}
|
||||
self.rows.insert(at.min(self.rows.len()), row);
|
||||
}
|
||||
|
||||
pub fn delete_row(&mut self, r: usize) {
|
||||
if r < self.rows.len() {
|
||||
self.rows.remove(r);
|
||||
}
|
||||
}
|
||||
|
||||
/// Insert a grid column at grid index `g` (cells spanning it widen instead).
|
||||
pub fn insert_col(&mut self, g: usize) {
|
||||
if self.grid.len() >= MAX_COLS {
|
||||
return;
|
||||
}
|
||||
let g = g.min(self.grid.len());
|
||||
let w = self.grid.get(g.min(self.grid.len().saturating_sub(1))).copied().unwrap_or(72.0);
|
||||
self.grid.insert(g, w);
|
||||
for r in 0..self.rows.len() {
|
||||
let mut x = 0;
|
||||
let mut idx = None;
|
||||
if let Some(row) = self.rows.get(r) {
|
||||
for (i, c) in row.cells.iter().enumerate() {
|
||||
if g <= x {
|
||||
idx = Some((i, false));
|
||||
break;
|
||||
}
|
||||
if g < x + c.span() {
|
||||
idx = Some((i, true));
|
||||
break;
|
||||
}
|
||||
x += c.span();
|
||||
}
|
||||
}
|
||||
if let Some(row) = self.rows.get_mut(r) {
|
||||
match idx {
|
||||
Some((i, true)) => {
|
||||
if let Some(c) = row.cells.get_mut(i) {
|
||||
c.props.span += 1;
|
||||
}
|
||||
}
|
||||
Some((i, false)) => {
|
||||
let mut c = Cell::empty();
|
||||
c.props.width = Some(w);
|
||||
row.cells.insert(i, c);
|
||||
}
|
||||
None => {
|
||||
let mut c = Cell::empty();
|
||||
c.props.width = Some(w);
|
||||
row.cells.push(c);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Delete grid column `g` (spanning cells shrink).
|
||||
pub fn delete_col(&mut self, g: usize) {
|
||||
if g >= self.grid.len() || self.grid.len() <= 1 {
|
||||
return;
|
||||
}
|
||||
self.grid.remove(g);
|
||||
for row in &mut self.rows {
|
||||
let mut x = 0;
|
||||
let mut rm = None;
|
||||
for (i, c) in row.cells.iter_mut().enumerate() {
|
||||
if g < x + c.span() {
|
||||
if c.span() > 1 {
|
||||
c.props.span -= 1;
|
||||
} else {
|
||||
rm = Some(i);
|
||||
}
|
||||
break;
|
||||
}
|
||||
x += c.span();
|
||||
}
|
||||
if let Some(i) = rm {
|
||||
row.cells.remove(i);
|
||||
}
|
||||
}
|
||||
self.rows.retain(|r| !r.cells.is_empty());
|
||||
}
|
||||
|
||||
/// Merge cells `c0..=c1` of row `r` horizontally (content is concatenated).
|
||||
pub fn merge_right(&mut self, r: usize, c0: usize, c1: usize) {
|
||||
let Some(row) = self.rows.get_mut(r) else { return };
|
||||
if c1 <= c0 || c1 >= row.cells.len() {
|
||||
return;
|
||||
}
|
||||
let removed: Vec<Cell> = row.cells.drain(c0 + 1..=c1).collect();
|
||||
if let Some(first) = row.cells.get_mut(c0) {
|
||||
for c in removed {
|
||||
first.props.span += c.props.span.max(1);
|
||||
let nonempty: Vec<_> = c.blocks.into_iter().filter(|b| b.as_para().is_none_or(|p| !p.is_empty())).collect();
|
||||
first.blocks.extend(nonempty);
|
||||
}
|
||||
if first.blocks.len() > 1 && first.blocks.first().and_then(|b| b.as_para()).is_some_and(Paragraph::is_empty) {
|
||||
first.blocks.remove(0);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Merge a rectangle of cells (rows r0..=r1, grid columns g0..=g1).
|
||||
pub fn merge(&mut self, r0: usize, r1: usize, g0: usize, g1: usize) {
|
||||
for r in r0..=r1.min(self.rows.len().saturating_sub(1)) {
|
||||
let (Some(a), Some(b)) = (self.cell_at_grid(r, g0), self.cell_at_grid(r, g1)) else { continue };
|
||||
self.merge_right(r, a, b);
|
||||
if let Some(c) = self.rows.get_mut(r).and_then(|row| row.cells.get_mut(a)) {
|
||||
c.props.vmerge = if r1 == r0 {
|
||||
VMerge::None
|
||||
} else if r == r0 {
|
||||
VMerge::Restart
|
||||
} else {
|
||||
VMerge::Continue
|
||||
};
|
||||
}
|
||||
}
|
||||
// Move content of continued cells into the restart cell.
|
||||
if r1 > r0
|
||||
&& let Some(a) = self.cell_at_grid(r0, g0)
|
||||
{
|
||||
let mut moved = Vec::new();
|
||||
for r in r0 + 1..=r1.min(self.rows.len().saturating_sub(1)) {
|
||||
if let Some(c) = self.cell_at_grid(r, g0).and_then(|ci| self.rows.get_mut(r).and_then(|row| row.cells.get_mut(ci))) {
|
||||
let blocks = std::mem::replace(&mut c.blocks, vec![para_block(Paragraph::new())]);
|
||||
moved.extend(blocks.into_iter().filter(|b| b.as_para().is_none_or(|p| !p.is_empty())));
|
||||
}
|
||||
}
|
||||
if let Some(c) = self.rows.get_mut(r0).and_then(|row| row.cells.get_mut(a)) {
|
||||
c.blocks.extend(moved);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Split cell `c` of row `r` into `n` cells horizontally.
|
||||
pub fn split_cell(&mut self, r: usize, c: usize, n: usize) {
|
||||
let n = n.clamp(1, MAX_COLS);
|
||||
let Some(row) = self.rows.get_mut(r) else { return };
|
||||
let Some(cell) = row.cells.get_mut(c) else { return };
|
||||
let span = cell.span();
|
||||
if n <= 1 {
|
||||
return;
|
||||
}
|
||||
let w = cell.props.width.map(|w| w / n as f32);
|
||||
cell.props.width = w;
|
||||
if span >= n {
|
||||
cell.props.span = (span - (n - 1)) as u32;
|
||||
for k in 1..n {
|
||||
let mut nc = Cell::empty();
|
||||
nc.props.width = w;
|
||||
row.cells.insert(c + k, nc);
|
||||
}
|
||||
} else {
|
||||
// Need more grid columns: widen the grid at this column for every other row.
|
||||
let g = self.grid_col(r, c);
|
||||
let extra = n - span;
|
||||
for _ in 0..extra {
|
||||
self.insert_col(g + span);
|
||||
}
|
||||
// After insert_col this row got empty cells inserted (or the cell widened): normalise
|
||||
// the target row to n cells spanning one column each.
|
||||
if let Some(row) = self.rows.get_mut(r) {
|
||||
let mut x = 0;
|
||||
for cell in row.cells.iter_mut() {
|
||||
if x == g {
|
||||
cell.props.span = 1;
|
||||
}
|
||||
x += cell.span();
|
||||
}
|
||||
let have: usize = row.cells.iter().map(Cell::span).sum();
|
||||
let want = self.grid.len();
|
||||
for _ in have..want {
|
||||
row.cells.insert((c + 1).min(row.cells.len()), Cell::empty());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn total_width(&self) -> f32 {
|
||||
self.grid.iter().sum()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn new_table_shape() {
|
||||
let t = Table::new(3, 4, 468.0);
|
||||
assert_eq!(t.rows.len(), 3);
|
||||
assert_eq!(t.cols(), 4);
|
||||
assert_eq!(t.total_width(), 468.0);
|
||||
let huge = Table::new(1_000_000, 1000, 100.0);
|
||||
assert_eq!(huge.rows.len(), MAX_ROWS);
|
||||
assert_eq!(huge.cols(), MAX_COLS);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn row_col_ops() {
|
||||
let mut t = Table::new(2, 2, 200.0);
|
||||
t.insert_row(1, 0);
|
||||
assert_eq!(t.rows.len(), 3);
|
||||
t.insert_col(1);
|
||||
assert_eq!(t.cols(), 3);
|
||||
assert!(t.rows.iter().all(|r| r.cells.len() == 3));
|
||||
t.delete_col(0);
|
||||
assert_eq!(t.cols(), 2);
|
||||
t.delete_row(0);
|
||||
assert_eq!(t.rows.len(), 2);
|
||||
t.delete_row(99);
|
||||
t.delete_col(99);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn merge_and_split() {
|
||||
let mut t = Table::new(2, 3, 300.0);
|
||||
t.merge_right(0, 0, 1);
|
||||
assert_eq!(t.rows[0].cells.len(), 2);
|
||||
assert_eq!(t.rows[0].cells[0].span(), 2);
|
||||
assert_eq!(t.cell_at_grid(0, 1), Some(0));
|
||||
assert_eq!(t.grid_col(0, 1), 2);
|
||||
t.insert_col(1);
|
||||
assert_eq!(t.rows[0].cells[0].span(), 3);
|
||||
t.split_cell(0, 0, 3);
|
||||
assert_eq!(t.rows[0].cells.len(), 4);
|
||||
let mut v = Table::new(3, 2, 100.0);
|
||||
v.merge(0, 2, 0, 0);
|
||||
assert_eq!(v.rows[0].cells[0].props.vmerge, VMerge::Restart);
|
||||
assert_eq!(v.rows[2].cells[0].props.vmerge, VMerge::Continue);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,76 @@
|
|||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn new_document_is_valid() {
|
||||
let d = Document::new();
|
||||
assert_eq!(d.body.len(), 1);
|
||||
assert_eq!(d.para_paths(StoryRef::Body).len(), 1);
|
||||
assert_eq!(d.plain_text(StoryRef::Body), "");
|
||||
assert_eq!(d.sections().len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn word_count() {
|
||||
let d = Document::from_text("Hello, world!\nThis is — a test 42.\n\n");
|
||||
assert_eq!(d.word_count(), 7);
|
||||
assert_eq!(count_words(" -- "), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sections_and_section_mut() {
|
||||
let mut d = Document::from_text("a\nb\nc");
|
||||
if let Some(Block::Para(p)) = d.body.get_mut(0).map(Arc::make_mut) {
|
||||
p.section = Some(Box::new(SectionProps { landscape: true, ..Default::default() }));
|
||||
}
|
||||
let s = d.sections();
|
||||
assert_eq!(s.len(), 2);
|
||||
assert_eq!(s[0].0, 0);
|
||||
assert_eq!(d.section_index_of(0), 0);
|
||||
assert_eq!(d.section_index_of(2), 1);
|
||||
d.section_mut(0).margin_left = 10.0;
|
||||
d.section_mut(2).margin_left = 20.0;
|
||||
assert_eq!(d.sections()[0].1.margin_left, 10.0);
|
||||
assert_eq!(d.last_section.margin_left, 20.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parts_and_media() {
|
||||
let mut d = Document::new();
|
||||
let id = d.add_part(PartKind::Header, Vec::new());
|
||||
assert_eq!(d.story(StoryRef::Part(id)).map(|b| b.len()), Some(1));
|
||||
let k1 = d.add_media(vec![1, 2, 3], "png");
|
||||
let k2 = d.add_media(vec![1, 2, 3], "png");
|
||||
let k3 = d.add_media(vec![4], "png");
|
||||
assert_eq!(k1, k2);
|
||||
assert_ne!(k1, k3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn clone_is_shallow() {
|
||||
let d = Document::from_text("a\nb");
|
||||
let mut e = d.clone();
|
||||
assert!(Arc::ptr_eq(&d.body[0], &e.body[0]));
|
||||
e.insert_text(&Pos::body(1, 0), "x", &CharProps::default()).unwrap();
|
||||
assert!(Arc::ptr_eq(&d.body[0], &e.body[0]));
|
||||
assert!(!Arc::ptr_eq(&d.body[1], &e.body[1]));
|
||||
assert_eq!(d.plain_text(StoryRef::Body), "a\nb");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn json_round_trip() {
|
||||
let mut d = Document::from_text("Hello\nWorld");
|
||||
d.format_range(&Pos::body(0, 0), &Pos::body(0, 5), &|c| c.bold = Some(true)).unwrap();
|
||||
let j = serde_json::to_string(&d).unwrap();
|
||||
let back: Document = serde_json::from_str(&j).unwrap();
|
||||
assert_eq!(back.plain_text(StoryRef::Body), "Hello\nWorld");
|
||||
assert_eq!(back.para(StoryRef::Body, &Path::top(0)).unwrap().runs[0].props.bold, Some(true));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ensure_nonempty_repairs() {
|
||||
let mut d = Document::new();
|
||||
d.body.clear();
|
||||
d.body.push(Arc::new(Block::Table(Table::new(1, 1, 100.0))));
|
||||
d.ensure_nonempty();
|
||||
assert!(matches!(d.body.last().map(|b| &**b), Some(Block::Para(_))));
|
||||
}
|
||||
|
|
@ -0,0 +1,16 @@
|
|||
[package]
|
||||
name = "wordcraft-fonts"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
description = "WordCraft fonts: font database (bundled OFL + system), metrics, glyph outlines, shaping"
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
|
||||
[dependencies]
|
||||
kurbo = { workspace = true }
|
||||
skrifa = { workspace = true }
|
||||
harfrust = { workspace = true }
|
||||
unicode-bidi = { workspace = true }
|
||||
log = { workspace = true }
|
||||
|
|
@ -0,0 +1,55 @@
|
|||
//! Optional craft-fonts build input (https://github.com/storytold/craft-fonts, recipe from its
|
||||
//! `docs/integration.md`). With `CRAFT_FONTS_DIR=<checkout>` the fonts in its
|
||||
//! `fonts/manifest.txt` are embedded as `CRAFT_FONTS`; unset, `CRAFT_FONTS` is empty. Web
|
||||
//! (wasm32) builds embed only the UI face, BIZ UDPGothic Regular, to stay within the web size
|
||||
//! budget. It only reads the local checkout: no network.
|
||||
use std::fmt::Write as _;
|
||||
use std::path::PathBuf;
|
||||
|
||||
fn main() {
|
||||
println!("cargo::rerun-if-env-changed=CRAFT_FONTS_DIR");
|
||||
println!("cargo::rerun-if-env-changed=CRAFT_FONTS_REQUIRED");
|
||||
let mut src = String::from("pub static CRAFT_FONTS: &[CraftFont] = &[\n");
|
||||
if let Some(dir) = std::env::var_os("CRAFT_FONTS_DIR").map(PathBuf::from) {
|
||||
match craft_fonts(&dir) {
|
||||
Ok(entries) => src.push_str(&entries),
|
||||
Err(e) if std::env::var_os("CRAFT_FONTS_REQUIRED").is_some() => {
|
||||
println!("cargo::error=CRAFT_FONTS_DIR={}: {e}", dir.display());
|
||||
}
|
||||
Err(e) => println!("cargo::warning=building without craft-fonts: CRAFT_FONTS_DIR={}: {e}", dir.display()),
|
||||
}
|
||||
}
|
||||
src.push_str("];\n");
|
||||
let out = PathBuf::from(std::env::var_os("OUT_DIR").unwrap_or_default()).join("craft_fonts.rs");
|
||||
if let Err(e) = std::fs::write(&out, src) {
|
||||
println!("cargo::error=writing {}: {e}", out.display());
|
||||
}
|
||||
}
|
||||
|
||||
/// One `CraftFont { .. }` initialiser per manifest line.
|
||||
fn craft_fonts(dir: &std::path::Path) -> Result<String, String> {
|
||||
let manifest = dir.join("fonts/manifest.txt");
|
||||
println!("cargo::rerun-if-changed={}", manifest.display());
|
||||
let text = std::fs::read_to_string(&manifest).map_err(|e| format!("{}: {e}", manifest.display()))?;
|
||||
let wasm = std::env::var("CARGO_CFG_TARGET_ARCH").is_ok_and(|a| a == "wasm32");
|
||||
let mut out = String::new();
|
||||
for line in text.lines().map(str::trim).filter(|l| !l.is_empty() && !l.starts_with('#')) {
|
||||
let f: Vec<&str> = line.split(" | ").map(str::trim).collect();
|
||||
let [family, style, file, scripts, ..] = f.as_slice() else {
|
||||
return Err(format!("malformed manifest line: {line}"));
|
||||
};
|
||||
if wasm && !(*family == "BIZ UDPGothic" && *style == "Regular") {
|
||||
continue;
|
||||
}
|
||||
let path = dir.join(file).canonicalize().map_err(|e| format!("{file}: {e}"))?;
|
||||
println!("cargo::rerun-if-changed={}", path.display());
|
||||
let scripts: Vec<String> = scripts.split(',').map(|s| format!("{:?}", s.trim())).collect();
|
||||
let _ = writeln!(
|
||||
out,
|
||||
" CraftFont {{ family: {family:?}, style: {style:?}, scripts: &[{}], bytes: include_bytes!({:?}) }},",
|
||||
scripts.join(", "),
|
||||
path.display().to_string(),
|
||||
);
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
|
@ -0,0 +1,896 @@
|
|||
//! Font database: bundled OFL fonts, user fonts, the installed system fonts (cataloged the first
|
||||
//! time a lookup needs them, native only), outline cache.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::sync::atomic::{AtomicU32, Ordering};
|
||||
use std::sync::{Arc, Mutex, RwLock};
|
||||
|
||||
use kurbo::BezPath;
|
||||
use skrifa::instance::{Location, LocationRef, Size};
|
||||
use skrifa::outline::{DrawSettings, OutlinePen};
|
||||
use skrifa::raw::FileRef;
|
||||
use skrifa::string::StringId;
|
||||
use skrifa::{GlyphId, MetadataProvider};
|
||||
|
||||
/// The family used when a requested family is unknown (the UI sans).
|
||||
pub const FALLBACK_FAMILY: &str = "Source Sans 3";
|
||||
|
||||
/// The bundled fonts (OFL), as font file bytes. Japanese fonts are not bundled: they come from
|
||||
/// the optional craft-fonts build input ([`crate::japanese_document_fonts`]).
|
||||
pub fn bundled() -> &'static [&'static [u8]] {
|
||||
BUNDLED
|
||||
}
|
||||
|
||||
static BUNDLED: &[&[u8]] = &[
|
||||
include_bytes!("../../../assets/fonts/SourceSans3-Regular.ttf"),
|
||||
include_bytes!("../../../assets/fonts/SourceSans3-Semibold.ttf"),
|
||||
include_bytes!("../../../assets/fonts/SourceSans3-Bold.ttf"),
|
||||
include_bytes!("../../../assets/fonts/SourceSans3-It.ttf"),
|
||||
include_bytes!("../../../assets/fonts/SourceSerif4-Regular.ttf"),
|
||||
include_bytes!("../../../assets/fonts/SourceSerif4-It.ttf"),
|
||||
include_bytes!("../../../assets/fonts/SourceSerif4-Semibold.ttf"),
|
||||
include_bytes!("../../../assets/fonts/SourceSerif4-Bold.ttf"),
|
||||
include_bytes!("../../../assets/fonts/SourceSerif4-BoldIt.ttf"),
|
||||
include_bytes!("../../../assets/fonts/Inter-Regular.ttf"),
|
||||
include_bytes!("../../../assets/fonts/Inter-Medium.ttf"),
|
||||
include_bytes!("../../../assets/fonts/Inter-SemiBold.ttf"),
|
||||
include_bytes!("../../../assets/fonts/JetBrainsMono-Regular.ttf"),
|
||||
];
|
||||
|
||||
#[derive(Clone)]
|
||||
enum FontBytes {
|
||||
Static(&'static [u8]),
|
||||
Owned(Arc<Vec<u8>>),
|
||||
}
|
||||
|
||||
/// One loaded font face.
|
||||
pub struct FontFace {
|
||||
id: u32,
|
||||
/// Typographic family name (e.g. "Source Sans 3").
|
||||
pub family: String,
|
||||
/// Typographic style name (e.g. "Semibold", "Italic").
|
||||
pub style: String,
|
||||
/// usWeightClass-style weight (400 = regular).
|
||||
pub weight: f32,
|
||||
pub italic: bool,
|
||||
bytes: FontBytes,
|
||||
index: u32,
|
||||
pub upem: f64,
|
||||
/// Ascender in font units (positive = up).
|
||||
pub ascent: f64,
|
||||
/// Descender in font units (positive = down).
|
||||
pub descent: f64,
|
||||
/// Cap height and x height in font units (estimated from the ascent when the font has no OS/2
|
||||
/// values).
|
||||
pub cap_height: f64,
|
||||
pub x_height: f64,
|
||||
pub shaper: harfrust::ShaperData,
|
||||
/// Variable fonts: the named instance's axis settings (user units; empty = default instance),
|
||||
/// the normalised location and the shaper's view of it.
|
||||
pub coords: Vec<([u8; 4], f32)>,
|
||||
location: Location,
|
||||
pub instance: Option<harfrust::ShaperInstance>,
|
||||
/// Basic Multilingual Plane coverage bitset, built on first use.
|
||||
bmp: std::sync::OnceLock<Box<[u64]>>,
|
||||
}
|
||||
|
||||
/// A cheap `Copy` handle to a face. Faces are never unloaded, so the handle lives for the rest of
|
||||
/// the process; unlike cloning an `Arc`, copying it touches no shared reference count (which made
|
||||
/// parallel composition scale negatively).
|
||||
#[derive(Clone, Copy)]
|
||||
pub struct FaceRef(&'static FontFace);
|
||||
|
||||
impl FaceRef {
|
||||
/// The handle for a loaded face (memoised per face).
|
||||
pub fn of(face: &Arc<FontFace>) -> FaceRef {
|
||||
static LEAKED: RwLock<Vec<Option<&'static FontFace>>> = RwLock::new(Vec::new());
|
||||
let id = face.id as usize;
|
||||
if let Some(Some(f)) = LEAKED.read().unwrap_or_else(|e| e.into_inner()).get(id) {
|
||||
return FaceRef(f);
|
||||
}
|
||||
let mut w = LEAKED.write().unwrap_or_else(|e| e.into_inner());
|
||||
if w.len() <= id {
|
||||
w.resize(id + 1, None);
|
||||
}
|
||||
let f: &'static FontFace = w[id].unwrap_or_else(|| {
|
||||
let keep: &'static Arc<FontFace> = Box::leak(Box::new(face.clone()));
|
||||
keep
|
||||
});
|
||||
w[id] = Some(f);
|
||||
FaceRef(f)
|
||||
}
|
||||
pub fn get(self) -> &'static FontFace {
|
||||
self.0
|
||||
}
|
||||
}
|
||||
|
||||
impl std::ops::Deref for FaceRef {
|
||||
type Target = FontFace;
|
||||
fn deref(&self) -> &FontFace {
|
||||
self.0
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Debug for FaceRef {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
self.0.fmt(f)
|
||||
}
|
||||
}
|
||||
|
||||
impl PartialEq for FaceRef {
|
||||
fn eq(&self, o: &FaceRef) -> bool {
|
||||
self.0.id == o.0.id
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Debug for FontFace {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
write!(f, "FontFace({} {})", self.family, self.style)
|
||||
}
|
||||
}
|
||||
|
||||
impl FontFace {
|
||||
pub fn data(&self) -> &[u8] {
|
||||
match &self.bytes {
|
||||
FontBytes::Static(b) => b,
|
||||
FontBytes::Owned(v) => v.as_slice(),
|
||||
}
|
||||
}
|
||||
pub fn skrifa(&self) -> Option<skrifa::FontRef<'_>> {
|
||||
skrifa::FontRef::from_index(self.data(), self.index).ok()
|
||||
}
|
||||
pub fn hb(&self) -> Option<harfrust::FontRef<'_>> {
|
||||
harfrust::FontRef::from_index(self.data(), self.index).ok()
|
||||
}
|
||||
/// Where in the design space glyphs, metrics and outlines come from.
|
||||
pub fn location(&self) -> LocationRef<'_> {
|
||||
(&self.location).into()
|
||||
}
|
||||
/// Is this a named instance of a variable font?
|
||||
pub fn is_variable(&self) -> bool {
|
||||
!self.coords.is_empty()
|
||||
}
|
||||
/// Face index within the font file (collections); 0 for plain fonts.
|
||||
pub fn index(&self) -> u32 {
|
||||
self.index
|
||||
}
|
||||
/// Unique id of this face within the process.
|
||||
pub fn id(&self) -> u32 {
|
||||
self.id
|
||||
}
|
||||
/// Does the face map `c` to a glyph?
|
||||
pub fn covers(&self, c: char) -> bool {
|
||||
let cp = c as u32;
|
||||
if cp < 0x1_0000 {
|
||||
let bits = self.bmp.get_or_init(|| {
|
||||
let mut b = vec![0u64; 1024].into_boxed_slice();
|
||||
if let Some(f) = self.skrifa() {
|
||||
for (cp, _) in f.charmap().mappings() {
|
||||
if cp < 0x1_0000 {
|
||||
b[(cp / 64) as usize] |= 1 << (cp % 64);
|
||||
}
|
||||
}
|
||||
}
|
||||
b
|
||||
});
|
||||
return bits[(cp / 64) as usize] & (1 << (cp % 64)) != 0;
|
||||
}
|
||||
self.skrifa().is_some_and(|f| f.charmap().map(c).is_some())
|
||||
}
|
||||
/// Units per em.
|
||||
pub fn units_per_em(&self) -> f64 {
|
||||
self.upem
|
||||
}
|
||||
/// (ascent, descent) in font units, both positive.
|
||||
pub fn vertical_metrics(&self) -> (f64, f64) {
|
||||
(self.ascent, self.descent)
|
||||
}
|
||||
/// Every mapped character and its glyph id, sorted by code point (the Glyphs panel).
|
||||
pub fn chars(&self) -> Vec<(char, u32)> {
|
||||
let Some(f) = self.skrifa() else { return vec![] };
|
||||
let mut v: Vec<(char, u32)> = f.charmap().mappings().filter_map(|(cp, g)| char::from_u32(cp).map(|c| (c, g.to_u32()))).collect();
|
||||
v.sort_unstable_by_key(|x| x.0);
|
||||
v.dedup_by_key(|x| x.0);
|
||||
v
|
||||
}
|
||||
/// Advance width of glyph `gid` in font units.
|
||||
pub fn advance(&self, gid: u32) -> f64 {
|
||||
self.skrifa()
|
||||
.and_then(|f| f.glyph_metrics(Size::unscaled(), self.location()).advance_width(GlyphId::new(gid)))
|
||||
.map(|a| a as f64)
|
||||
.unwrap_or(self.upem * 0.5)
|
||||
}
|
||||
/// Glyph id for `c` (0 = .notdef).
|
||||
pub fn glyph_for(&self, c: char) -> u32 {
|
||||
self.skrifa().and_then(|f| f.charmap().map(c)).map(|g| g.to_u32()).unwrap_or(0)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
#[derive(Clone, Debug)]
|
||||
struct CatalogEntry {
|
||||
family: String,
|
||||
style: String,
|
||||
path: std::path::PathBuf,
|
||||
}
|
||||
|
||||
/// Process-wide font database.
|
||||
pub struct FontDb {
|
||||
faces: RwLock<Vec<Arc<FontFace>>>,
|
||||
outlines: Mutex<HashMap<(u32, u32), Arc<BezPath>>>,
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
catalog: RwLock<Vec<CatalogEntry>>,
|
||||
/// The folders the system font scan reads (the platform's font folders for [`FontDb::global`]).
|
||||
#[cfg_attr(target_arch = "wasm32", allow(dead_code))]
|
||||
font_dirs: Vec<std::path::PathBuf>,
|
||||
/// Set once the font folders have been scanned. Lookups by family name wait for the first
|
||||
/// scan, so what they find doesn't depend on what ran before them.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
cataloged: std::sync::OnceLock<()>,
|
||||
/// System fallback state: enabled, and characters no system font covers.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
sys: Mutex<SysFallback>,
|
||||
}
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
#[derive(Default)]
|
||||
struct SysFallback {
|
||||
enabled: bool,
|
||||
misses: std::collections::HashSet<char>,
|
||||
}
|
||||
|
||||
/// Families tried (when installed) for characters the loaded fonts lack: CJK, symbols, emoji.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
const SYSTEM_FALLBACKS: &[&str] = &[
|
||||
"Helvetica Neue",
|
||||
"Arial",
|
||||
"Segoe UI",
|
||||
"Noto Sans",
|
||||
"DejaVu Sans",
|
||||
"PingFang SC",
|
||||
"Hiragino Sans",
|
||||
"Hiragino Kaku Gothic ProN",
|
||||
"Apple SD Gothic Neo",
|
||||
"Heiti SC",
|
||||
"STHeiti",
|
||||
"Microsoft YaHei",
|
||||
"Yu Gothic",
|
||||
"Malgun Gothic",
|
||||
"Noto Sans CJK SC",
|
||||
"Noto Sans CJK JP",
|
||||
"Arial Unicode MS",
|
||||
"Apple Symbols",
|
||||
"Segoe UI Symbol",
|
||||
"Noto Sans Symbols",
|
||||
"Noto Sans Symbols2",
|
||||
"Noto Emoji",
|
||||
"Segoe UI Emoji",
|
||||
"Apple Color Emoji",
|
||||
"Noto Color Emoji",
|
||||
];
|
||||
|
||||
static NEXT_ID: AtomicU32 = AtomicU32::new(1);
|
||||
const OUTLINE_CACHE_MAX: usize = 50_000;
|
||||
|
||||
fn name(font: &skrifa::FontRef<'_>, ids: &[StringId]) -> Option<String> {
|
||||
ids.iter().find_map(|id| font.localized_strings(*id).english_or_first().map(|s| s.to_string()).filter(|s| !s.is_empty()))
|
||||
}
|
||||
|
||||
/// A face's family and style names (the default instance's style for a variable font).
|
||||
fn face_names(f: &skrifa::FontRef<'_>) -> Option<(String, String)> {
|
||||
let family = name(f, &[StringId::TYPOGRAPHIC_FAMILY_NAME, StringId::FAMILY_NAME])?;
|
||||
let style = name(f, &[StringId::TYPOGRAPHIC_SUBFAMILY_NAME, StringId::SUBFAMILY_NAME]).unwrap_or_else(|| "Regular".into());
|
||||
Some((family, style))
|
||||
}
|
||||
|
||||
/// (family, style) of every face in the font file at `path`, reading only its table directories
|
||||
/// and `name` tables: a scan opens hundreds of font files, many of them megabytes long. A
|
||||
/// variable font is cataloged under its default style; its named instances appear once it loads.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn file_face_names(path: &std::path::Path) -> Vec<(String, String)> {
|
||||
use std::io::{Read, Seek, SeekFrom};
|
||||
/// Caps on what a (possibly damaged) file can make the scan read.
|
||||
const MAX_FACES: u32 = 256;
|
||||
const MAX_NAME_TABLE: u32 = 1 << 20;
|
||||
let Ok(mut file) = std::fs::File::open(path) else { return vec![] };
|
||||
let mut read_at = |offset: u64, len: usize| -> Option<Vec<u8>> {
|
||||
let mut buf = vec![0; len];
|
||||
file.seek(SeekFrom::Start(offset)).ok()?;
|
||||
file.read_exact(&mut buf).ok()?;
|
||||
Some(buf)
|
||||
};
|
||||
let be32 = |b: &[u8], at: usize| b.get(at..at + 4).and_then(|s| s.try_into().ok()).map(u32::from_be_bytes);
|
||||
let Some(head) = read_at(0, 12) else { return vec![] };
|
||||
// A collection lists where each face's table directory starts.
|
||||
let starts: Vec<u32> = if head.starts_with(b"ttcf") {
|
||||
let n = be32(&head, 8).unwrap_or(0).min(MAX_FACES) as usize;
|
||||
read_at(12, n * 4).map(|b| b.as_chunks::<4>().0.iter().map(|c| u32::from_be_bytes(*c)).collect()).unwrap_or_default()
|
||||
} else {
|
||||
vec![0]
|
||||
};
|
||||
starts
|
||||
.into_iter()
|
||||
.filter_map(|start| {
|
||||
let dir = read_at(start.into(), 12)?;
|
||||
let tables = u16::from_be_bytes(dir.get(4..6)?.try_into().ok()?) as usize;
|
||||
let records = read_at(u64::from(start) + 12, tables * 16)?;
|
||||
let rec = records.as_chunks::<16>().0.iter().find(|r| r.starts_with(b"name"))?;
|
||||
let (offset, len) = (be32(rec, 8)?, be32(rec, 12)?);
|
||||
if len > MAX_NAME_TABLE {
|
||||
return None;
|
||||
}
|
||||
let table = read_at(offset.into(), len as usize)?;
|
||||
// A one-table font holding just the `name` table, to read it as the font itself would.
|
||||
let mut font = Vec::with_capacity(28 + table.len());
|
||||
font.extend_from_slice(&0x0001_0000_u32.to_be_bytes());
|
||||
font.extend_from_slice(&[0, 1, 0, 16, 0, 0, 0, 0]);
|
||||
font.extend_from_slice(b"name");
|
||||
for v in [0, 28, len] {
|
||||
font.extend_from_slice(&u32::to_be_bytes(v));
|
||||
}
|
||||
font.extend_from_slice(&table);
|
||||
face_names(&skrifa::FontRef::new(&font).ok()?)
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// The platform's font folders (the system's and the user's), scanned by [`FontDb::global`].
|
||||
/// Empty on wasm.
|
||||
pub fn system_font_dirs() -> Vec<std::path::PathBuf> {
|
||||
let mut dirs: Vec<std::path::PathBuf> = Vec::new();
|
||||
if cfg!(target_arch = "wasm32") {
|
||||
return dirs;
|
||||
}
|
||||
let home = std::env::var_os("HOME").map(std::path::PathBuf::from);
|
||||
if cfg!(target_os = "macos") {
|
||||
dirs.extend(["/System/Library/Fonts", "/Library/Fonts"].map(Into::into));
|
||||
if let Some(h) = &home {
|
||||
dirs.push(h.join("Library/Fonts"));
|
||||
}
|
||||
// Fonts macOS ships as assets (PingFang, Yu Mincho, …): com_apple_MobileAsset_Font<N>.
|
||||
for parent in ["/System/Library/AssetsV2", "/System/Library/AssetsV2/PreinstalledAssetsV2/InstallWithOs"] {
|
||||
let Ok(entries) = std::fs::read_dir(parent) else { continue };
|
||||
for e in entries.flatten() {
|
||||
if e.file_name().to_string_lossy().starts_with("com_apple_MobileAsset_Font") {
|
||||
dirs.push(e.path());
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if cfg!(windows) {
|
||||
let root = std::env::var_os("WINDIR").map(std::path::PathBuf::from).unwrap_or_else(|| "C:\\Windows".into());
|
||||
dirs.push(root.join("Fonts"));
|
||||
if let Some(l) = std::env::var_os("LOCALAPPDATA") {
|
||||
dirs.push(std::path::PathBuf::from(l).join("Microsoft\\Windows\\Fonts"));
|
||||
}
|
||||
} else {
|
||||
dirs.extend(["/usr/share/fonts", "/usr/local/share/fonts"].map(Into::into));
|
||||
if let Some(h) = &home {
|
||||
dirs.push(h.join(".fonts"));
|
||||
dirs.push(h.join(".local/share/fonts"));
|
||||
}
|
||||
}
|
||||
dirs
|
||||
}
|
||||
|
||||
/// One face found in a font file: index, family, style and (variable fonts) the named instance's
|
||||
/// axis settings.
|
||||
type Found = (u32, String, String, Vec<([u8; 4], f32)>);
|
||||
|
||||
/// Parse every face in `data` (a font file or collection); a variable font yields one face per
|
||||
/// named instance (InDesign lists them as styles).
|
||||
fn enumerate_faces(data: &[u8]) -> Vec<Found> {
|
||||
let count = match FileRef::new(data) {
|
||||
Ok(FileRef::Font(_)) => 1,
|
||||
Ok(FileRef::Collection(c)) => c.len(),
|
||||
Err(_) => 0,
|
||||
};
|
||||
let mut out = Vec::new();
|
||||
for i in 0..count {
|
||||
let Ok(f) = skrifa::FontRef::from_index(data, i) else { continue };
|
||||
let Some((family, style)) = face_names(&f) else { continue };
|
||||
let axes = f.axes();
|
||||
let mut named = Vec::new();
|
||||
for ni in f.named_instances().iter() {
|
||||
let Some(style) = name(&f, &[ni.subfamily_name_id()]) else { continue };
|
||||
if named.iter().any(|(_, s, _): &(u32, String, _)| s.eq_ignore_ascii_case(&style)) {
|
||||
continue;
|
||||
}
|
||||
let coords: Vec<([u8; 4], f32)> = axes.iter().zip(ni.user_coords()).map(|(a, v)| (a.tag().to_be_bytes(), v)).collect();
|
||||
named.push((i, style, coords));
|
||||
}
|
||||
if named.is_empty() {
|
||||
out.push((i, family, style, Vec::new()));
|
||||
} else {
|
||||
out.extend(named.into_iter().map(|(i, s, c)| (i, family.clone(), s, c)));
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// The compiled-in Source Sans 3 Regular, for a database that somehow has no faces at all.
|
||||
pub(crate) fn last_resort_face() -> Arc<FontFace> {
|
||||
static FACE: std::sync::OnceLock<Arc<FontFace>> = std::sync::OnceLock::new();
|
||||
FACE.get_or_init(|| {
|
||||
// The font is compiled in (`include_bytes!`), so parsing it can't depend on input; the
|
||||
// `last_resort_face_parses` test proves it on every run.
|
||||
#[allow(clippy::expect_used)]
|
||||
let face = make_face(FontBytes::Static(BUNDLED[0]), 0, FALLBACK_FAMILY.into(), "Regular".into(), Vec::new())
|
||||
.expect("the compiled-in Source Sans 3 Regular parses");
|
||||
Arc::new(face)
|
||||
})
|
||||
.clone()
|
||||
}
|
||||
|
||||
fn make_face(bytes: FontBytes, index: u32, family: String, style: String, coords: Vec<([u8; 4], f32)>) -> Option<FontFace> {
|
||||
let data: &[u8] = match &bytes {
|
||||
FontBytes::Static(b) => b,
|
||||
FontBytes::Owned(v) => v.as_slice(),
|
||||
};
|
||||
let f = skrifa::FontRef::from_index(data, index).ok()?;
|
||||
let settings: Vec<(skrifa::Tag, f32)> = coords.iter().map(|(t, v)| (skrifa::Tag::new(t), *v)).collect();
|
||||
let location = if coords.is_empty() { Location::default() } else { f.axes().location(settings.iter().copied()) };
|
||||
let m = f.metrics(Size::unscaled(), &location);
|
||||
let a = f.attributes();
|
||||
let hb = harfrust::FontRef::from_index(data, index).ok()?;
|
||||
let shaper = harfrust::ShaperData::new(&hb);
|
||||
let instance = (!coords.is_empty())
|
||||
.then(|| harfrust::ShaperInstance::from_variations(&hb, settings.iter().map(|(t, v)| harfrust::Variation { tag: *t, value: *v })));
|
||||
let axis = |tag: &[u8; 4]| coords.iter().find(|(t, _)| t == tag).map(|(_, v)| *v);
|
||||
let weight = axis(b"wght").unwrap_or(if coords.is_empty() { a.weight.value() } else { style_weight(&style) });
|
||||
let italic = axis(b"ital")
|
||||
.map(|v| v >= 0.5)
|
||||
.or(axis(b"slnt").map(|v| v.abs() > 0.1))
|
||||
.unwrap_or(!matches!(a.style, skrifa::attribute::Style::Normal) || style_italic(&style));
|
||||
Some(FontFace {
|
||||
id: NEXT_ID.fetch_add(1, Ordering::Relaxed),
|
||||
family,
|
||||
style,
|
||||
weight,
|
||||
italic,
|
||||
upem: m.units_per_em.max(1) as f64,
|
||||
ascent: m.ascent as f64,
|
||||
descent: -(m.descent as f64),
|
||||
cap_height: m.cap_height.map(|v| v as f64).filter(|v| *v > 0.0).unwrap_or(m.ascent as f64 * 0.72),
|
||||
x_height: m.x_height.map(|v| v as f64).filter(|v| *v > 0.0).unwrap_or(m.ascent as f64 * 0.5),
|
||||
shaper,
|
||||
coords,
|
||||
location,
|
||||
instance,
|
||||
bytes,
|
||||
index,
|
||||
bmp: std::sync::OnceLock::new(),
|
||||
})
|
||||
}
|
||||
|
||||
/// The named style part of a style with axis settings (`Bold {wght:650}` → `Bold`).
|
||||
pub fn base_style(style: &str) -> &str {
|
||||
style.split('{').next().unwrap_or(style).trim()
|
||||
}
|
||||
|
||||
fn norm(s: &str) -> String {
|
||||
s.chars().filter(|c| c.is_alphanumeric()).flat_map(|c| c.to_lowercase()).collect()
|
||||
}
|
||||
|
||||
/// Weight implied by a style name.
|
||||
fn style_weight(style: &str) -> f32 {
|
||||
let s = norm(style);
|
||||
const TABLE: &[(&str, f32)] = &[
|
||||
("extralight", 200.0),
|
||||
("ultralight", 200.0),
|
||||
("semibold", 600.0),
|
||||
("demibold", 600.0),
|
||||
("extrabold", 800.0),
|
||||
("ultrabold", 800.0),
|
||||
("hairline", 100.0),
|
||||
("thin", 100.0),
|
||||
("light", 300.0),
|
||||
("medium", 500.0),
|
||||
("bold", 700.0),
|
||||
("black", 900.0),
|
||||
("heavy", 900.0),
|
||||
];
|
||||
TABLE.iter().find(|(k, _)| s.contains(k)).map(|(_, w)| *w).unwrap_or(400.0)
|
||||
}
|
||||
|
||||
fn style_italic(style: &str) -> bool {
|
||||
let s = norm(style);
|
||||
s.contains("italic") || s.contains("oblique") || s == "it"
|
||||
}
|
||||
|
||||
impl FontDb {
|
||||
/// A database holding the bundled fonts, then the craft-fonts Japanese faces (Mincho first,
|
||||
/// so they are the fallback for Japanese text after the requested and bundled fonts; empty
|
||||
/// without `CRAFT_FONTS_DIR`), whose system font scan reads `font_dirs`.
|
||||
pub fn with_font_dirs(font_dirs: Vec<std::path::PathBuf>) -> Self {
|
||||
let mut faces = Vec::new();
|
||||
let craft = crate::japanese_document_fonts().into_iter().map(|f| f.bytes);
|
||||
for data in BUNDLED.iter().copied().chain(craft) {
|
||||
for (i, family, style, coords) in enumerate_faces(data) {
|
||||
if let Some(f) = make_face(FontBytes::Static(data), i, family, style, coords) {
|
||||
faces.push(Arc::new(f));
|
||||
}
|
||||
}
|
||||
}
|
||||
Self {
|
||||
faces: RwLock::new(faces),
|
||||
outlines: Mutex::new(HashMap::new()),
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
catalog: RwLock::new(Vec::new()),
|
||||
font_dirs,
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
cataloged: std::sync::OnceLock::new(),
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
sys: Mutex::new(SysFallback { enabled: true, ..Default::default() }),
|
||||
}
|
||||
}
|
||||
|
||||
/// Enable or disable the lazy system-font fallback for characters the loaded fonts lack
|
||||
/// (native only; on by default).
|
||||
pub fn set_system_fallback(&self, on: bool) {
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
{
|
||||
self.sys.lock().unwrap_or_else(|e| e.into_inner()).enabled = on;
|
||||
}
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
let _ = on;
|
||||
}
|
||||
|
||||
/// Process-wide database: the bundled fonts, then the installed system fonts, cataloged the
|
||||
/// first time a lookup by family name needs them (or ahead of time by
|
||||
/// [`FontDb::scan_in_background`]).
|
||||
pub fn global() -> &'static FontDb {
|
||||
static DB: std::sync::OnceLock<FontDb> = std::sync::OnceLock::new();
|
||||
DB.get_or_init(|| FontDb::with_font_dirs(system_font_dirs()))
|
||||
}
|
||||
|
||||
fn read_faces(&self) -> std::sync::RwLockReadGuard<'_, Vec<Arc<FontFace>>> {
|
||||
self.faces.read().unwrap_or_else(|e| e.into_inner())
|
||||
}
|
||||
|
||||
/// The system font catalog, scanned first if it hasn't been yet.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn read_catalog(&self) -> std::sync::RwLockReadGuard<'_, Vec<CatalogEntry>> {
|
||||
self.ensure_catalog();
|
||||
self.catalog.read().unwrap_or_else(|e| e.into_inner())
|
||||
}
|
||||
|
||||
/// Family names available (loaded plus cataloged system fonts), sorted and deduplicated.
|
||||
pub fn families(&self) -> Vec<String> {
|
||||
let mut v: Vec<String> = self.read_faces().iter().map(|f| f.family.clone()).collect();
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
v.extend(self.read_catalog().iter().map(|c| c.family.clone()));
|
||||
v.sort_by_key(|a| a.to_lowercase());
|
||||
v.dedup_by(|a, b| a.eq_ignore_ascii_case(b));
|
||||
v
|
||||
}
|
||||
|
||||
/// Style names available for `family` (Regular first, then by weight).
|
||||
pub fn styles(&self, family: &str) -> Vec<String> {
|
||||
let mut v: Vec<(bool, f32, String)> = self
|
||||
.read_faces()
|
||||
.iter()
|
||||
.filter(|f| f.family.eq_ignore_ascii_case(family) && !f.style.contains('{'))
|
||||
.map(|f| (f.italic, f.weight, f.style.clone()))
|
||||
.collect();
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
for c in self.read_catalog().iter() {
|
||||
if c.family.eq_ignore_ascii_case(family) && !v.iter().any(|(_, _, s)| s.eq_ignore_ascii_case(&c.style)) {
|
||||
v.push((style_italic(&c.style), style_weight(&c.style), c.style.clone()));
|
||||
}
|
||||
}
|
||||
v.sort_by(|a, b| a.0.cmp(&b.0).then(a.1.total_cmp(&b.1)).then(a.2.cmp(&b.2)));
|
||||
v.dedup_by(|a, b| a.2 == b.2);
|
||||
v.into_iter().map(|t| t.2).collect()
|
||||
}
|
||||
|
||||
/// Add a user font (TTF/OTF/TTC bytes). Returns the number of faces added (0 if unparseable or
|
||||
/// every face was already present).
|
||||
pub fn add_font(&self, bytes: Vec<u8>) -> usize {
|
||||
let data = Arc::new(bytes);
|
||||
let mut added = 0;
|
||||
for (i, family, style, coords) in enumerate_faces(&data) {
|
||||
if self.read_faces().iter().any(|f| f.family.eq_ignore_ascii_case(&family) && f.style.eq_ignore_ascii_case(&style)) {
|
||||
continue;
|
||||
}
|
||||
if let Some(f) = make_face(FontBytes::Owned(data.clone()), i, family, style, coords) {
|
||||
self.faces.write().unwrap_or_else(|e| e.into_inner()).push(Arc::new(f));
|
||||
added += 1;
|
||||
}
|
||||
}
|
||||
added
|
||||
}
|
||||
|
||||
/// Catalog the installed fonts unless that has been done: the first caller scans the font
|
||||
/// folders, any other waits for that scan to finish.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn ensure_catalog(&self) {
|
||||
self.cataloged.get_or_init(|| {
|
||||
self.scan_font_dirs();
|
||||
});
|
||||
}
|
||||
|
||||
/// Catalog the installed fonts on a background thread, so the first lookup by family name
|
||||
/// (opening a file, the font menus) doesn't wait for the scan. A no-op once they are
|
||||
/// cataloged, and on wasm.
|
||||
pub fn scan_in_background(&'static self) {
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
if self.cataloged.get().is_none() {
|
||||
// A failed spawn leaves the scan to the first lookup that needs it.
|
||||
let _ = std::thread::Builder::new().name("font-scan".into()).spawn(move || self.ensure_catalog());
|
||||
}
|
||||
}
|
||||
|
||||
/// Scan the font folders again (fonts installed or removed since), cataloging the faces
|
||||
/// found (native only). Font data is loaded when a cataloged family is first resolved.
|
||||
/// Returns the number of faces cataloged.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub fn load_system_fonts(&self) -> usize {
|
||||
// The first scan, or a rescan once it is done (never both at once).
|
||||
let mut first = None;
|
||||
self.cataloged.get_or_init(|| first = Some(self.scan_font_dirs()));
|
||||
first.unwrap_or_else(|| self.scan_font_dirs())
|
||||
}
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn scan_font_dirs(&self) -> usize {
|
||||
let mut found = Vec::new();
|
||||
let mut stack = self.font_dirs.clone();
|
||||
// Each folder once, however links lead back to it.
|
||||
let mut visited = std::collections::HashSet::new();
|
||||
while let Some(d) = stack.pop() {
|
||||
if !visited.insert(std::fs::canonicalize(&d).unwrap_or_else(|_| d.clone())) {
|
||||
continue;
|
||||
}
|
||||
let Ok(rd) = std::fs::read_dir(&d) else { continue };
|
||||
for e in rd.flatten() {
|
||||
let p = e.path();
|
||||
if p.is_dir() {
|
||||
stack.push(p);
|
||||
continue;
|
||||
}
|
||||
let ext = p.extension().and_then(|e| e.to_str()).map(|e| e.to_ascii_lowercase());
|
||||
if !matches!(ext.as_deref(), Some("ttf" | "otf" | "ttc" | "otc")) {
|
||||
continue;
|
||||
}
|
||||
for (family, style) in file_face_names(&p) {
|
||||
found.push(CatalogEntry { family, style, path: p.clone() });
|
||||
}
|
||||
}
|
||||
}
|
||||
let n = found.len();
|
||||
log::debug!("cataloged {n} system font faces");
|
||||
*self.catalog.write().unwrap_or_else(|e| e.into_inner()) = found;
|
||||
n
|
||||
}
|
||||
|
||||
/// Load the files of the installed `family`. Returns whether any face was added.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn load_cataloged(&self, family: &str) -> bool {
|
||||
let paths: Vec<std::path::PathBuf> = {
|
||||
let cat = self.read_catalog();
|
||||
let mut p: Vec<_> = cat.iter().filter(|c| c.family.eq_ignore_ascii_case(family)).map(|c| c.path.clone()).collect();
|
||||
p.sort();
|
||||
p.dedup();
|
||||
p
|
||||
};
|
||||
let mut any = false;
|
||||
for p in paths {
|
||||
if let Ok(data) = std::fs::read(&p) {
|
||||
any |= self.add_font(data) > 0;
|
||||
}
|
||||
}
|
||||
any
|
||||
}
|
||||
|
||||
/// Resolve a family + style to a face, falling back to the closest style of the family, then to
|
||||
/// Source Sans 3 Regular. Installed system fonts are found by name whatever ran before.
|
||||
pub fn face(&self, family: &str, style: &str) -> Arc<FontFace> {
|
||||
if let Some(f) = self.find(family, style) {
|
||||
return f;
|
||||
}
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
if self.load_cataloged(family)
|
||||
&& let Some(f) = self.find(family, style)
|
||||
{
|
||||
return f;
|
||||
}
|
||||
self.find(FALLBACK_FAMILY, style)
|
||||
.or_else(|| self.find(FALLBACK_FAMILY, "Regular"))
|
||||
.or_else(|| self.read_faces().first().cloned())
|
||||
.unwrap_or_else(last_resort_face)
|
||||
}
|
||||
|
||||
/// Is `family` available (loaded, or installed on the system)?
|
||||
pub fn has_family(&self, family: &str) -> bool {
|
||||
if self.is_loaded(family) {
|
||||
return true;
|
||||
}
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
if self.read_catalog().iter().any(|c| c.family.eq_ignore_ascii_case(family)) {
|
||||
return true;
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
fn is_loaded(&self, family: &str) -> bool {
|
||||
self.read_faces().iter().any(|f| f.family.eq_ignore_ascii_case(family))
|
||||
}
|
||||
|
||||
/// A variable font's axes: (tag, name, min, default, max), empty for static fonts.
|
||||
pub fn axes(&self, family: &str, style: &str) -> Vec<(String, String, f32, f32, f32)> {
|
||||
let face = self.face(family, base_style(style));
|
||||
let Some(f) = face.skrifa() else { return vec![] };
|
||||
f.axes()
|
||||
.iter()
|
||||
.map(|a| {
|
||||
let tag = String::from_utf8_lossy(&a.tag().to_be_bytes()).to_string();
|
||||
let name = f.localized_strings(a.name_id()).english_or_first().map(|s| s.to_string()).unwrap_or_else(|| tag.clone());
|
||||
(tag, name, a.min_value(), a.default_value(), a.max_value())
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// `Style {wght:650,wdth:90}`: the named style's font at those axis values, made on first use.
|
||||
fn instance(&self, family: &str, style: &str) -> Option<Arc<FontFace>> {
|
||||
let open = style.find('{')?;
|
||||
let base = self.find(family, style[..open].trim())?;
|
||||
if base.skrifa().is_none_or(|f| f.axes().is_empty()) {
|
||||
return Some(base);
|
||||
}
|
||||
let mut coords = base.coords.clone();
|
||||
for kv in style[open + 1..].trim_end_matches('}').split(',') {
|
||||
let (k, v) = kv.split_once(':')?;
|
||||
let k = k.trim().as_bytes();
|
||||
let v: f32 = v.trim().parse().ok()?;
|
||||
if k.len() != 4 {
|
||||
return None;
|
||||
}
|
||||
let tag = [k[0], k[1], k[2], k[3]];
|
||||
match coords.iter_mut().find(|(t, _)| *t == tag) {
|
||||
Some(c) => c.1 = v,
|
||||
None => coords.push((tag, v)),
|
||||
}
|
||||
}
|
||||
let f = make_face(base.bytes.clone(), base.index, base.family.clone(), style.to_string(), coords)?;
|
||||
let f = Arc::new(f);
|
||||
self.faces.write().unwrap_or_else(|e| e.into_inner()).push(f.clone());
|
||||
Some(f)
|
||||
}
|
||||
|
||||
fn find(&self, family: &str, style: &str) -> Option<Arc<FontFace>> {
|
||||
if style.contains('{') {
|
||||
{
|
||||
let faces = self.read_faces();
|
||||
if let Some(f) = faces.iter().find(|f| f.family.eq_ignore_ascii_case(family) && f.style == style) {
|
||||
return Some(f.clone());
|
||||
}
|
||||
}
|
||||
return self.instance(family, style);
|
||||
}
|
||||
let faces = self.read_faces();
|
||||
let cands: Vec<&Arc<FontFace>> = faces.iter().filter(|f| f.family.eq_ignore_ascii_case(family)).collect();
|
||||
if cands.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let ns = norm(style);
|
||||
if let Some(f) = cands.iter().find(|f| norm(&f.style) == ns) {
|
||||
return Some((*f).clone());
|
||||
}
|
||||
let (tw, ti) = (style_weight(style), style_italic(style));
|
||||
cands
|
||||
.iter()
|
||||
.min_by(|a, b| {
|
||||
let sa = (a.weight - tw).abs() + if a.italic != ti { 1000.0 } else { 0.0 };
|
||||
let sb = (b.weight - tw).abs() + if b.italic != ti { 1000.0 } else { 0.0 };
|
||||
sa.total_cmp(&sb)
|
||||
})
|
||||
.map(|f| (*f).clone())
|
||||
}
|
||||
|
||||
/// First face (fallback family first, then load order) that covers `c`; on native, system
|
||||
/// fonts are cataloged and loaded lazily the first time no loaded face covers a character.
|
||||
pub fn fallback_for(&self, c: char, exclude: u32) -> Option<Arc<FontFace>> {
|
||||
if let Some(f) = self.loaded_fallback(c, exclude) {
|
||||
return Some(f);
|
||||
}
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
if self.system_fallback(c) {
|
||||
return self.loaded_fallback(c, exclude);
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
fn loaded_fallback(&self, c: char, exclude: u32) -> Option<Arc<FontFace>> {
|
||||
let faces = self.read_faces();
|
||||
let mut order: Vec<&Arc<FontFace>> = faces.iter().filter(|f| f.id != exclude).collect();
|
||||
order.sort_by_key(|f| (!f.family.eq_ignore_ascii_case(FALLBACK_FAMILY), f.italic, (f.weight - 400.0).abs() as i32));
|
||||
order.into_iter().find(|f| f.covers(c)).cloned()
|
||||
}
|
||||
|
||||
/// Load a system font covering `c` (preferred fallback families first, then any cataloged
|
||||
/// file under 40 MB). Returns true if one was loaded. Misses are remembered.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn system_fallback(&self, c: char) -> bool {
|
||||
if c.is_control() || c.is_whitespace() {
|
||||
return false;
|
||||
}
|
||||
{
|
||||
let sys = self.sys.lock().unwrap_or_else(|e| e.into_inner());
|
||||
if !sys.enabled || sys.misses.contains(&c) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
let covered = |db: &FontDb| db.read_faces().iter().any(|f| f.covers(c));
|
||||
let cataloged: Vec<String> = self.read_catalog().iter().map(|e| e.family.clone()).collect();
|
||||
for fam in SYSTEM_FALLBACKS {
|
||||
if !self.is_loaded(fam) && cataloged.iter().any(|f| f.eq_ignore_ascii_case(fam)) {
|
||||
self.load_cataloged(fam);
|
||||
if covered(self) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut paths: Vec<std::path::PathBuf> = self.catalog.read().unwrap_or_else(|e| e.into_inner()).iter().map(|e| e.path.clone()).collect();
|
||||
paths.sort();
|
||||
paths.dedup();
|
||||
for p in paths {
|
||||
if std::fs::metadata(&p).map(|m| m.len() > 40 << 20).unwrap_or(true) {
|
||||
continue;
|
||||
}
|
||||
let Ok(data) = std::fs::read(&p) else { continue };
|
||||
let hit =
|
||||
enumerate_faces(&data).iter().any(|(i, _, _, _)| skrifa::FontRef::from_index(&data, *i).is_ok_and(|f| f.charmap().map(c).is_some()));
|
||||
if hit && self.add_font(data) > 0 && covered(self) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
self.sys.lock().unwrap_or_else(|e| e.into_inner()).misses.insert(c);
|
||||
false
|
||||
}
|
||||
|
||||
/// Glyph outline in font units, y-down (flipped), cached per (face, glyph).
|
||||
pub fn outline(&self, face: &FontFace, gid: u32) -> Arc<BezPath> {
|
||||
let key = (face.id, gid);
|
||||
if let Some(p) = self.outlines.lock().unwrap_or_else(|e| e.into_inner()).get(&key) {
|
||||
return p.clone();
|
||||
}
|
||||
let mut pen = FlipPen(BezPath::new());
|
||||
if let Some(f) = face.skrifa()
|
||||
&& let Some(g) = f.outline_glyphs().get(GlyphId::new(gid))
|
||||
{
|
||||
let _ = g.draw(DrawSettings::unhinted(Size::unscaled(), face.location()), &mut pen);
|
||||
}
|
||||
let p = Arc::new(pen.0);
|
||||
let mut cache = self.outlines.lock().unwrap_or_else(|e| e.into_inner());
|
||||
if cache.len() >= OUTLINE_CACHE_MAX {
|
||||
cache.clear();
|
||||
}
|
||||
cache.insert(key, p.clone());
|
||||
p
|
||||
}
|
||||
}
|
||||
|
||||
struct FlipPen(BezPath);
|
||||
|
||||
impl OutlinePen for FlipPen {
|
||||
fn move_to(&mut self, x: f32, y: f32) {
|
||||
self.0.move_to((x as f64, -y as f64));
|
||||
}
|
||||
fn line_to(&mut self, x: f32, y: f32) {
|
||||
self.0.line_to((x as f64, -y as f64));
|
||||
}
|
||||
fn quad_to(&mut self, cx0: f32, cy0: f32, x: f32, y: f32) {
|
||||
self.0.quad_to((cx0 as f64, -cy0 as f64), (x as f64, -y as f64));
|
||||
}
|
||||
fn curve_to(&mut self, cx0: f32, cy0: f32, cx1: f32, cy1: f32, x: f32, y: f32) {
|
||||
self.0.curve_to((cx0 as f64, -cy0 as f64), (cx1 as f64, -cy1 as f64), (x as f64, -y as f64));
|
||||
}
|
||||
fn close(&mut self) {
|
||||
self.0.close_path();
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
#[path = "tests_sysfonts.rs"]
|
||||
mod tests_sysfonts;
|
||||
|
|
@ -0,0 +1,330 @@
|
|||
//! WordCraft fonts: the font database (bundled OFL families + user/system fonts), vertical
|
||||
//! metrics, glyph outlines and OpenType shaping.
|
||||
//!
|
||||
//! Shaping here is style-agnostic: [`shape`] turns a string in one face into glyph ids, clusters
|
||||
//! and advances in font units. `wordcraft-compose` applies sizes, tracking, scaling and
|
||||
//! justification on top.
|
||||
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
|
||||
#![forbid(unsafe_code)]
|
||||
|
||||
mod fontdb;
|
||||
pub mod word;
|
||||
|
||||
pub use fontdb::{FALLBACK_FAMILY, FaceRef, FontDb, FontFace, base_style, bundled, system_font_dirs};
|
||||
pub use harfrust::Feature;
|
||||
use harfrust::{Direction, ShapeOptions, Tag, UnicodeBuffer};
|
||||
pub use kurbo::BezPath;
|
||||
use skrifa::MetadataProvider;
|
||||
use skrifa::instance::Size;
|
||||
|
||||
/// A font from the optional craft-fonts build input (https://github.com/storytold/craft-fonts;
|
||||
/// empty unless built with `CRAFT_FONTS_DIR`, see `build.rs`).
|
||||
pub struct CraftFont {
|
||||
pub family: &'static str,
|
||||
pub style: &'static str,
|
||||
/// ISO 15924 scripts the font is for, e.g. `"Jpan"`.
|
||||
pub scripts: &'static [&'static str],
|
||||
pub bytes: &'static [u8],
|
||||
}
|
||||
|
||||
include!(concat!(env!("OUT_DIR"), "/craft_fonts.rs"));
|
||||
|
||||
/// The craft-fonts faces for Japanese (`"Jpan"`), in manifest order. Empty without craft-fonts.
|
||||
pub fn japanese_fonts() -> impl Iterator<Item = &'static CraftFont> {
|
||||
CRAFT_FONTS.iter().filter(|f| f.scripts.contains(&"Jpan"))
|
||||
}
|
||||
|
||||
/// Japanese faces in document-fallback order: Mincho (serif) families first, matching the
|
||||
/// serif default text font, then the others; Regular before other styles.
|
||||
pub fn japanese_document_fonts() -> Vec<&'static CraftFont> {
|
||||
let mut v: Vec<_> = japanese_fonts().collect();
|
||||
v.sort_by_key(|f| (!f.family.contains("Mincho"), f.style != "Regular"));
|
||||
v
|
||||
}
|
||||
|
||||
/// Japanese faces in UI order: BIZ UDPGothic first (the UI face), then the others; `bold` puts
|
||||
/// bold styles before regular ones.
|
||||
pub fn japanese_ui_fonts(bold: bool) -> Vec<&'static CraftFont> {
|
||||
let mut v: Vec<_> = japanese_fonts().collect();
|
||||
v.sort_by_key(|f| (f.family != "BIZ UDPGothic", (f.style == "Bold") != bold));
|
||||
v
|
||||
}
|
||||
|
||||
/// The bundled serif used when a document asks for nothing better.
|
||||
pub const DEFAULT_FAMILY: &str = "Source Serif 4";
|
||||
|
||||
/// One shaped glyph, in font units.
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub struct ShapedGlyph {
|
||||
pub gid: u32,
|
||||
/// Byte offset (in the shaped string) of the cluster this glyph belongs to.
|
||||
pub cluster: usize,
|
||||
pub x_advance: i32,
|
||||
pub x_offset: i32,
|
||||
pub y_offset: i32,
|
||||
}
|
||||
|
||||
/// An OpenType feature setting: `"liga"`, `"-kern"`, `"ss01"`.
|
||||
pub fn feature(tag: &str) -> Option<Feature> {
|
||||
let (on, t) = match tag.strip_prefix('-') {
|
||||
Some(r) => (false, r),
|
||||
None => (true, tag.strip_prefix('+').unwrap_or(tag)),
|
||||
};
|
||||
let b = t.as_bytes();
|
||||
if b.len() != 4 {
|
||||
return None;
|
||||
}
|
||||
Some(Feature::new(Tag::new(&[b[0], b[1], b[2], b[3]]), on as u32, ..))
|
||||
}
|
||||
|
||||
/// A strong right-to-left character (Hebrew, Arabic, …)?
|
||||
pub fn is_rtl(c: char) -> bool {
|
||||
use unicode_bidi::BidiClass::{AL, R};
|
||||
matches!(unicode_bidi::bidi_class(c), R | AL)
|
||||
}
|
||||
|
||||
/// Maximal runs of one direction (neutrals join the run they're in): (byte range, right-to-left).
|
||||
fn direction_runs(text: &str) -> Vec<(std::ops::Range<usize>, bool)> {
|
||||
let mut runs: Vec<(std::ops::Range<usize>, bool)> = Vec::new();
|
||||
let mut cur: Option<bool> = None;
|
||||
let mut start = 0;
|
||||
for (i, c) in text.char_indices() {
|
||||
let strong = if is_rtl(c) {
|
||||
Some(true)
|
||||
} else if unicode_bidi::bidi_class(c) == unicode_bidi::BidiClass::L {
|
||||
Some(false)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
match (cur, strong) {
|
||||
(None, Some(d)) => cur = Some(d),
|
||||
(Some(a), Some(d)) if a != d => {
|
||||
runs.push((start..i, a));
|
||||
start = i;
|
||||
cur = Some(d);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
runs.push((start..text.len(), cur.unwrap_or(false)));
|
||||
runs
|
||||
}
|
||||
|
||||
/// Shape `text` with `face`. `chars` lets callers substitute characters (e.g. uppercase for All
|
||||
/// Caps) while keeping clusters pointing into the original string. Glyphs come in logical
|
||||
/// order: right-to-left runs are shaped right to left, then their clusters put back in text
|
||||
/// order (each cluster's glyphs keep the shaper's order); line layout reorders them visually.
|
||||
pub fn shape(face: &FontFace, text: &str, features: &[Feature], map: impl Fn(char) -> char) -> Vec<ShapedGlyph> {
|
||||
if !text.chars().any(is_rtl) {
|
||||
return shape_dir(face, text, features, &map, false);
|
||||
}
|
||||
let mut out = Vec::with_capacity(text.len());
|
||||
for (r, rtl) in direction_runs(text) {
|
||||
let mut g = shape_dir(face, &text[r.clone()], features, &map, rtl);
|
||||
for x in &mut g {
|
||||
x.cluster += r.start;
|
||||
}
|
||||
if rtl {
|
||||
// Visual (clusters descending) → logical, cluster by cluster.
|
||||
let mut groups: Vec<Vec<ShapedGlyph>> = Vec::new();
|
||||
for x in g {
|
||||
match groups.last_mut() {
|
||||
Some(last) if last[0].cluster == x.cluster => last.push(x),
|
||||
_ => groups.push(vec![x]),
|
||||
}
|
||||
}
|
||||
groups.sort_by_key(|grp| grp[0].cluster);
|
||||
g = groups.into_iter().flatten().collect();
|
||||
}
|
||||
out.extend(g);
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn shape_dir(face: &FontFace, text: &str, features: &[Feature], map: &impl Fn(char) -> char, rtl: bool) -> Vec<ShapedGlyph> {
|
||||
let mut out = Vec::with_capacity(text.len());
|
||||
let shaped = face.hb().map(|hb| {
|
||||
let shaper = face.shaper.shaper(&hb).instance(face.instance.as_ref()).build();
|
||||
let mut buf = UnicodeBuffer::new();
|
||||
for (i, c) in text.char_indices() {
|
||||
buf.add(map(c), i as u32);
|
||||
}
|
||||
buf.guess_segment_properties();
|
||||
buf.set_direction(if rtl { Direction::RightToLeft } else { Direction::LeftToRight });
|
||||
let gb = shaper.shape(buf, ShapeOptions::new().features(features));
|
||||
for (info, pos) in gb.glyph_infos().iter().zip(gb.glyph_positions()) {
|
||||
out.push(ShapedGlyph {
|
||||
gid: info.glyph_id,
|
||||
cluster: info.cluster as usize,
|
||||
x_advance: pos.x_advance,
|
||||
x_offset: pos.x_offset,
|
||||
y_offset: pos.y_offset,
|
||||
});
|
||||
}
|
||||
});
|
||||
if shaped.is_none()
|
||||
&& let Some(f) = face.skrifa()
|
||||
{
|
||||
let cmap = f.charmap();
|
||||
let gm = f.glyph_metrics(Size::unscaled(), face.location());
|
||||
for (i, c) in text.char_indices() {
|
||||
let g = cmap.map(map(c)).unwrap_or_default();
|
||||
let adv = gm.advance_width(g).unwrap_or(face.upem as f32 * 0.5);
|
||||
out.push(ShapedGlyph { gid: g.to_u32(), cluster: i, x_advance: adv.round() as i32, x_offset: 0, y_offset: 0 });
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Glyph id of the first of `chars` the face has (0 = .notdef).
|
||||
pub fn first_glyph(face: &FontFace, chars: &[char]) -> u32 {
|
||||
chars.iter().map(|c| face.glyph_for(*c)).find(|g| *g != 0).unwrap_or(0)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn last_resort_face_parses() {
|
||||
let f = fontdb::last_resort_face();
|
||||
assert_eq!(f.family, fontdb::FALLBACK_FAMILY);
|
||||
assert!(f.upem > 0.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bundled_families_load() {
|
||||
let db = FontDb::global();
|
||||
let fams = db.families();
|
||||
assert!(fams.iter().any(|f| f == DEFAULT_FAMILY), "{fams:?}");
|
||||
assert!(fams.iter().any(|f| f == "Source Sans 3"));
|
||||
let styles = db.styles(DEFAULT_FAMILY);
|
||||
for s in ["Regular", "Italic", "Bold", "Semibold"] {
|
||||
assert!(styles.iter().any(|x| x == s), "{s} in {styles:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shaping_produces_clusters_and_advances() {
|
||||
let face = FontDb::global().face(DEFAULT_FAMILY, "Regular");
|
||||
let g = shape(&face, "Hello", &[], |c| c);
|
||||
assert_eq!(g.len(), 5);
|
||||
assert_eq!(g.iter().map(|g| g.cluster).collect::<Vec<_>>(), vec![0, 1, 2, 3, 4]);
|
||||
assert!(g.iter().all(|g| g.x_advance > 0 && g.gid != 0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ligatures_can_be_disabled() {
|
||||
let face = FontDb::global().face(DEFAULT_FAMILY, "Regular");
|
||||
let on = shape(&face, "office", &[], |c| c);
|
||||
let off = shape(&face, "office", &[feature("-liga").unwrap()], |c| c);
|
||||
assert!(on.len() <= off.len());
|
||||
assert_eq!(off.len(), 6);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mapping_keeps_clusters() {
|
||||
let face = FontDb::global().face(DEFAULT_FAMILY, "Regular");
|
||||
let g = shape(&face, "ab", &[], |c| c.to_ascii_uppercase());
|
||||
assert_eq!(g[0].gid, face.glyph_for('A'));
|
||||
assert_eq!(g[1].cluster, 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn variable_font_named_instances_are_styles() {
|
||||
// Uses a variable system font when one is installed (macOS ships several).
|
||||
let Some(data) =
|
||||
["/System/Library/Fonts/Supplemental/Skia.ttf", "/System/Library/Fonts/NewYork.ttf", "/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf"]
|
||||
.iter()
|
||||
.find_map(|p| std::fs::read(p).ok())
|
||||
else {
|
||||
return;
|
||||
};
|
||||
let db = FontDb::global();
|
||||
db.add_font(data.clone());
|
||||
let Some(fam) = skrifa::FontRef::new(&data).ok().and_then(|f| {
|
||||
(f.named_instances().len() > 1)
|
||||
.then(|| f.localized_strings(skrifa::string::StringId::FAMILY_NAME).english_or_first().map(|s| s.to_string()))?
|
||||
}) else {
|
||||
return;
|
||||
};
|
||||
let styles = db.styles(&fam);
|
||||
assert!(styles.len() > 1, "{styles:?}");
|
||||
let faces: Vec<_> = styles.iter().map(|s| db.face(&fam, s)).filter(|f| f.is_variable()).collect();
|
||||
let light = faces.iter().min_by(|a, b| a.weight.total_cmp(&b.weight)).unwrap();
|
||||
let heavy = faces.iter().max_by(|a, b| a.weight.total_cmp(&b.weight)).unwrap();
|
||||
assert!(heavy.weight > light.weight, "{light:?} {heavy:?}");
|
||||
let ink = |f: &FontFace| {
|
||||
let g = shape(f, "H", &[], |c| c);
|
||||
let b = kurbo::Shape::bounding_box(&*db.outline(f, g[0].gid));
|
||||
((b.area() * 100.0).round() as i64, g[0].x_advance)
|
||||
};
|
||||
assert_ne!(ink(light), ink(heavy), "instances draw differently");
|
||||
// Free axis values: a style with settings makes that instance.
|
||||
let axes = db.axes(&fam, &light.style);
|
||||
let wght = axes.iter().find(|a| a.0 == "wght").expect("a weight axis");
|
||||
let mid = (wght.2 + wght.4) / 2.0;
|
||||
let custom = db.face(&fam, &format!("{} {{wght:{mid}}}", light.style));
|
||||
assert!(custom.is_variable() && custom.coords.iter().any(|(t, v)| t == b"wght" && *v == mid));
|
||||
assert!(!db.styles(&fam).iter().any(|s| s.contains('{')), "instances aren't listed");
|
||||
let static_face = db.face(DEFAULT_FAMILY, "Regular {wght:700}");
|
||||
assert_eq!(static_face.style, "Regular", "static fonts ignore axis settings");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn outlines_and_metrics() {
|
||||
let db = FontDb::global();
|
||||
let face = db.face(DEFAULT_FAMILY, "Bold");
|
||||
assert_eq!(face.style, "Bold");
|
||||
let o = db.outline(&face, face.glyph_for('O'));
|
||||
assert!(!o.elements().is_empty());
|
||||
assert!(face.ascent > 0.0 && face.descent > 0.0 && face.cap_height > face.x_height);
|
||||
assert!(feature("abc").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn craft_fonts_japanese_faces_cover_japanese_and_have_vertical_forms() {
|
||||
if CRAFT_FONTS.is_empty() {
|
||||
eprintln!("skipped: built without craft-fonts (set CRAFT_FONTS_DIR to a checkout)");
|
||||
return;
|
||||
}
|
||||
let fonts = japanese_document_fonts();
|
||||
assert!(!fonts.is_empty(), "craft-fonts has Japanese fonts");
|
||||
assert!(fonts[0].family.contains("Mincho"), "documents fall back to a Mincho first");
|
||||
assert_eq!(japanese_ui_fonts(false)[0].family, "BIZ UDPGothic");
|
||||
assert_eq!(japanese_ui_fonts(true)[0].style, "Bold");
|
||||
let db = FontDb::with_font_dirs(Vec::new());
|
||||
db.set_system_fallback(false);
|
||||
for cf in fonts {
|
||||
// Inspect the embedded face directly so this cannot pass through an OS fallback.
|
||||
let font = skrifa::FontRef::new(cf.bytes).unwrap();
|
||||
for ch in "日本語縦書き横書きルビ、。「」".chars() {
|
||||
assert_ne!(font.charmap().map(ch).unwrap_or_default(), skrifa::GlyphId::NOTDEF, "{} lacks {ch}", cf.family);
|
||||
}
|
||||
let face = db.face(cf.family, cf.style);
|
||||
assert_eq!(face.family, cf.family);
|
||||
let horizontal = shape(&face, "「」、。", &[], |c| c);
|
||||
let vertical = shape(&face, "「」、。", &[feature("vert").unwrap(), feature("vrt2").unwrap()], |c| c);
|
||||
assert_eq!(horizontal.len(), vertical.len());
|
||||
assert!(horizontal.iter().zip(&vertical).any(|(h, v)| h.gid != v.gid), "{} has vertical forms", cf.family);
|
||||
}
|
||||
// Japanese in a Latin face falls back to a craft-fonts Mincho, without system fonts.
|
||||
let latin = db.face(DEFAULT_FAMILY, "Regular");
|
||||
let fb = db.fallback_for('語', latin.id()).unwrap();
|
||||
assert!(fb.family.contains("Mincho"), "{fb:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn works_without_craft_fonts() {
|
||||
// Always true without CRAFT_FONTS_DIR; with it, the bundled fonts must still all load.
|
||||
let db = FontDb::with_font_dirs(Vec::new());
|
||||
db.set_system_fallback(false);
|
||||
assert_eq!(db.face(DEFAULT_FAMILY, "Regular").family, DEFAULT_FAMILY);
|
||||
assert!(!shape(&db.face(DEFAULT_FAMILY, "Regular"), "Hello", &[], |c| c).is_empty());
|
||||
if CRAFT_FONTS.is_empty() {
|
||||
assert!(japanese_fonts().next().is_none() && japanese_document_fonts().is_empty());
|
||||
let latin = db.face(DEFAULT_FAMILY, "Regular");
|
||||
assert!(db.fallback_for('語', latin.id()).is_none(), "no bundled Japanese font");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,136 @@
|
|||
//! Installed system fonts: found by family name by every lookup, whatever ran before it (#22).
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use super::*;
|
||||
|
||||
const FAMILY: &str = "Sysfont Sans3";
|
||||
|
||||
/// A bundled Source Sans 3 file renamed [`FAMILY`] (as long as the original name).
|
||||
fn renamed(file: &str) -> Vec<u8> {
|
||||
let mut data = std::fs::read(Path::new(env!("CARGO_MANIFEST_DIR")).join("../../assets/fonts").join(file)).unwrap();
|
||||
let utf16 = |s: &str| s.encode_utf16().flat_map(u16::to_be_bytes).collect::<Vec<u8>>();
|
||||
for (from, to) in [(utf16("Source Sans 3"), utf16(FAMILY)), (b"Source Sans 3".to_vec(), FAMILY.as_bytes().to_vec())] {
|
||||
let mut i = 0;
|
||||
while let Some(at) = data[i..].windows(from.len()).position(|w| w == from) {
|
||||
data[i + at..i + at + to.len()].copy_from_slice(&to);
|
||||
i += at + to.len();
|
||||
}
|
||||
}
|
||||
data
|
||||
}
|
||||
|
||||
/// The fonts in `faces` as one collection (TTC) file.
|
||||
fn collection(faces: &[Vec<u8>]) -> Vec<u8> {
|
||||
let mut out = b"ttcf".to_vec();
|
||||
out.extend_from_slice(&0x0001_0000_u32.to_be_bytes());
|
||||
out.extend_from_slice(&(faces.len() as u32).to_be_bytes());
|
||||
let mut base = 12 + 4 * faces.len();
|
||||
for f in faces {
|
||||
out.extend_from_slice(&(base as u32).to_be_bytes());
|
||||
base += f.len();
|
||||
}
|
||||
for f in faces {
|
||||
// Table offsets count from the start of the collection.
|
||||
let start = out.len() as u32;
|
||||
let mut f = f.clone();
|
||||
let tables = u16::from_be_bytes([f[4], f[5]]) as usize;
|
||||
for r in 0..tables {
|
||||
let at = 12 + r * 16 + 8;
|
||||
let off = u32::from_be_bytes(f[at..at + 4].try_into().unwrap()) + start;
|
||||
f[at..at + 4].copy_from_slice(&off.to_be_bytes());
|
||||
}
|
||||
out.extend_from_slice(&f);
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// A font folder (with a subfolder, a damaged font and a file that isn't a font) holding
|
||||
/// [`FAMILY`] Regular and Bold.
|
||||
fn font_dir(name: &str) -> PathBuf {
|
||||
let dir = std::env::temp_dir().join(format!("dc-sysfonts-{}-{name}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
std::fs::create_dir_all(dir.join("Sub")).unwrap();
|
||||
std::fs::write(dir.join("Sysfont-Regular.ttf"), renamed("SourceSans3-Regular.ttf")).unwrap();
|
||||
std::fs::write(dir.join("Sub/Sysfont-Bold.TTF"), renamed("SourceSans3-Bold.ttf")).unwrap();
|
||||
std::fs::write(dir.join("damaged.ttf"), b"ttcf\0\x01\0\0\xff\xff\xff\xff").unwrap();
|
||||
std::fs::write(dir.join("readme.txt"), FAMILY).unwrap();
|
||||
dir
|
||||
}
|
||||
|
||||
fn has(list: &[String], family: &str) -> bool {
|
||||
list.iter().any(|f| f == family)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_lookup_by_name_finds_installed_fonts_in_a_fresh_database() {
|
||||
let dir = font_dir("fresh");
|
||||
// Each lookup is the first thing a new database is asked. A folder listed twice is read once.
|
||||
let db = || FontDb::with_font_dirs(vec![dir.clone(), dir.join("Sub/..")]);
|
||||
let f = db().face("sysfont sans3", "Bold");
|
||||
assert_eq!((f.family.as_str(), f.style.as_str()), (FAMILY, "Bold"));
|
||||
assert!(db().has_family(FAMILY));
|
||||
assert!(has(&db().families(), FAMILY));
|
||||
assert_eq!(db().styles(FAMILY), ["Regular", "Bold"]);
|
||||
assert_eq!(db().load_system_fonts(), 2);
|
||||
// Missing fonts stay missing.
|
||||
let db = db();
|
||||
assert!(!db.has_family("No Such Font"));
|
||||
assert_eq!(db.face("No Such Font", "Regular").family, FALLBACK_FAMILY);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_scan_reads_collections_and_loads_fonts_only_when_used() {
|
||||
let dir = font_dir("collection");
|
||||
std::fs::remove_file(dir.join("Sysfont-Regular.ttf")).unwrap();
|
||||
std::fs::remove_file(dir.join("Sub/Sysfont-Bold.TTF")).unwrap();
|
||||
std::fs::write(dir.join("Sysfont.ttc"), collection(&[renamed("SourceSans3-Regular.ttf"), renamed("SourceSans3-Bold.ttf")])).unwrap();
|
||||
let db = FontDb::with_font_dirs(vec![dir.clone()]);
|
||||
assert_eq!(db.styles(FAMILY), ["Regular", "Bold"]);
|
||||
// Cataloged, not loaded.
|
||||
assert!(!db.is_loaded(FAMILY));
|
||||
let bold = db.face(FAMILY, "Bold");
|
||||
assert_eq!((bold.style.as_str(), bold.index()), ("Bold", 1));
|
||||
assert_eq!(db.face(FAMILY, "Regular").index(), 0, "the collection's faces load together");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rescanning_finds_fonts_installed_since() {
|
||||
let dir = font_dir("rescan");
|
||||
let later = dir.join("Later");
|
||||
let db = FontDb::with_font_dirs(vec![later.clone()]);
|
||||
assert!(!db.has_family(FAMILY));
|
||||
std::fs::create_dir_all(&later).unwrap();
|
||||
std::fs::copy(dir.join("Sysfont-Regular.ttf"), later.join("Sysfont-Regular.ttf")).unwrap();
|
||||
assert!(!db.has_family(FAMILY), "the folders are scanned once, not on every miss");
|
||||
assert_eq!(db.load_system_fonts(), 1);
|
||||
assert!(db.has_family(FAMILY) && has(&db.families(), FAMILY));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_background_scan_serves_the_first_lookup() {
|
||||
let dir = font_dir("background");
|
||||
let db: &'static FontDb = Box::leak(Box::new(FontDb::with_font_dirs(vec![dir])));
|
||||
db.scan_in_background();
|
||||
// Waits for the scan when it is still running.
|
||||
assert!(has(&db.families(), FAMILY));
|
||||
assert_eq!(db.load_system_fonts(), 2, "a rescan catalogs the two faces again");
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn a_link_back_to_a_parent_folder_ends_the_scan() {
|
||||
let dir = font_dir("loop");
|
||||
std::os::unix::fs::symlink(&dir, dir.join("Sub/loop")).unwrap();
|
||||
let db = FontDb::with_font_dirs(vec![dir]);
|
||||
assert_eq!(db.load_system_fonts(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_scan_reads_names_without_loading_the_font() {
|
||||
let dir = font_dir("names");
|
||||
assert_eq!(file_face_names(&dir.join("Sysfont-Regular.ttf")), [(FAMILY.to_string(), "Regular".to_string())]);
|
||||
assert!(file_face_names(&dir.join("damaged.ttf")).is_empty());
|
||||
assert!(file_face_names(&dir.join("readme.txt")).is_empty());
|
||||
assert!(file_face_names(&dir.join("missing.ttf")).is_empty());
|
||||
}
|
||||
|
|
@ -0,0 +1,129 @@
|
|||
//! Word-processor font resolution: map a requested family + bold/italic to an available face,
|
||||
//! with metric-compatible substitutes for common proprietary document fonts, and Word-style line
|
||||
//! metrics (Windows ascent/descent, which is what Word uses for single line spacing).
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::sync::{Mutex, OnceLock};
|
||||
|
||||
use skrifa::raw::TableProvider;
|
||||
|
||||
use crate::{FaceRef, FontDb};
|
||||
|
||||
/// A requested font resolved to a face.
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
pub struct Resolved {
|
||||
pub face: FaceRef,
|
||||
/// The face isn't bold but bold was asked for: embolden when drawing.
|
||||
pub synth_bold: bool,
|
||||
/// The face isn't italic but italic was asked for: slant when drawing.
|
||||
pub synth_italic: bool,
|
||||
/// The requested family wasn't available.
|
||||
pub substituted: bool,
|
||||
}
|
||||
|
||||
/// Substitutes to try, in order, for a family that isn't installed. Open fonts that are
|
||||
/// metric-compatible come first (Carlito ↔ Calibri, Caladea ↔ Cambria, Liberation ↔ Arial /
|
||||
/// Times New Roman / Courier New), then common system fonts, then our bundled families.
|
||||
fn substitutes(family: &str) -> &'static [&'static str] {
|
||||
const SANS: &[&str] = &["Carlito", "Helvetica Neue", "Arial", "Liberation Sans", "Noto Sans", "DejaVu Sans", "Source Sans 3"];
|
||||
const SERIF: &[&str] = &["Caladea", "Times New Roman", "Liberation Serif", "Georgia", "Noto Serif", "DejaVu Serif", "Source Serif 4"];
|
||||
const MONO: &[&str] = &["Courier New", "Liberation Mono", "Menlo", "Consolas", "DejaVu Sans Mono", "JetBrains Mono"];
|
||||
const ARIAL: &[&str] = &["Liberation Sans", "Helvetica", "Helvetica Neue", "Arimo", "Source Sans 3"];
|
||||
const TIMES: &[&str] = &["Liberation Serif", "Times", "Tinos", "Source Serif 4"];
|
||||
const SYMBOL: &[&str] = &["Apple Symbols", "Segoe UI Symbol", "DejaVu Sans", "Noto Sans Symbols", "Source Sans 3"];
|
||||
let f = family.to_ascii_lowercase();
|
||||
match f.as_str() {
|
||||
"arial" | "helvetica" | "arial nova" => ARIAL,
|
||||
"times new roman" | "times" => TIMES,
|
||||
"courier new" | "courier" | "consolas" | "lucida console" | "cascadia code" | "cascadia mono" => MONO,
|
||||
"symbol" | "wingdings" | "segoe ui symbol" | "webdings" => SYMBOL,
|
||||
"cambria" | "cambria math" | "georgia" | "garamond" | "book antiqua" | "palatino linotype" | "constantia" | "baskerville old face"
|
||||
| "century" | "century schoolbook" | "bookman old style" | "sitka text" | "sitka" | "aptos serif" => SERIF,
|
||||
_ if f.contains("mono") || f.contains("code") => MONO,
|
||||
_ if f.contains("serif") && !f.contains("sans") => SERIF,
|
||||
_ => SANS,
|
||||
}
|
||||
}
|
||||
|
||||
fn style_name(bold: bool, italic: bool) -> &'static str {
|
||||
match (bold, italic) {
|
||||
(false, false) => "Regular",
|
||||
(true, false) => "Bold",
|
||||
(false, true) => "Italic",
|
||||
(true, true) => "Bold Italic",
|
||||
}
|
||||
}
|
||||
|
||||
/// Resolve `family` with bold/italic to a face (memoised).
|
||||
pub fn resolve(family: &str, bold: bool, italic: bool) -> Resolved {
|
||||
static CACHE: OnceLock<Mutex<HashMap<(String, bool, bool), Resolved>>> = OnceLock::new();
|
||||
let cache = CACHE.get_or_init(|| Mutex::new(HashMap::new()));
|
||||
let key = (family.to_string(), bold, italic);
|
||||
if let Some(r) = cache.lock().unwrap_or_else(|e| e.into_inner()).get(&key) {
|
||||
return *r;
|
||||
}
|
||||
let db = FontDb::global();
|
||||
let (fam, substituted) = if db.has_family(family) {
|
||||
(family.to_string(), false)
|
||||
} else {
|
||||
let s = substitutes(family).iter().find(|s| db.has_family(s)).copied().unwrap_or(crate::FALLBACK_FAMILY);
|
||||
(s.to_string(), true)
|
||||
};
|
||||
let face = db.face(&fam, style_name(bold, italic));
|
||||
let r = Resolved {
|
||||
synth_bold: bold && face.weight < 600.0,
|
||||
synth_italic: italic && !face.italic,
|
||||
substituted,
|
||||
face: FaceRef::of(&face),
|
||||
};
|
||||
cache.lock().unwrap_or_else(|e| e.into_inner()).insert(key, r);
|
||||
r
|
||||
}
|
||||
|
||||
/// Line metrics for a face in font units: (ascent, descent), both positive, the way word
|
||||
/// processors measure single line spacing (OS/2 usWinAscent/usWinDescent, falling back to the
|
||||
/// hhea values plus line gap).
|
||||
pub fn line_metrics(face: &crate::FontFace) -> (f64, f64) {
|
||||
static CACHE: OnceLock<Mutex<HashMap<u32, (f64, f64)>>> = OnceLock::new();
|
||||
let cache = CACHE.get_or_init(|| Mutex::new(HashMap::new()));
|
||||
if let Some(v) = cache.lock().unwrap_or_else(|e| e.into_inner()).get(&face.id()) {
|
||||
return *v;
|
||||
}
|
||||
let mut v = (face.ascent.max(1.0), face.descent.abs());
|
||||
if let Some(f) = face.skrifa() {
|
||||
if let Ok(os2) = f.os2() {
|
||||
let (a, d) = (os2.us_win_ascent() as f64, os2.us_win_descent() as f64);
|
||||
if a > 0.0 && a + d < face.upem * 4.0 {
|
||||
v = (a, d);
|
||||
}
|
||||
} else if let Ok(hhea) = f.hhea() {
|
||||
let gap = hhea.line_gap().to_i16().max(0) as f64;
|
||||
v = (hhea.ascender().to_i16() as f64 + gap, (hhea.descender().to_i16() as f64).abs());
|
||||
}
|
||||
}
|
||||
cache.lock().unwrap_or_else(|e| e.into_inner()).insert(face.id(), v);
|
||||
v
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn unknown_family_substitutes() {
|
||||
let r = resolve("Definitely Not A Font 123", false, false);
|
||||
assert!(r.substituted);
|
||||
let b = resolve("Source Serif 4", true, false);
|
||||
assert!(!b.substituted);
|
||||
assert!(!b.synth_bold);
|
||||
let (a, d) = line_metrics(&b.face);
|
||||
assert!(a > 0.0 && d >= 0.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn synthesizes_missing_styles() {
|
||||
// JetBrains Mono ships only Regular here.
|
||||
let r = resolve("JetBrains Mono", true, true);
|
||||
assert!(r.synth_bold && r.synth_italic);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,23 @@
|
|||
//! A new session finds the installed system fonts by family name (#22). One test in its own
|
||||
//! process, so the process-wide database starts fresh, as it does when the app starts.
|
||||
// Integration tests: unwrapping and panicking on failure is fine here, unlike in shipped code.
|
||||
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
|
||||
|
||||
use wordcraft_fonts::{FontDb, system_font_dirs};
|
||||
|
||||
#[test]
|
||||
fn a_fresh_session_finds_installed_fonts_by_name() {
|
||||
let bundled = FontDb::with_font_dirs(vec![]).families();
|
||||
let probe = FontDb::with_font_dirs(system_font_dirs());
|
||||
probe.load_system_fonts();
|
||||
// An installed family that isn't bundled and that loads.
|
||||
let installed = probe.families();
|
||||
let Some(family) = installed.iter().filter(|f| !bundled.contains(f)).find(|f| probe.face(f, "Regular").family == **f) else {
|
||||
eprintln!("no system fonts installed: nothing to check");
|
||||
return;
|
||||
};
|
||||
// The first lookup in the session: nothing has scanned the font folders yet.
|
||||
assert!(FontDb::global().has_family(family));
|
||||
assert_eq!(&FontDb::global().face(family, "Regular").family, family);
|
||||
assert!(FontDb::global().families().contains(family));
|
||||
}
|
||||
|
|
@ -0,0 +1,12 @@
|
|||
[package]
|
||||
name = "wordcraft-geom"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
description = "WordCraft geometry and units: points, twips, EMU, inches, centimetres; rectangles; measurement parsing"
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
|
||||
[dependencies]
|
||||
serde = { workspace = true }
|
||||
|
|
@ -0,0 +1,244 @@
|
|||
//! WordCraft geometry and units.
|
||||
//!
|
||||
//! Layout works in **points** (1/72 inch, `f32`). File formats use other units: OOXML uses
|
||||
//! twentieths of a point (twips) for most lengths, half-points for font sizes, eighths of a point
|
||||
//! for borders and English Metric Units (EMU, 914 400 per inch) for drawings. The conversions live
|
||||
//! here so every crate agrees on them.
|
||||
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// Points per inch.
|
||||
pub const PT_PER_IN: f32 = 72.0;
|
||||
/// Twips (1/20 pt) per point.
|
||||
pub const TWIPS_PER_PT: f32 = 20.0;
|
||||
/// English Metric Units per point.
|
||||
pub const EMU_PER_PT: f32 = 12_700.0;
|
||||
/// Points per centimetre.
|
||||
pub const PT_PER_CM: f32 = 72.0 / 2.54;
|
||||
|
||||
/// Twips → points.
|
||||
pub fn twips(v: i64) -> f32 {
|
||||
v as f32 / TWIPS_PER_PT
|
||||
}
|
||||
/// Points → twips (rounded).
|
||||
pub fn to_twips(pt: f32) -> i64 {
|
||||
finite(pt * TWIPS_PER_PT).round() as i64
|
||||
}
|
||||
/// EMU → points.
|
||||
pub fn emu(v: i64) -> f32 {
|
||||
v as f32 / EMU_PER_PT
|
||||
}
|
||||
/// Points → EMU (rounded).
|
||||
pub fn to_emu(pt: f32) -> i64 {
|
||||
(finite(pt) as f64 * EMU_PER_PT as f64).round() as i64
|
||||
}
|
||||
/// Half-points (OOXML font sizes) → points.
|
||||
pub fn half_points(v: i64) -> f32 {
|
||||
v as f32 / 2.0
|
||||
}
|
||||
/// Points → half-points (rounded).
|
||||
pub fn to_half_points(pt: f32) -> i64 {
|
||||
(finite(pt) * 2.0).round() as i64
|
||||
}
|
||||
|
||||
/// Replace NaN/inf with 0 so casts and arithmetic stay defined.
|
||||
pub fn finite(v: f32) -> f32 {
|
||||
if v.is_finite() { v } else { 0.0 }
|
||||
}
|
||||
|
||||
/// A measurement unit shown in the UI (Word's Options › Advanced › "Show measurements in units of").
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, Default)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum Unit {
|
||||
#[default]
|
||||
Inches,
|
||||
Centimeters,
|
||||
Millimeters,
|
||||
Points,
|
||||
Picas,
|
||||
}
|
||||
|
||||
impl Unit {
|
||||
pub fn suffix(self) -> &'static str {
|
||||
match self {
|
||||
Unit::Inches => "\"",
|
||||
Unit::Centimeters => " cm",
|
||||
Unit::Millimeters => " mm",
|
||||
Unit::Points => " pt",
|
||||
Unit::Picas => " pi",
|
||||
}
|
||||
}
|
||||
pub fn pt_per_unit(self) -> f32 {
|
||||
match self {
|
||||
Unit::Inches => PT_PER_IN,
|
||||
Unit::Centimeters => PT_PER_CM,
|
||||
Unit::Millimeters => PT_PER_CM / 10.0,
|
||||
Unit::Points => 1.0,
|
||||
Unit::Picas => 12.0,
|
||||
}
|
||||
}
|
||||
/// Format `pt` in this unit the way Word's fields show it (`1"`, `2.54 cm`, `12 pt`).
|
||||
pub fn format(self, pt: f32) -> String {
|
||||
let v = finite(pt) / self.pt_per_unit();
|
||||
let s = format!("{v:.2}");
|
||||
let s = s.trim_end_matches('0').trim_end_matches('.');
|
||||
let s = if s == "-0" { "0" } else { s };
|
||||
format!("{s}{}", self.suffix())
|
||||
}
|
||||
}
|
||||
|
||||
/// Parse a measurement typed by a user: `1"`, `1 in`, `2.5cm`, `12pt`, `3 pi`, `10 mm`, `1.5 li`
|
||||
/// (lines are 12 pt). A bare number is in `default`. Returns points.
|
||||
pub fn parse_measure(s: &str, default: Unit) -> Option<f32> {
|
||||
let t = s.trim().to_ascii_lowercase();
|
||||
if t.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let split = t.find(|c: char| !(c.is_ascii_digit() || c == '.' || c == '-' || c == '+' || c == ',')).unwrap_or(t.len());
|
||||
let (num, unit) = t.split_at(split);
|
||||
let v: f32 = num.replace(',', ".").parse().ok()?;
|
||||
if !v.is_finite() {
|
||||
return None;
|
||||
}
|
||||
let k = match unit.trim() {
|
||||
"" => default.pt_per_unit(),
|
||||
"\"" | "in" | "inch" | "inches" => PT_PER_IN,
|
||||
"cm" => PT_PER_CM,
|
||||
"mm" => PT_PER_CM / 10.0,
|
||||
"pt" | "pts" | "point" | "points" => 1.0,
|
||||
"pi" | "pc" | "pica" | "picas" => 12.0,
|
||||
"li" | "line" | "lines" => 12.0,
|
||||
"px" => 0.75,
|
||||
_ => return None,
|
||||
};
|
||||
Some(v * k)
|
||||
}
|
||||
|
||||
/// A point in page space (points, y down).
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Default, Serialize, Deserialize)]
|
||||
pub struct Point {
|
||||
pub x: f32,
|
||||
pub y: f32,
|
||||
}
|
||||
|
||||
impl Point {
|
||||
pub const fn new(x: f32, y: f32) -> Self {
|
||||
Point { x, y }
|
||||
}
|
||||
}
|
||||
|
||||
/// An axis-aligned rectangle (points, y down). `w`/`h` are never negative when built with [`Rect::new`].
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Default, Serialize, Deserialize)]
|
||||
pub struct Rect {
|
||||
pub x: f32,
|
||||
pub y: f32,
|
||||
pub w: f32,
|
||||
pub h: f32,
|
||||
}
|
||||
|
||||
impl Rect {
|
||||
pub fn new(x: f32, y: f32, w: f32, h: f32) -> Self {
|
||||
Rect { x: finite(x), y: finite(y), w: finite(w).max(0.0), h: finite(h).max(0.0) }
|
||||
}
|
||||
pub fn right(&self) -> f32 {
|
||||
self.x + self.w
|
||||
}
|
||||
pub fn bottom(&self) -> f32 {
|
||||
self.y + self.h
|
||||
}
|
||||
pub fn contains(&self, p: Point) -> bool {
|
||||
p.x >= self.x && p.x <= self.right() && p.y >= self.y && p.y <= self.bottom()
|
||||
}
|
||||
pub fn intersects(&self, o: &Rect) -> bool {
|
||||
self.x < o.right() && o.x < self.right() && self.y < o.bottom() && o.y < self.bottom()
|
||||
}
|
||||
pub fn union(&self, o: &Rect) -> Rect {
|
||||
let x = self.x.min(o.x);
|
||||
let y = self.y.min(o.y);
|
||||
Rect::new(x, y, self.right().max(o.right()) - x, self.bottom().max(o.bottom()) - y)
|
||||
}
|
||||
pub fn inset(&self, l: f32, t: f32, r: f32, b: f32) -> Rect {
|
||||
Rect::new(self.x + l, self.y + t, self.w - l - r, self.h - t - b)
|
||||
}
|
||||
}
|
||||
|
||||
/// Standard paper sizes (Word's Layout › Size gallery), in points (portrait).
|
||||
pub const PAPER_SIZES: &[(&str, f32, f32)] = &[
|
||||
("Letter", 612.0, 792.0),
|
||||
("Legal", 612.0, 1008.0),
|
||||
("Executive", 522.0, 756.0),
|
||||
("A3", 841.89, 1190.55),
|
||||
("A4", 595.28, 841.89),
|
||||
("A5", 419.53, 595.28),
|
||||
("B4 (JIS)", 728.5, 1031.8),
|
||||
("B5 (JIS)", 515.9, 728.5),
|
||||
("Tabloid", 792.0, 1224.0),
|
||||
("Statement", 396.0, 612.0),
|
||||
("Envelope #10", 297.0, 684.0),
|
||||
("Envelope DL", 311.8, 623.6),
|
||||
];
|
||||
|
||||
/// The paper size whose dimensions match (either orientation, within half a point).
|
||||
pub fn paper_name(w: f32, h: f32) -> Option<&'static str> {
|
||||
PAPER_SIZES
|
||||
.iter()
|
||||
.find(|(_, pw, ph)| ((pw - w).abs() < 0.5 && (ph - h).abs() < 0.5) || ((pw - h).abs() < 0.5 && (ph - w).abs() < 0.5))
|
||||
.map(|(n, _, _)| *n)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn unit_round_trips() {
|
||||
assert_eq!(twips(1440), 72.0);
|
||||
assert_eq!(to_twips(72.0), 1440);
|
||||
assert_eq!(emu(914_400), 72.0);
|
||||
assert_eq!(to_emu(72.0), 914_400);
|
||||
assert_eq!(half_points(24), 12.0);
|
||||
assert_eq!(to_half_points(10.5), 21);
|
||||
assert_eq!(to_twips(f32::NAN), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_measurements() {
|
||||
assert_eq!(parse_measure("1\"", Unit::Points), Some(72.0));
|
||||
assert_eq!(parse_measure("1 in", Unit::Points), Some(72.0));
|
||||
assert_eq!(parse_measure("12pt", Unit::Inches), Some(12.0));
|
||||
assert_eq!(parse_measure("2", Unit::Inches), Some(144.0));
|
||||
assert_eq!(parse_measure("1,5 li", Unit::Inches), Some(18.0));
|
||||
let cm = parse_measure("2.54 cm", Unit::Inches).unwrap();
|
||||
assert!((cm - 72.0).abs() < 0.01);
|
||||
assert_eq!(parse_measure("abc", Unit::Inches), None);
|
||||
assert_eq!(parse_measure("", Unit::Inches), None);
|
||||
assert_eq!(parse_measure("1 furlong", Unit::Inches), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn formats_measurements() {
|
||||
assert_eq!(Unit::Inches.format(72.0), "1\"");
|
||||
assert_eq!(Unit::Inches.format(90.0), "1.25\"");
|
||||
assert_eq!(Unit::Points.format(12.0), "12 pt");
|
||||
assert_eq!(Unit::Centimeters.format(72.0), "2.54 cm");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rect_ops() {
|
||||
let a = Rect::new(0.0, 0.0, 10.0, 10.0);
|
||||
let b = Rect::new(5.0, 5.0, 10.0, 10.0);
|
||||
assert!(a.intersects(&b));
|
||||
assert_eq!(a.union(&b), Rect::new(0.0, 0.0, 15.0, 15.0));
|
||||
assert!(a.contains(Point::new(5.0, 5.0)));
|
||||
assert_eq!(Rect::new(0.0, 0.0, -5.0, f32::NAN).w, 0.0);
|
||||
assert_eq!(a.inset(1.0, 2.0, 3.0, 4.0), Rect::new(1.0, 2.0, 6.0, 4.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn paper_names() {
|
||||
assert_eq!(paper_name(612.0, 792.0), Some("Letter"));
|
||||
assert_eq!(paper_name(792.0, 612.0), Some("Letter"));
|
||||
assert_eq!(paper_name(100.0, 100.0), None);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,19 @@
|
|||
[package]
|
||||
name = "wordcraft-layout"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
description = "WordCraft layout: shaping, line breaking, tabs, lists, tables, pagination, headers/footers, hit testing"
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
|
||||
[dependencies]
|
||||
wordcraft-geom = { workspace = true }
|
||||
wordcraft-doc = { workspace = true }
|
||||
wordcraft-fonts = { workspace = true }
|
||||
unicode-linebreak = { workspace = true }
|
||||
unicode-segmentation = { workspace = true }
|
||||
serde = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
log = { workspace = true }
|
||||
|
|
@ -0,0 +1,369 @@
|
|||
//! Page → draw items, shared by the raster renderer, the PDF exporter and thumbnails.
|
||||
|
||||
use wordcraft_doc::para::{InlineObject, ShapeKind};
|
||||
use wordcraft_doc::props::{BorderStyle, Rgb, TextColor, Underline};
|
||||
use wordcraft_doc::{Document, Path, StoryRef};
|
||||
use wordcraft_fonts::FaceRef;
|
||||
use wordcraft_geom::Rect;
|
||||
|
||||
use crate::para::{ClKind, LineEnd, ParaLayout};
|
||||
use crate::{Page, Placed};
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum Stroke {
|
||||
Solid,
|
||||
Dotted,
|
||||
Dashed,
|
||||
Double,
|
||||
Wave,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub enum Draw {
|
||||
Glyphs {
|
||||
face: FaceRef,
|
||||
size: f32,
|
||||
/// Glyph id, x, baseline y (page coordinates).
|
||||
glyphs: Vec<(u32, f32, f32)>,
|
||||
color: Rgb,
|
||||
alpha: f32,
|
||||
synth_bold: bool,
|
||||
synth_italic: bool,
|
||||
/// The text these glyphs show (for PDF text extraction), and the hyperlink.
|
||||
text: String,
|
||||
link: Option<String>,
|
||||
},
|
||||
Fill {
|
||||
rect: Rect,
|
||||
color: Rgb,
|
||||
alpha: f32,
|
||||
},
|
||||
Line {
|
||||
x0: f32,
|
||||
y0: f32,
|
||||
x1: f32,
|
||||
y1: f32,
|
||||
width: f32,
|
||||
color: Rgb,
|
||||
stroke: Stroke,
|
||||
alpha: f32,
|
||||
},
|
||||
Image {
|
||||
rect: Rect,
|
||||
media: String,
|
||||
crop: [f32; 4],
|
||||
alpha: f32,
|
||||
},
|
||||
Shape {
|
||||
rect: Rect,
|
||||
kind: ShapeKind,
|
||||
fill: Option<Rgb>,
|
||||
stroke: Option<Rgb>,
|
||||
stroke_width: f32,
|
||||
},
|
||||
/// A formatting mark (¶ · → ↵) in the UI's mark colour.
|
||||
Mark {
|
||||
x: f32,
|
||||
baseline: f32,
|
||||
size: f32,
|
||||
ch: char,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct DisplayOptions {
|
||||
/// Show formatting marks (¶).
|
||||
pub marks: bool,
|
||||
/// Dim headers/footers (editing the body) or the body (editing a header/footer).
|
||||
pub dim_header: bool,
|
||||
pub dim_body: bool,
|
||||
/// Show tracked changes as markup (coloured, underlined/struck).
|
||||
pub markup: bool,
|
||||
}
|
||||
|
||||
impl Default for DisplayOptions {
|
||||
fn default() -> Self {
|
||||
DisplayOptions { marks: false, dim_header: true, dim_body: false, markup: true }
|
||||
}
|
||||
}
|
||||
|
||||
/// Colour for revisions by author index.
|
||||
pub fn revision_color(i: u32) -> Rgb {
|
||||
const C: [Rgb; 6] = [Rgb(0xB0, 0x1E, 0x8F), Rgb(0x1E, 0x6E, 0xB0), Rgb(0x2E, 0x8B, 0x3E), Rgb(0xC0, 0x5A, 0x10), Rgb(0x70, 0x3C, 0xB0), Rgb(0x0E, 0x7C, 0x86)];
|
||||
C.get(i as usize % C.len()).copied().unwrap_or(C[0])
|
||||
}
|
||||
|
||||
fn stroke_of(u: Underline) -> Stroke {
|
||||
match u {
|
||||
Underline::Dotted => Stroke::Dotted,
|
||||
Underline::Dash | Underline::DotDash | Underline::DotDotDash => Stroke::Dashed,
|
||||
Underline::Double => Stroke::Double,
|
||||
Underline::Wave | Underline::DoubleWave => Stroke::Wave,
|
||||
_ => Stroke::Solid,
|
||||
}
|
||||
}
|
||||
|
||||
/// Draw items for a page.
|
||||
pub fn page_display(doc: &Document, page: &Page, opts: &DisplayOptions) -> Vec<Draw> {
|
||||
let mut out = Vec::new();
|
||||
let ha = if opts.dim_header { 0.45 } else { 1.0 };
|
||||
let ba = if opts.dim_body { 0.45 } else { 1.0 };
|
||||
for it in &page.header {
|
||||
item(doc, it, opts, ha, &mut out);
|
||||
}
|
||||
for it in &page.footer {
|
||||
item(doc, it, opts, ha, &mut out);
|
||||
}
|
||||
for it in &page.items {
|
||||
item(doc, it, opts, ba, &mut out);
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn border_stroke(s: BorderStyle) -> Stroke {
|
||||
match s {
|
||||
BorderStyle::Dotted => Stroke::Dotted,
|
||||
BorderStyle::Dashed | BorderStyle::DotDash => Stroke::Dashed,
|
||||
BorderStyle::Double | BorderStyle::Triple => Stroke::Double,
|
||||
BorderStyle::Wave => Stroke::Wave,
|
||||
_ => Stroke::Solid,
|
||||
}
|
||||
}
|
||||
|
||||
fn item(doc: &Document, it: &Placed, opts: &DisplayOptions, alpha: f32, out: &mut Vec<Draw>) {
|
||||
match it {
|
||||
Placed::Fill { rect, color } => out.push(Draw::Fill { rect: *rect, color: *color, alpha }),
|
||||
Placed::Rule { x0, y0, x1, y1, border } => out.push(Draw::Line {
|
||||
x0: *x0,
|
||||
y0: *y0,
|
||||
x1: *x1,
|
||||
y1: *y1,
|
||||
width: if border.style == BorderStyle::Thick { border.width.max(1.5) } else { border.width.max(0.25) },
|
||||
color: border.color.unwrap_or(Rgb::BLACK),
|
||||
stroke: border_stroke(border.style),
|
||||
alpha,
|
||||
}),
|
||||
Placed::Image { rect, media, crop, .. } => out.push(Draw::Image { rect: *rect, media: media.clone(), crop: *crop, alpha }),
|
||||
Placed::Shape { rect, kind, fill, stroke, stroke_width } => {
|
||||
out.push(Draw::Shape { rect: *rect, kind: *kind, fill: *fill, stroke: *stroke, stroke_width: *stroke_width })
|
||||
}
|
||||
Placed::Cell { .. } => {}
|
||||
Placed::Lines { story, path, para, l0, l1, x, y } => lines(doc, *story, path, para, *l0, *l1, *x, *y, opts, alpha, out),
|
||||
}
|
||||
}
|
||||
|
||||
fn lines(doc: &Document, story: StoryRef, path: &Path, pl: &ParaLayout, l0: usize, l1: usize, x: f32, y: f32, opts: &DisplayOptions, alpha: f32, out: &mut Vec<Draw>) {
|
||||
let Some(first) = pl.lines.get(l0) else { return };
|
||||
let para = doc.para(story, path);
|
||||
for li in l0..l1 {
|
||||
let Some(line) = pl.lines.get(li) else { continue };
|
||||
let top = y + (line.top - first.top);
|
||||
let base = top + (line.baseline - line.top);
|
||||
let bottom = top + line.height;
|
||||
// Backgrounds first: highlight and character shading.
|
||||
for k in line.c0..line.c1 {
|
||||
let (Some(c), Some(cx), Some(nx)) = (pl.clusters.get(k), line.xs.get(k - line.c0), line.xs.get(k + 1 - line.c0)) else { continue };
|
||||
let Some(st) = pl.styles.get(c.style as usize) else { continue };
|
||||
if c.kind == ClKind::Marker {
|
||||
continue;
|
||||
}
|
||||
if let Some(h) = st.rc.highlight.or(st.rc.shading) {
|
||||
out.push(Draw::Fill { rect: Rect::new(x + cx, top, (nx - cx).max(0.0), line.height), color: h, alpha });
|
||||
}
|
||||
}
|
||||
// Label.
|
||||
if li == 0
|
||||
&& let Some(lab) = &pl.label
|
||||
&& let Some(st) = pl.styles.get(lab.style as usize)
|
||||
{
|
||||
let gx = x + lab.x;
|
||||
out.push(Draw::Glyphs {
|
||||
face: st.face,
|
||||
size: st.size,
|
||||
glyphs: lab.glyphs.iter().map(|g| (g.gid, gx + g.dx, base - st.shift - g.dy)).collect(),
|
||||
color: text_color(&st.rc.color, None),
|
||||
alpha,
|
||||
synth_bold: st.synth_bold,
|
||||
synth_italic: st.synth_italic,
|
||||
text: lab.text.clone(),
|
||||
link: None,
|
||||
});
|
||||
}
|
||||
// Glyph runs grouped by style.
|
||||
let mut k = line.c0;
|
||||
while k < line.c1 {
|
||||
let Some(c) = pl.clusters.get(k) else { break };
|
||||
let si = c.style;
|
||||
let Some(st) = pl.styles.get(si as usize) else {
|
||||
k += 1;
|
||||
continue;
|
||||
};
|
||||
let mut glyphs = Vec::new();
|
||||
let mut text = String::new();
|
||||
let run_start = k;
|
||||
while k < line.c1 {
|
||||
let Some(c) = pl.clusters.get(k) else { break };
|
||||
if c.style != si {
|
||||
break;
|
||||
}
|
||||
let cx = x + line.xs.get(k - line.c0).copied().unwrap_or(0.0);
|
||||
if matches!(c.kind, ClKind::Text | ClKind::Space) {
|
||||
for g in pl.glyphs.get(c.g0 as usize..c.g1 as usize).into_iter().flatten() {
|
||||
glyphs.push((g.gid, cx + g.dx, base - st.shift - g.dy));
|
||||
}
|
||||
if let Some(p) = para {
|
||||
text.push_str(p.text.get(c.start..c.end).unwrap_or(""));
|
||||
}
|
||||
}
|
||||
k += 1;
|
||||
}
|
||||
let run_end = k;
|
||||
let rc = &st.rc;
|
||||
let rev = rc.ins.or(rc.del).filter(|_| opts.markup);
|
||||
let color = match rev {
|
||||
Some(r) => revision_color(doc.revisions.get(r as usize).map(|v| author_index(doc, &v.author)).unwrap_or(0)),
|
||||
None => text_color(&rc.color, rc.shading.or(rc.highlight)),
|
||||
};
|
||||
if !glyphs.is_empty() {
|
||||
out.push(Draw::Glyphs {
|
||||
face: st.face,
|
||||
size: st.size,
|
||||
glyphs,
|
||||
color,
|
||||
alpha,
|
||||
synth_bold: st.synth_bold,
|
||||
synth_italic: st.synth_italic,
|
||||
text,
|
||||
link: rc.link.clone(),
|
||||
});
|
||||
}
|
||||
// Decorations across the run (underline skips trailing spaces of the line).
|
||||
let x0 = x + line.xs.get(run_start - line.c0).copied().unwrap_or(0.0);
|
||||
let mut end_k = run_end;
|
||||
while end_k > run_start && pl.clusters.get(end_k - 1).is_some_and(|c| matches!(c.kind, ClKind::Space | ClKind::Marker | ClKind::LineBreak)) && end_k == line.c1 {
|
||||
end_k -= 1;
|
||||
}
|
||||
let x1 = x + line.xs.get(end_k - line.c0).copied().unwrap_or(0.0);
|
||||
let thick = (st.size / 18.0).max(0.5);
|
||||
let underline = if rc.ins.is_some() && opts.markup { Underline::Single } else { rc.underline };
|
||||
if underline != Underline::None && x1 > x0 {
|
||||
let uy = base - st.shift + st.size * 0.12;
|
||||
let ucolor = rc.underline_color.unwrap_or(color);
|
||||
if underline == Underline::Words {
|
||||
for kk in run_start..end_k {
|
||||
let Some(c) = pl.clusters.get(kk) else { continue };
|
||||
if c.kind != ClKind::Text {
|
||||
continue;
|
||||
}
|
||||
let a = x + line.xs.get(kk - line.c0).copied().unwrap_or(0.0);
|
||||
let b = x + line.xs.get(kk + 1 - line.c0).copied().unwrap_or(a);
|
||||
out.push(Draw::Line { x0: a, y0: uy, x1: b, y1: uy, width: thick, color: ucolor, stroke: Stroke::Solid, alpha });
|
||||
}
|
||||
} else {
|
||||
let w = if underline == Underline::Thick { thick * 2.0 } else { thick };
|
||||
out.push(Draw::Line { x0, y0: uy, x1, y1: uy, width: w, color: ucolor, stroke: stroke_of(underline), alpha });
|
||||
}
|
||||
}
|
||||
if (rc.strike || rc.double_strike || (rc.del.is_some() && opts.markup)) && x1 > x0 {
|
||||
let sy = base - st.shift - st.size * 0.28;
|
||||
let stroke = if rc.double_strike { Stroke::Double } else { Stroke::Solid };
|
||||
out.push(Draw::Line { x0, y0: sy, x1, y1: sy, width: thick, color, stroke, alpha });
|
||||
}
|
||||
}
|
||||
// Tab leaders.
|
||||
for (k, leader) in &line.leaders {
|
||||
let (Some(c), Some(a), Some(b)) = (pl.clusters.get(*k), line.xs.get(k - line.c0), line.xs.get(k + 1 - line.c0)) else { continue };
|
||||
let Some(st) = pl.styles.get(c.style as usize) else { continue };
|
||||
let Some(ch) = leader.char() else { continue };
|
||||
let gid = st.face.glyph_for(ch);
|
||||
let adv = (st.face.advance(gid) as f32 * st.size / st.face.upem.max(1.0) as f32).max(1.0);
|
||||
let (mut gx, end) = (x + a + adv * 0.5, x + b - adv * 1.2);
|
||||
let mut glyphs = Vec::new();
|
||||
// Align leader dots to a grid so consecutive lines line up.
|
||||
gx = (gx / adv).ceil() * adv;
|
||||
while gx < end && glyphs.len() < 2000 {
|
||||
glyphs.push((gid, gx, base));
|
||||
gx += adv;
|
||||
}
|
||||
out.push(Draw::Glyphs {
|
||||
face: st.face,
|
||||
size: st.size,
|
||||
glyphs,
|
||||
color: text_color(&st.rc.color, None),
|
||||
alpha,
|
||||
synth_bold: false,
|
||||
synth_italic: false,
|
||||
text: String::new(),
|
||||
link: None,
|
||||
});
|
||||
}
|
||||
// Inline objects.
|
||||
for k in line.c0..line.c1 {
|
||||
let Some(c) = pl.clusters.get(k) else { continue };
|
||||
let ClKind::Object(oi) = c.kind else { continue };
|
||||
if c.obj_h <= 0.0 {
|
||||
continue;
|
||||
}
|
||||
let cx = x + line.xs.get(k - line.c0).copied().unwrap_or(0.0);
|
||||
let rect = Rect::new(cx, base - c.obj_h, c.adv, c.obj_h);
|
||||
match para.and_then(|p| p.objects.get(oi)) {
|
||||
Some(InlineObject::Image { media, crop, .. }) => out.push(Draw::Image { rect, media: media.clone(), crop: *crop, alpha }),
|
||||
Some(InlineObject::Shape { kind, fill, stroke, stroke_width, .. }) => {
|
||||
out.push(Draw::Shape { rect, kind: *kind, fill: *fill, stroke: *stroke, stroke_width: *stroke_width })
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
// Formatting marks.
|
||||
if opts.marks {
|
||||
let msize = pl.styles.first().map(|s| s.size).unwrap_or(11.0);
|
||||
for k in line.c0..line.c1 {
|
||||
let Some(c) = pl.clusters.get(k) else { continue };
|
||||
let cx = x + line.xs.get(k - line.c0).copied().unwrap_or(0.0);
|
||||
let nx = x + line.xs.get(k + 1 - line.c0).copied().unwrap_or(cx);
|
||||
let size = pl.styles.get(c.style as usize).map(|s| s.size).unwrap_or(msize);
|
||||
match c.kind {
|
||||
ClKind::Space => out.push(Draw::Mark { x: (cx + nx) / 2.0 - size * 0.12, baseline: base - size * 0.08, size, ch: '·' }),
|
||||
ClKind::Tab => out.push(Draw::Mark { x: cx + ((nx - cx) / 2.0 - size * 0.3).max(0.0), baseline: base, size, ch: '→' }),
|
||||
ClKind::LineBreak => out.push(Draw::Mark { x: cx + 1.0, baseline: base, size, ch: '↵' }),
|
||||
ClKind::PageBreak | ClKind::ColumnBreak => {
|
||||
let label = if c.kind == ClKind::PageBreak { '⤓' } else { '⇥' };
|
||||
out.push(Draw::Line { x0: cx + 2.0, y0: base - size * 0.3, x1: x + line.right, y1: base - size * 0.3, width: 0.5, color: Rgb(0x60, 0x60, 0x60), stroke: Stroke::Dotted, alpha });
|
||||
out.push(Draw::Mark { x: cx + 1.0, baseline: base, size, ch: label });
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
if line.end == LineEnd::Para {
|
||||
let ex = x + line.xs.last().copied().unwrap_or(line.left);
|
||||
let size = pl.lines.first().map(|_| msize).unwrap_or(msize);
|
||||
out.push(Draw::Mark { x: ex + 1.0, baseline: base, size, ch: '¶' });
|
||||
}
|
||||
}
|
||||
let _ = bottom;
|
||||
}
|
||||
}
|
||||
|
||||
fn author_index(doc: &Document, author: &str) -> u32 {
|
||||
let mut seen: Vec<&str> = Vec::new();
|
||||
for r in &doc.revisions {
|
||||
if !seen.contains(&r.author.as_str()) {
|
||||
seen.push(&r.author);
|
||||
}
|
||||
}
|
||||
seen.iter().position(|a| *a == author).unwrap_or(0) as u32
|
||||
}
|
||||
|
||||
/// Automatic colour is black, or white on a dark background.
|
||||
pub fn text_color(c: &TextColor, background: Option<Rgb>) -> Rgb {
|
||||
match c {
|
||||
TextColor::Rgb(r) => *r,
|
||||
TextColor::Auto => {
|
||||
if background.is_some_and(|b| b.luma() < 100.0) {
|
||||
Rgb::WHITE
|
||||
} else {
|
||||
Rgb::BLACK
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,93 @@
|
|||
//! Field results computed at layout time (page numbers) and note numbering.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
|
||||
use wordcraft_doc::section::NumFormat;
|
||||
|
||||
/// Context for fields that depend on where text lands.
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub struct FieldCtx {
|
||||
/// Current page number (as numbered) and its format.
|
||||
pub page: u32,
|
||||
pub page_format: NumFormat,
|
||||
/// Total pages in the document.
|
||||
pub pages: u32,
|
||||
/// Pages in the current section.
|
||||
pub section_pages: u32,
|
||||
pub section: u32,
|
||||
/// Note part id → number, in document order.
|
||||
pub notes: Arc<HashMap<u32, u32>>,
|
||||
/// Document title / author for TITLE / AUTHOR fields.
|
||||
pub title: Arc<str>,
|
||||
pub author: Arc<str>,
|
||||
pub filename: Arc<str>,
|
||||
}
|
||||
|
||||
impl FieldCtx {
|
||||
pub fn note_number(&self, id: u32) -> u32 {
|
||||
self.notes.get(&id).copied().unwrap_or(1)
|
||||
}
|
||||
/// A key for caches: the parts of the context a page-dependent paragraph depends on.
|
||||
pub fn page_key(&self) -> (u32, u32, u32, u32) {
|
||||
(self.page, self.pages, self.section_pages, self.section)
|
||||
}
|
||||
}
|
||||
|
||||
/// The field's name (first word of the instruction, upper-cased).
|
||||
pub fn field_name(instr: &str) -> String {
|
||||
instr.split_whitespace().next().unwrap_or("").trim_start_matches('=').to_ascii_uppercase()
|
||||
}
|
||||
|
||||
/// `\* FORMAT` switch value (Arabic, roman, ROMAN, alphabetic, ALPHABETIC, CardText, Ordinal…).
|
||||
fn format_switch(instr: &str) -> Option<NumFormat> {
|
||||
let mut it = instr.split_whitespace();
|
||||
while let Some(w) = it.next() {
|
||||
if w == "\\*" {
|
||||
let v = it.next()?;
|
||||
return Some(match v {
|
||||
"roman" => NumFormat::LowerRoman,
|
||||
"ROMAN" | "Roman" => NumFormat::UpperRoman,
|
||||
"alphabetic" => NumFormat::LowerLetter,
|
||||
"ALPHABETIC" | "Alphabetic" => NumFormat::UpperLetter,
|
||||
"CardText" | "cardtext" => NumFormat::CardinalText,
|
||||
"OrdText" | "ordtext" => NumFormat::OrdinalText,
|
||||
"Ordinal" | "ordinal" => NumFormat::Ordinal,
|
||||
"Arabic" | "arabic" => NumFormat::Decimal,
|
||||
_ => continue,
|
||||
});
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Display text for a field and whether it depends on the page it lands on.
|
||||
pub fn field_text(instr: &str, result: &str, ctx: &FieldCtx) -> (String, bool) {
|
||||
let name = field_name(instr);
|
||||
let fmt = format_switch(instr);
|
||||
match name.as_str() {
|
||||
"PAGE" => (fmt.unwrap_or(ctx.page_format).format(ctx.page), true),
|
||||
"NUMPAGES" => (fmt.unwrap_or(NumFormat::Decimal).format(ctx.pages), true),
|
||||
"SECTIONPAGES" => (fmt.unwrap_or(NumFormat::Decimal).format(ctx.section_pages), true),
|
||||
"SECTION" => (fmt.unwrap_or(NumFormat::Decimal).format(ctx.section), true),
|
||||
"TITLE" if result.is_empty() => (ctx.title.to_string(), false),
|
||||
"AUTHOR" if result.is_empty() => (ctx.author.to_string(), false),
|
||||
"FILENAME" if result.is_empty() => (ctx.filename.to_string(), false),
|
||||
_ => (result.to_string(), false),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn page_fields() {
|
||||
let ctx = FieldCtx { page: 4, pages: 12, ..Default::default() };
|
||||
assert_eq!(field_text("PAGE", "", &ctx), ("4".into(), true));
|
||||
assert_eq!(field_text(" PAGE \\* ROMAN ", "", &ctx).0, "IV");
|
||||
assert_eq!(field_text("NUMPAGES \\* CardText", "", &ctx).0, "Twelve");
|
||||
assert_eq!(field_text("DATE \\@ \"M/d/yyyy\"", "1/2/2026", &ctx), ("1/2/2026".into(), false));
|
||||
assert_eq!(field_name("=SUM(ABOVE)"), "SUM(ABOVE)");
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,266 @@
|
|||
//! Hit testing and caret geometry.
|
||||
|
||||
use wordcraft_doc::{Document, Path, Pos, StoryRef};
|
||||
use wordcraft_geom::Rect;
|
||||
|
||||
use crate::para::ParaLayout;
|
||||
use crate::{DocLayout, Page, Placed};
|
||||
|
||||
/// Caret geometry on a page.
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub struct Caret {
|
||||
pub page: usize,
|
||||
pub x: f32,
|
||||
pub top: f32,
|
||||
pub height: f32,
|
||||
}
|
||||
|
||||
/// One placed line, resolved to page coordinates.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct LineHit<'a> {
|
||||
pub story: StoryRef,
|
||||
pub path: &'a Path,
|
||||
pub para: &'a ParaLayout,
|
||||
pub li: usize,
|
||||
pub x: f32,
|
||||
pub top: f32,
|
||||
pub bottom: f32,
|
||||
pub left: f32,
|
||||
pub right: f32,
|
||||
}
|
||||
|
||||
fn items_of(page: &Page, story: StoryRef) -> Box<dyn Iterator<Item = &Placed> + '_> {
|
||||
match story {
|
||||
StoryRef::Body => Box::new(page.items.iter()),
|
||||
StoryRef::Part(id) => {
|
||||
let h = page.header_story == Some(id);
|
||||
let f = page.footer_story == Some(id);
|
||||
Box::new(page.header.iter().filter(move |_| h).chain(page.footer.iter().filter(move |_| f)).chain(page.items.iter()))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Every line on a page that belongs to `story` (body, or a header/footer part).
|
||||
pub fn page_lines(page: &Page, story: StoryRef) -> Vec<LineHit<'_>> {
|
||||
let mut v = Vec::new();
|
||||
for it in items_of(page, story) {
|
||||
if let Placed::Lines { story: s, path, para, l0, l1, x, y } = it {
|
||||
if *s != story {
|
||||
continue;
|
||||
}
|
||||
let Some(first) = para.lines.get(*l0) else { continue };
|
||||
for li in *l0..*l1 {
|
||||
let Some(l) = para.lines.get(li) else { continue };
|
||||
let top = y + (l.top - first.top);
|
||||
v.push(LineHit { story: *s, path, para, li, x: *x, top, bottom: top + l.height, left: x + l.left, right: x + l.right });
|
||||
}
|
||||
}
|
||||
}
|
||||
v
|
||||
}
|
||||
|
||||
impl DocLayout {
|
||||
/// The position nearest to (x, y) on `page`, in `story`.
|
||||
pub fn hit(&self, page: usize, x: f32, y: f32, story: StoryRef) -> Option<Pos> {
|
||||
let p = self.pages.get(page)?;
|
||||
let lines = page_lines(p, story);
|
||||
let best = lines.iter().min_by(|a, b| score(a, x, y).total_cmp(&score(b, x, y)))?;
|
||||
let off = best.para.off_at_x(best.li, x - best.x);
|
||||
Some(Pos { story: best.story, path: best.path.clone(), off })
|
||||
}
|
||||
|
||||
/// Which header/footer (if any) is at (x, y) on a page — for double-click editing.
|
||||
pub fn header_footer_at(&self, page: usize, y: f32) -> Option<(StoryRef, bool)> {
|
||||
let p = self.pages.get(page)?;
|
||||
if y < p.body.y {
|
||||
return p.header_story.map(|id| (StoryRef::Part(id), true));
|
||||
}
|
||||
if y > p.body.bottom() {
|
||||
return p.footer_story.map(|id| (StoryRef::Part(id), false));
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Pieces of a paragraph: (page, item index).
|
||||
fn pieces(&self, story: StoryRef, path: &Path, page_hint: usize) -> Vec<(usize, &Placed)> {
|
||||
if story == StoryRef::Body {
|
||||
return self
|
||||
.index
|
||||
.get(&(story, path.clone()))
|
||||
.map(|v| v.iter().filter_map(|(pi, ii)| self.pages.get(*pi).and_then(|p| p.items.get(*ii)).map(|it| (*pi, it))).collect())
|
||||
.unwrap_or_default();
|
||||
}
|
||||
// Headers/footers repeat: prefer the hinted page, else the first that shows the part.
|
||||
let mut order: Vec<usize> = (0..self.pages.len()).collect();
|
||||
if page_hint < order.len() {
|
||||
order.retain(|p| *p != page_hint);
|
||||
order.insert(0, page_hint);
|
||||
}
|
||||
for pi in order {
|
||||
let Some(p) = self.pages.get(pi) else { continue };
|
||||
let found: Vec<(usize, &Placed)> = items_of(p, story)
|
||||
.filter(|it| matches!(it, Placed::Lines { story: s, path: q, .. } if *s == story && q == path))
|
||||
.map(|it| (pi, it))
|
||||
.collect();
|
||||
if !found.is_empty() {
|
||||
return found;
|
||||
}
|
||||
}
|
||||
Vec::new()
|
||||
}
|
||||
|
||||
/// Caret geometry for a position.
|
||||
pub fn caret(&self, pos: &Pos) -> Option<Caret> {
|
||||
self.caret_on(pos, 0)
|
||||
}
|
||||
|
||||
pub fn caret_on(&self, pos: &Pos, page_hint: usize) -> Option<Caret> {
|
||||
for (pi, it) in self.pieces(pos.story, &pos.path, page_hint) {
|
||||
let Placed::Lines { para, l0, l1, x, y, .. } = it else { continue };
|
||||
let li = para.line_of(pos.off);
|
||||
if li < *l0 || li >= *l1 {
|
||||
continue;
|
||||
}
|
||||
let first = para.lines.get(*l0)?;
|
||||
let l = para.lines.get(li)?;
|
||||
let cx = para.x_of(li, pos.off)?;
|
||||
let top = y + (l.top - first.top);
|
||||
// Caret spans the text height (ascent + descent), not the full line spacing.
|
||||
let (asc, desc) = para.caret_metrics(li);
|
||||
let base = top + (l.baseline - l.top);
|
||||
let h = (asc + desc).min(l.height.max(asc + desc));
|
||||
let ctop = (base - asc).max(top);
|
||||
let _ = h;
|
||||
return Some(Caret { page: pi, x: x + cx, top: ctop, height: (base + desc - ctop).max(4.0) });
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// The position one line up (`dir < 0`) or down from `pos`, keeping `goal_x` (page x).
|
||||
pub fn vertical(&self, pos: &Pos, goal_x: Option<f32>, dir: i32, page_hint: usize) -> Option<(Pos, f32)> {
|
||||
let c = self.caret_on(pos, page_hint)?;
|
||||
let gx = goal_x.unwrap_or(c.x);
|
||||
let page = self.pages.get(c.page)?;
|
||||
let lines = page_lines(page, pos.story);
|
||||
let cur_mid = c.top + c.height / 2.0;
|
||||
let cands: Vec<&LineHit> = lines
|
||||
.iter()
|
||||
.filter(|l| {
|
||||
let mid = (l.top + l.bottom) / 2.0;
|
||||
if dir < 0 { mid < cur_mid - 1.0 && l.bottom <= c.top + c.height * 0.5 } else { mid > cur_mid + 1.0 && l.top >= c.top + c.height * 0.5 }
|
||||
})
|
||||
.collect();
|
||||
let target = if dir < 0 {
|
||||
cands.iter().filter(|l| gx >= l.left - 2.0 && gx <= l.right + 2.0).max_by(|a, b| a.top.total_cmp(&b.top)).or_else(|| cands.iter().max_by(|a, b| a.top.total_cmp(&b.top)))
|
||||
} else {
|
||||
cands.iter().filter(|l| gx >= l.left - 2.0 && gx <= l.right + 2.0).min_by(|a, b| a.top.total_cmp(&b.top)).or_else(|| cands.iter().min_by(|a, b| a.top.total_cmp(&b.top)))
|
||||
};
|
||||
if let Some(l) = target {
|
||||
let off = l.para.off_at_x(l.li, gx - l.x);
|
||||
return Some((Pos { story: l.story, path: l.path.clone(), off }, gx));
|
||||
}
|
||||
// Next/previous page.
|
||||
let np = if dir < 0 { c.page.checked_sub(1)? } else { c.page + 1 };
|
||||
let p = self.pages.get(np)?;
|
||||
let lines = page_lines(p, pos.story);
|
||||
let l = if dir < 0 { lines.iter().max_by(|a, b| a.top.total_cmp(&b.top))? } else { lines.iter().min_by(|a, b| a.top.total_cmp(&b.top))? };
|
||||
let off = l.para.off_at_x(l.li, gx - l.x);
|
||||
Some((Pos { story: l.story, path: l.path.clone(), off }, gx))
|
||||
}
|
||||
|
||||
/// Start and end of the visual line holding `pos` (Home / End).
|
||||
pub fn line_bounds(&self, pos: &Pos, page_hint: usize) -> Option<(Pos, Pos)> {
|
||||
for (_, it) in self.pieces(pos.story, &pos.path, page_hint) {
|
||||
let Placed::Lines { para, l0, l1, .. } = it else { continue };
|
||||
let li = para.line_of(pos.off);
|
||||
if li < *l0 || li >= *l1 {
|
||||
continue;
|
||||
}
|
||||
let l = para.lines.get(li)?;
|
||||
let mut end = l.stop;
|
||||
// Don't put the caret after a trailing break on that line, nor past wrap spaces.
|
||||
if matches!(l.end, crate::LineEnd::LineBreak | crate::LineEnd::PageBreak | crate::LineEnd::ColumnBreak) && end > l.start {
|
||||
end = para.clusters.get(l.c1.saturating_sub(1)).map(|c| c.start).unwrap_or(end);
|
||||
} else if l.end == crate::LineEnd::Wrap {
|
||||
end = para.off_at_x(li, l.right + 1000.0);
|
||||
}
|
||||
let mk = |off| Pos { story: pos.story, path: pos.path.clone(), off };
|
||||
return Some((mk(l.start), mk(end)));
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Highlight rectangles for the selection `a..b`, per page.
|
||||
pub fn selection_rects(&self, doc: &Document, a: &Pos, b: &Pos, page_hint: usize) -> Vec<(usize, Rect)> {
|
||||
let (a, b) = if a <= b { (a, b) } else { (b, a) };
|
||||
let mut out = Vec::new();
|
||||
for path in doc.paths_between(a, b) {
|
||||
for (pi, it) in self.pieces(a.story, &path, page_hint) {
|
||||
let Placed::Lines { para, l0, l1, x, y, .. } = it else { continue };
|
||||
let Some(first) = para.lines.get(*l0) else { continue };
|
||||
for li in *l0..*l1 {
|
||||
let Some(l) = para.lines.get(li) else { continue };
|
||||
let from = if path == a.path { a.off.max(l.start) } else { l.start };
|
||||
let to = if path == b.path { b.off.min(l.stop) } else { l.stop };
|
||||
if path == a.path && a.off > l.stop && !(l.stop == a.off) {
|
||||
continue;
|
||||
}
|
||||
if path == b.path && b.off < l.start {
|
||||
continue;
|
||||
}
|
||||
if from > to {
|
||||
continue;
|
||||
}
|
||||
let x0 = para.x_of(li, from).unwrap_or(l.left);
|
||||
let mut x1 = para.x_of(li, to).unwrap_or(x0);
|
||||
// Selection continuing past the paragraph end shows the paragraph mark.
|
||||
let last_line = li + 1 == para.lines.len();
|
||||
if last_line && path != b.path {
|
||||
x1 += 5.0;
|
||||
}
|
||||
if path == b.path && to >= l.stop && li + 1 < para.lines.len() && b.off > l.stop {
|
||||
x1 = para.x_of(li, l.stop).unwrap_or(x1);
|
||||
}
|
||||
if x1 <= x0 && !(last_line && path != b.path) {
|
||||
continue;
|
||||
}
|
||||
let top = y + (l.top - first.top);
|
||||
out.push((pi, Rect::new(x + x0, top, (x1 - x0).max(0.0), l.height)));
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Page index for a body position (first page showing its line).
|
||||
pub fn page_of(&self, pos: &Pos) -> Option<usize> {
|
||||
self.caret(pos).map(|c| c.page)
|
||||
}
|
||||
|
||||
/// Table cell under a point: (story, table path, row, cell).
|
||||
pub fn cell_at(&self, page: usize, x: f32, y: f32) -> Option<(StoryRef, Path, usize, usize)> {
|
||||
let p = self.pages.get(page)?;
|
||||
p.items.iter().rev().find_map(|it| match it {
|
||||
Placed::Cell { rect, table, row, cell, story } if rect.contains(wordcraft_geom::Point::new(x, y)) => Some((*story, table.clone(), *row, *cell)),
|
||||
_ => None,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
fn score(l: &LineHit, x: f32, y: f32) -> f32 {
|
||||
let dy = if y < l.top {
|
||||
l.top - y
|
||||
} else if y > l.bottom {
|
||||
y - l.bottom
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
let dx = if x < l.left - 4.0 {
|
||||
l.left - 4.0 - x
|
||||
} else if x > l.right + 4.0 {
|
||||
x - l.right - 4.0
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
dy * 4.0 + dx
|
||||
}
|
||||
|
|
@ -0,0 +1,724 @@
|
|||
//! WordCraft layout: turns a [`Document`] into pages.
|
||||
//!
|
||||
//! - [`para`]: one paragraph → lines (shaping, first-fit line breaking, tabs, list labels,
|
||||
//! alignment). Results are memoised per paragraph revision in a [`LayoutCache`].
|
||||
//! - [`layout`]: sections → pages and columns; tables; keep/widow rules; page breaks; headers
|
||||
//! and footers; page-number fields.
|
||||
//! - [`display`]: a page → draw items (glyph runs, rules, fills, images) for the renderers.
|
||||
//! - [`hit`]: point ↔ position, caret geometry, line navigation, selection rectangles.
|
||||
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
|
||||
|
||||
pub mod display;
|
||||
pub mod fields;
|
||||
pub mod hit;
|
||||
pub mod para;
|
||||
mod table;
|
||||
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::hash::{Hash, Hasher};
|
||||
use std::sync::Arc;
|
||||
|
||||
use wordcraft_doc::numbering::Counters;
|
||||
use wordcraft_doc::para::{InlineObject, Wrap};
|
||||
use wordcraft_doc::props::{Border, CharProps, Rgb};
|
||||
use wordcraft_doc::section::{SectionProps, SectionStart};
|
||||
use wordcraft_doc::{Block, Blocks, Document, Paragraph, Path, StoryRef};
|
||||
use wordcraft_geom::Rect;
|
||||
|
||||
pub use fields::FieldCtx;
|
||||
pub use para::{LineEnd, ParaLayout};
|
||||
|
||||
/// How the document is viewed.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Default, serde::Serialize, serde::Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum ViewMode {
|
||||
#[default]
|
||||
Print,
|
||||
/// One page as wide as the window, no page breaks.
|
||||
Web,
|
||||
/// Draft/Outline: like web, page breaks shown as rules.
|
||||
Draft,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub struct LayoutOptions {
|
||||
pub view: ViewMode,
|
||||
/// Width of the window in Web/Draft views, points.
|
||||
pub web_width: f32,
|
||||
pub show_hidden: bool,
|
||||
}
|
||||
|
||||
/// Something placed on a page (page coordinates, points, y down).
|
||||
#[derive(Clone, Debug)]
|
||||
pub enum Placed {
|
||||
/// Lines `l0..l1` of a paragraph; `y` is the top of line `l0`; `x` the column's left edge.
|
||||
Lines { story: StoryRef, path: Path, para: Arc<ParaLayout>, l0: usize, l1: usize, x: f32, y: f32 },
|
||||
Fill { rect: Rect, color: Rgb },
|
||||
Rule { x0: f32, y0: f32, x1: f32, y1: f32, border: Border },
|
||||
Image { rect: Rect, media: String, crop: [f32; 4], story: StoryRef, path: Path, off: usize },
|
||||
Shape { rect: Rect, kind: wordcraft_doc::para::ShapeKind, fill: Option<Rgb>, stroke: Option<Rgb>, stroke_width: f32 },
|
||||
/// A table cell's area (for hit testing and cell selection).
|
||||
Cell { rect: Rect, table: Path, row: usize, cell: usize, story: StoryRef },
|
||||
}
|
||||
|
||||
impl Placed {
|
||||
fn translate(&mut self, dx: f32, dy: f32) {
|
||||
match self {
|
||||
Placed::Lines { x, y, .. } => {
|
||||
*x += dx;
|
||||
*y += dy;
|
||||
}
|
||||
Placed::Fill { rect, .. } | Placed::Image { rect, .. } | Placed::Shape { rect, .. } | Placed::Cell { rect, .. } => {
|
||||
rect.x += dx;
|
||||
rect.y += dy;
|
||||
}
|
||||
Placed::Rule { x0, y0, x1, y1, .. } => {
|
||||
*x0 += dx;
|
||||
*x1 += dx;
|
||||
*y0 += dy;
|
||||
*y1 += dy;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// One laid-out page.
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub struct Page {
|
||||
pub w: f32,
|
||||
pub h: f32,
|
||||
/// Index into `Document::sections()`.
|
||||
pub section: usize,
|
||||
/// Page number as displayed (after section restarts).
|
||||
pub number: u32,
|
||||
pub items: Vec<Placed>,
|
||||
/// Header / footer items (drawn dimmed while editing the body).
|
||||
pub header: Vec<Placed>,
|
||||
pub footer: Vec<Placed>,
|
||||
/// The body text area (margins).
|
||||
pub body: Rect,
|
||||
pub header_story: Option<u32>,
|
||||
pub footer_story: Option<u32>,
|
||||
/// Top-level body block index at the start of the page.
|
||||
pub first_block: usize,
|
||||
}
|
||||
|
||||
/// The whole layout.
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub struct DocLayout {
|
||||
pub pages: Vec<Page>,
|
||||
/// Index: (story, paragraph path) → (page, item index) of each placed piece, in order.
|
||||
pub index: HashMap<(StoryRef, Path), Vec<(usize, usize)>>,
|
||||
/// Layout time, milliseconds.
|
||||
pub ms: f64,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Hash)]
|
||||
struct Key {
|
||||
rev: u64,
|
||||
width: u32,
|
||||
label: Option<String>,
|
||||
page: Option<(u32, u32, u32, u32)>,
|
||||
table_chr: u64,
|
||||
notes: u64,
|
||||
}
|
||||
|
||||
/// Memoised paragraph layouts.
|
||||
#[derive(Default)]
|
||||
pub struct LayoutCache {
|
||||
paras: HashMap<Key, Arc<ParaLayout>>,
|
||||
env: u64,
|
||||
used: HashSet<Key>,
|
||||
pub hits: u64,
|
||||
pub misses: u64,
|
||||
}
|
||||
|
||||
impl LayoutCache {
|
||||
pub fn new() -> Self {
|
||||
Self::default()
|
||||
}
|
||||
pub fn len(&self) -> usize {
|
||||
self.paras.len()
|
||||
}
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.paras.is_empty()
|
||||
}
|
||||
pub fn clear(&mut self) {
|
||||
self.paras.clear();
|
||||
}
|
||||
}
|
||||
|
||||
fn hash_of<T: Hash>(t: &T) -> u64 {
|
||||
let mut h = std::collections::hash_map::DefaultHasher::new();
|
||||
t.hash(&mut h);
|
||||
h.finish()
|
||||
}
|
||||
|
||||
fn env_hash(doc: &Document, opts: &LayoutOptions) -> u64 {
|
||||
let s = serde_json::to_string(&(&doc.styles, &doc.numbering, doc.settings.default_tab, &doc.settings.footnote_format)).unwrap_or_default();
|
||||
hash_of(&(s, opts.show_hidden))
|
||||
}
|
||||
|
||||
fn has_page_fields(p: &Paragraph) -> bool {
|
||||
p.objects.iter().any(|o| match o {
|
||||
InlineObject::Field { instr, .. } => {
|
||||
matches!(fields::field_name(instr).as_str(), "PAGE" | "NUMPAGES" | "SECTIONPAGES" | "SECTION")
|
||||
}
|
||||
_ => false,
|
||||
})
|
||||
}
|
||||
|
||||
/// Walk state while paginating.
|
||||
struct Ctx<'a> {
|
||||
doc: &'a Document,
|
||||
cache: &'a mut LayoutCache,
|
||||
opts: &'a LayoutOptions,
|
||||
counters: Counters,
|
||||
fields: FieldCtx,
|
||||
notes_hash: u64,
|
||||
}
|
||||
|
||||
impl Ctx<'_> {
|
||||
fn para(&mut self, p: &Paragraph, width: f32, table_chr: Option<&CharProps>) -> Arc<ParaLayout> {
|
||||
let label = p.props.numbering.or_else(|| self.doc.styles.resolve_para(&p.props).numbering).filter(|n| n.num != 0).and_then(|n| {
|
||||
// Only count paragraphs that will show (list labels advance in order).
|
||||
self.counters.next_label(&self.doc.numbering, n.num, n.level)
|
||||
});
|
||||
let page = if has_page_fields(p) { Some(self.fields.page_key()) } else { None };
|
||||
let key = Key {
|
||||
rev: p.rev,
|
||||
width: width.to_bits(),
|
||||
label: label.as_ref().map(|(t, l)| format!("{t}|{}|{}|{:?}", l.indent, l.hanging, l.suffix)),
|
||||
page,
|
||||
table_chr: table_chr.map(|c| hash_of(&format!("{c:?}"))).unwrap_or(0),
|
||||
notes: if p.objects.iter().any(|o| matches!(o, InlineObject::NoteRef { .. })) { self.notes_hash } else { 0 },
|
||||
};
|
||||
self.cache.used.insert(key.clone());
|
||||
if let Some(pl) = self.cache.paras.get(&key) {
|
||||
self.cache.hits += 1;
|
||||
return pl.clone();
|
||||
}
|
||||
self.cache.misses += 1;
|
||||
let env = para::ParaEnv { doc: self.doc, width, label, fields: &self.fields, show_hidden: self.opts.show_hidden, table_chr };
|
||||
let pl = Arc::new(para::layout_para(p, &env));
|
||||
self.cache.paras.insert(key, pl.clone());
|
||||
pl
|
||||
}
|
||||
}
|
||||
|
||||
/// Note part ids in document order → numbers (footnotes and endnotes numbered separately).
|
||||
fn note_numbers(doc: &Document) -> HashMap<u32, u32> {
|
||||
let mut m = HashMap::new();
|
||||
let (mut f, mut e) = (0u32, 0u32);
|
||||
for path in doc.para_paths(StoryRef::Body) {
|
||||
if let Some(p) = doc.para(StoryRef::Body, &path) {
|
||||
for o in &p.objects {
|
||||
if let InlineObject::NoteRef { kind, id, .. } = o {
|
||||
let n = match kind {
|
||||
wordcraft_doc::para::NoteKind::Footnote => {
|
||||
f += 1;
|
||||
f
|
||||
}
|
||||
wordcraft_doc::para::NoteKind::Endnote => {
|
||||
e += 1;
|
||||
e
|
||||
}
|
||||
};
|
||||
m.insert(*id, n);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
m
|
||||
}
|
||||
|
||||
/// Lay out a block list into a free-standing box of `width` (no page breaks): table cells,
|
||||
/// headers, footers, text boxes. Returns items relative to (0, 0) and the height.
|
||||
fn layout_box(ctx: &mut Ctx, story: StoryRef, blocks: &Blocks, prefix: &[u32], width: f32, table_chr: Option<&CharProps>, depth: usize) -> (Vec<Placed>, f32) {
|
||||
let mut items = Vec::new();
|
||||
let mut y = 0.0f32;
|
||||
let mut prev_after = 0.0f32;
|
||||
let mut prev_style: Option<(String, bool)> = None;
|
||||
for (i, b) in blocks.iter().enumerate() {
|
||||
let mut path = prefix.to_vec();
|
||||
path.push(i as u32);
|
||||
match &**b {
|
||||
Block::Para(p) => {
|
||||
let pl = ctx.para(p, width, table_chr);
|
||||
let ctxl = pl.rp.contextual_spacing;
|
||||
let same = prev_style.as_ref().is_some_and(|(s, c)| *s == pl.rp.style && (*c || ctxl));
|
||||
let before = if same && ctxl { 0.0 } else { pl.rp.space_before };
|
||||
if same && ctxl {
|
||||
y -= prev_after;
|
||||
}
|
||||
// Word: space between paragraphs is before + after (no collapsing).
|
||||
y += if i == 0 { before } else { before.max(0.0) };
|
||||
push_para(&mut items, story, &path, &pl, 0, pl.lines.len(), 0.0, y, width);
|
||||
y += pl.height + pl.rp.space_after;
|
||||
prev_after = if same && ctxl { 0.0 } else { pl.rp.space_after };
|
||||
prev_style = Some((pl.rp.style.clone(), ctxl));
|
||||
}
|
||||
Block::Table(t) => {
|
||||
if depth > 8 {
|
||||
continue;
|
||||
}
|
||||
let tl = table::layout_table(ctx, story, t, &path, width, depth + 1);
|
||||
let mut ty = y;
|
||||
for row in &tl.rows {
|
||||
for it in &row.items {
|
||||
let mut it = it.clone();
|
||||
it.translate(tl.x, ty);
|
||||
items.push(it);
|
||||
}
|
||||
ty += row.height;
|
||||
}
|
||||
y = ty;
|
||||
prev_after = 0.0;
|
||||
prev_style = None;
|
||||
}
|
||||
}
|
||||
}
|
||||
(items, y.max(0.0))
|
||||
}
|
||||
|
||||
/// Paragraph lines plus their shading and borders.
|
||||
fn push_para(items: &mut Vec<Placed>, story: StoryRef, path: &[u32], pl: &Arc<ParaLayout>, l0: usize, l1: usize, x: f32, y: f32, width: f32) {
|
||||
let (Some(first), Some(last)) = (pl.lines.get(l0), l1.checked_sub(1).and_then(|k| pl.lines.get(k))) else {
|
||||
return;
|
||||
};
|
||||
let h = last.top + last.height - first.top;
|
||||
let left = x + pl.rp.indent_left.min(pl.rp.indent_left + pl.rp.indent_first);
|
||||
let right = x + width - pl.rp.indent_right;
|
||||
if let Some(c) = pl.rp.shading {
|
||||
let pad = pl.rp.borders.as_ref().map(|b| b.left.map(|l| l.space).unwrap_or(4.0)).unwrap_or(0.0);
|
||||
items.push(Placed::Fill { rect: Rect::new(left - pad, y, right - left + pad * 2.0, h), color: c });
|
||||
}
|
||||
items.push(Placed::Lines { story, path: Path(path.to_vec()), para: pl.clone(), l0, l1, x, y });
|
||||
if let Some(b) = &pl.rp.borders {
|
||||
let sp = |o: &Option<Border>| o.map(|b| b.space).unwrap_or(0.0);
|
||||
let (lx, rx) = (left - sp(&b.left), right + sp(&b.right));
|
||||
let (ty, by) = (y - sp(&b.top), y + h + sp(&b.bottom));
|
||||
if let Some(t) = b.top.filter(|_| l0 == 0)
|
||||
&& t.is_visible()
|
||||
{
|
||||
items.push(Placed::Rule { x0: lx, y0: ty, x1: rx, y1: ty, border: t });
|
||||
}
|
||||
if let Some(t) = b.bottom.filter(|_| l1 >= pl.lines.len())
|
||||
&& t.is_visible()
|
||||
{
|
||||
items.push(Placed::Rule { x0: lx, y0: by, x1: rx, y1: by, border: t });
|
||||
}
|
||||
if let Some(t) = b.left.filter(Border::is_visible) {
|
||||
items.push(Placed::Rule { x0: lx, y0: ty, x1: lx, y1: by, border: t });
|
||||
}
|
||||
if let Some(t) = b.right.filter(Border::is_visible) {
|
||||
items.push(Placed::Rule { x0: rx, y0: ty, x1: rx, y1: by, border: t });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct PageBuilder<'a> {
|
||||
pages: Vec<Page>,
|
||||
sect: &'a SectionProps,
|
||||
sect_idx: usize,
|
||||
col: usize,
|
||||
cols: Vec<(f32, f32)>,
|
||||
y: f32,
|
||||
top: f32,
|
||||
bottom: f32,
|
||||
number: u32,
|
||||
web: bool,
|
||||
}
|
||||
|
||||
impl PageBuilder<'_> {
|
||||
fn new_page(&mut self, first_block: usize, body_top: f32) {
|
||||
let s = self.sect;
|
||||
self.number += 1;
|
||||
let (w, h) = if self.web { (s.page_w, f32::MAX / 4.0) } else { (s.page_w, s.page_h) };
|
||||
let body = Rect::new(s.margin_left + s.gutter, body_top, s.text_width(), (s.page_h - s.margin_bottom - body_top).max(36.0));
|
||||
self.pages.push(Page { w, h, section: self.sect_idx, number: self.number, body, first_block, ..Default::default() });
|
||||
self.col = 0;
|
||||
self.cols = s.column_boxes();
|
||||
self.top = body_top;
|
||||
self.bottom = if self.web { f32::MAX / 8.0 } else { s.page_h - s.margin_bottom };
|
||||
self.y = self.top;
|
||||
}
|
||||
fn col_x(&self) -> f32 {
|
||||
self.sect.margin_left + self.sect.gutter + self.cols.get(self.col).map(|c| c.0).unwrap_or(0.0)
|
||||
}
|
||||
fn col_w(&self) -> f32 {
|
||||
self.cols.get(self.col).map(|c| c.1).unwrap_or_else(|| self.sect.text_width())
|
||||
}
|
||||
fn at_top(&self) -> bool {
|
||||
self.y <= self.top + 0.01
|
||||
}
|
||||
/// Move to the next column or page.
|
||||
fn advance(&mut self, block: usize, body_top: f32) {
|
||||
if self.col + 1 < self.cols.len() {
|
||||
self.col += 1;
|
||||
self.y = self.top;
|
||||
} else {
|
||||
self.new_page(block, body_top);
|
||||
}
|
||||
}
|
||||
fn page(&mut self) -> Option<&mut Page> {
|
||||
self.pages.last_mut()
|
||||
}
|
||||
}
|
||||
|
||||
/// Lay out the whole document.
|
||||
pub fn layout(doc: &Document, cache: &mut LayoutCache, opts: &LayoutOptions) -> DocLayout {
|
||||
let t0 = now_ms();
|
||||
let env = env_hash(doc, opts);
|
||||
if env != cache.env {
|
||||
cache.paras.clear();
|
||||
cache.env = env;
|
||||
}
|
||||
cache.used.clear();
|
||||
let notes = note_numbers(doc);
|
||||
let notes_hash = hash_of(&{
|
||||
let mut v: Vec<_> = notes.iter().map(|(a, b)| (*a, *b)).collect();
|
||||
v.sort();
|
||||
v
|
||||
});
|
||||
let mut ctx = Ctx {
|
||||
doc,
|
||||
cache,
|
||||
opts,
|
||||
counters: Counters::default(),
|
||||
fields: FieldCtx {
|
||||
page: 1,
|
||||
pages: 1,
|
||||
section_pages: 1,
|
||||
section: 1,
|
||||
notes: Arc::new(notes),
|
||||
title: doc.core.title.as_str().into(),
|
||||
author: doc.core.creator.as_str().into(),
|
||||
..Default::default()
|
||||
},
|
||||
notes_hash,
|
||||
};
|
||||
let sections = doc.sections();
|
||||
let web = opts.view != ViewMode::Print;
|
||||
let default_sect = SectionProps::default();
|
||||
let mut web_sect;
|
||||
let first_sect = match sections.first() {
|
||||
Some((_, s)) => *s,
|
||||
None => &default_sect,
|
||||
};
|
||||
let sect_ref = if web {
|
||||
web_sect = first_sect.clone();
|
||||
web_sect.page_w = opts.web_width.max(144.0);
|
||||
web_sect.margin_left = 18.0;
|
||||
web_sect.margin_right = 18.0;
|
||||
web_sect.margin_top = 18.0;
|
||||
web_sect.gutter = 0.0;
|
||||
web_sect.columns = Default::default();
|
||||
&web_sect
|
||||
} else {
|
||||
first_sect
|
||||
};
|
||||
let mut pb = PageBuilder { pages: Vec::new(), sect: sect_ref, sect_idx: 0, col: 0, cols: Vec::new(), y: 0.0, top: 0.0, bottom: 0.0, number: 0, web };
|
||||
let mut block = 0usize;
|
||||
for (si, (end, sect)) in sections.iter().enumerate() {
|
||||
let sect: &SectionProps = if web { sect_ref } else { sect };
|
||||
pb.sect = sect;
|
||||
pb.sect_idx = si;
|
||||
let body_top = if web { sect.margin_top } else { body_top_for(&mut ctx, sect, si) };
|
||||
let restart = sect.page_num_start;
|
||||
let start = if si == 0 { SectionStart::NextPage } else { sect.start };
|
||||
if web && si > 0 {
|
||||
// one long page
|
||||
} else if pb.pages.is_empty() || start != SectionStart::Continuous {
|
||||
if let Some(n) = restart {
|
||||
pb.number = n.saturating_sub(1);
|
||||
}
|
||||
pb.new_page(block, body_top);
|
||||
if !web && matches!(start, SectionStart::EvenPage | SectionStart::OddPage) {
|
||||
let want_even = start == SectionStart::EvenPage;
|
||||
if (pb.number % 2 == 0) != want_even {
|
||||
pb.new_page(block, body_top);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Continuous: new column layout below the current text.
|
||||
pb.cols = sect.column_boxes();
|
||||
pb.col = 0;
|
||||
pb.top = pb.y;
|
||||
}
|
||||
let last = (*end).min(doc.body.len().saturating_sub(1));
|
||||
while block <= last {
|
||||
let Some(b) = doc.body.get(block) else { break };
|
||||
match &**b {
|
||||
Block::Para(p) => place_para(&mut ctx, &mut pb, p, block, body_top),
|
||||
Block::Table(t) => place_table(&mut ctx, &mut pb, t, block, body_top),
|
||||
}
|
||||
block += 1;
|
||||
}
|
||||
}
|
||||
if pb.pages.is_empty() {
|
||||
pb.new_page(0, sect_ref.margin_top);
|
||||
}
|
||||
let mut pages = pb.pages;
|
||||
if web && let Some(p) = pages.first_mut() {
|
||||
let bottom = p.items.iter().filter_map(|it| if let Placed::Lines { y, para, l0, l1, .. } = it { item_bottom(*y, para, *l0, *l1) } else { None }).fold(0.0f32, f32::max);
|
||||
p.h = bottom + 36.0;
|
||||
}
|
||||
if !web {
|
||||
headers_footers(&mut ctx, &mut pages, §ions);
|
||||
}
|
||||
// Evict paragraph layouts that weren't used this pass if the cache grew large.
|
||||
if ctx.cache.paras.len() > 4 * ctx.cache.used.len().max(1024) {
|
||||
let used = std::mem::take(&mut ctx.cache.used);
|
||||
ctx.cache.paras.retain(|k, _| used.contains(k));
|
||||
ctx.cache.used = used;
|
||||
}
|
||||
let mut index: HashMap<(StoryRef, Path), Vec<(usize, usize)>> = HashMap::new();
|
||||
for (pi, p) in pages.iter().enumerate() {
|
||||
for (ii, it) in p.items.iter().enumerate() {
|
||||
if let Placed::Lines { story, path, .. } = it {
|
||||
index.entry((*story, path.clone())).or_default().push((pi, ii));
|
||||
}
|
||||
}
|
||||
}
|
||||
DocLayout { pages, index, ms: now_ms() - t0 }
|
||||
}
|
||||
|
||||
fn item_bottom(y: f32, para: &ParaLayout, l0: usize, l1: usize) -> Option<f32> {
|
||||
let f = para.lines.get(l0)?;
|
||||
let l = para.lines.get(l1.checked_sub(1)?)?;
|
||||
Some(y + l.top + l.height - f.top)
|
||||
}
|
||||
|
||||
/// Where body text starts: the top margin, or below the header if it's taller.
|
||||
fn body_top_for(ctx: &mut Ctx, sect: &SectionProps, _si: usize) -> f32 {
|
||||
let Some(id) = sect.headers.default else { return sect.margin_top };
|
||||
let Some(part) = ctx.doc.parts.get(&id) else { return sect.margin_top };
|
||||
let blocks = part.blocks.clone();
|
||||
let (_, h) = layout_box(ctx, StoryRef::Part(id), &blocks, &[], sect.text_width(), None, 0);
|
||||
sect.margin_top.max(sect.header + h + 6.0)
|
||||
}
|
||||
|
||||
fn place_para(ctx: &mut Ctx, pb: &mut PageBuilder, p: &Paragraph, block: usize, body_top: f32) {
|
||||
let width = pb.col_w();
|
||||
ctx.fields.page = pb.number;
|
||||
let pl = ctx.para(p, width, None);
|
||||
if pl.rp.page_break_before && !pb.at_top() && !pb.web {
|
||||
pb.new_page(block, body_top);
|
||||
}
|
||||
pb.y += pl.rp.space_before;
|
||||
let n = pl.lines.len();
|
||||
// Keep lines together: if it doesn't fit but would on an empty column, move it.
|
||||
if pl.rp.keep_lines || pl.rp.keep_next {
|
||||
let need = pl.height + if pl.rp.keep_next { next_first_line(ctx, block, width) } else { 0.0 };
|
||||
if pb.y + need > pb.bottom && !pb.at_top() && need <= pb.bottom - pb.top {
|
||||
pb.advance(block, body_top);
|
||||
pb.y += pl.rp.space_before.min(0.0);
|
||||
}
|
||||
}
|
||||
let mut l0 = 0;
|
||||
while l0 < n {
|
||||
// How many lines fit?
|
||||
let Some(first) = pl.lines.get(l0) else { break };
|
||||
let mut l1 = l0;
|
||||
let mut forced_break = None;
|
||||
while l1 < n {
|
||||
let Some(l) = pl.lines.get(l1) else { break };
|
||||
let bottom = pb.y + (l.top + l.height - first.top);
|
||||
if bottom > pb.bottom + 0.01 && l1 > l0 {
|
||||
break;
|
||||
}
|
||||
if bottom > pb.bottom + 0.01 && l1 == l0 && !pb.at_top() {
|
||||
break;
|
||||
}
|
||||
l1 += 1;
|
||||
if matches!(l.end, LineEnd::PageBreak | LineEnd::ColumnBreak) && !pb.web {
|
||||
forced_break = Some(l.end);
|
||||
break;
|
||||
}
|
||||
}
|
||||
// Widow/orphan control (2-line minimum at either end).
|
||||
if forced_break.is_none() && l1 < n && pl.rp.widow_control && n >= 2 {
|
||||
if l1 - l0 == 1 && l0 == 0 && !pb.at_top() {
|
||||
l1 = l0; // orphan: move the first line to the next page
|
||||
} else if n - l1 == 1 && l1 - l0 >= 3 {
|
||||
l1 -= 1; // widow: take one more line along
|
||||
}
|
||||
}
|
||||
if l1 == l0 {
|
||||
if pb.at_top() {
|
||||
l1 = l0 + 1; // can't fit even one line on an empty page: overflow
|
||||
} else {
|
||||
pb.advance(block, body_top);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
let x = pb.col_x();
|
||||
let y = pb.y;
|
||||
let mut items = Vec::new();
|
||||
push_para(&mut items, StoryRef::Body, &[block as u32], &pl, l0, l1, x, y, width);
|
||||
if let Some(pg) = pb.page() {
|
||||
pg.items.extend(items);
|
||||
}
|
||||
if let (Some(f), Some(l)) = (pl.lines.get(l0), pl.lines.get(l1 - 1)) {
|
||||
pb.y = y + (l.top + l.height - f.top);
|
||||
}
|
||||
l0 = l1;
|
||||
match forced_break {
|
||||
Some(LineEnd::PageBreak) => pb.new_page(block, body_top),
|
||||
Some(LineEnd::ColumnBreak) => pb.advance(block, body_top),
|
||||
_ if l0 < n => pb.advance(block, body_top),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
pb.y += pl.rp.space_after;
|
||||
}
|
||||
|
||||
/// Height of the first line of the block after `block` (for keep-with-next).
|
||||
fn next_first_line(ctx: &mut Ctx, block: usize, width: f32) -> f32 {
|
||||
match ctx.doc.body.get(block + 1).map(|b| &**b) {
|
||||
Some(Block::Para(p)) => {
|
||||
// Don't advance list counters for a lookahead: lay out without the label.
|
||||
let rp = ctx.doc.styles.resolve_para(&p.props);
|
||||
let env = para::ParaEnv { doc: ctx.doc, width, label: None, fields: &ctx.fields, show_hidden: ctx.opts.show_hidden, table_chr: None };
|
||||
let _ = rp;
|
||||
let pl = para::layout_para(p, &env);
|
||||
pl.rp.space_before + pl.lines.first().map(|l| l.height).unwrap_or(0.0)
|
||||
}
|
||||
Some(Block::Table(_)) => 24.0,
|
||||
None => 0.0,
|
||||
}
|
||||
}
|
||||
|
||||
fn place_table(ctx: &mut Ctx, pb: &mut PageBuilder, t: &wordcraft_doc::Table, block: usize, body_top: f32) {
|
||||
let width = pb.col_w();
|
||||
let tl = table::layout_table(ctx, StoryRef::Body, t, &[block as u32], width, 1);
|
||||
let header_rows: Vec<usize> = (0..t.rows.len()).take_while(|r| t.rows.get(*r).is_some_and(|row| row.props.header)).collect();
|
||||
for (ri, row) in tl.rows.iter().enumerate() {
|
||||
if pb.y + row.height > pb.bottom + 0.01 && !pb.at_top() {
|
||||
pb.advance(block, body_top);
|
||||
// Repeat header rows.
|
||||
if !header_rows.contains(&ri) {
|
||||
for hr in &header_rows {
|
||||
if let Some(h) = tl.rows.get(*hr) {
|
||||
let (x, y) = (pb.col_x() + tl.x, pb.y);
|
||||
if let Some(pg) = pb.page() {
|
||||
for it in &h.items {
|
||||
let mut it = it.clone();
|
||||
it.translate(x, y);
|
||||
pg.items.push(it);
|
||||
}
|
||||
}
|
||||
pb.y += h.height;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
let (x, y) = (pb.col_x() + tl.x, pb.y);
|
||||
if let Some(pg) = pb.page() {
|
||||
for it in &row.items {
|
||||
let mut it = it.clone();
|
||||
it.translate(x, y);
|
||||
pg.items.push(it);
|
||||
}
|
||||
}
|
||||
pb.y += row.height;
|
||||
}
|
||||
}
|
||||
|
||||
fn headers_footers(ctx: &mut Ctx, pages: &mut [Page], sections: &[(usize, &SectionProps)]) {
|
||||
let total = pages.len() as u32;
|
||||
// Pages per section.
|
||||
let mut per_section: HashMap<usize, u32> = HashMap::new();
|
||||
for p in pages.iter() {
|
||||
*per_section.entry(p.section).or_default() += 1;
|
||||
}
|
||||
let even_odd = ctx.doc.settings.even_odd_headers;
|
||||
let mut first_of_section: HashSet<usize> = HashSet::new();
|
||||
let mut seen = HashSet::new();
|
||||
for (i, p) in pages.iter().enumerate() {
|
||||
if seen.insert(p.section) {
|
||||
first_of_section.insert(i);
|
||||
}
|
||||
}
|
||||
for (i, page) in pages.iter_mut().enumerate() {
|
||||
let Some((_, sect)) = sections.get(page.section) else { continue };
|
||||
// Inherit header/footer references from earlier sections (Word's "link to previous").
|
||||
let pick = |get: &dyn Fn(&SectionProps) -> wordcraft_doc::section::HeaderSet| -> Option<u32> {
|
||||
let mut set = get(sect);
|
||||
for k in (0..page.section).rev() {
|
||||
if set.default.is_some() && set.first.is_some() && set.even.is_some() {
|
||||
break;
|
||||
}
|
||||
if let Some((_, s)) = sections.get(k) {
|
||||
let o = get(s);
|
||||
set.default = set.default.or(o.default);
|
||||
set.first = set.first.or(o.first);
|
||||
set.even = set.even.or(o.even);
|
||||
}
|
||||
}
|
||||
if sect.title_page && first_of_section.contains(&i) {
|
||||
return set.first;
|
||||
}
|
||||
if even_odd && page.number % 2 == 0 {
|
||||
return set.even.or(set.default);
|
||||
}
|
||||
set.default
|
||||
};
|
||||
let hid = pick(&|s: &SectionProps| s.headers);
|
||||
let fid = pick(&|s: &SectionProps| s.footers);
|
||||
ctx.fields.page = page.number;
|
||||
ctx.fields.page_format = sect.page_num_format;
|
||||
ctx.fields.pages = total;
|
||||
ctx.fields.section_pages = per_section.get(&page.section).copied().unwrap_or(1);
|
||||
ctx.fields.section = page.section as u32 + 1;
|
||||
let w = sect.text_width();
|
||||
let x = sect.margin_left + sect.gutter;
|
||||
if let Some(id) = hid
|
||||
&& let Some(part) = ctx.doc.parts.get(&id)
|
||||
{
|
||||
let blocks = part.blocks.clone();
|
||||
let (mut items, _) = layout_box(ctx, StoryRef::Part(id), &blocks, &[], w, None, 0);
|
||||
for it in &mut items {
|
||||
it.translate(x, sect.header);
|
||||
}
|
||||
page.header = items;
|
||||
page.header_story = Some(id);
|
||||
}
|
||||
if let Some(id) = fid
|
||||
&& let Some(part) = ctx.doc.parts.get(&id)
|
||||
{
|
||||
let blocks = part.blocks.clone();
|
||||
let (mut items, h) = layout_box(ctx, StoryRef::Part(id), &blocks, &[], w, None, 0);
|
||||
for it in &mut items {
|
||||
it.translate(x, sect.page_h - sect.footer - h);
|
||||
}
|
||||
page.footer = items;
|
||||
page.footer_story = Some(id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Is an object floating (not laid out inline)?
|
||||
pub fn is_floating(o: &InlineObject) -> bool {
|
||||
match o {
|
||||
InlineObject::Image { float, .. } | InlineObject::Shape { float, .. } => float.wrap != Wrap::Inline,
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
/// Milliseconds since an arbitrary epoch (monotonic on native; 0 on wasm).
|
||||
pub fn now_ms() -> f64 {
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
{
|
||||
static T0: std::sync::OnceLock<std::time::Instant> = std::sync::OnceLock::new();
|
||||
T0.get_or_init(std::time::Instant::now).elapsed().as_secs_f64() * 1000.0
|
||||
}
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
{
|
||||
0.0
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
|
|
@ -0,0 +1,863 @@
|
|||
//! Paragraph layout: resolve runs, shape them into clusters, break lines (first-fit, as word
|
||||
//! processors do), place tabs, list labels and alignment.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use wordcraft_doc::numbering::{Level, LevelSuffix};
|
||||
use wordcraft_doc::para::{COLUMN_BREAK, InlineObject, LINE_BREAK, NoteKind, OBJ, PAGE_BREAK, SOFT_HYPHEN};
|
||||
use wordcraft_doc::props::{Align, CharProps, LineSpacing, TabAlign, TabLeader, TabStop};
|
||||
use wordcraft_doc::resolve::{ResolvedChar, ResolvedPara};
|
||||
use wordcraft_doc::{Document, Paragraph};
|
||||
use wordcraft_fonts::FaceRef;
|
||||
|
||||
use crate::fields::{FieldCtx, field_text};
|
||||
|
||||
/// A face at a size with the formatting needed to draw it.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct StyleRun {
|
||||
pub face: FaceRef,
|
||||
pub size: f32,
|
||||
pub synth_bold: bool,
|
||||
pub synth_italic: bool,
|
||||
pub rc: Arc<ResolvedChar>,
|
||||
/// Line ascent/descent at this size, points.
|
||||
pub ascent: f32,
|
||||
pub descent: f32,
|
||||
/// Baseline shift (superscript etc.), points, positive up.
|
||||
pub shift: f32,
|
||||
}
|
||||
|
||||
/// A positioned glyph inside its cluster.
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
pub struct Glyph {
|
||||
pub gid: u32,
|
||||
/// Offset from the cluster's x, points.
|
||||
pub dx: f32,
|
||||
pub dy: f32,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum ClKind {
|
||||
Text,
|
||||
Space,
|
||||
Tab,
|
||||
LineBreak,
|
||||
PageBreak,
|
||||
ColumnBreak,
|
||||
/// Inline object with its index in `Paragraph::objects`.
|
||||
Object(usize),
|
||||
/// Zero-width marker (bookmark, comment anchor) or hidden text.
|
||||
Marker,
|
||||
}
|
||||
|
||||
/// The smallest unit of layout: a grapheme cluster (or object, tab, break).
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct Cluster {
|
||||
pub start: usize,
|
||||
pub end: usize,
|
||||
pub adv: f32,
|
||||
pub kind: ClKind,
|
||||
/// Index into `ParaLayout::styles`.
|
||||
pub style: u16,
|
||||
/// Glyph range in `ParaLayout::glyphs`.
|
||||
pub g0: u32,
|
||||
pub g1: u32,
|
||||
/// A line may break after this cluster.
|
||||
pub break_after: bool,
|
||||
/// Height above the baseline for objects (images), points.
|
||||
pub obj_h: f32,
|
||||
/// The cluster is a decimal separator (decimal tabs align on it).
|
||||
pub dot: bool,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum LineEnd {
|
||||
/// Wrapped (or last line).
|
||||
Wrap,
|
||||
LineBreak,
|
||||
PageBreak,
|
||||
ColumnBreak,
|
||||
/// End of paragraph.
|
||||
Para,
|
||||
}
|
||||
|
||||
/// A laid-out line. Coordinates are relative to the paragraph's content box: x from the
|
||||
/// column's left edge, y from the top of the first line.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct Line {
|
||||
pub top: f32,
|
||||
pub height: f32,
|
||||
pub baseline: f32,
|
||||
/// Cluster index range.
|
||||
pub c0: usize,
|
||||
pub c1: usize,
|
||||
/// x of each cluster in the line, plus the end x (len = c1 - c0 + 1).
|
||||
pub xs: Vec<f32>,
|
||||
/// For tab clusters: the leader to draw across them.
|
||||
pub leaders: Vec<(usize, TabLeader)>,
|
||||
pub end: LineEnd,
|
||||
/// Byte range of the paragraph covered by the line.
|
||||
pub start: usize,
|
||||
pub stop: usize,
|
||||
/// Left and right edges available to the line.
|
||||
pub left: f32,
|
||||
pub right: f32,
|
||||
}
|
||||
|
||||
/// The list label drawn on the first line.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct Label {
|
||||
pub text: String,
|
||||
pub style: u16,
|
||||
pub glyphs: Vec<Glyph>,
|
||||
pub x: f32,
|
||||
pub width: f32,
|
||||
}
|
||||
|
||||
/// A laid-out paragraph.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct ParaLayout {
|
||||
pub rp: ResolvedPara,
|
||||
pub styles: Vec<StyleRun>,
|
||||
pub glyphs: Vec<Glyph>,
|
||||
pub clusters: Vec<Cluster>,
|
||||
pub lines: Vec<Line>,
|
||||
pub label: Option<Label>,
|
||||
/// Sum of line heights (without space before/after).
|
||||
pub height: f32,
|
||||
pub text_len: usize,
|
||||
/// Paragraph contains fields whose text depends on the page.
|
||||
pub has_page_fields: bool,
|
||||
/// Note references (part id) in this paragraph, by cluster index.
|
||||
pub notes: Vec<(usize, u32)>,
|
||||
}
|
||||
|
||||
/// Inputs that change a paragraph's layout beyond its own content.
|
||||
pub struct ParaEnv<'a> {
|
||||
pub doc: &'a Document,
|
||||
/// Column width, points.
|
||||
pub width: f32,
|
||||
pub label: Option<(String, Level)>,
|
||||
pub fields: &'a FieldCtx,
|
||||
pub show_hidden: bool,
|
||||
/// Extra style applied to every run (table style conditional formatting), under direct formatting.
|
||||
pub table_chr: Option<&'a CharProps>,
|
||||
}
|
||||
|
||||
fn style_key(rc: &ResolvedChar) -> String {
|
||||
format!("{}|{}|{}|{}|{}", rc.font, rc.size, rc.bold, rc.italic, rc.vert_align as u8)
|
||||
}
|
||||
|
||||
struct Builder<'a> {
|
||||
env: &'a ParaEnv<'a>,
|
||||
styles: Vec<StyleRun>,
|
||||
style_index: std::collections::HashMap<(String, u32, bool), u16>,
|
||||
glyphs: Vec<Glyph>,
|
||||
clusters: Vec<Cluster>,
|
||||
}
|
||||
|
||||
impl<'a> Builder<'a> {
|
||||
/// Style index for a resolved char in `face` (caps-scaled `size` override for small caps).
|
||||
fn style(&mut self, rc: &Arc<ResolvedChar>, face_override: Option<FaceRef>, small: bool) -> u16 {
|
||||
let r = wordcraft_fonts::word::resolve(&rc.font, rc.bold, rc.italic);
|
||||
let face = face_override.unwrap_or(r.face);
|
||||
let key = (format!("{}|{:?}|{}|{}", style_key(rc), rc.color, rc.underline as u8, small), face.id(), rc.strike || rc.double_strike || rc.link.is_some());
|
||||
let key = (format!("{}|{:?}|{:?}|{:?}|{:?}|{}", key.0, rc.highlight, rc.shading, rc.ins, rc.del, rc.hidden), key.1, key.2);
|
||||
if let Some(i) = self.style_index.get(&key) {
|
||||
return *i;
|
||||
}
|
||||
let size = rc.draw_size() * if small { 0.8 } else { 1.0 };
|
||||
let (a, d) = wordcraft_fonts::word::line_metrics(&face);
|
||||
let k = size as f64 / face.upem.max(1.0);
|
||||
let st = StyleRun {
|
||||
face,
|
||||
size,
|
||||
synth_bold: r.synth_bold && face_override.is_none(),
|
||||
synth_italic: r.synth_italic && face_override.is_none(),
|
||||
rc: rc.clone(),
|
||||
ascent: (a * k) as f32,
|
||||
descent: (d * k) as f32,
|
||||
shift: rc.baseline_shift(),
|
||||
};
|
||||
let i = self.styles.len().min(u16::MAX as usize) as u16;
|
||||
self.styles.push(st);
|
||||
self.style_index.insert(key, i);
|
||||
i
|
||||
}
|
||||
|
||||
/// Shape `text` (a piece of the paragraph starting at byte `base`) in one style and append clusters.
|
||||
fn shape(&mut self, text: &str, base: usize, rc: &Arc<ResolvedChar>, kind_override: Option<ClKind>) {
|
||||
if text.is_empty() {
|
||||
return;
|
||||
}
|
||||
// Split by font coverage (fallback faces) and small caps case.
|
||||
let primary = wordcraft_fonts::word::resolve(&rc.font, rc.bold, rc.italic).face;
|
||||
let mut seg_start = 0;
|
||||
let mut cur: Option<(Option<FaceRef>, bool)> = None;
|
||||
let mut segs: Vec<(usize, usize, Option<FaceRef>, bool)> = Vec::new();
|
||||
for (i, c) in text.char_indices() {
|
||||
let face = if primary.covers(c) || c.is_whitespace() || c.is_control() || c == SOFT_HYPHEN {
|
||||
None
|
||||
} else {
|
||||
wordcraft_fonts::FontDb::global().fallback_for(c, primary.id()).map(|f| FaceRef::of(&f))
|
||||
};
|
||||
let small = rc.small_caps && !rc.caps && c.is_lowercase();
|
||||
let k = (face, small);
|
||||
match cur {
|
||||
Some(p) if p.0.map(|f| f.id()) == k.0.map(|f| f.id()) && p.1 == k.1 => {}
|
||||
Some(p) => {
|
||||
segs.push((seg_start, i, p.0, p.1));
|
||||
seg_start = i;
|
||||
cur = Some(k);
|
||||
}
|
||||
None => cur = Some(k),
|
||||
}
|
||||
}
|
||||
if let Some(p) = cur {
|
||||
segs.push((seg_start, text.len(), p.0, p.1));
|
||||
}
|
||||
for (a, b, face, small) in segs {
|
||||
let Some(sub) = text.get(a..b) else { continue };
|
||||
let si = self.style(rc, face, small);
|
||||
let Some(st) = self.styles.get(si as usize).cloned() else { continue };
|
||||
let upper = rc.caps || small;
|
||||
let shaped = wordcraft_fonts::shape(&st.face, sub, &[], |c| if upper { c.to_uppercase().next().unwrap_or(c) } else { c });
|
||||
let k = st.size / st.face.upem.max(1.0) as f32;
|
||||
let hscale = rc.scale / 100.0;
|
||||
// Group glyphs by cluster byte offset; graphemes may span several shaper clusters.
|
||||
let bounds: Vec<usize> = unicode_segmentation::UnicodeSegmentation::grapheme_indices(sub, true).map(|(i, _)| i).collect();
|
||||
let mut gi = 0usize;
|
||||
for (bi, &gs) in bounds.iter().enumerate() {
|
||||
let ge = bounds.get(bi + 1).copied().unwrap_or(sub.len());
|
||||
let g0 = self.glyphs.len() as u32;
|
||||
let mut adv = 0.0f32;
|
||||
while let Some(g) = shaped.get(gi) {
|
||||
if g.cluster >= ge {
|
||||
break;
|
||||
}
|
||||
self.glyphs.push(Glyph { gid: g.gid, dx: adv + g.x_offset as f32 * k * hscale, dy: g.y_offset as f32 * k });
|
||||
adv += g.x_advance as f32 * k * hscale;
|
||||
gi += 1;
|
||||
}
|
||||
let g1 = self.glyphs.len() as u32;
|
||||
let s = sub.get(gs..ge).unwrap_or("");
|
||||
let ch = s.chars().next().unwrap_or(' ');
|
||||
let kind = kind_override.unwrap_or(if ch == ' ' || ch == '\u{3000}' {
|
||||
ClKind::Space
|
||||
} else if ch == SOFT_HYPHEN {
|
||||
ClKind::Marker
|
||||
} else {
|
||||
ClKind::Text
|
||||
});
|
||||
let adv = if kind == ClKind::Marker { 0.0 } else { adv + rc.spacing };
|
||||
self.clusters.push(Cluster {
|
||||
start: base + a + gs,
|
||||
end: base + a + ge,
|
||||
adv,
|
||||
kind,
|
||||
style: si,
|
||||
g0,
|
||||
g1: if kind == ClKind::Marker { g0 } else { g1 },
|
||||
break_after: false,
|
||||
obj_h: 0.0,
|
||||
dot: s == "." || s == ",",
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// One cluster for a whole object with its display text (fields, note references).
|
||||
fn shape_atomic(&mut self, text: &str, start: usize, end: usize, rc: &Arc<ResolvedChar>) {
|
||||
let before = self.clusters.len();
|
||||
self.shape(text, start, rc, None);
|
||||
let added: Vec<Cluster> = self.clusters.drain(before..).collect();
|
||||
let Some(first) = added.first() else {
|
||||
let si = self.style(rc, None, false);
|
||||
let g = self.glyphs.len() as u32;
|
||||
self.clusters.push(Cluster { start, end, adv: 0.0, kind: ClKind::Marker, style: si, g0: g, g1: g, break_after: false, obj_h: 0.0, dot: false });
|
||||
return;
|
||||
};
|
||||
// Merge into one cluster: rebase glyph dx onto the first cluster.
|
||||
let mut x = 0.0;
|
||||
for c in &added {
|
||||
for g in self.glyphs.get_mut(c.g0 as usize..c.g1 as usize).into_iter().flatten() {
|
||||
g.dx += x;
|
||||
}
|
||||
x += c.adv;
|
||||
}
|
||||
let g0 = first.g0;
|
||||
let style = first.style;
|
||||
let g1 = added.last().map(|c| c.g1).unwrap_or(g0);
|
||||
self.clusters.push(Cluster { start, end, adv: x, kind: ClKind::Text, style, g0, g1, break_after: false, obj_h: 0.0, dot: false });
|
||||
}
|
||||
}
|
||||
|
||||
/// Lay out one paragraph.
|
||||
pub fn layout_para(p: &Paragraph, env: &ParaEnv) -> ParaLayout {
|
||||
let doc = env.doc;
|
||||
let mut rp = doc.styles.resolve_para(&p.props);
|
||||
// List level indents apply unless the paragraph sets its own.
|
||||
if let Some((_, lvl)) = &env.label {
|
||||
if p.props.indent_left.is_none() {
|
||||
rp.indent_left = lvl.indent;
|
||||
}
|
||||
if p.props.indent_first.is_none() {
|
||||
rp.indent_first = -lvl.hanging;
|
||||
}
|
||||
}
|
||||
let para_style = p.props.style.as_deref();
|
||||
let resolve = |c: &CharProps| -> Arc<ResolvedChar> {
|
||||
match env.table_chr {
|
||||
Some(t) => {
|
||||
let merged = t.clone().overlaid(c);
|
||||
Arc::new(doc.styles.resolve_char(para_style, &merged))
|
||||
}
|
||||
None => Arc::new(doc.styles.resolve_char(para_style, c)),
|
||||
}
|
||||
};
|
||||
let mut b = Builder { env, styles: Vec::new(), style_index: Default::default(), glyphs: Vec::new(), clusters: Vec::new() };
|
||||
let mark_rc = resolve(&p.mark);
|
||||
let mark_style = b.style(&mark_rc, None, false);
|
||||
let mut has_page_fields = false;
|
||||
let mut notes = Vec::new();
|
||||
let mut obj_index = 0usize;
|
||||
for (range, props) in p.run_ranges() {
|
||||
let rc = resolve(props);
|
||||
let Some(text) = p.text.get(range.clone()) else { continue };
|
||||
if rc.hidden && !b.env.show_hidden {
|
||||
let si = b.style(&rc, None, false);
|
||||
let g = b.glyphs.len() as u32;
|
||||
for (i, c) in text.char_indices() {
|
||||
if c == OBJ {
|
||||
obj_index += 1;
|
||||
}
|
||||
b.clusters.push(Cluster {
|
||||
start: range.start + i,
|
||||
end: range.start + i + c.len_utf8(),
|
||||
adv: 0.0,
|
||||
kind: ClKind::Marker,
|
||||
style: si,
|
||||
g0: g,
|
||||
g1: g,
|
||||
break_after: false,
|
||||
obj_h: 0.0,
|
||||
dot: false,
|
||||
});
|
||||
}
|
||||
continue;
|
||||
}
|
||||
let mut seg = 0;
|
||||
for (i, c) in text.char_indices() {
|
||||
let special = matches!(c, '\t' | LINE_BREAK | PAGE_BREAK | COLUMN_BREAK | OBJ | '\r');
|
||||
if !special {
|
||||
continue;
|
||||
}
|
||||
if let Some(s) = text.get(seg..i) {
|
||||
b.shape(s, range.start + seg, &rc, None);
|
||||
}
|
||||
seg = i + c.len_utf8();
|
||||
let (start, end) = (range.start + i, range.start + i + c.len_utf8());
|
||||
let si = b.style(&rc, None, false);
|
||||
let g = b.glyphs.len() as u32;
|
||||
let mut push = |b: &mut Builder, kind: ClKind, adv: f32, h: f32| {
|
||||
b.clusters.push(Cluster { start, end, adv, kind, style: si, g0: g, g1: g, break_after: false, obj_h: h, dot: false })
|
||||
};
|
||||
match c {
|
||||
'\t' => push(&mut b, ClKind::Tab, 0.0, 0.0),
|
||||
LINE_BREAK | '\r' => push(&mut b, ClKind::LineBreak, 0.0, 0.0),
|
||||
PAGE_BREAK => push(&mut b, ClKind::PageBreak, 0.0, 0.0),
|
||||
COLUMN_BREAK => push(&mut b, ClKind::ColumnBreak, 0.0, 0.0),
|
||||
_ => {
|
||||
let k = obj_index;
|
||||
obj_index += 1;
|
||||
match p.objects.get(k) {
|
||||
Some(InlineObject::Image { w, h, float, .. }) | Some(InlineObject::Shape { w, h, float, .. }) => {
|
||||
if float.wrap == wordcraft_doc::para::Wrap::Inline {
|
||||
let maxw = (env.width - rp.indent_left.max(0.0) - rp.indent_right.max(0.0)).max(18.0);
|
||||
let (w, h) = (w.clamp(1.0, 4000.0), h.clamp(1.0, 4000.0));
|
||||
let s = if w > maxw { maxw / w } else { 1.0 };
|
||||
push(&mut b, ClKind::Object(k), w * s, h * s);
|
||||
} else {
|
||||
push(&mut b, ClKind::Object(k), 0.0, 0.0);
|
||||
}
|
||||
}
|
||||
Some(InlineObject::Field { instr, result, .. }) => {
|
||||
let (t, page_dep) = field_text(instr, result, env.fields);
|
||||
has_page_fields |= page_dep;
|
||||
b.shape_atomic(&t, start, end, &rc);
|
||||
}
|
||||
Some(InlineObject::NoteRef { kind, id, custom }) => {
|
||||
let num = if custom.is_empty() {
|
||||
let n = env.fields.note_number(*id);
|
||||
match kind {
|
||||
NoteKind::Footnote => doc.settings.footnote_format.format(n),
|
||||
NoteKind::Endnote => doc.settings.endnote_format.format(n),
|
||||
}
|
||||
} else {
|
||||
custom.clone()
|
||||
};
|
||||
let mut sup = (*rc).clone();
|
||||
sup.vert_align = wordcraft_doc::props::VertAlign::Superscript;
|
||||
b.shape_atomic(&num, start, end, &Arc::new(sup));
|
||||
notes.push((b.clusters.len().saturating_sub(1), *id));
|
||||
}
|
||||
Some(InlineObject::Equation { linear, .. }) => {
|
||||
let mut eq = (*rc).clone();
|
||||
eq.italic = true;
|
||||
eq.font = "Cambria Math".into();
|
||||
b.shape_atomic(linear, start, end, &Arc::new(eq));
|
||||
}
|
||||
Some(InlineObject::Opaque { text, .. }) => b.shape_atomic(text, start, end, &rc),
|
||||
_ => push(&mut b, ClKind::Marker, 0.0, 0.0),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if let Some(s) = text.get(seg..) {
|
||||
b.shape(s, range.start + seg, &rc, None);
|
||||
}
|
||||
}
|
||||
// Break opportunities.
|
||||
let mut opps = std::collections::HashSet::new();
|
||||
for (i, o) in unicode_linebreak::linebreaks(&p.text) {
|
||||
if o == unicode_linebreak::BreakOpportunity::Allowed || i < p.text.len() {
|
||||
opps.insert(i);
|
||||
}
|
||||
}
|
||||
for c in &mut b.clusters {
|
||||
c.break_after = opps.contains(&c.end) || matches!(c.kind, ClKind::Object(_) | ClKind::Tab);
|
||||
}
|
||||
|
||||
// List label.
|
||||
let label = env.label.as_ref().map(|(text, lvl)| {
|
||||
let lc = p.mark.clone().overlaid(&lvl.chr);
|
||||
let rc = resolve(&lc);
|
||||
let before_c = b.clusters.len();
|
||||
let before_g = b.glyphs.len();
|
||||
b.shape(text, 0, &rc, None);
|
||||
let cl: Vec<Cluster> = b.clusters.drain(before_c..).collect();
|
||||
let mut glyphs = Vec::new();
|
||||
let mut x = 0.0;
|
||||
let mut style = mark_style;
|
||||
for c in &cl {
|
||||
style = c.style;
|
||||
for g in b.glyphs.get(c.g0 as usize..c.g1 as usize).into_iter().flatten() {
|
||||
glyphs.push(Glyph { dx: g.dx + x, ..*g });
|
||||
}
|
||||
x += c.adv;
|
||||
}
|
||||
b.glyphs.truncate(before_g);
|
||||
Label { text: text.clone(), style, glyphs, x: 0.0, width: x }
|
||||
});
|
||||
|
||||
let mut pl = ParaLayout {
|
||||
rp,
|
||||
styles: b.styles,
|
||||
glyphs: b.glyphs,
|
||||
clusters: b.clusters,
|
||||
lines: Vec::new(),
|
||||
label,
|
||||
height: 0.0,
|
||||
text_len: p.text.len(),
|
||||
has_page_fields,
|
||||
notes,
|
||||
};
|
||||
break_lines(&mut pl, env, mark_style, env.label.as_ref().map(|(_, l)| l.suffix));
|
||||
pl
|
||||
}
|
||||
|
||||
/// Next tab stop after `x` (column coordinates): explicit stops, the hanging-indent implicit stop,
|
||||
/// then default stops.
|
||||
fn next_tab(x: f32, tabs: &[TabStop], default_tab: f32, hanging_at: Option<f32>) -> TabStop {
|
||||
let mut best: Option<TabStop> = tabs.iter().copied().filter(|t| t.pos > x + 0.01).min_by(|a, b| a.pos.total_cmp(&b.pos));
|
||||
if let Some(h) = hanging_at
|
||||
&& h > x + 0.01
|
||||
&& best.is_none_or(|t| h < t.pos)
|
||||
{
|
||||
best = Some(TabStop { pos: h, align: TabAlign::Left, leader: TabLeader::None });
|
||||
}
|
||||
best.unwrap_or_else(|| {
|
||||
let d = if default_tab > 1.0 { default_tab } else { 36.0 };
|
||||
let n = ((x + 0.01) / d).floor() + 1.0;
|
||||
TabStop { pos: (n * d).min(1e6), align: TabAlign::Left, leader: TabLeader::None }
|
||||
})
|
||||
}
|
||||
|
||||
fn break_lines(pl: &mut ParaLayout, env: &ParaEnv, mark_style: u16, suffix: Option<LevelSuffix>) {
|
||||
let rp = pl.rp.clone();
|
||||
let width = env.width.max(12.0);
|
||||
let right_edge = (width - rp.indent_right).max(1.0);
|
||||
let default_tab = env.doc.settings.default_tab;
|
||||
let first_left = rp.indent_left + rp.indent_first;
|
||||
let hanging_at = if rp.indent_first < 0.0 { Some(rp.indent_left) } else { None };
|
||||
let n = pl.clusters.len();
|
||||
let mut lines: Vec<Line> = Vec::new();
|
||||
let mut i = 0usize;
|
||||
let mut top = 0.0f32;
|
||||
let mut first = true;
|
||||
loop {
|
||||
let left = if first { first_left } else { rp.indent_left };
|
||||
let mut x = left;
|
||||
// Label on the first line.
|
||||
if first && let Some(lab) = pl.label.as_mut() {
|
||||
lab.x = left;
|
||||
let end = left + lab.width;
|
||||
x = match suffix.unwrap_or(LevelSuffix::Tab) {
|
||||
LevelSuffix::Tab => {
|
||||
let t = next_tab(end, &rp.tabs, default_tab, hanging_at);
|
||||
t.pos
|
||||
}
|
||||
LevelSuffix::Space => end + pl.styles.get(lab.style as usize).map(|s| s.size * 0.25).unwrap_or(3.0),
|
||||
LevelSuffix::Nothing => end,
|
||||
};
|
||||
}
|
||||
let line_start_x = x;
|
||||
let c0 = i;
|
||||
let mut xs: Vec<f32> = Vec::new();
|
||||
let mut leaders = Vec::new();
|
||||
let mut last_break: Option<usize> = None; // cluster index after which we may break
|
||||
let mut end = LineEnd::Para;
|
||||
let mut j = i;
|
||||
let mut pending_tab: Option<(usize, TabStop, f32)> = None; // tab cluster, stop, x where tab started
|
||||
while j < n {
|
||||
let Some(c) = pl.clusters.get(j).cloned() else { break };
|
||||
match c.kind {
|
||||
ClKind::LineBreak | ClKind::PageBreak | ClKind::ColumnBreak => {
|
||||
resolve_tab(pl, &mut pending_tab, &mut xs, c0, x, &mut x);
|
||||
xs.push(x);
|
||||
end = match c.kind {
|
||||
ClKind::LineBreak => LineEnd::LineBreak,
|
||||
ClKind::PageBreak => LineEnd::PageBreak,
|
||||
_ => LineEnd::ColumnBreak,
|
||||
};
|
||||
j += 1;
|
||||
break;
|
||||
}
|
||||
ClKind::Tab => {
|
||||
resolve_tab(pl, &mut pending_tab, &mut xs, c0, x, &mut x);
|
||||
let stop = next_tab(x, &rp.tabs, default_tab, hanging_at);
|
||||
xs.push(x);
|
||||
if stop.pos > right_edge + 0.01 && j > c0 && stop.align == TabAlign::Left {
|
||||
// Tab past the right indent wraps (Word moves it to the next line).
|
||||
if let Some(c) = pl.clusters.get_mut(j) {
|
||||
c.adv = 0.0;
|
||||
}
|
||||
x = right_edge.max(x);
|
||||
last_break = Some(j);
|
||||
j += 1;
|
||||
continue;
|
||||
}
|
||||
match stop.align {
|
||||
TabAlign::Left | TabAlign::Bar | TabAlign::Clear => {
|
||||
let w = (stop.pos - x).max(0.0);
|
||||
if let Some(c) = pl.clusters.get_mut(j) {
|
||||
c.adv = w;
|
||||
}
|
||||
if stop.leader != TabLeader::None {
|
||||
leaders.push((j, stop.leader));
|
||||
}
|
||||
x += w;
|
||||
}
|
||||
_ => {
|
||||
pending_tab = Some((j, stop, x));
|
||||
if stop.leader != TabLeader::None {
|
||||
leaders.push((j, stop.leader));
|
||||
}
|
||||
}
|
||||
}
|
||||
last_break = Some(j);
|
||||
j += 1;
|
||||
continue;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
let fits = x + c.adv <= right_edge + 0.01 || c.kind == ClKind::Space || c.kind == ClKind::Marker;
|
||||
if !fits && j > c0 {
|
||||
// Wrap: back up to the last break opportunity on this line.
|
||||
end = LineEnd::Wrap;
|
||||
if let Some(bk) = last_break.filter(|bk| *bk >= c0) {
|
||||
xs.truncate(bk + 1 - c0);
|
||||
j = bk + 1;
|
||||
// Recompute x for the kept clusters.
|
||||
x = xs.last().copied().unwrap_or(line_start_x) + pl.clusters.get(bk).map(|c| c.adv).unwrap_or(0.0);
|
||||
} else {
|
||||
// No opportunity: break here (a long word).
|
||||
}
|
||||
break;
|
||||
}
|
||||
xs.push(x);
|
||||
x += c.adv;
|
||||
// Decimal/center/right tab: shift pending text as it grows.
|
||||
if let Some((tj, stop, tx)) = pending_tab {
|
||||
let seg_w = x - tx;
|
||||
let shift = match stop.align {
|
||||
TabAlign::Right => stop.pos - tx - seg_w,
|
||||
TabAlign::Center => stop.pos - tx - seg_w / 2.0,
|
||||
TabAlign::Decimal => {
|
||||
// Width up to the first '.' in the segment.
|
||||
let mut w = 0.0;
|
||||
let mut found = false;
|
||||
for k in tj + 1..=j {
|
||||
let Some(cc) = pl.clusters.get(k) else { break };
|
||||
let s = pl_text_at(cc);
|
||||
if found {
|
||||
break;
|
||||
}
|
||||
if s {
|
||||
found = true;
|
||||
break;
|
||||
}
|
||||
w += cc.adv;
|
||||
}
|
||||
stop.pos - tx - if found { w } else { seg_w }
|
||||
}
|
||||
_ => 0.0,
|
||||
};
|
||||
let shift = shift.max(0.0);
|
||||
let old = pl.clusters.get(tj).map(|c| c.adv).unwrap_or(0.0);
|
||||
if (shift - old).abs() > 0.001 {
|
||||
let delta = shift - old;
|
||||
if let Some(c) = pl.clusters.get_mut(tj) {
|
||||
c.adv = shift;
|
||||
}
|
||||
let from = tj + 1 - c0;
|
||||
for v in xs.iter_mut().skip(from) {
|
||||
*v += delta;
|
||||
}
|
||||
x += delta;
|
||||
}
|
||||
}
|
||||
if c.break_after {
|
||||
last_break = Some(j);
|
||||
}
|
||||
j += 1;
|
||||
}
|
||||
if j >= n && end != LineEnd::Wrap && !matches!(end, LineEnd::LineBreak | LineEnd::PageBreak | LineEnd::ColumnBreak) {
|
||||
end = LineEnd::Para;
|
||||
}
|
||||
let c1 = j.max(c0);
|
||||
// Ensure progress.
|
||||
let (c1, j) = if c1 == c0 && c0 < n { (c0 + 1, c0 + 1) } else { (c1, j) };
|
||||
while xs.len() < c1 - c0 {
|
||||
let last = xs.last().copied().unwrap_or(line_start_x);
|
||||
let adv = pl.clusters.get(c0 + xs.len().saturating_sub(1)).map(|c| c.adv).unwrap_or(0.0);
|
||||
xs.push(if xs.is_empty() { line_start_x } else { last + adv });
|
||||
}
|
||||
xs.truncate(c1 - c0);
|
||||
let end_x = match (xs.last(), c1.checked_sub(1).and_then(|k| pl.clusters.get(k))) {
|
||||
(Some(lx), Some(c)) => lx + c.adv,
|
||||
_ => line_start_x,
|
||||
};
|
||||
xs.push(end_x);
|
||||
pending_tab = None;
|
||||
let _ = pending_tab;
|
||||
|
||||
// Vertical metrics.
|
||||
let (mut asc, mut desc) = (0.0f32, 0.0f32);
|
||||
let mut any = false;
|
||||
for c in pl.clusters.get(c0..c1).into_iter().flatten() {
|
||||
if c.kind == ClKind::Marker {
|
||||
continue;
|
||||
}
|
||||
if let Some(st) = pl.styles.get(c.style as usize) {
|
||||
let (a, d) = if matches!(c.kind, ClKind::Object(_)) && c.obj_h > 0.0 {
|
||||
(c.obj_h, 0.0)
|
||||
} else {
|
||||
(st.ascent + st.shift.max(0.0), st.descent + (-st.shift).max(0.0))
|
||||
};
|
||||
asc = asc.max(a);
|
||||
desc = desc.max(d);
|
||||
any = true;
|
||||
}
|
||||
}
|
||||
if !any || (asc == 0.0 && desc == 0.0) {
|
||||
if let Some(st) = pl.styles.get(mark_style as usize) {
|
||||
asc = st.ascent;
|
||||
desc = st.descent;
|
||||
}
|
||||
}
|
||||
if first && let Some(lab) = &pl.label
|
||||
&& let Some(st) = pl.styles.get(lab.style as usize)
|
||||
{
|
||||
asc = asc.max(st.ascent);
|
||||
desc = desc.max(st.descent);
|
||||
}
|
||||
let natural = asc + desc;
|
||||
let height = match rp.line_spacing {
|
||||
LineSpacing::Multiple(m) => natural * m,
|
||||
LineSpacing::AtLeast(v) => natural.max(v),
|
||||
LineSpacing::Exactly(v) => v,
|
||||
};
|
||||
let baseline = top + height - desc;
|
||||
|
||||
// Alignment / justification (trailing spaces hang).
|
||||
let mut content_end = xs.last().copied().unwrap_or(line_start_x);
|
||||
for k in (c0..c1).rev() {
|
||||
match pl.clusters.get(k).map(|c| c.kind) {
|
||||
Some(ClKind::Space) | Some(ClKind::Marker) | Some(ClKind::LineBreak) | Some(ClKind::PageBreak) | Some(ClKind::ColumnBreak) => {
|
||||
content_end = xs.get(k - c0).copied().unwrap_or(content_end);
|
||||
}
|
||||
_ => break,
|
||||
}
|
||||
}
|
||||
let slack = right_edge - content_end;
|
||||
let last_tab = (c0..c1).rev().find(|k| pl.clusters.get(*k).is_some_and(|c| c.kind == ClKind::Tab));
|
||||
let align_from = last_tab.map(|k| k + 1).unwrap_or(c0);
|
||||
let shift = match rp.align {
|
||||
Align::Center => slack / 2.0,
|
||||
Align::Right => slack,
|
||||
_ => 0.0,
|
||||
};
|
||||
if shift > 0.0 && slack > 0.0 {
|
||||
for v in xs.iter_mut().skip(align_from - c0) {
|
||||
*v += shift;
|
||||
}
|
||||
}
|
||||
let justify = (rp.align == Align::Justify && end == LineEnd::Wrap) || rp.align == Align::Distribute;
|
||||
if justify && slack > 0.0 {
|
||||
// Spaces inside the content (after the last tab).
|
||||
let spaces: Vec<usize> = (align_from..c1)
|
||||
.filter(|k| {
|
||||
pl.clusters.get(*k).is_some_and(|c| c.kind == ClKind::Space) && xs.get(k - c0).copied().unwrap_or(f32::MAX) < content_end - 0.01
|
||||
})
|
||||
.collect();
|
||||
if rp.align == Align::Distribute || spaces.is_empty() {
|
||||
let cnt = (c1 - align_from).saturating_sub(1).max(1) as f32;
|
||||
if rp.align == Align::Distribute {
|
||||
let per = slack / cnt;
|
||||
for (q, v) in xs.iter_mut().enumerate().skip(align_from - c0) {
|
||||
let k = (q + c0 - align_from) as f32;
|
||||
*v += per * k.min(cnt);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
let per = slack / spaces.len() as f32;
|
||||
let mut add = 0.0;
|
||||
let mut si = 0;
|
||||
for k in align_from..c1 {
|
||||
if let Some(v) = xs.get_mut(k - c0) {
|
||||
*v += add;
|
||||
}
|
||||
if spaces.get(si) == Some(&k) {
|
||||
add += per;
|
||||
si += 1;
|
||||
}
|
||||
}
|
||||
if let Some(v) = xs.last_mut() {
|
||||
*v += add;
|
||||
}
|
||||
}
|
||||
}
|
||||
let start = pl.clusters.get(c0).map(|c| c.start).unwrap_or(pl.text_len);
|
||||
let stop = if c1 > c0 { pl.clusters.get(c1 - 1).map(|c| c.end).unwrap_or(pl.text_len) } else { start };
|
||||
lines.push(Line { top, height, baseline, c0, c1, xs, leaders, end, start, stop, left: if first { first_left } else { rp.indent_left }, right: right_edge });
|
||||
top += height;
|
||||
first = false;
|
||||
i = j;
|
||||
if i >= n {
|
||||
// A paragraph ending with a line break gets an empty last line.
|
||||
if matches!(end, LineEnd::LineBreak | LineEnd::PageBreak | LineEnd::ColumnBreak) {
|
||||
let (asc, desc) = pl.styles.get(mark_style as usize).map(|s| (s.ascent, s.descent)).unwrap_or((10.0, 3.0));
|
||||
let natural = asc + desc;
|
||||
let h = match rp.line_spacing {
|
||||
LineSpacing::Multiple(m) => natural * m,
|
||||
LineSpacing::AtLeast(v) => natural.max(v),
|
||||
LineSpacing::Exactly(v) => v,
|
||||
};
|
||||
let x0 = rp.indent_left;
|
||||
lines.push(Line {
|
||||
top,
|
||||
height: h,
|
||||
baseline: top + h - desc,
|
||||
c0: n,
|
||||
c1: n,
|
||||
xs: vec![x0],
|
||||
leaders: Vec::new(),
|
||||
end: LineEnd::Para,
|
||||
start: pl.text_len,
|
||||
stop: pl.text_len,
|
||||
left: x0,
|
||||
right: right_edge,
|
||||
});
|
||||
top += h;
|
||||
}
|
||||
break;
|
||||
}
|
||||
if lines.len() > 200_000 {
|
||||
break;
|
||||
}
|
||||
}
|
||||
pl.height = top;
|
||||
pl.lines = lines;
|
||||
}
|
||||
|
||||
fn pl_text_at(c: &Cluster) -> bool {
|
||||
c.dot
|
||||
}
|
||||
|
||||
fn resolve_tab(_pl: &mut ParaLayout, pending: &mut Option<(usize, TabStop, f32)>, _xs: &mut [f32], _c0: usize, _x: f32, _xo: &mut f32) {
|
||||
*pending = None;
|
||||
}
|
||||
|
||||
impl ParaLayout {
|
||||
/// x of byte offset `off` within line `li` (clamped to the line).
|
||||
pub fn x_of(&self, li: usize, off: usize) -> Option<f32> {
|
||||
let l = self.lines.get(li)?;
|
||||
for k in l.c0..l.c1 {
|
||||
let c = self.clusters.get(k)?;
|
||||
if off <= c.start {
|
||||
return l.xs.get(k - l.c0).copied();
|
||||
}
|
||||
if off < c.end {
|
||||
return l.xs.get(k - l.c0).copied();
|
||||
}
|
||||
}
|
||||
l.xs.last().copied()
|
||||
}
|
||||
/// The line holding offset `off` (a position at a wrap point belongs to the next line).
|
||||
pub fn line_of(&self, off: usize) -> usize {
|
||||
for (i, l) in self.lines.iter().enumerate() {
|
||||
let next_start = self.lines.get(i + 1).map(|n| n.start);
|
||||
match next_start {
|
||||
Some(ns) if off < ns => return i,
|
||||
Some(_) => {}
|
||||
None => return i,
|
||||
}
|
||||
if matches!(l.end, LineEnd::LineBreak | LineEnd::PageBreak | LineEnd::ColumnBreak) && off < l.stop {
|
||||
return i;
|
||||
}
|
||||
}
|
||||
self.lines.len().saturating_sub(1)
|
||||
}
|
||||
/// Byte offset closest to x on line `li`.
|
||||
pub fn off_at_x(&self, li: usize, x: f32) -> usize {
|
||||
let Some(l) = self.lines.get(li) else { return self.text_len };
|
||||
let mut best = l.start;
|
||||
let mut bestd = f32::MAX;
|
||||
for k in l.c0..=l.c1 {
|
||||
let Some(cx) = l.xs.get(k - l.c0) else { break };
|
||||
let off = if k < l.c1 { self.clusters.get(k).map(|c| c.start).unwrap_or(l.stop) } else { l.stop };
|
||||
// Don't place the caret after a line-ending break or wrap-trailing space on a wrapped line.
|
||||
if k == l.c1 && k > l.c0 {
|
||||
let lastc = self.clusters.get(k - 1);
|
||||
if lastc.is_some_and(|c| matches!(c.kind, ClKind::LineBreak | ClKind::PageBreak | ClKind::ColumnBreak)) {
|
||||
continue;
|
||||
}
|
||||
if l.end == LineEnd::Wrap && self.lines.get(li + 1).is_some() {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
let d = (cx - x).abs();
|
||||
if d < bestd {
|
||||
bestd = d;
|
||||
best = off;
|
||||
}
|
||||
}
|
||||
best
|
||||
}
|
||||
/// Line ascent/descent of the paragraph mark (for empty paragraphs and caret height).
|
||||
pub fn caret_metrics(&self, li: usize) -> (f32, f32) {
|
||||
let Some(l) = self.lines.get(li) else { return (10.0, 3.0) };
|
||||
(l.baseline - l.top, l.top + l.height - l.baseline)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,241 @@
|
|||
//! Table layout: column widths, cell boxes, row heights, merged cells, borders and shading,
|
||||
//! table-style conditional formatting. Rows are laid out as free-standing boxes; pagination
|
||||
//! moves whole rows.
|
||||
|
||||
use wordcraft_doc::props::{Align, Border, Borders, CharProps, HeightRule, Rgb, VAlign, VMerge};
|
||||
use wordcraft_doc::styles::TableStyleParts;
|
||||
use wordcraft_doc::{StoryRef, Table};
|
||||
use wordcraft_geom::Rect;
|
||||
|
||||
use crate::{Ctx, Placed, layout_box};
|
||||
|
||||
pub struct RowLayout {
|
||||
pub height: f32,
|
||||
/// Items relative to the table's top-left (x) and the row's top (y).
|
||||
pub items: Vec<Placed>,
|
||||
}
|
||||
|
||||
pub struct TableLayout {
|
||||
/// Table x offset within the column.
|
||||
pub x: f32,
|
||||
pub width: f32,
|
||||
pub rows: Vec<RowLayout>,
|
||||
}
|
||||
|
||||
const DEFAULT_MARGINS: [f32; 4] = [0.0, 5.4, 0.0, 5.4];
|
||||
|
||||
fn style_parts(ctx: &Ctx, t: &Table) -> Option<TableStyleParts> {
|
||||
let id = t.props.style.as_deref()?;
|
||||
ctx.doc.styles.chain(id).iter().rev().find_map(|s| s.table.clone())
|
||||
}
|
||||
|
||||
pub fn layout_table(ctx: &mut Ctx, story: StoryRef, t: &Table, path: &[u32], avail: f32, depth: usize) -> TableLayout {
|
||||
let parts = style_parts(ctx, t);
|
||||
let ncols = t.cols().max(1);
|
||||
// Column widths.
|
||||
let mut grid: Vec<f32> = if t.grid.len() == ncols { t.grid.iter().map(|w| if w.is_finite() { w.max(4.0) } else { 72.0 }).collect() } else { vec![avail / ncols as f32; ncols] };
|
||||
let mut total: f32 = grid.iter().sum();
|
||||
let target = match (t.props.width_pct, t.props.width) {
|
||||
(Some(p), _) if p > 0.0 => Some(avail * (p / 100.0).min(1.0)),
|
||||
(_, Some(w)) if w > 0.0 => Some(w),
|
||||
_ => None,
|
||||
};
|
||||
if let Some(tw) = target {
|
||||
let k = tw / total.max(1.0);
|
||||
grid.iter_mut().for_each(|w| *w *= k);
|
||||
total = tw;
|
||||
}
|
||||
if total > avail * 1.5 && !t.props.fixed {
|
||||
let k = avail / total.max(1.0);
|
||||
grid.iter_mut().for_each(|w| *w *= k);
|
||||
total = avail;
|
||||
}
|
||||
let mut colx = Vec::with_capacity(ncols + 1);
|
||||
let mut acc = 0.0;
|
||||
for w in &grid {
|
||||
colx.push(acc);
|
||||
acc += w;
|
||||
}
|
||||
colx.push(acc);
|
||||
// Word places the table so cell text lines up with the margin: shift left by the left cell margin.
|
||||
let margins_def = t.props.cell_margins.unwrap_or(DEFAULT_MARGINS);
|
||||
let indent = t.props.indent.unwrap_or(0.0);
|
||||
let x = match t.props.align {
|
||||
Some(Align::Center) => (avail - total) / 2.0,
|
||||
Some(Align::Right) => avail - total,
|
||||
_ => indent - margins_def[1],
|
||||
};
|
||||
let tborders = t.props.borders.or_else(|| parts.as_ref().and_then(|p| p.borders));
|
||||
let nrows = t.rows.len();
|
||||
let header_rows = t.props.look.header_row;
|
||||
// First pass: lay out every cell's content.
|
||||
struct CellBox {
|
||||
items: Vec<Placed>,
|
||||
h: f32,
|
||||
x: f32,
|
||||
w: f32,
|
||||
valign: VAlign,
|
||||
fill: Option<Rgb>,
|
||||
borders: Borders,
|
||||
vmerge: VMerge,
|
||||
margins: [f32; 4],
|
||||
g: usize,
|
||||
span: usize,
|
||||
}
|
||||
let mut rows: Vec<Vec<CellBox>> = Vec::with_capacity(nrows);
|
||||
let mut heights: Vec<f32> = Vec::with_capacity(nrows);
|
||||
for (ri, row) in t.rows.iter().enumerate() {
|
||||
let mut g = 0usize;
|
||||
let mut cells = Vec::with_capacity(row.cells.len());
|
||||
let mut rh = 0.0f32;
|
||||
let is_header = header_rows && ri == 0;
|
||||
let is_total = t.props.look.total_row && ri + 1 == nrows && nrows > 1;
|
||||
let band = t.props.look.banded_rows && !is_header && (ri - usize::from(header_rows)) % 2 == 0;
|
||||
for (ci, cell) in row.cells.iter().enumerate() {
|
||||
let span = cell.span();
|
||||
let x0 = colx.get(g).copied().unwrap_or(acc);
|
||||
let x1 = colx.get((g + span).min(ncols)).copied().unwrap_or(acc);
|
||||
let margins = cell.props.margins.unwrap_or(margins_def);
|
||||
let cw = (x1 - x0 - margins[1] - margins[3]).max(4.0);
|
||||
let mut chr: Option<CharProps> = None;
|
||||
let mut fill = cell.props.shading;
|
||||
if let Some(p) = &parts {
|
||||
if is_header {
|
||||
chr = Some(p.header_chr.clone());
|
||||
fill = fill.or(p.header_fill);
|
||||
} else if is_total {
|
||||
chr = Some(p.total_chr.clone());
|
||||
} else if t.props.look.first_column && g == 0 && !p.first_col_chr.is_empty() {
|
||||
chr = Some(p.first_col_chr.clone());
|
||||
}
|
||||
if band && fill.is_none() {
|
||||
fill = p.band_fill;
|
||||
}
|
||||
}
|
||||
let mut cpath = path.to_vec();
|
||||
cpath.push(ri as u32);
|
||||
cpath.push(ci as u32);
|
||||
let (mut items, h) = if cell.props.vmerge == VMerge::Continue {
|
||||
(Vec::new(), 0.0)
|
||||
} else {
|
||||
layout_box(ctx, story, &cell.blocks, &cpath, cw, chr.as_ref(), depth)
|
||||
};
|
||||
for it in &mut items {
|
||||
it.translate(x0 + margins[1], margins[0]);
|
||||
}
|
||||
let h = h + margins[0] + margins[2];
|
||||
if cell.props.vmerge != VMerge::Restart {
|
||||
rh = rh.max(h);
|
||||
}
|
||||
// Effective borders: cell > table (outer vs inside).
|
||||
let tb = tborders.unwrap_or_default();
|
||||
let edge = |own: Option<Border>, outer: bool, outer_b: Option<Border>, inner_b: Option<Border>| own.or(if outer { outer_b } else { inner_b });
|
||||
let cb = cell.props.borders.unwrap_or_default();
|
||||
let mut borders = Borders {
|
||||
top: edge(cb.top, ri == 0, tb.top, tb.between),
|
||||
bottom: edge(cb.bottom, ri + 1 == nrows, tb.bottom, tb.between),
|
||||
left: edge(cb.left, g == 0, tb.left, tb.inside_v),
|
||||
right: edge(cb.right, g + span >= ncols, tb.right, tb.inside_v),
|
||||
between: None,
|
||||
inside_v: None,
|
||||
};
|
||||
if is_total && let Some(b) = parts.as_ref().and_then(|p| p.total_border_top) {
|
||||
borders.top = Some(b);
|
||||
}
|
||||
cells.push(CellBox { items, h, x: x0, w: x1 - x0, valign: cell.props.valign, fill, borders, vmerge: cell.props.vmerge, margins, g, span });
|
||||
g += span;
|
||||
}
|
||||
let rh = match (row.props.height, row.props.height_rule) {
|
||||
(Some(h), HeightRule::Exact) if h > 0.0 => h,
|
||||
(Some(h), _) if h > 0.0 => rh.max(h),
|
||||
_ => rh,
|
||||
};
|
||||
heights.push(rh.max(4.0));
|
||||
rows.push(cells);
|
||||
}
|
||||
// Vertically merged cells: grow the last row of the merge if the content is taller.
|
||||
for ri in 0..rows.len() {
|
||||
let restarts: Vec<(usize, f32, usize)> =
|
||||
rows.get(ri).map(|r| r.iter().filter(|c| c.vmerge == VMerge::Restart).map(|c| (c.g, c.h, c.span)).collect()).unwrap_or_default();
|
||||
for (g, h, _) in restarts {
|
||||
let mut end = ri;
|
||||
while rows.get(end + 1).is_some_and(|r| r.iter().any(|c| c.g == g && c.vmerge == VMerge::Continue)) {
|
||||
end += 1;
|
||||
}
|
||||
let have: f32 = heights.get(ri..=end).map(|s| s.iter().sum()).unwrap_or(0.0);
|
||||
if h > have
|
||||
&& let Some(last) = heights.get_mut(end)
|
||||
{
|
||||
*last += h - have;
|
||||
}
|
||||
}
|
||||
}
|
||||
// Grid columns holding a vertical-merge continuation, per row.
|
||||
let cont: Vec<Vec<usize>> = t
|
||||
.rows
|
||||
.iter()
|
||||
.map(|r| {
|
||||
let mut gx = 0;
|
||||
r.cells
|
||||
.iter()
|
||||
.filter_map(|x| {
|
||||
let g = gx;
|
||||
gx += x.span();
|
||||
(x.props.vmerge == VMerge::Continue).then_some(g)
|
||||
})
|
||||
.collect()
|
||||
})
|
||||
.collect();
|
||||
// Second pass: place cells with their row height, fills and borders.
|
||||
let mut out = Vec::with_capacity(rows.len());
|
||||
for (ri, cells) in rows.into_iter().enumerate() {
|
||||
let rh = heights.get(ri).copied().unwrap_or(0.0);
|
||||
let mut items = Vec::new();
|
||||
for (ci, c) in cells.into_iter().enumerate() {
|
||||
// A restart cell spans several rows: its height is their sum.
|
||||
let mut span_h = rh;
|
||||
if c.vmerge == VMerge::Restart {
|
||||
let mut k = ri + 1;
|
||||
while cont.get(k).is_some_and(|v| v.contains(&c.g)) {
|
||||
span_h += heights.get(k).copied().unwrap_or(0.0);
|
||||
k += 1;
|
||||
}
|
||||
}
|
||||
let rect = Rect::new(c.x, 0.0, c.w, span_h);
|
||||
if c.vmerge != VMerge::Continue
|
||||
&& let Some(f) = c.fill
|
||||
{
|
||||
items.push(Placed::Fill { rect, color: f });
|
||||
}
|
||||
let dy = match c.valign {
|
||||
VAlign::Top => 0.0,
|
||||
VAlign::Center => ((span_h - c.h) / 2.0).max(0.0),
|
||||
VAlign::Bottom => (span_h - c.h).max(0.0),
|
||||
};
|
||||
let _ = c.margins;
|
||||
for mut it in c.items {
|
||||
it.translate(0.0, dy);
|
||||
items.push(it);
|
||||
}
|
||||
let b = c.borders;
|
||||
let (x0, x1, y0, y1) = (c.x, c.x + c.w, 0.0, rh);
|
||||
let top_vis = c.vmerge != VMerge::Continue;
|
||||
if top_vis && let Some(e) = b.top.filter(Border::is_visible) {
|
||||
items.push(Placed::Rule { x0, y0, x1, y1: y0, border: e });
|
||||
}
|
||||
let last_of_merge = c.vmerge == VMerge::None || !cont.get(ri + 1).is_some_and(|v| v.contains(&c.g));
|
||||
if last_of_merge && let Some(e) = b.bottom.filter(Border::is_visible) {
|
||||
items.push(Placed::Rule { x0, y0: y1, x1, y1, border: e });
|
||||
}
|
||||
if let Some(e) = b.left.filter(Border::is_visible) {
|
||||
items.push(Placed::Rule { x0, y0, x1: x0, y1, border: e });
|
||||
}
|
||||
if let Some(e) = b.right.filter(Border::is_visible) {
|
||||
items.push(Placed::Rule { x0: x1, y0, x1, y1, border: e });
|
||||
}
|
||||
items.push(Placed::Cell { rect: Rect::new(c.x, 0.0, c.w, rh), table: wordcraft_doc::Path(path.to_vec()), row: ri, cell: ci, story });
|
||||
}
|
||||
out.push(RowLayout { height: rh, items });
|
||||
}
|
||||
TableLayout { x, width: total, rows: out }
|
||||
}
|
||||
|
|
@ -0,0 +1,228 @@
|
|||
use super::*;
|
||||
use wordcraft_doc::para::InlineObject;
|
||||
use wordcraft_doc::props::{Align, ParaProps};
|
||||
use wordcraft_doc::{Pos, Table};
|
||||
|
||||
fn lay(doc: &Document) -> DocLayout {
|
||||
let mut c = LayoutCache::new();
|
||||
layout(doc, &mut c, &LayoutOptions::default())
|
||||
}
|
||||
|
||||
fn lines_of(l: &DocLayout) -> usize {
|
||||
l.pages.iter().flat_map(|p| p.items.iter()).map(|it| if let Placed::Lines { l0, l1, .. } = it { l1 - l0 } else { 0 }).sum()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_doc_has_one_page() {
|
||||
let d = Document::new();
|
||||
let l = lay(&d);
|
||||
assert_eq!(l.pages.len(), 1);
|
||||
assert_eq!(lines_of(&l), 1);
|
||||
let c = l.caret(&Pos::body(0, 0)).unwrap();
|
||||
assert_eq!(c.page, 0);
|
||||
assert!((c.x - 72.0).abs() < 0.5, "{c:?}");
|
||||
assert!(c.top >= 72.0 && c.height > 8.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn long_paragraph_wraps_and_paginates() {
|
||||
let text = "The quick brown fox jumps over the lazy dog. ".repeat(400);
|
||||
let d = Document::from_text(&text);
|
||||
let l = lay(&d);
|
||||
assert!(l.pages.len() >= 3, "pages {}", l.pages.len());
|
||||
// Every line fits the text width.
|
||||
for p in &l.pages {
|
||||
for it in &p.items {
|
||||
if let Placed::Lines { para, l0, l1, x, .. } = it {
|
||||
for li in *l0..*l1 {
|
||||
let line = ¶.lines[li];
|
||||
let w = para.x_of(li, line.stop).unwrap() + x;
|
||||
assert!(w <= 612.0 - 72.0 + 30.0, "line too wide: {w}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hit_and_caret_agree() {
|
||||
let d = Document::from_text("Hello world, this is WordCraft.\nSecond paragraph here.");
|
||||
let l = lay(&d);
|
||||
for off in [0, 5, 12, 31] {
|
||||
let pos = Pos::body(0, off);
|
||||
let c = l.caret(&pos).unwrap();
|
||||
let back = l.hit(c.page, c.x + 0.1, c.top + c.height / 2.0, StoryRef::Body).unwrap();
|
||||
assert_eq!(back, pos, "off {off}");
|
||||
}
|
||||
let c1 = l.caret(&Pos::body(1, 0)).unwrap();
|
||||
let (down, _) = l.vertical(&Pos::body(0, 0), None, 1, 0).unwrap();
|
||||
assert_eq!(down.path, Path::top(1));
|
||||
assert!(c1.top > l.caret(&Pos::body(0, 0)).unwrap().top);
|
||||
let (up, _) = l.vertical(&Pos::body(1, 3), None, -1, 0).unwrap();
|
||||
assert_eq!(up.path, Path::top(0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn page_break_char_starts_new_page() {
|
||||
let mut d = Document::from_text("one");
|
||||
d.insert_text(&Pos::body(0, 3), "\u{000C}", &Default::default()).unwrap();
|
||||
let l = lay(&d);
|
||||
assert_eq!(l.pages.len(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn center_and_right_alignment() {
|
||||
let mut d = Document::from_text("abc\nabc\nabc");
|
||||
d.format_paragraphs(&Pos::body(1, 0), &Pos::body(1, 0), &|p| p.align = Some(Align::Center)).unwrap();
|
||||
d.format_paragraphs(&Pos::body(2, 0), &Pos::body(2, 0), &|p| p.align = Some(Align::Right)).unwrap();
|
||||
let l = lay(&d);
|
||||
let x0 = l.caret(&Pos::body(0, 0)).unwrap().x;
|
||||
let x1 = l.caret(&Pos::body(1, 0)).unwrap().x;
|
||||
let x2 = l.caret(&Pos::body(2, 3)).unwrap().x;
|
||||
assert!((x0 - 72.0).abs() < 0.5);
|
||||
assert!(x1 > 250.0 && x1 < 320.0, "{x1}");
|
||||
assert!((x2 - 540.0).abs() < 1.0, "{x2}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tabs_land_on_default_stops() {
|
||||
let d = Document::from_text("a\tb");
|
||||
let l = lay(&d);
|
||||
let xb = l.caret(&Pos::body(0, 2)).unwrap().x;
|
||||
assert!((xb - (72.0 + 36.0)).abs() < 0.5, "{xb}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn right_tab_aligns_text_end() {
|
||||
let mut d = Document::from_text("left\tright");
|
||||
d.format_paragraphs(&Pos::body(0, 0), &Pos::body(0, 0), &|p| {
|
||||
p.tabs = Some(vec![wordcraft_doc::props::TabStop { pos: 400.0, align: wordcraft_doc::props::TabAlign::Right, leader: Default::default() }])
|
||||
})
|
||||
.unwrap();
|
||||
let l = lay(&d);
|
||||
let end = l.caret(&Pos::body(0, 10)).unwrap().x;
|
||||
assert!((end - 472.0).abs() < 1.0, "{end}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lists_get_labels_and_indent() {
|
||||
let mut d = Document::from_text("one\ntwo\nthree");
|
||||
let id = d.numbering.add_list(wordcraft_doc::ListKind::Numbered);
|
||||
d.format_paragraphs(&Pos::body(0, 0), &Pos::body(2, 0), &|p| p.numbering = Some(wordcraft_doc::props::NumRef { num: id, level: 0 })).unwrap();
|
||||
let l = lay(&d);
|
||||
let labels: Vec<String> = l.pages[0]
|
||||
.items
|
||||
.iter()
|
||||
.filter_map(|it| if let Placed::Lines { para, .. } = it { para.label.as_ref().map(|x| x.text.clone()) } else { None })
|
||||
.collect();
|
||||
assert_eq!(labels, vec!["1.", "2.", "3."]);
|
||||
let x = l.caret(&Pos::body(0, 0)).unwrap().x;
|
||||
assert!((x - 108.0).abs() < 0.5, "{x}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tables_lay_out_cells() {
|
||||
let mut d = Document::from_text("before\nafter");
|
||||
let mut t = Table::new(2, 3, 468.0);
|
||||
t.rows[0].cells[1].blocks = vec![wordcraft_doc::para_block(wordcraft_doc::Paragraph::with_text("cell text", Default::default()))];
|
||||
d.insert_block(StoryRef::Body, &Path::top(1), wordcraft_doc::Block::Table(t)).unwrap();
|
||||
let l = lay(&d);
|
||||
let pos = Pos { story: StoryRef::Body, path: Path(vec![1, 0, 1, 0]), off: 0 };
|
||||
let c = l.caret(&pos).unwrap();
|
||||
assert!(c.x > 72.0 + 150.0 && c.x < 72.0 + 170.0, "{c:?}");
|
||||
let after = l.caret(&Pos::body(2, 0)).unwrap();
|
||||
assert!(after.top > c.top + 20.0);
|
||||
let rules = l.pages[0].items.iter().filter(|i| matches!(i, Placed::Rule { .. })).count();
|
||||
assert!(rules >= 12, "rules {rules}");
|
||||
assert!(l.cell_at(0, c.x, c.top + 2.0).is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn headers_and_page_fields() {
|
||||
let mut d = Document::from_text(&"para\n".repeat(120));
|
||||
let mut hp = wordcraft_doc::Paragraph::new();
|
||||
hp.insert_object(0, InlineObject::Field { instr: "PAGE".into(), result: String::new(), locked: false }, &Default::default()).unwrap();
|
||||
let id = d.add_part(wordcraft_doc::PartKind::Footer, vec![wordcraft_doc::para_block(hp)]);
|
||||
d.last_section.footers.default = Some(id);
|
||||
let l = lay(&d);
|
||||
assert!(l.pages.len() >= 2);
|
||||
for (i, p) in l.pages.iter().enumerate() {
|
||||
assert_eq!(p.footer_story, Some(id));
|
||||
let label = p.footer.iter().find_map(|it| if let Placed::Lines { para, .. } = it { Some(para.clusters.len()) } else { None });
|
||||
assert!(label.is_some(), "page {i}");
|
||||
}
|
||||
let c = l.caret_on(&Pos { story: StoryRef::Part(id), path: Path::top(0), off: 0 }, 1).unwrap();
|
||||
assert_eq!(c.page, 1);
|
||||
assert!(c.top > 700.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cache_reuses_unchanged_paragraphs() {
|
||||
let d = Document::from_text(&"some text here\n".repeat(50));
|
||||
let mut c = LayoutCache::new();
|
||||
layout(&d, &mut c, &LayoutOptions::default());
|
||||
let misses = c.misses;
|
||||
let mut d2 = d.clone();
|
||||
d2.insert_text(&Pos::body(10, 0), "x", &Default::default()).unwrap();
|
||||
layout(&d2, &mut c, &LayoutOptions::default());
|
||||
assert_eq!(c.misses, misses + 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn selection_rects_cover_range() {
|
||||
let d = Document::from_text("Hello world\nSecond line");
|
||||
let l = lay(&d);
|
||||
let r = l.selection_rects(&d, &Pos::body(0, 6), &Pos::body(1, 6), 0);
|
||||
assert_eq!(r.len(), 2);
|
||||
assert!(r[0].1.w > 20.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn display_has_glyphs_and_marks() {
|
||||
let mut d = Document::from_text("Hello\tworld");
|
||||
d.format_range(&Pos::body(0, 0), &Pos::body(0, 5), &|c| c.underline = Some(wordcraft_doc::props::Underline::Single)).unwrap();
|
||||
let l = lay(&d);
|
||||
let items = display::page_display(&d, &l.pages[0], &display::DisplayOptions { marks: true, ..Default::default() });
|
||||
assert!(items.iter().any(|i| matches!(i, display::Draw::Glyphs { .. })));
|
||||
assert!(items.iter().any(|i| matches!(i, display::Draw::Line { .. })));
|
||||
assert!(items.iter().any(|i| matches!(i, display::Draw::Mark { ch: '¶', .. })));
|
||||
assert!(items.iter().any(|i| matches!(i, display::Draw::Mark { ch: '→', .. })));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn web_view_is_one_page() {
|
||||
let d = Document::from_text(&"text ".repeat(3000));
|
||||
let mut c = LayoutCache::new();
|
||||
let l = layout(&d, &mut c, &LayoutOptions { view: ViewMode::Web, web_width: 800.0, show_hidden: false });
|
||||
assert_eq!(l.pages.len(), 1);
|
||||
assert!(l.pages[0].h > 800.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hostile_props_do_not_panic() {
|
||||
let mut d = Document::from_text("x\ny");
|
||||
d.format_paragraphs(&Pos::body(0, 0), &Pos::body(1, 0), &|p| {
|
||||
*p = ParaProps { indent_left: Some(1e9), indent_right: Some(1e9), indent_first: Some(-1e9), ..Default::default() }
|
||||
})
|
||||
.unwrap();
|
||||
d.last_section.page_w = 1.0;
|
||||
d.last_section.margin_left = 500.0;
|
||||
let l = lay(&d);
|
||||
assert!(!l.pages.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn layout_is_fast() {
|
||||
let d = Document::from_text(&"Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore.\n".repeat(3000));
|
||||
let mut c = LayoutCache::new();
|
||||
let t0 = now_ms();
|
||||
let l = layout(&d, &mut c, &LayoutOptions::default());
|
||||
let cold = now_ms() - t0;
|
||||
let mut d2 = d.clone();
|
||||
d2.insert_text(&Pos::body(1500, 0), "x", &Default::default()).unwrap();
|
||||
let t1 = now_ms();
|
||||
let _ = layout(&d2, &mut c, &LayoutOptions::default());
|
||||
let warm = now_ms() - t1;
|
||||
eprintln!("3000 paragraphs, {} pages: cold {cold:.1} ms, warm {warm:.1} ms", l.pages.len());
|
||||
assert!(warm < cold);
|
||||
}
|
||||
|
|
@ -0,0 +1,21 @@
|
|||
[package]
|
||||
name = "wordcraft-render"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
description = "WordCraft rasteriser: pages and thumbnails from the layout display list (vello_cpu), PNG/JPEG export"
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
|
||||
[dependencies]
|
||||
wordcraft-geom = { workspace = true }
|
||||
wordcraft-doc = { workspace = true }
|
||||
wordcraft-fonts = { workspace = true }
|
||||
wordcraft-layout = { workspace = true }
|
||||
vello_cpu = { workspace = true }
|
||||
image = { workspace = true }
|
||||
log = { workspace = true }
|
||||
|
||||
[target.'cfg(not(target_arch = "wasm32"))'.dependencies]
|
||||
vello_cpu = { workspace = true, features = ["multithreading"] }
|
||||
|
|
@ -0,0 +1,438 @@
|
|||
//! WordCraft rasteriser: draws a page's display list with vello_cpu.
|
||||
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
use vello_cpu::kurbo::{self, Affine, BezPath, Shape};
|
||||
use vello_cpu::peniko;
|
||||
use vello_cpu::{Pixmap, RenderContext, Resources};
|
||||
use wordcraft_doc::Document;
|
||||
use wordcraft_doc::para::ShapeKind;
|
||||
use wordcraft_doc::props::Rgb;
|
||||
use wordcraft_fonts::FontDb;
|
||||
use wordcraft_layout::Page;
|
||||
use wordcraft_layout::display::{DisplayOptions, Draw, Stroke, page_display};
|
||||
|
||||
/// Largest raster side vello_cpu handles comfortably.
|
||||
pub const MAX_SIDE: u32 = 16_000;
|
||||
|
||||
/// A rendered image (premultiplied RGBA8, row-major).
|
||||
#[derive(Clone, Default)]
|
||||
pub struct Rendered {
|
||||
pub width: u32,
|
||||
pub height: u32,
|
||||
pub pixels: Vec<u8>,
|
||||
}
|
||||
|
||||
impl Rendered {
|
||||
pub fn to_straight(&self) -> Vec<u8> {
|
||||
let mut out = self.pixels.clone();
|
||||
for px in out.chunks_exact_mut(4) {
|
||||
let a = px[3] as u32;
|
||||
if a != 0 && a != 255 {
|
||||
for c in px.iter_mut().take(3) {
|
||||
*c = ((*c as u32 * 255 + a / 2) / a).min(255) as u8;
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
/// PNG bytes (empty on failure, logged).
|
||||
pub fn to_png(&self) -> Vec<u8> {
|
||||
let mut px = self.to_straight();
|
||||
px.resize(self.width as usize * self.height as usize * 4, 0);
|
||||
let Some(img) = image::RgbaImage::from_raw(self.width, self.height, px) else {
|
||||
log::error!("PNG encode: bad size {}×{}", self.width, self.height);
|
||||
return Vec::new();
|
||||
};
|
||||
let mut buf = Vec::new();
|
||||
if let Err(e) = img.write_to(&mut std::io::Cursor::new(&mut buf), image::ImageFormat::Png) {
|
||||
log::error!("PNG encode: {e}");
|
||||
return Vec::new();
|
||||
}
|
||||
buf
|
||||
}
|
||||
pub fn to_jpeg(&self, quality: u8) -> Vec<u8> {
|
||||
let rgba = self.to_straight();
|
||||
let rgb: Vec<u8> = rgba
|
||||
.chunks_exact(4)
|
||||
.flat_map(|p| {
|
||||
let a = p[3] as u32;
|
||||
let mix = |c: u8| ((c as u32 * a + 255 * (255 - a)) / 255) as u8;
|
||||
[mix(p[0]), mix(p[1]), mix(p[2])]
|
||||
})
|
||||
.collect();
|
||||
let mut buf = Vec::new();
|
||||
let enc = image::codecs::jpeg::JpegEncoder::new_with_quality(&mut buf, quality.clamp(1, 100));
|
||||
let _ = image::ImageEncoder::write_image(enc, &rgb, self.width, self.height, image::ExtendedColorType::Rgb8);
|
||||
buf
|
||||
}
|
||||
/// Straight RGBA at (x, y) (transparent outside).
|
||||
pub fn pixel(&self, x: u32, y: u32) -> [u8; 4] {
|
||||
if x >= self.width || y >= self.height {
|
||||
return [0; 4];
|
||||
}
|
||||
let i = ((y as usize * self.width as usize) + x as usize) * 4;
|
||||
let Some(p) = self.pixels.get(i..i + 4) else { return [0; 4] };
|
||||
let a = p[3] as u32;
|
||||
if a == 0 {
|
||||
return [0; 4];
|
||||
}
|
||||
let un = |c: u8| ((c as u32 * 255 + a / 2) / a).min(255) as u8;
|
||||
[un(p[0]), un(p[1]), un(p[2]), p[3]]
|
||||
}
|
||||
}
|
||||
|
||||
fn color(c: Rgb, alpha: f32) -> peniko::Color {
|
||||
peniko::Color::from_rgba8(c.0, c.1, c.2, (alpha.clamp(0.0, 1.0) * 255.0) as u8)
|
||||
}
|
||||
|
||||
/// Decoded images by media key (and content pointer), shared across renders.
|
||||
fn image_cache() -> &'static Mutex<HashMap<(String, usize), Option<Arc<Pixmap>>>> {
|
||||
static C: std::sync::OnceLock<Mutex<HashMap<(String, usize), Option<Arc<Pixmap>>>>> = std::sync::OnceLock::new();
|
||||
C.get_or_init(|| Mutex::new(HashMap::new()))
|
||||
}
|
||||
|
||||
/// Decode an encoded image to a premultiplied pixmap.
|
||||
pub fn decode_pixmap(bytes: &[u8]) -> Option<Pixmap> {
|
||||
let img = image::load_from_memory(bytes).ok()?.to_rgba8();
|
||||
let (w, h) = img.dimensions();
|
||||
if w == 0 || h == 0 || w > u16::MAX as u32 || h > u16::MAX as u32 {
|
||||
return None;
|
||||
}
|
||||
let data: Vec<vello_cpu::color::PremulRgba8> = img
|
||||
.pixels()
|
||||
.map(|p| {
|
||||
let a = p[3] as u16;
|
||||
let m = |c: u8| ((c as u16 * a + 127) / 255) as u8;
|
||||
vello_cpu::color::PremulRgba8 { r: m(p[0]), g: m(p[1]), b: m(p[2]), a: p[3] }
|
||||
})
|
||||
.collect();
|
||||
Some(Pixmap::from_parts(data, w as u16, h as u16))
|
||||
}
|
||||
|
||||
/// Pixel size of an encoded image.
|
||||
pub fn image_size(bytes: &[u8]) -> Option<(u32, u32)> {
|
||||
image::ImageReader::new(std::io::Cursor::new(bytes)).with_guessed_format().ok()?.into_dimensions().ok()
|
||||
}
|
||||
|
||||
fn pixmap_for(doc: &Document, key: &str) -> Option<Arc<Pixmap>> {
|
||||
let bytes = doc.media.get(key)?;
|
||||
let ck = (key.to_string(), Arc::as_ptr(bytes) as usize);
|
||||
if let Some(v) = image_cache().lock().unwrap_or_else(|e| e.into_inner()).get(&ck) {
|
||||
return v.clone();
|
||||
}
|
||||
let pm = decode_pixmap(bytes).map(Arc::new);
|
||||
let mut c = image_cache().lock().unwrap_or_else(|e| e.into_inner());
|
||||
if c.len() > 256 {
|
||||
c.clear();
|
||||
}
|
||||
c.insert(ck, pm.clone());
|
||||
pm
|
||||
}
|
||||
|
||||
/// Options for a page raster.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct RenderOptions {
|
||||
pub display: DisplayOptions,
|
||||
/// Paper colour (page colour from Design › Page Color, else white).
|
||||
pub paper: Rgb,
|
||||
/// Colour for formatting marks.
|
||||
pub mark_color: Rgb,
|
||||
}
|
||||
|
||||
impl Default for RenderOptions {
|
||||
fn default() -> Self {
|
||||
RenderOptions { display: DisplayOptions::default(), paper: Rgb::WHITE, mark_color: Rgb(0x2B, 0x57, 0x9A) }
|
||||
}
|
||||
}
|
||||
|
||||
/// Render a whole page at `scale` pixels per point.
|
||||
pub fn render_page(doc: &Document, page: &Page, scale: f32, opts: &RenderOptions) -> Rendered {
|
||||
let scale = if scale.is_finite() && scale > 0.0 { scale } else { 1.0 };
|
||||
let w = ((page.w * scale).ceil() as u32).clamp(1, MAX_SIDE);
|
||||
let h = ((page.h.min(1e6) * scale).ceil() as u32).clamp(1, MAX_SIDE);
|
||||
render_region(doc, page, w, h, Affine::scale(scale as f64), opts)
|
||||
}
|
||||
|
||||
/// Render a page into a `w`×`h` raster with `view` (page points → pixels).
|
||||
pub fn render_region(doc: &Document, page: &Page, w: u32, h: u32, view: Affine, opts: &RenderOptions) -> Rendered {
|
||||
let (w16, h16) = (w.clamp(1, MAX_SIDE) as u16, h.clamp(1, MAX_SIDE) as u16);
|
||||
let mut ctx = RenderContext::new(w16, h16);
|
||||
ctx.set_transform(Affine::IDENTITY);
|
||||
ctx.set_paint(color(doc.settings.page_color.unwrap_or(opts.paper), 1.0));
|
||||
ctx.fill_rect(&kurbo::Rect::new(0.0, 0.0, w16 as f64, h16 as f64));
|
||||
let items = page_display(doc, page, &opts.display);
|
||||
let visible = view.inverse().transform_rect_bbox(kurbo::Rect::new(0.0, 0.0, w16 as f64, h16 as f64)).inflate(40.0, 40.0);
|
||||
if let Some(wm) = &doc.settings.watermark {
|
||||
draw_watermark(&mut ctx, view, page, wm);
|
||||
}
|
||||
for it in &items {
|
||||
draw(&mut ctx, doc, it, view, &visible, opts);
|
||||
}
|
||||
ctx.flush();
|
||||
let mut pixels = vec![0u8; w16 as usize * h16 as usize * 4];
|
||||
let mut res = Resources::new();
|
||||
if let Some(pm) = vello_cpu::PixmapMut::new(w16, h16, &mut pixels) {
|
||||
ctx.render(pm, &mut res);
|
||||
}
|
||||
Rendered { width: w16 as u32, height: h16 as u32, pixels }
|
||||
}
|
||||
|
||||
fn draw_watermark(ctx: &mut RenderContext, view: Affine, page: &Page, wm: &wordcraft_doc::Watermark) {
|
||||
let r = wordcraft_fonts::word::resolve(&wm.font, false, false);
|
||||
let face = r.face;
|
||||
let glyphs = wordcraft_fonts::shape(&face, &wm.text, &[], |c| c);
|
||||
let upem = face.upem.max(1.0);
|
||||
let raw_w: f64 = glyphs.iter().map(|g| g.x_advance as f64).sum::<f64>() / upem;
|
||||
if raw_w <= 0.0 {
|
||||
return;
|
||||
}
|
||||
let diag = if wm.diagonal { ((page.w as f64).powi(2) + (page.h.min(2000.0) as f64).powi(2)).sqrt() } else { page.w as f64 };
|
||||
let size = (diag * 0.7 / raw_w).min(300.0);
|
||||
let k = size / upem;
|
||||
let angle = if wm.diagonal { -((page.h.min(2000.0) as f64) / page.w.max(1.0) as f64).atan() } else { 0.0 };
|
||||
let base = Affine::translate((page.w as f64 / 2.0, page.h.min(2000.0) as f64 / 2.0)) * Affine::rotate(angle) * Affine::translate((-raw_w * size / 2.0, size * 0.35));
|
||||
ctx.set_paint(color(wm.color, if wm.semitransparent { 0.5 } else { 1.0 }));
|
||||
let db = FontDb::global();
|
||||
let mut x = 0.0;
|
||||
for g in &glyphs {
|
||||
let o = db.outline(&face, g.gid);
|
||||
ctx.set_transform(view * base * Affine::translate((x, 0.0)) * Affine::scale(k));
|
||||
ctx.fill_path(&o);
|
||||
x += g.x_advance as f64 * k;
|
||||
}
|
||||
}
|
||||
|
||||
fn draw(ctx: &mut RenderContext, doc: &Document, it: &Draw, view: Affine, visible: &kurbo::Rect, opts: &RenderOptions) {
|
||||
match it {
|
||||
Draw::Fill { rect, color: c, alpha } => {
|
||||
let r = kurbo::Rect::new(rect.x as f64, rect.y as f64, rect.right() as f64, rect.bottom() as f64);
|
||||
if !r.overlaps(*visible) {
|
||||
return;
|
||||
}
|
||||
ctx.set_transform(view);
|
||||
ctx.set_paint(color(*c, *alpha));
|
||||
ctx.fill_rect(&r);
|
||||
}
|
||||
Draw::Line { x0, y0, x1, y1, width, color: c, stroke, alpha } => {
|
||||
ctx.set_transform(view);
|
||||
ctx.set_paint(color(*c, *alpha));
|
||||
let w = *width as f64;
|
||||
let mut st = kurbo::Stroke::new(w);
|
||||
match stroke {
|
||||
Stroke::Dotted => st = st.with_dashes(0.0, [w, w * 2.0]),
|
||||
Stroke::Dashed => st = st.with_dashes(0.0, [w * 4.0, w * 2.0]),
|
||||
_ => {}
|
||||
}
|
||||
let line = |ctx: &mut RenderContext, dy: f64| {
|
||||
let mut p = BezPath::new();
|
||||
p.move_to((*x0 as f64, *y0 as f64 + dy));
|
||||
p.line_to((*x1 as f64, *y1 as f64 + dy));
|
||||
ctx.stroke_path(&p);
|
||||
};
|
||||
if *stroke == Stroke::Wave {
|
||||
let mut p = BezPath::new();
|
||||
let (a, b) = ((*x0).min(*x1) as f64, (*x0).max(*x1) as f64);
|
||||
let y = *y0 as f64;
|
||||
let amp = (w * 1.2).max(0.8);
|
||||
let step = amp * 2.0;
|
||||
p.move_to((a, y));
|
||||
let mut x = a;
|
||||
let mut up = true;
|
||||
while x < b && x - a < 20_000.0 {
|
||||
let nx = (x + step).min(b);
|
||||
p.quad_to(((x + nx) / 2.0, if up { y - amp } else { y + amp }), (nx, y));
|
||||
x = nx;
|
||||
up = !up;
|
||||
}
|
||||
ctx.set_stroke(kurbo::Stroke::new(w * 0.8));
|
||||
ctx.stroke_path(&p);
|
||||
return;
|
||||
}
|
||||
if *stroke == Stroke::Double {
|
||||
ctx.set_stroke(kurbo::Stroke::new(w * 0.6));
|
||||
line(ctx, -w * 0.9);
|
||||
line(ctx, w * 0.9);
|
||||
return;
|
||||
}
|
||||
ctx.set_stroke(st);
|
||||
line(ctx, 0.0);
|
||||
}
|
||||
Draw::Glyphs { face, size, glyphs, color: c, alpha, synth_bold, synth_italic, .. } => {
|
||||
let db = FontDb::global();
|
||||
let k = *size as f64 / face.upem.max(1.0);
|
||||
ctx.set_paint(color(*c, *alpha));
|
||||
let skew = if *synth_italic { Affine::new([1.0, 0.0, -0.21, 1.0, 0.0, 0.0]) } else { Affine::IDENTITY };
|
||||
if *synth_bold {
|
||||
ctx.set_stroke(kurbo::Stroke::new(face.upem * 0.03));
|
||||
}
|
||||
for (gid, x, y) in glyphs {
|
||||
let (gx, gy) = (*x as f64, *y as f64);
|
||||
if gx < visible.x0 - 100.0 || gx > visible.x1 || gy < visible.y0 || gy > visible.y1 + 200.0 {
|
||||
continue;
|
||||
}
|
||||
let o = db.outline(face, *gid);
|
||||
if o.elements().is_empty() {
|
||||
continue;
|
||||
}
|
||||
ctx.set_transform(view * Affine::translate((gx, gy)) * skew * Affine::scale(k));
|
||||
ctx.fill_path(&o);
|
||||
if *synth_bold {
|
||||
ctx.stroke_path(&o);
|
||||
}
|
||||
}
|
||||
}
|
||||
Draw::Mark { x, baseline, size, ch } => {
|
||||
let face = wordcraft_fonts::word::resolve("Source Sans 3", false, false).face;
|
||||
let gid = face.glyph_for(*ch);
|
||||
let face = if gid == 0 {
|
||||
match FontDb::global().fallback_for(*ch, face.id()) {
|
||||
Some(f) => wordcraft_fonts::FaceRef::of(&f),
|
||||
None => return,
|
||||
}
|
||||
} else {
|
||||
face
|
||||
};
|
||||
let gid = face.glyph_for(*ch);
|
||||
let o = FontDb::global().outline(&face, gid);
|
||||
let k = *size as f64 / face.upem.max(1.0);
|
||||
ctx.set_paint(color(opts.mark_color, 0.9));
|
||||
ctx.set_transform(view * Affine::translate((*x as f64, *baseline as f64)) * Affine::scale(k));
|
||||
ctx.fill_path(&o);
|
||||
}
|
||||
Draw::Image { rect, media, crop, alpha } => {
|
||||
let r = kurbo::Rect::new(rect.x as f64, rect.y as f64, rect.right() as f64, rect.bottom() as f64);
|
||||
if !r.overlaps(*visible) {
|
||||
return;
|
||||
}
|
||||
let Some(pm) = pixmap_for(doc, media) else {
|
||||
ctx.set_transform(view);
|
||||
ctx.set_paint(color(Rgb(0xD0, 0xD0, 0xD0), 1.0));
|
||||
ctx.fill_rect(&r);
|
||||
return;
|
||||
};
|
||||
let (pw, ph) = (pm.width().max(1) as f64, pm.height().max(1) as f64);
|
||||
let [cl, ct, cr, cb] = crop.map(|v| if v.is_finite() { v.clamp(0.0, 0.95) as f64 } else { 0.0 });
|
||||
let vis_w = (1.0 - cl - cr).max(0.05);
|
||||
let vis_h = (1.0 - ct - cb).max(0.05);
|
||||
let sx = r.width() / (pw * vis_w);
|
||||
let sy = r.height() / (ph * vis_h);
|
||||
ctx.set_transform(view);
|
||||
ctx.set_paint(vello_cpu::Image {
|
||||
image: vello_cpu::ImageSource::Pixmap(pm),
|
||||
sampler: peniko::ImageSampler::default().with_quality(peniko::ImageQuality::Medium).with_alpha(alpha.clamp(0.0, 1.0)),
|
||||
});
|
||||
ctx.set_paint_transform(Affine::translate((r.x0 - cl * pw * sx, r.y0 - ct * ph * sy)) * Affine::scale_non_uniform(sx, sy));
|
||||
ctx.fill_rect(&r);
|
||||
ctx.reset_paint_transform();
|
||||
}
|
||||
Draw::Shape { rect, kind, fill, stroke, stroke_width } => {
|
||||
let r = kurbo::Rect::new(rect.x as f64, rect.y as f64, rect.right() as f64, rect.bottom() as f64);
|
||||
let path = shape_path(*kind, r);
|
||||
ctx.set_transform(view);
|
||||
if let Some(f) = fill {
|
||||
ctx.set_paint(color(*f, 1.0));
|
||||
ctx.fill_path(&path);
|
||||
}
|
||||
if let Some(s) = stroke {
|
||||
ctx.set_paint(color(*s, 1.0));
|
||||
ctx.set_stroke(kurbo::Stroke::new(stroke_width.max(0.25) as f64));
|
||||
ctx.stroke_path(&path);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Outline of a basic shape in a rectangle.
|
||||
pub fn shape_path(kind: ShapeKind, r: kurbo::Rect) -> BezPath {
|
||||
let (cx, cy) = (r.center().x, r.center().y);
|
||||
let poly = |pts: &[(f64, f64)]| {
|
||||
let mut p = BezPath::new();
|
||||
for (i, (x, y)) in pts.iter().enumerate() {
|
||||
if i == 0 {
|
||||
p.move_to((*x, *y));
|
||||
} else {
|
||||
p.line_to((*x, *y));
|
||||
}
|
||||
}
|
||||
p.close_path();
|
||||
p
|
||||
};
|
||||
match kind {
|
||||
ShapeKind::Rectangle | ShapeKind::TextBox => r.to_path(0.1),
|
||||
ShapeKind::RoundedRectangle => kurbo::RoundedRect::from_rect(r, r.width().min(r.height()) * 0.16).to_path(0.1),
|
||||
ShapeKind::Ellipse => kurbo::Ellipse::from_rect(r).to_path(0.1),
|
||||
ShapeKind::Triangle => poly(&[(cx, r.y0), (r.x1, r.y1), (r.x0, r.y1)]),
|
||||
ShapeKind::Diamond => poly(&[(cx, r.y0), (r.x1, cy), (cx, r.y1), (r.x0, cy)]),
|
||||
ShapeKind::Line => {
|
||||
let mut p = BezPath::new();
|
||||
p.move_to((r.x0, r.y1));
|
||||
p.line_to((r.x1, r.y0));
|
||||
p
|
||||
}
|
||||
ShapeKind::Arrow => {
|
||||
let h = r.height();
|
||||
poly(&[(r.x0, cy - h * 0.2), (r.x1 - h * 0.5, cy - h * 0.2), (r.x1 - h * 0.5, r.y0), (r.x1, cy), (r.x1 - h * 0.5, r.y1), (r.x1 - h * 0.5, cy + h * 0.2), (r.x0, cy + h * 0.2)])
|
||||
}
|
||||
ShapeKind::Star => {
|
||||
let mut pts = Vec::new();
|
||||
for i in 0..10 {
|
||||
let a = std::f64::consts::PI * (i as f64) / 5.0 - std::f64::consts::FRAC_PI_2;
|
||||
let rad = if i % 2 == 0 { 1.0 } else { 0.4 };
|
||||
pts.push((cx + a.cos() * r.width() / 2.0 * rad, cy + a.sin() * r.height() / 2.0 * rad));
|
||||
}
|
||||
poly(&pts)
|
||||
}
|
||||
ShapeKind::Heart => {
|
||||
let (w, h) = (r.width(), r.height());
|
||||
let mut p = BezPath::new();
|
||||
p.move_to((cx, r.y0 + h * 0.3));
|
||||
p.curve_to((cx, r.y0), (r.x0, r.y0), (r.x0, r.y0 + h * 0.3));
|
||||
p.curve_to((r.x0, r.y0 + h * 0.6), (cx - w * 0.1, r.y0 + h * 0.75), (cx, r.y1));
|
||||
p.curve_to((cx + w * 0.1, r.y0 + h * 0.75), (r.x1, r.y0 + h * 0.6), (r.x1, r.y0 + h * 0.3));
|
||||
p.curve_to((r.x1, r.y0), (cx, r.y0), (cx, r.y0 + h * 0.3));
|
||||
p.close_path();
|
||||
p
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use wordcraft_layout::{LayoutCache, LayoutOptions, layout};
|
||||
|
||||
#[test]
|
||||
fn renders_text_dark_pixels() {
|
||||
let d = Document::from_text("Hello WordCraft");
|
||||
let l = layout(&d, &mut LayoutCache::new(), &LayoutOptions::default());
|
||||
let img = render_page(&d, &l.pages[0], 1.0, &RenderOptions::default());
|
||||
assert_eq!((img.width, img.height), (612, 792));
|
||||
// Somewhere in the first text line there are dark pixels; the margins are white.
|
||||
let dark = (72..300).flat_map(|x| (72..100).map(move |y| (x, y))).filter(|(x, y)| img.pixel(*x, *y)[0] < 128).count();
|
||||
assert!(dark > 30, "dark {dark}");
|
||||
assert_eq!(img.pixel(5, 5), [255, 255, 255, 255]);
|
||||
assert!(!img.to_png().is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hostile_scale_is_clamped() {
|
||||
let d = Document::new();
|
||||
let l = layout(&d, &mut LayoutCache::new(), &LayoutOptions::default());
|
||||
let img = render_page(&d, &l.pages[0], f32::NAN, &RenderOptions::default());
|
||||
assert_eq!(img.width, 612);
|
||||
let big = render_page(&d, &l.pages[0], 1e9, &RenderOptions::default());
|
||||
assert!(big.width <= MAX_SIDE);
|
||||
assert!(decode_pixmap(b"not an image").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shapes_have_paths() {
|
||||
let r = kurbo::Rect::new(0.0, 0.0, 10.0, 10.0);
|
||||
for k in [ShapeKind::Rectangle, ShapeKind::Ellipse, ShapeKind::Star, ShapeKind::Heart, ShapeKind::Arrow, ShapeKind::Line] {
|
||||
assert!(!shape_path(k, r).elements().is_empty());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,42 @@
|
|||
ArtCraft brand assets: license
|
||||
|
||||
The ArtCraft name, wordmark and mark (the "ArtCraft Marks"), including every file in this
|
||||
directory in every color, size and format, are owned by the ArtCraft Team. They are trademarks
|
||||
and are NOT open source. They are not covered by DesignCraft's MIT OR Apache-2.0 license, and nothing in
|
||||
that license, in NOTICE or in any other file of this repository grants any right to them.
|
||||
|
||||
Permission
|
||||
|
||||
The ArtCraft Team grants a limited, non-exclusive, non-transferable, revocable, royalty-free
|
||||
permission to use the ArtCraft Marks only:
|
||||
|
||||
1. as part of copies of this repository; and
|
||||
2. within DesignCraft itself, its documentation and its official builds, to identify DesignCraft as made
|
||||
by the ArtCraft team and community and to link to getartcraft.com and its community.
|
||||
|
||||
Conditions
|
||||
|
||||
The permission holds only while every one of these is true:
|
||||
|
||||
- The marks are used exactly as supplied in this directory. Do not redraw, recolor, crop,
|
||||
stretch, rotate, animate, outline or add effects to them, extract parts of them, or combine
|
||||
them with any other name, mark or artwork.
|
||||
- They are used only in the context of DesignCraft, and only to say that the ArtCraft team makes it.
|
||||
They must never suggest that the ArtCraft Team made, sponsors, endorses, certifies or supports
|
||||
any other product, service, fork, modified version, plug-in, organization or person.
|
||||
- Neither the marks nor the ArtCraft name appear in the name, logo, icon, domain, social media
|
||||
handle, app-store listing, advertising or marketing of anything other than DesignCraft.
|
||||
|
||||
Forks and modified versions
|
||||
|
||||
If you distribute or publish a modified version of DesignCraft, or any work derived from it, you must
|
||||
remove the ArtCraft Marks from it (or replace them with your own) and must not call it ArtCraft or
|
||||
present it as an ArtCraft product. You may say, in plain text, that your work is based on DesignCraft by
|
||||
the ArtCraft team. A fork kept only to propose changes back to this repository may keep the marks
|
||||
unchanged.
|
||||
|
||||
Everything else
|
||||
|
||||
Any use not expressly permitted here needs prior written permission from the ArtCraft Team
|
||||
(https://getartcraft.com/). The permission ends automatically for anyone who breaks these terms.
|
||||
All other rights are reserved.
|
||||
|
After Width: | Height: | Size: 31 KiB |
|
After Width: | Height: | Size: 5.3 KiB |
|
After Width: | Height: | Size: 31 KiB |
|
After Width: | Height: | Size: 5.3 KiB |
|
After Width: | Height: | Size: 26 KiB |
|
|
@ -0,0 +1,4 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="-10 -19.42 136.34 136.34" role="img" aria-label="ArtCraft">
|
||||
<title>ArtCraft</title>
|
||||
<path fill="#0b0b0c" d="M104.28,49.49L81.55,0h-31.23l-3.17,4.76L14.75,53.63,0,75.85l21.55,21.55,63.79-36.94,16.99,37.04,14.01-21.74-12.06-26.27ZM32.89,65.66l32.42-48.87,10.91,23.77-43.32,25.09Z"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 341 B |
|
After Width: | Height: | Size: 24 KiB |
|
|
@ -0,0 +1,4 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="-10 -19.42 136.34 136.34" role="img" aria-label="ArtCraft">
|
||||
<title>ArtCraft</title>
|
||||
<path fill="#4e7bfb" d="M104.28,49.49L81.55,0h-31.23l-3.17,4.76L14.75,53.63,0,75.85l21.55,21.55,63.79-36.94,16.99,37.04,14.01-21.74-12.06-26.27ZM32.89,65.66l32.42-48.87,10.91,23.77-43.32,25.09Z"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 341 B |
|
|
@ -0,0 +1,2 @@
|
|||
max_width = 150
|
||||
use_small_heuristics = "Max"
|
||||
|
|
@ -0,0 +1,9 @@
|
|||
[package]
|
||||
name = "xtask"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
publish = false
|
||||
|
||||
[dependencies]
|
||||
serde_json = { workspace = true }
|
||||
|
|
@ -0,0 +1,83 @@
|
|||
//! `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",
|
||||
"indd",
|
||||
"idml",
|
||||
"aco",
|
||||
"abr",
|
||||
"designcraft",
|
||||
"mp4",
|
||||
"wav",
|
||||
"mp3",
|
||||
];
|
||||
const ASSET_DIRS: &[&str] = &["assets/", "docs/images/", "examples/"];
|
||||
|
||||
pub fn is_asset(path: &str) -> bool {
|
||||
let ext = Path::new(path).extension().and_then(|e| e.to_str()).map(str::to_ascii_lowercase).unwrap_or_default();
|
||||
ASSET_DIRS.iter().any(|d| path.starts_with(d)) || ASSET_EXT.contains(&ext.as_str())
|
||||
}
|
||||
|
||||
/// Paths that need an ASSETS.md entry but lack one.
|
||||
pub fn missing(files: &[String], assets_md: &str) -> Vec<String> {
|
||||
files.iter().filter(|f| is_asset(f) && !assets_md.contains(&format!("`{f}`"))).cloned().collect()
|
||||
}
|
||||
|
||||
pub fn run(root: &Path) -> Result<(), String> {
|
||||
let out = Command::new("git")
|
||||
.current_dir(root)
|
||||
.args(["ls-files", "--cached", "--others", "--exclude-standard"])
|
||||
.output()
|
||||
.map_err(|e| format!("git ls-files: {e}"))?;
|
||||
let files: Vec<String> = String::from_utf8_lossy(&out.stdout).lines().filter(|l| root.join(l).exists()).map(str::to_owned).collect();
|
||||
let md = std::fs::read_to_string(root.join("ASSETS.md")).map_err(|e| format!("ASSETS.md: {e}"))?;
|
||||
let miss = missing(&files, &md);
|
||||
if miss.is_empty() {
|
||||
println!("assets: all {} asset files attributed in ASSETS.md", files.iter().filter(|f| is_asset(f)).count());
|
||||
Ok(())
|
||||
} else {
|
||||
Err(format!("{} asset file(s) lack an ASSETS.md entry (author, source, licence):\n {}", miss.len(), miss.join("\n ")))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn detects_unattributed_assets() {
|
||||
let files = vec!["assets/icons/a.svg".to_string(), "crates/x/src/lib.rs".into(), "docs/images/b.png".into(), "tests/fixture.png".into()];
|
||||
let md = "| `assets/icons/a.svg` | me | here | MIT |";
|
||||
assert_eq!(missing(&files, md), vec!["docs/images/b.png".to_string(), "tests/fixture.png".into()]);
|
||||
assert!(!is_asset("crates/x/src/lib.rs"));
|
||||
assert!(is_asset("assets/fonts/OFL.txt"));
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,104 @@
|
|||
//! `cargo xtask ico <out.ico> <in.png>…`: pack PNG images into a Windows `.ico`.
|
||||
//!
|
||||
//! Vista and later read PNG-compressed icon entries, so each PNG is stored as is. The size comes
|
||||
//! from the PNG's IHDR chunk; 256 px is written as 0, per the ICO format.
|
||||
|
||||
use std::path::Path;
|
||||
|
||||
/// Width and height from a PNG's IHDR chunk.
|
||||
pub fn png_size(png: &[u8]) -> Result<(u32, u32), String> {
|
||||
const SIG: &[u8] = b"\x89PNG\r\n\x1a\n";
|
||||
if png.len() < 24 || &png[..8] != SIG || &png[12..16] != b"IHDR" {
|
||||
return Err("not a PNG file".into());
|
||||
}
|
||||
let w = u32::from_be_bytes([png[16], png[17], png[18], png[19]]);
|
||||
let h = u32::from_be_bytes([png[20], png[21], png[22], png[23]]);
|
||||
Ok((w, h))
|
||||
}
|
||||
|
||||
/// An ICO file holding `pngs`, which must be square and at most 256 px.
|
||||
pub fn pack(pngs: &[Vec<u8>]) -> Result<Vec<u8>, String> {
|
||||
if pngs.is_empty() || pngs.len() > u16::MAX as usize {
|
||||
return Err("need at least one PNG".into());
|
||||
}
|
||||
let mut out = Vec::new();
|
||||
out.extend_from_slice(&0u16.to_le_bytes()); // reserved
|
||||
out.extend_from_slice(&1u16.to_le_bytes()); // type: icon
|
||||
out.extend_from_slice(&(pngs.len() as u16).to_le_bytes());
|
||||
let mut offset = 6 + 16 * pngs.len();
|
||||
for png in pngs {
|
||||
let (w, h) = png_size(png)?;
|
||||
if w != h || w == 0 || w > 256 {
|
||||
return Err(format!("icon images must be square and at most 256 px, got {w}x{h}"));
|
||||
}
|
||||
let dim = if w == 256 { 0 } else { w as u8 };
|
||||
out.extend_from_slice(&[dim, dim, 0, 0]); // width, height, palette size, reserved
|
||||
out.extend_from_slice(&1u16.to_le_bytes()); // colour planes
|
||||
out.extend_from_slice(&32u16.to_le_bytes()); // bits per pixel
|
||||
out.extend_from_slice(&(png.len() as u32).to_le_bytes());
|
||||
out.extend_from_slice(&(offset as u32).to_le_bytes());
|
||||
offset += png.len();
|
||||
}
|
||||
for png in pngs {
|
||||
out.extend_from_slice(png);
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
pub fn run(args: &[&str]) -> Result<(), String> {
|
||||
let [out, inputs @ ..] = args else {
|
||||
return Err("usage: cargo xtask ico <out.ico> <in.png>…".into());
|
||||
};
|
||||
let pngs = inputs.iter().map(|p| std::fs::read(p).map_err(|e| format!("read {p}: {e}"))).collect::<Result<Vec<_>, _>>()?;
|
||||
let ico = pack(&pngs)?;
|
||||
std::fs::write(Path::new(out), &ico).map_err(|e| format!("write {out}: {e}"))?;
|
||||
println!("{out}: {} images, {} bytes", pngs.len(), ico.len());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn fake_png(size: u32) -> Vec<u8> {
|
||||
let mut v = b"\x89PNG\r\n\x1a\n\0\0\0\x0dIHDR".to_vec();
|
||||
v.extend_from_slice(&size.to_be_bytes());
|
||||
v.extend_from_slice(&size.to_be_bytes());
|
||||
v.extend_from_slice(&[8, 6, 0, 0, 0, 1, 2, 3, 4]);
|
||||
v
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn packs_directory_and_payloads() {
|
||||
let a = fake_png(16);
|
||||
let b = fake_png(256);
|
||||
let ico = pack(&[a.clone(), b.clone()]).unwrap();
|
||||
assert_eq!(&ico[..6], &[0, 0, 1, 0, 2, 0]);
|
||||
// Entry 0: 16 px at offset 6 + 32.
|
||||
assert_eq!(&ico[6..8], &[16, 16]);
|
||||
assert_eq!(u32::from_le_bytes(ico[14..18].try_into().unwrap()), a.len() as u32);
|
||||
assert_eq!(u32::from_le_bytes(ico[18..22].try_into().unwrap()), 38);
|
||||
// Entry 1: 256 px stored as 0, right after the first payload.
|
||||
assert_eq!(&ico[22..24], &[0, 0]);
|
||||
assert_eq!(u32::from_le_bytes(ico[34..38].try_into().unwrap()), 38 + a.len() as u32);
|
||||
assert_eq!(&ico[38..38 + a.len()], &a[..]);
|
||||
assert_eq!(&ico[38 + a.len()..], &b[..]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_bad_input() {
|
||||
assert!(pack(&[]).is_err());
|
||||
assert!(pack(&[fake_png(512)]).is_err());
|
||||
assert!(pack(&[b"GIF89a".to_vec()]).is_err());
|
||||
assert_eq!(png_size(&fake_png(48)).unwrap(), (48, 48));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn committed_icon_is_valid() {
|
||||
let path = crate::root().join("assets/app-icon/designcraft.ico");
|
||||
let Ok(ico) = std::fs::read(&path) else { return };
|
||||
assert_eq!(&ico[..4], &[0, 0, 1, 0]);
|
||||
let n = u16::from_le_bytes([ico[4], ico[5]]) as usize;
|
||||
assert!(n >= 4, "expected several sizes in {}", path.display());
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,340 @@
|
|||
//! Dependency layering rules (plan/architecture.md §3).
|
||||
//!
|
||||
//! The rule engine works on a small, metadata-independent model so it can be
|
||||
//! unit-tested; `from_metadata` builds that model from `cargo metadata`.
|
||||
|
||||
use serde_json::Value;
|
||||
|
||||
/// Where a workspace crate sits.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum Class {
|
||||
/// Regular layered crate.
|
||||
Layer(u8),
|
||||
/// Layer 0, and additionally may depend on no workspace crate at all.
|
||||
Standalone,
|
||||
/// Test tooling: may depend on anything up to L5 (it sits at L6 for rule
|
||||
/// purposes); other crates may use it only as a dev-dependency.
|
||||
Testkit,
|
||||
/// Binaries and build tooling: exempt from the rules.
|
||||
Exempt,
|
||||
}
|
||||
|
||||
impl Class {
|
||||
fn layer(self) -> Option<u8> {
|
||||
match self {
|
||||
Class::Layer(l) => Some(l),
|
||||
Class::Standalone => Some(0),
|
||||
Class::Testkit => Some(6),
|
||||
Class::Exempt => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The layering table. Names are package names without the `designcraft-`
|
||||
/// prefix.
|
||||
pub const TABLE: &[(&str, Class)] = &[
|
||||
("geom", Class::Layer(0)),
|
||||
("color", Class::Layer(0)),
|
||||
("doc", Class::Layer(1)),
|
||||
("fonts", Class::Layer(1)),
|
||||
("compose", Class::Layer(2)),
|
||||
("images", Class::Layer(2)),
|
||||
("render", Class::Layer(3)),
|
||||
("pdf", Class::Layer(3)),
|
||||
("idml", Class::Layer(3)),
|
||||
("epub", Class::Layer(3)),
|
||||
("textimport", Class::Layer(3)),
|
||||
("format", Class::Layer(3)),
|
||||
("tools", Class::Layer(4)),
|
||||
("engine", Class::Layer(5)),
|
||||
("ui-egui", Class::Layer(6)),
|
||||
("mcp", Class::Layer(6)),
|
||||
("testkit", Class::Testkit),
|
||||
// L7 apps and tooling
|
||||
("designcraft", Class::Exempt),
|
||||
("cli", Class::Exempt),
|
||||
("web", Class::Exempt),
|
||||
("xtask", Class::Exempt),
|
||||
];
|
||||
|
||||
/// Explicit orderings *within* a layer (earlier may be used by later).
|
||||
/// The L0 foundation is a small chain: `raster` builds on `color` and
|
||||
/// `geom`, which the §3 diagram draws on one line. The GPU backend (`gpu`)
|
||||
/// reuses the CPU reference (`compose`) for LUTs and parity tests.
|
||||
pub const INTRA_LAYER_ORDER: &[&[&str]] = &[&["geom", "color"], &["doc", "fonts"]];
|
||||
|
||||
fn intra_layer_allowed(from: &str, to: &str) -> bool {
|
||||
let (from, to) = (short_name(from), short_name(to));
|
||||
INTRA_LAYER_ORDER.iter().any(|chain| match (chain.iter().position(|n| *n == from), chain.iter().position(|n| *n == to)) {
|
||||
(Some(f), Some(t)) => t < f,
|
||||
_ => false,
|
||||
})
|
||||
}
|
||||
|
||||
/// External crates that constitute a UI toolkit / windowing dependency.
|
||||
/// Entries ending in `*` are prefixes.
|
||||
pub const UI_CRATES: &[&str] = &["egui", "eframe", "winit", "egui_kittest", "rfd", "bevy*"];
|
||||
|
||||
/// First layer allowed to use UI crates.
|
||||
pub const UI_MIN_LAYER: u8 = 6;
|
||||
|
||||
pub fn short_name(pkg: &str) -> &str {
|
||||
pkg.strip_prefix("designcraft-").unwrap_or(pkg)
|
||||
}
|
||||
|
||||
pub fn classify(pkg: &str) -> Option<Class> {
|
||||
let s = short_name(pkg);
|
||||
TABLE.iter().find(|(n, _)| *n == s).map(|(_, c)| *c)
|
||||
}
|
||||
|
||||
fn is_ui_crate(name: &str) -> bool {
|
||||
UI_CRATES.iter().any(|p| match p.strip_suffix('*') {
|
||||
Some(prefix) => name.starts_with(prefix),
|
||||
None => name == *p,
|
||||
})
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum DepKind {
|
||||
Normal,
|
||||
Dev,
|
||||
Build,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Dep {
|
||||
pub name: String,
|
||||
pub kind: DepKind,
|
||||
/// `true` if the dependency is a workspace member.
|
||||
pub workspace: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Crate {
|
||||
pub name: String,
|
||||
pub deps: Vec<Dep>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum Violation {
|
||||
Unregistered { krate: String },
|
||||
Upward { krate: String, dep: String, from: u8, to: u8, kind: DepKind },
|
||||
StandaloneHasWorkspaceDep { krate: String, dep: String },
|
||||
TestkitAsNormalDep { krate: String },
|
||||
UiBelowL6 { krate: String, dep: String, layer: u8 },
|
||||
}
|
||||
|
||||
impl std::fmt::Display for Violation {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
Violation::Unregistered { krate } => {
|
||||
write!(f, "{krate}: unknown workspace crate; register it in xtask/src/layers.rs TABLE (see plan/architecture.md §3)")
|
||||
}
|
||||
Violation::Upward { krate, dep, from, to, kind } => {
|
||||
write!(f, "{krate} (L{from}) -> {dep} (L{to}) [{kind:?}]: may only depend on strictly lower layers")
|
||||
}
|
||||
Violation::StandaloneHasWorkspaceDep { krate, dep } => {
|
||||
write!(f, "{krate}: standalone crate must not depend on workspace crate {dep}")
|
||||
}
|
||||
Violation::TestkitAsNormalDep { krate } => {
|
||||
write!(f, "{krate}: designcraft-testkit may only be a dev-dependency")
|
||||
}
|
||||
Violation::UiBelowL6 { krate, dep, layer } => {
|
||||
write!(f, "{krate} (L{layer}) depends on UI crate `{dep}`; UI toolkits are only allowed in L6+")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Check all rules. Returns violations sorted for stable output.
|
||||
pub fn check(crates: &[Crate]) -> Vec<Violation> {
|
||||
let mut out = Vec::new();
|
||||
for c in crates {
|
||||
let Some(class) = classify(&c.name) else {
|
||||
out.push(Violation::Unregistered { krate: c.name.clone() });
|
||||
continue;
|
||||
};
|
||||
if class == Class::Exempt {
|
||||
continue;
|
||||
}
|
||||
let layer = class.layer().unwrap_or(0);
|
||||
for d in &c.deps {
|
||||
// Self dev-dependencies (e.g. to enable features in tests) are fine.
|
||||
if d.name == c.name {
|
||||
continue;
|
||||
}
|
||||
if d.workspace {
|
||||
if class == Class::Standalone {
|
||||
out.push(Violation::StandaloneHasWorkspaceDep { krate: c.name.clone(), dep: d.name.clone() });
|
||||
continue;
|
||||
}
|
||||
match classify(&d.name) {
|
||||
// Unregistered deps are reported on their own entry.
|
||||
None => {}
|
||||
Some(Class::Testkit) => {
|
||||
if d.kind != DepKind::Dev {
|
||||
out.push(Violation::TestkitAsNormalDep { krate: c.name.clone() });
|
||||
}
|
||||
}
|
||||
Some(dc) => {
|
||||
let to = dc.layer().unwrap_or(u8::MAX);
|
||||
if to >= layer && !(to == layer && intra_layer_allowed(&c.name, &d.name)) {
|
||||
out.push(Violation::Upward {
|
||||
krate: c.name.clone(),
|
||||
dep: d.name.clone(),
|
||||
from: layer,
|
||||
to: if to == u8::MAX { 7 } else { to },
|
||||
kind: d.kind,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if layer < UI_MIN_LAYER && is_ui_crate(&d.name) {
|
||||
out.push(Violation::UiBelowL6 { krate: c.name.clone(), dep: d.name.clone(), layer });
|
||||
}
|
||||
}
|
||||
}
|
||||
out.sort_by_key(|v| v.to_string());
|
||||
out.dedup();
|
||||
out
|
||||
}
|
||||
|
||||
/// Build the model from `cargo metadata --format-version 1 --no-deps`.
|
||||
pub fn from_metadata(meta: &Value) -> Result<Vec<Crate>, String> {
|
||||
let pkgs = meta["packages"].as_array().ok_or("metadata: no packages array")?;
|
||||
let members: Vec<&str> = pkgs.iter().filter_map(|p| p["name"].as_str()).collect();
|
||||
let mut out = Vec::new();
|
||||
for p in pkgs {
|
||||
let name = p["name"].as_str().ok_or("package without name")?.to_owned();
|
||||
let mut deps = Vec::new();
|
||||
for d in p["dependencies"].as_array().into_iter().flatten() {
|
||||
let dname = d["name"].as_str().unwrap_or_default().to_owned();
|
||||
let kind = match d["kind"].as_str() {
|
||||
Some("dev") => DepKind::Dev,
|
||||
Some("build") => DepKind::Build,
|
||||
_ => DepKind::Normal,
|
||||
};
|
||||
let workspace = members.contains(&dname.as_str()) || d["path"].is_string();
|
||||
deps.push(Dep { name: dname, kind, workspace });
|
||||
}
|
||||
out.push(Crate { name, deps });
|
||||
}
|
||||
out.sort_by(|a, b| a.name.cmp(&b.name));
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
pub fn describe(class: Option<Class>) -> String {
|
||||
match class {
|
||||
Some(Class::Layer(l)) => format!("L{l}"),
|
||||
Some(Class::Standalone) => "L0 standalone".into(),
|
||||
Some(Class::Testkit) => "testkit".into(),
|
||||
Some(Class::Exempt) => "exempt".into(),
|
||||
None => "UNREGISTERED".into(),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn c(name: &str, deps: &[(&str, DepKind, bool)]) -> Crate {
|
||||
Crate { name: name.into(), deps: deps.iter().map(|(n, k, w)| Dep { name: (*n).into(), kind: *k, workspace: *w }).collect() }
|
||||
}
|
||||
use DepKind::*;
|
||||
|
||||
#[test]
|
||||
fn clean_downward_graph_passes() {
|
||||
let g = [
|
||||
c("designcraft-geom", &[("kurbo", Normal, false)]),
|
||||
c("designcraft-doc", &[("designcraft-geom", Normal, true)]),
|
||||
c("designcraft-engine", &[("designcraft-doc", Normal, true), ("designcraft-testkit", Dev, true)]),
|
||||
c("designcraft-ui-egui", &[("designcraft-engine", Normal, true), ("egui", Normal, false)]),
|
||||
c("designcraft-cli", &[("designcraft-ui-egui", Normal, true)]),
|
||||
];
|
||||
assert!(check(&g).is_empty(), "{:?}", check(&g));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn upward_dependency_flagged() {
|
||||
let v = check(&[c("designcraft-doc", &[("designcraft-engine", Normal, true)])]);
|
||||
assert!(matches!(v[..], [Violation::Upward { from: 1, to: 5, .. }]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sideways_dependency_flagged() {
|
||||
let v = check(&[c("designcraft-compose", &[("designcraft-images", Normal, true)])]);
|
||||
assert!(matches!(v[..], [Violation::Upward { from: 2, to: 2, .. }]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn l0_foundation_chain_allowed_one_way() {
|
||||
assert!(check(&[c("designcraft-color", &[("designcraft-geom", Normal, true)])]).is_empty());
|
||||
let v = check(&[c("designcraft-geom", &[("designcraft-color", Normal, true)])]);
|
||||
assert!(matches!(v[..], [Violation::Upward { from: 0, to: 0, .. }]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn self_dev_dependency_ignored() {
|
||||
assert!(check(&[c("designcraft-doc", &[("designcraft-doc", Dev, true)])]).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn upward_dev_dependency_flagged() {
|
||||
let v = check(&[c("designcraft-geom", &[("designcraft-doc", Dev, true)])]);
|
||||
assert!(matches!(v[..], [Violation::Upward { kind: Dev, .. }]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ui_crates_below_l6_flagged() {
|
||||
for dep in ["egui", "eframe", "winit", "egui_kittest", "rfd", "bevy_ecs", "bevy"] {
|
||||
let v = check(&[c("designcraft-engine", &[(dep, Normal, false)])]);
|
||||
assert!(matches!(v[..], [Violation::UiBelowL6 { layer: 5, .. }]), "{dep}");
|
||||
}
|
||||
assert!(check(&[c("designcraft-engine", &[("egui_extras_not", Normal, false)])]).is_empty());
|
||||
assert!(check(&[c("designcraft-mcp", &[("winit", Normal, false)])]).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unregistered_crate_is_error() {
|
||||
let v = check(&[c("designcraft-mystery", &[])]);
|
||||
assert!(matches!(&v[..], [Violation::Unregistered { krate }] if krate == "designcraft-mystery"));
|
||||
assert!(v[0].to_string().contains("register"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn testkit_only_as_dev_dependency() {
|
||||
let v = check(&[c("designcraft-render", &[("designcraft-testkit", Normal, true)])]);
|
||||
assert!(matches!(v[..], [Violation::TestkitAsNormalDep { .. }]));
|
||||
assert!(check(&[c("designcraft-render", &[("designcraft-testkit", Dev, true)])]).is_empty());
|
||||
// testkit itself may use anything up to L5 but not L6 crates.
|
||||
assert!(check(&[c("designcraft-testkit", &[("designcraft-engine", Normal, true)])]).is_empty());
|
||||
assert!(!check(&[c("designcraft-testkit", &[("designcraft-ui-egui", Normal, true)])]).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn apps_and_xtask_exempt() {
|
||||
for app in ["designcraft", "designcraft-cli", "designcraft-web", "xtask"] {
|
||||
assert!(check(&[c(app, &[("egui", Normal, false), ("designcraft-ui-egui", Normal, true)])]).is_empty());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn metadata_parsing() {
|
||||
let meta: Value = serde_json::from_str(
|
||||
r#"{"packages":[
|
||||
{"name":"designcraft-doc","dependencies":[
|
||||
{"name":"designcraft-geom","kind":null,"path":"/x/crates/geom"},
|
||||
{"name":"serde","kind":null},
|
||||
{"name":"proptest","kind":"dev"}]},
|
||||
{"name":"designcraft-geom","dependencies":[]}
|
||||
]}"#,
|
||||
)
|
||||
.unwrap();
|
||||
let g = from_metadata(&meta).unwrap();
|
||||
assert_eq!(g.len(), 2);
|
||||
let doc = g.iter().find(|c| c.name == "designcraft-doc").unwrap();
|
||||
assert!(doc.deps[0].workspace && !doc.deps[1].workspace);
|
||||
assert_eq!(doc.deps[2].kind, Dev);
|
||||
assert!(check(&g).is_empty());
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,236 @@
|
|||
//! Workspace tooling: `cargo xtask <command>`.
|
||||
//!
|
||||
//! Pure Rust (std + serde_json). External tools (`cargo`, `curl`, `tar`) are
|
||||
//! invoked through `std::process::Command`.
|
||||
|
||||
mod assets;
|
||||
mod ico;
|
||||
mod layers;
|
||||
mod parity;
|
||||
mod stats;
|
||||
mod version;
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::{Command, ExitCode};
|
||||
|
||||
const USAGE: &str = "\
|
||||
usage: cargo xtask <command>
|
||||
|
||||
commands:
|
||||
assets check that every icon/image/font/asset is attributed in ASSETS.md
|
||||
layers enforce the crate dependency layering (plan/architecture.md §3)
|
||||
wasm cargo check --target wasm32-unknown-unknown for the wasm-safe crates
|
||||
ci fmt --check, clippy -D warnings, test, assets, layers, wasm (stops at first failure)
|
||||
corpus [--download]
|
||||
show where test corpora live; --download fetches PngSuite into corpus/pngsuite
|
||||
stats [--exact] count tests and lines per crate (--exact: ask the test harness via `-- --list`)
|
||||
parity recompute the InDesign feature-parity summary in docs/parity.md
|
||||
ico <out.ico> <in.png>...
|
||||
pack square PNGs (<= 256 px) into a Windows .ico (see packaging/icons.sh)
|
||||
";
|
||||
|
||||
fn main() -> ExitCode {
|
||||
let args: Vec<String> = std::env::args().skip(1).collect();
|
||||
let rest: Vec<&str> = args.iter().skip(1).map(String::as_str).collect();
|
||||
let result = match args.first().map(String::as_str) {
|
||||
Some("assets") => assets::run(&root()),
|
||||
Some("layers") => cmd_layers(),
|
||||
Some("wasm") => cmd_wasm(),
|
||||
Some("ci") => cmd_ci(),
|
||||
Some("corpus") => cmd_corpus(rest.contains(&"--download")),
|
||||
Some("stats") => stats::run(&root(), rest.contains(&"--exact")),
|
||||
Some("parity") => parity::run(&root()),
|
||||
Some("ico") => ico::run(&rest),
|
||||
Some("version") => version::run(&root(), &rest),
|
||||
Some("-h" | "--help" | "help") | None => {
|
||||
print!("{USAGE}");
|
||||
Ok(())
|
||||
}
|
||||
Some(other) => Err(format!("unknown command `{other}`\n\n{USAGE}")),
|
||||
};
|
||||
match result {
|
||||
Ok(()) => ExitCode::SUCCESS,
|
||||
Err(e) => {
|
||||
eprintln!("error: {e}");
|
||||
ExitCode::FAILURE
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Workspace root (parent of the xtask crate).
|
||||
pub fn root() -> PathBuf {
|
||||
Path::new(env!("CARGO_MANIFEST_DIR")).parent().expect("xtask has a parent dir").to_path_buf()
|
||||
}
|
||||
|
||||
pub fn cargo() -> Command {
|
||||
let mut c = Command::new(std::env::var("CARGO").unwrap_or_else(|_| "cargo".into()));
|
||||
c.current_dir(root());
|
||||
c
|
||||
}
|
||||
|
||||
fn run(mut cmd: Command, what: &str) -> Result<(), String> {
|
||||
eprintln!("$ {what}");
|
||||
let status = cmd.status().map_err(|e| format!("{what}: failed to spawn: {e}"))?;
|
||||
if status.success() { Ok(()) } else { Err(format!("{what}: exited with {status}")) }
|
||||
}
|
||||
|
||||
pub fn metadata() -> Result<serde_json::Value, String> {
|
||||
let out = cargo().args(["metadata", "--format-version", "1", "--no-deps"]).output().map_err(|e| format!("cargo metadata: {e}"))?;
|
||||
if !out.status.success() {
|
||||
return Err(format!("cargo metadata failed:\n{}", String::from_utf8_lossy(&out.stderr)));
|
||||
}
|
||||
serde_json::from_slice(&out.stdout).map_err(|e| format!("cargo metadata: bad JSON: {e}"))
|
||||
}
|
||||
|
||||
fn cmd_layers() -> Result<(), String> {
|
||||
let crates = layers::from_metadata(&metadata()?)?;
|
||||
println!("Dependency layering (plan/architecture.md §3)\n");
|
||||
println!("{:<28} {:<14} workspace deps", "crate", "layer");
|
||||
for c in &crates {
|
||||
let ws: Vec<String> = c
|
||||
.deps
|
||||
.iter()
|
||||
.filter(|d| d.workspace)
|
||||
.map(|d| {
|
||||
let k = match d.kind {
|
||||
layers::DepKind::Normal => "",
|
||||
layers::DepKind::Dev => " (dev)",
|
||||
layers::DepKind::Build => " (build)",
|
||||
};
|
||||
format!("{}{k}", layers::short_name(&d.name))
|
||||
})
|
||||
.collect();
|
||||
println!("{:<28} {:<14} {}", c.name, layers::describe(layers::classify(&c.name)), ws.join(", "));
|
||||
}
|
||||
let violations = layers::check(&crates);
|
||||
println!();
|
||||
if violations.is_empty() {
|
||||
println!("OK: {} crates, no layering violations.", crates.len());
|
||||
Ok(())
|
||||
} else {
|
||||
println!("{} violation(s):", violations.len());
|
||||
for v in &violations {
|
||||
println!(" - {v}");
|
||||
}
|
||||
Err(format!("{} layering violation(s)", violations.len()))
|
||||
}
|
||||
}
|
||||
|
||||
/// Workspace packages that must build for wasm32: all L0–L5 crates plus
|
||||
/// the egui shell.
|
||||
fn wasm_set() -> Result<Vec<String>, String> {
|
||||
let crates = layers::from_metadata(&metadata()?)?;
|
||||
Ok(crates
|
||||
.into_iter()
|
||||
.filter(|c| match layers::classify(&c.name) {
|
||||
Some(layers::Class::Layer(l)) => l <= 5 || layers::short_name(&c.name) == "ui-egui",
|
||||
Some(layers::Class::Standalone) => true,
|
||||
_ => false,
|
||||
})
|
||||
.map(|c| c.name)
|
||||
.collect())
|
||||
}
|
||||
|
||||
fn cmd_wasm() -> Result<(), String> {
|
||||
let set = wasm_set()?;
|
||||
let mut results = Vec::new();
|
||||
for pkg in &set {
|
||||
let mut c = cargo();
|
||||
c.args(["check", "--target", "wasm32-unknown-unknown", "-p", pkg]);
|
||||
let ok = run(c, &format!("cargo check --target wasm32-unknown-unknown -p {pkg}")).is_ok();
|
||||
results.push((pkg.clone(), ok));
|
||||
}
|
||||
println!("\nwasm32-unknown-unknown check:");
|
||||
for (p, ok) in &results {
|
||||
println!(" {:<6} {p}", if *ok { "ok" } else { "FAIL" });
|
||||
}
|
||||
let failed = results.iter().filter(|r| !r.1).count();
|
||||
if failed == 0 { Ok(()) } else { Err(format!("{failed} crate(s) failed the wasm check")) }
|
||||
}
|
||||
|
||||
fn cmd_ci() -> Result<(), String> {
|
||||
type Step = (&'static str, Box<dyn Fn() -> Result<(), String>>);
|
||||
let steps: Vec<Step> = vec![
|
||||
(
|
||||
"fmt",
|
||||
Box::new(|| {
|
||||
let mut c = cargo();
|
||||
c.args(["fmt", "--all", "--", "--check"]);
|
||||
run(c, "cargo fmt --all -- --check")
|
||||
}),
|
||||
),
|
||||
(
|
||||
"clippy",
|
||||
Box::new(|| {
|
||||
let mut c = cargo();
|
||||
c.args(["clippy", "--workspace", "--all-targets", "--", "-D", "warnings"]);
|
||||
run(c, "cargo clippy --workspace --all-targets -- -D warnings")
|
||||
}),
|
||||
),
|
||||
(
|
||||
"test",
|
||||
Box::new(|| {
|
||||
let mut c = cargo();
|
||||
c.args(["test", "--workspace"]);
|
||||
run(c, "cargo test --workspace")
|
||||
}),
|
||||
),
|
||||
("assets", Box::new(|| assets::run(&root()))),
|
||||
("layers", Box::new(cmd_layers)),
|
||||
("wasm", Box::new(cmd_wasm)),
|
||||
];
|
||||
let mut done = Vec::new();
|
||||
for (name, f) in &steps {
|
||||
eprintln!("\n=== ci: {name} ===");
|
||||
if let Err(e) = f() {
|
||||
println!("\nCI summary:");
|
||||
for d in &done {
|
||||
println!(" ok {d}");
|
||||
}
|
||||
println!(" FAIL {name}: {e}");
|
||||
for (n, _) in steps.iter().skip(done.len() + 1) {
|
||||
println!(" skip {n}");
|
||||
}
|
||||
return Err(format!("ci failed at `{name}`"));
|
||||
}
|
||||
done.push(*name);
|
||||
}
|
||||
println!("\nCI summary: all {} steps passed ({})", done.len(), done.join(", "));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
const PNGSUITE_URL: &str = "http://www.schaik.com/pngsuite/PngSuite-2017jul19.tgz";
|
||||
|
||||
fn cmd_corpus(download: bool) -> Result<(), String> {
|
||||
let corpus = root().join("corpus");
|
||||
println!(
|
||||
"Test corpora live under {} (git-ignored, never committed).
|
||||
Tests that use a corpus skip cleanly when it is absent.
|
||||
|
||||
corpus/pngsuite/ PngSuite (public domain) — designcraft-codecs compares every file
|
||||
against the `image` crate. Fetch: cargo xtask corpus --download
|
||||
corpus/psd/ PSD samples from MIT/BSD projects (ag-psd, psd-tools test data).
|
||||
Copy files in manually; licences must be MIT/BSD/CC0.
|
||||
corpus/tiff/ libtiff pics (optional)
|
||||
corpus/exr/ OpenEXR sample images (optional)
|
||||
corpus/raw/ raw.pixls.us samples, CC0 (optional)
|
||||
",
|
||||
corpus.display()
|
||||
);
|
||||
if !download {
|
||||
return Ok(());
|
||||
}
|
||||
let dest = corpus.join("pngsuite");
|
||||
std::fs::create_dir_all(&dest).map_err(|e| format!("create {}: {e}", dest.display()))?;
|
||||
let tgz = corpus.join("PngSuite-2017jul19.tgz");
|
||||
let mut curl = Command::new("curl");
|
||||
curl.args(["-fsSL", "-o"]).arg(&tgz).arg(PNGSUITE_URL);
|
||||
run(curl, &format!("curl {PNGSUITE_URL}"))?;
|
||||
let mut tar = Command::new("tar");
|
||||
tar.arg("-xzf").arg(&tgz).arg("-C").arg(&dest);
|
||||
run(tar, "tar -xzf PngSuite-2017jul19.tgz")?;
|
||||
let _ = std::fs::remove_file(&tgz);
|
||||
let n = std::fs::read_dir(&dest).map(|d| d.flatten().filter(|e| e.path().extension().is_some_and(|x| x == "png")).count()).unwrap_or(0);
|
||||
println!("PngSuite: {n} PNG files in {}", dest.display());
|
||||
if n == 0 { Err("no PNG files extracted".into()) } else { Ok(()) }
|
||||
}
|
||||
|
|
@ -0,0 +1,99 @@
|
|||
//! `cargo xtask parity`: recompute the feature-parity summary in docs/parity.md from its row
|
||||
//! table (each InDesign feature scored D / P / M, weighted by priority).
|
||||
|
||||
use std::path::Path;
|
||||
|
||||
const START: &str = "<!-- SUMMARY -->";
|
||||
const ROWS: &str = "\n## Rows";
|
||||
|
||||
/// (area, feature, priority 0–2, value 0 / 0.5 / 1) for each table row.
|
||||
pub fn rows(text: &str) -> Vec<(String, String, u8, f64)> {
|
||||
let mut out = Vec::new();
|
||||
for l in text.lines() {
|
||||
let cells: Vec<&str> = l.trim().trim_matches('|').split('|').map(str::trim).collect();
|
||||
if cells.len() != 4 {
|
||||
continue;
|
||||
}
|
||||
let pri = match cells[2] {
|
||||
"P0" => 0,
|
||||
"P1" => 1,
|
||||
"P2" => 2,
|
||||
_ => continue,
|
||||
};
|
||||
let v = match cells[3] {
|
||||
"D" => 1.0,
|
||||
"P" => 0.5,
|
||||
"M" => 0.0,
|
||||
_ => continue,
|
||||
};
|
||||
out.push((cells[0].to_string(), cells[1].to_string(), pri, v));
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn weight(p: u8) -> f64 {
|
||||
[3.0, 2.0, 1.0][p as usize]
|
||||
}
|
||||
|
||||
pub fn summary(rows: &[(String, String, u8, f64)]) -> String {
|
||||
let pct = |rs: &[&(String, String, u8, f64)]| {
|
||||
let tot: f64 = rs.iter().map(|r| weight(r.2)).sum();
|
||||
if tot == 0.0 { 0.0 } else { 100.0 * rs.iter().map(|r| weight(r.2) * r.3).sum::<f64>() / tot }
|
||||
};
|
||||
let all: Vec<_> = rows.iter().collect();
|
||||
let mut s = format!(
|
||||
"**Weighted breadth parity: {:.0}%** over {} features.\n\n| Priority | Features | Done | Partial | Missing | Parity |\n|---|---|---|---|---|---|\n",
|
||||
pct(&all),
|
||||
rows.len()
|
||||
);
|
||||
for p in 0..3u8 {
|
||||
let rr: Vec<_> = rows.iter().filter(|r| r.2 == p).collect();
|
||||
let n = |v: f64| rr.iter().filter(|r| r.3 == v).count();
|
||||
let unweighted = if rr.is_empty() { 0.0 } else { 100.0 * rr.iter().map(|r| r.3).sum::<f64>() / rr.len() as f64 };
|
||||
s += &format!("| P{p} | {} | {} | {} | {} | {unweighted:.0}% |\n", rr.len(), n(1.0), n(0.5), n(0.0));
|
||||
}
|
||||
s += "\n| Area | Parity |\n|---|---|\n";
|
||||
let mut areas: Vec<&str> = Vec::new();
|
||||
for r in rows {
|
||||
if !areas.contains(&r.0.as_str()) {
|
||||
areas.push(&r.0);
|
||||
}
|
||||
}
|
||||
for a in areas {
|
||||
let rr: Vec<_> = rows.iter().filter(|r| r.0 == a).collect();
|
||||
s += &format!("| {a} | {:.0}% |\n", pct(&rr));
|
||||
}
|
||||
let open: Vec<&str> = rows.iter().filter(|r| r.2 == 0 && r.3 < 1.0).map(|r| r.1.as_str()).collect();
|
||||
s += &format!("\nOpen P0 items: {}.\n", open.join(", "));
|
||||
s
|
||||
}
|
||||
|
||||
pub fn run(root: &Path) -> Result<(), String> {
|
||||
let path = root.join("docs/parity.md");
|
||||
let text = std::fs::read_to_string(&path).map_err(|e| format!("{}: {e}", path.display()))?;
|
||||
let (Some(a), Some(b)) = (text.find(START), text.find(ROWS)) else {
|
||||
return Err("docs/parity.md needs a `<!-- SUMMARY -->` marker before `## Rows`".into());
|
||||
};
|
||||
let rows = rows(&text[b..]);
|
||||
let s = summary(&rows);
|
||||
let new = format!("{}{START}\n{s}{}", &text[..a], &text[b..]);
|
||||
std::fs::write(&path, new).map_err(|e| e.to_string())?;
|
||||
print!("{s}");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn scores_rows() {
|
||||
let t = "| Area | Feature | Priority | Status |\n|---|---|---|---|\n| A | x | P0 | D |\n| A | y | P2 | M |\n| B | z | P1 | P |\n";
|
||||
let r = rows(t);
|
||||
assert_eq!(r.len(), 3);
|
||||
let s = summary(&r);
|
||||
// (3·1 + 1·0 + 2·0.5) / 6 = 67%
|
||||
assert!(s.contains("**Weighted breadth parity: 67%**"), "{s}");
|
||||
assert!(s.contains("Open P0 items: ."));
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,118 @@
|
|||
//! `cargo xtask stats`: tests and lines of Rust per crate.
|
||||
|
||||
use std::path::Path;
|
||||
|
||||
#[derive(Default, Clone, Copy)]
|
||||
struct Counts {
|
||||
files: usize,
|
||||
lines: usize,
|
||||
tests: usize,
|
||||
proptests: usize,
|
||||
}
|
||||
|
||||
fn walk(dir: &Path, c: &mut Counts) {
|
||||
let Ok(rd) = std::fs::read_dir(dir) else {
|
||||
return;
|
||||
};
|
||||
for e in rd.flatten() {
|
||||
let p = e.path();
|
||||
let name = e.file_name();
|
||||
let name = name.to_string_lossy();
|
||||
if p.is_dir() {
|
||||
// Nested fuzz workspaces and build output are not part of the crate.
|
||||
if name != "target" && name != "fuzz" && !name.starts_with('.') {
|
||||
walk(&p, c);
|
||||
}
|
||||
} else if name.ends_with(".rs")
|
||||
&& let Ok(s) = std::fs::read_to_string(&p)
|
||||
{
|
||||
c.files += 1;
|
||||
c.lines += s.lines().count();
|
||||
let (t, pt) = count_tests(&s);
|
||||
c.tests += t;
|
||||
c.proptests += pt;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// `#[test]` attributes and `proptest!` blocks (each block may hold many
|
||||
/// property tests, and macro-generated tests are only counted once — use
|
||||
/// `--exact` for harness-accurate numbers).
|
||||
fn count_tests(src: &str) -> (usize, usize) {
|
||||
let mut tests = 0;
|
||||
let mut props = 0;
|
||||
for line in src.lines() {
|
||||
let t = line.trim_start();
|
||||
if t.starts_with("//") {
|
||||
continue;
|
||||
}
|
||||
tests += t.matches("#[test]").count();
|
||||
if t.starts_with("proptest!") {
|
||||
props += 1;
|
||||
}
|
||||
}
|
||||
(tests, props)
|
||||
}
|
||||
|
||||
fn exact_tests(pkg: &str) -> Option<usize> {
|
||||
let out = crate::cargo().args(["test", "-q", "-p", pkg, "--", "--list"]).output().ok()?;
|
||||
if !out.status.success() {
|
||||
return None;
|
||||
}
|
||||
Some(String::from_utf8_lossy(&out.stdout).lines().filter(|l| l.ends_with(": test")).count())
|
||||
}
|
||||
|
||||
pub fn run(_root: &Path, exact: bool) -> Result<(), String> {
|
||||
let meta = crate::metadata()?;
|
||||
let mut rows = Vec::new();
|
||||
for p in meta["packages"].as_array().into_iter().flatten() {
|
||||
let name = p["name"].as_str().unwrap_or("?").to_owned();
|
||||
let Some(dir) = p["manifest_path"].as_str().and_then(|m| Path::new(m).parent().map(Path::to_path_buf)) else {
|
||||
continue;
|
||||
};
|
||||
let mut c = Counts::default();
|
||||
walk(&dir, &mut c);
|
||||
let ex = if exact { exact_tests(&name) } else { None };
|
||||
rows.push((name, c, ex));
|
||||
}
|
||||
rows.sort_by(|a, b| a.0.cmp(&b.0));
|
||||
|
||||
let exact_col = if exact { format!(" {:>8}", "harness") } else { String::new() };
|
||||
println!("{:<26} {:>6} {:>8} {:>7} {:>10}{exact_col}", "crate", "files", "lines", "#[test]", "proptest!");
|
||||
println!("{}", "-".repeat(62 + if exact { 9 } else { 0 }));
|
||||
let mut total = Counts::default();
|
||||
let mut total_exact = 0;
|
||||
for (name, c, ex) in &rows {
|
||||
let ex_s = match (exact, ex) {
|
||||
(false, _) => String::new(),
|
||||
(true, Some(n)) => {
|
||||
total_exact += n;
|
||||
format!(" {n:>8}")
|
||||
}
|
||||
(true, None) => format!(" {:>8}", "error"),
|
||||
};
|
||||
println!("{name:<26} {:>6} {:>8} {:>7} {:>10}{ex_s}", c.files, c.lines, c.tests, c.proptests);
|
||||
total.files += c.files;
|
||||
total.lines += c.lines;
|
||||
total.tests += c.tests;
|
||||
total.proptests += c.proptests;
|
||||
}
|
||||
println!("{}", "-".repeat(62 + if exact { 9 } else { 0 }));
|
||||
let ex_s = if exact { format!(" {total_exact:>8}") } else { String::new() };
|
||||
println!("{:<26} {:>6} {:>8} {:>7} {:>10}{ex_s}", "TOTAL", total.files, total.lines, total.tests, total.proptests);
|
||||
if !exact {
|
||||
println!("\n(#[test] counts source attributes; macro-generated tests count once. Use --exact for harness counts.)");
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::count_tests;
|
||||
|
||||
#[test]
|
||||
fn counts_attributes_and_proptest_blocks() {
|
||||
let src = "#[test]\nfn a() {}\n// #[test] commented\n #[test]\nfn b() {}\nproptest! {\n}\n";
|
||||
assert_eq!(count_tests(src), (2, 1));
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,152 @@
|
|||
//! `cargo xtask version [set X.Y.Z[-pre]]`: the workspace version, the single source of truth.
|
||||
//!
|
||||
//! The version lives in `[workspace.package] version` in the root `Cargo.toml`; every crate
|
||||
//! inherits it with `version.workspace = true`, and the packaging scripts read it from here.
|
||||
|
||||
use std::path::Path;
|
||||
|
||||
/// The `version = "…"` value inside `[workspace.package]`.
|
||||
pub fn read(manifest: &str) -> Result<String, String> {
|
||||
let (_, line) = find_line(manifest)?;
|
||||
let v = line.split_once('=').map(|(_, v)| v.trim().trim_matches('"').to_string()).unwrap_or_default();
|
||||
if v.is_empty() {
|
||||
return Err("empty [workspace.package] version".into());
|
||||
}
|
||||
Ok(v)
|
||||
}
|
||||
|
||||
/// `manifest` with the workspace version replaced; everything else is byte-for-byte unchanged.
|
||||
pub fn replace(manifest: &str, new: &str) -> Result<String, String> {
|
||||
validate(new)?;
|
||||
let (idx, _) = find_line(manifest)?;
|
||||
let mut out = String::with_capacity(manifest.len() + 8);
|
||||
for (i, line) in manifest.split_inclusive('\n').enumerate() {
|
||||
if i == idx {
|
||||
let eol = if line.ends_with("\r\n") {
|
||||
"\r\n"
|
||||
} else if line.ends_with('\n') {
|
||||
"\n"
|
||||
} else {
|
||||
""
|
||||
};
|
||||
out.push_str(&format!("version = \"{new}\"{eol}"));
|
||||
} else {
|
||||
out.push_str(line);
|
||||
}
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// Semver without build metadata: `MAJOR.MINOR.PATCH` plus an optional `-pre.release` tag.
|
||||
pub fn validate(v: &str) -> Result<(), String> {
|
||||
let bad = || Err(format!("`{v}` is not a version like 1.2.3 or 1.2.3-rc.1"));
|
||||
let (core, pre) = match v.split_once('-') {
|
||||
Some((c, p)) => (c, Some(p)),
|
||||
None => (v, None),
|
||||
};
|
||||
let nums: Vec<&str> = core.split('.').collect();
|
||||
if nums.len() != 3 || nums.iter().any(|n| n.is_empty() || !n.bytes().all(|b| b.is_ascii_digit()) || (n.len() > 1 && n.starts_with('0'))) {
|
||||
return bad();
|
||||
}
|
||||
if let Some(p) = pre
|
||||
&& (p.is_empty() || p.split('.').any(|id| id.is_empty() || !id.bytes().all(|b| b.is_ascii_alphanumeric() || b == b'-')))
|
||||
{
|
||||
return bad();
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Index and text of the `version` line in `[workspace.package]`.
|
||||
fn find_line(manifest: &str) -> Result<(usize, &str), String> {
|
||||
let mut in_section = false;
|
||||
for (i, line) in manifest.lines().enumerate() {
|
||||
let t = line.trim();
|
||||
if t.starts_with('[') {
|
||||
in_section = t == "[workspace.package]";
|
||||
continue;
|
||||
}
|
||||
if in_section
|
||||
&& let Some(rest) = t.strip_prefix("version")
|
||||
&& rest.trim_start().starts_with('=')
|
||||
{
|
||||
return Ok((i, line));
|
||||
}
|
||||
}
|
||||
Err("no `version = \"…\"` in [workspace.package] of the root Cargo.toml".into())
|
||||
}
|
||||
|
||||
pub fn run(root: &Path, args: &[&str]) -> Result<(), String> {
|
||||
let path = root.join("Cargo.toml");
|
||||
let text = std::fs::read_to_string(&path).map_err(|e| format!("read {}: {e}", path.display()))?;
|
||||
match args {
|
||||
[] => {
|
||||
println!("{}", read(&text)?);
|
||||
Ok(())
|
||||
}
|
||||
["set", new] => {
|
||||
let old = read(&text)?;
|
||||
let updated = replace(&text, new)?;
|
||||
// Write atomically: a half-written root manifest breaks every build in the workspace.
|
||||
let tmp = root.join("target").join("Cargo.toml.xtask-version");
|
||||
std::fs::create_dir_all(root.join("target")).map_err(|e| format!("create target/: {e}"))?;
|
||||
std::fs::write(&tmp, updated).map_err(|e| format!("write {}: {e}", tmp.display()))?;
|
||||
std::fs::rename(&tmp, &path).map_err(|e| format!("replace {}: {e}", path.display()))?;
|
||||
// Refresh the workspace members' entries in Cargo.lock (no dependency upgrades).
|
||||
let mut c = crate::cargo();
|
||||
c.args(["update", "--workspace", "--offline"]);
|
||||
if crate::run(c, "cargo update --workspace --offline").is_err() {
|
||||
let mut c = crate::cargo();
|
||||
c.args(["update", "--workspace"]);
|
||||
crate::run(c, "cargo update --workspace")?;
|
||||
}
|
||||
println!("version: {old} -> {new}");
|
||||
Ok(())
|
||||
}
|
||||
_ => Err("usage: cargo xtask version [set X.Y.Z[-pre]]".into()),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
const MANIFEST: &str = "[workspace]\nmembers = [\"a\"]\n\n[workspace.package]\nversion = \"0.1.0\"\nedition = \"2024\"\n\n[workspace.dependencies]\nfoo = { version = \"1\" }\n";
|
||||
|
||||
#[test]
|
||||
fn reads_the_workspace_version() {
|
||||
assert_eq!(read(MANIFEST).unwrap(), "0.1.0");
|
||||
assert!(read("[package]\nversion = \"1.0.0\"\n").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn replaces_only_the_workspace_version() {
|
||||
let out = replace(MANIFEST, "1.2.3-rc.1").unwrap();
|
||||
assert_eq!(read(&out).unwrap(), "1.2.3-rc.1");
|
||||
assert_eq!(out, MANIFEST.replace("version = \"0.1.0\"", "version = \"1.2.3-rc.1\""));
|
||||
assert!(out.contains("foo = { version = \"1\" }"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn keeps_crlf_line_endings() {
|
||||
let crlf = MANIFEST.replace('\n', "\r\n");
|
||||
let out = replace(&crlf, "2.0.0").unwrap();
|
||||
assert_eq!(out, crlf.replace("\"0.1.0\"", "\"2.0.0\""));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validates_versions() {
|
||||
for ok in ["0.1.0", "1.20.300", "1.0.0-rc.1", "1.0.0-alpha", "1.0.0-x-y.2"] {
|
||||
assert!(validate(ok).is_ok(), "{ok}");
|
||||
}
|
||||
for bad in ["1.0", "1.0.0.0", "v1.0.0", "01.0.0", "1.0.0-", "1.0.0-a..b", "1.0.0+meta", "1.a.0", ""] {
|
||||
assert!(validate(bad).is_err(), "{bad}");
|
||||
}
|
||||
assert!(replace(MANIFEST, "nope").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_real_manifest_has_a_valid_version() {
|
||||
let text = std::fs::read_to_string(crate::root().join("Cargo.toml")).unwrap();
|
||||
validate(&read(&text).unwrap()).unwrap();
|
||||
}
|
||||
}
|
||||