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>
This commit is contained in:
Brandon Thomas 2026-10-05 22:30:16 -04:00
parent 4e1fb3aba9
commit d1e80df4da
71 changed files with 12430 additions and 2 deletions

11
.gitignore vendored
View File

@ -1,3 +1,10 @@
plan/
plans/
*~
plan/
/target
/dist
/corpus
/log
.DS_Store
/.claude/
/target-*/
/craft-fonts/

1016
Cargo.lock generated Normal file

File diff suppressed because it is too large Load Diff

77
Cargo.toml Normal file
View File

@ -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

177
LICENSE-APACHE Normal file
View File

@ -0,0 +1,177 @@
Apache License
Version 2.0, January 2004
http://www.apache.org/licenses/
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
1. Definitions.
"License" shall mean the terms and conditions for use, reproduction,
and distribution as defined by Sections 1 through 9 of this document.
"Licensor" shall mean the copyright owner or entity authorized by
the copyright owner that is granting the License.
"Legal Entity" shall mean the union of the acting entity and all
other entities that control, are controlled by, or are under common
control with that entity. For the purposes of this definition,
"control" means (i) the power, direct or indirect, to cause the
direction or management of such entity, whether by contract or
otherwise, or (ii) ownership of fifty percent (50%) or more of the
outstanding shares, or (iii) beneficial ownership of such entity.
"You" (or "Your") shall mean an individual or Legal Entity
exercising permissions granted by this License.
"Source" form shall mean the preferred form for making modifications,
including but not limited to software source code, documentation
source, and configuration files.
"Object" form shall mean any form resulting from mechanical
transformation or translation of a Source form, including but
not limited to compiled object code, generated documentation,
and conversions to other media types.
"Work" shall mean the work of authorship, whether in Source or
Object form, made available under the License, as indicated by a
copyright notice that is included in or attached to the work
(an example is provided in the Appendix below).
"Derivative Works" shall mean any work, whether in Source or Object
form, that is based on (or derived from) the Work and for which the
editorial revisions, annotations, elaborations, or other modifications
represent, as a whole, an original work of authorship. For the purposes
of this License, Derivative Works shall not include works that remain
separable from, or merely link (or bind by name) to the interfaces of,
the Work and Derivative Works thereof.
"Contribution" shall mean any work of authorship, including
the original version of the Work and any modifications or additions
to that Work or Derivative Works thereof, that is intentionally
submitted to Licensor for inclusion in the Work by the copyright owner
or by an individual or Legal Entity authorized to submit on behalf of
the copyright owner. For the purposes of this definition, "submitted"
means any form of electronic, verbal, or written communication sent
to the Licensor or its representatives, including but not limited to
communication on electronic mailing lists, source code control systems,
and issue tracking systems that are managed by, or on behalf of, the
Licensor for the purpose of discussing and improving the Work, but
excluding communication that is conspicuously marked or otherwise
designated in writing by the copyright owner as "Not a Contribution."
"Contributor" shall mean Licensor and any individual or Legal Entity
on behalf of whom a Contribution has been received by Licensor and
subsequently incorporated within the Work.
2. Grant of Copyright License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
copyright license to reproduce, prepare Derivative Works of,
publicly display, publicly perform, sublicense, and distribute the
Work and such Derivative Works in Source or Object form.
3. Grant of Patent License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
(except as stated in this section) patent license to make, have made,
use, offer to sell, sell, import, and otherwise transfer the Work,
where such license applies only to those patent claims licensable
by such Contributor that are necessarily infringed by their
Contribution(s) alone or by combination of their Contribution(s)
with the Work to which such Contribution(s) was submitted. If You
institute patent litigation against any entity (including a
cross-claim or counterclaim in a lawsuit) alleging that the Work
or a Contribution incorporated within the Work constitutes direct
or contributory patent infringement, then any patent licenses
granted to You under this License for that Work shall terminate
as of the date such litigation is filed.
4. Redistribution. You may reproduce and distribute copies of the
Work or Derivative Works thereof in any medium, with or without
modifications, and in Source or Object form, provided that You
meet the following conditions:
(a) You must give any other recipients of the Work or
Derivative Works a copy of this License; and
(b) You must cause any modified files to carry prominent notices
stating that You changed the files; and
(c) You must retain, in the Source form of any Derivative Works
that You distribute, all copyright, patent, trademark, and
attribution notices from the Source form of the Work,
excluding those notices that do not pertain to any part of
the Derivative Works; and
(d) If the Work includes a "NOTICE" text file as part of its
distribution, then any Derivative Works that You distribute must
include a readable copy of the attribution notices contained
within such NOTICE file, excluding those notices that do not
pertain to any part of the Derivative Works, in at least one
of the following places: within a NOTICE text file distributed
as part of the Derivative Works; within the Source form or
documentation, if provided along with the Derivative Works; or,
within a display generated by the Derivative Works, if and
wherever such third-party notices normally appear. The contents
of the NOTICE file are for informational purposes only and
do not modify the License. You may add Your own attribution
notices within Derivative Works that You distribute, alongside
or as an addendum to the NOTICE text from the Work, provided
that such additional attribution notices cannot be construed
as modifying the License.
You may add Your own copyright statement to Your modifications and
may provide additional or different license terms and conditions
for use, reproduction, or distribution of Your modifications, or
for any such Derivative Works as a whole, provided Your use,
reproduction, and distribution of the Work otherwise complies with
the conditions stated in this License.
5. Submission of Contributions. Unless You explicitly state otherwise,
any Contribution intentionally submitted for inclusion in the Work
by You to the Licensor shall be under the terms and conditions of
this License, without any additional terms or conditions.
Notwithstanding the above, nothing herein shall supersede or modify
the terms of any separate license agreement you may have executed
with Licensor regarding such Contributions.
6. Trademarks. This License does not grant permission to use the trade
names, trademarks, service marks, or product names of the Licensor,
except as required for reasonable and customary use in describing the
origin of the Work and reproducing the content of the NOTICE file.
7. Disclaimer of Warranty. Unless required by applicable law or
agreed to in writing, Licensor provides the Work (and each
Contributor provides its Contributions) on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
implied, including, without limitation, any warranties or conditions
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
PARTICULAR PURPOSE. You are solely responsible for determining the
appropriateness of using or redistributing the Work and assume any
risks associated with Your exercise of permissions under this License.
8. Limitation of Liability. In no event and under no legal theory,
whether in tort (including negligence), contract, or otherwise,
unless required by applicable law (such as deliberate and grossly
negligent acts) or agreed to in writing, shall any Contributor be
liable to You for damages, including any direct, indirect, special,
incidental, or consequential damages of any character arising as a
result of this License or out of the use or inability to use the
Work (including but not limited to damages for loss of goodwill,
work stoppage, computer failure or malfunction, or any and all
other commercial damages or losses), even if such Contributor
has been advised of the possibility of such damages.
9. Accepting Warranty or Additional Liability. While redistributing
the Work or Derivative Works thereof, You may choose to offer,
and charge a fee for, acceptance of support, warranty, indemnity,
or other liability obligations and/or rights consistent with this
License. However, in accepting such obligations, You may act only
on Your own behalf and on Your sole responsibility, not on behalf
of any other Contributor, and only if You agree to indemnify,
defend, and hold each Contributor harmless for any liability
incurred by, or claims asserted against, such Contributor by reason
of your accepting any such warranty or additional liability.
END OF TERMS AND CONDITIONS

21
LICENSE-MIT Normal file
View File

@ -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.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@ -0,0 +1,92 @@
Copyright (c) 2016 The Inter Project Authors (https://github.com/rsms/inter)
This Font Software is licensed under the SIL Open Font License, Version 1.1.
This license is copied below, and is also available with a FAQ at:
http://scripts.sil.org/OFL
-----------------------------------------------------------
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
-----------------------------------------------------------
PREAMBLE
The goals of the Open Font License (OFL) are to stimulate worldwide
development of collaborative font projects, to support the font creation
efforts of academic and linguistic communities, and to provide a free and
open framework in which fonts may be shared and improved in partnership
with others.
The OFL allows the licensed fonts to be used, studied, modified and
redistributed freely as long as they are not sold by themselves. The
fonts, including any derivative works, can be bundled, embedded,
redistributed and/or sold with any software provided that any reserved
names are not used by derivative works. The fonts and derivatives,
however, cannot be released under any other type of license. The
requirement for fonts to remain under this license does not apply
to any document created using the fonts or their derivatives.
DEFINITIONS
"Font Software" refers to the set of files released by the Copyright
Holder(s) under this license and clearly marked as such. This may
include source files, build scripts and documentation.
"Reserved Font Name" refers to any names specified as such after the
copyright statement(s).
"Original Version" refers to the collection of Font Software components as
distributed by the Copyright Holder(s).
"Modified Version" refers to any derivative made by adding to, deleting,
or substituting -- in part or in whole -- any of the components of the
Original Version, by changing formats or by porting the Font Software to a
new environment.
"Author" refers to any designer, engineer, programmer, technical
writer or other person who contributed to the Font Software.
PERMISSION AND CONDITIONS
Permission is hereby granted, free of charge, to any person obtaining
a copy of the Font Software, to use, study, copy, merge, embed, modify,
redistribute, and sell modified and unmodified copies of the Font
Software, subject to the following conditions:
1) Neither the Font Software nor any of its individual components,
in Original or Modified Versions, may be sold by itself.
2) Original or Modified Versions of the Font Software may be bundled,
redistributed and/or sold with any software, provided that each copy
contains the above copyright notice and this license. These can be
included either as stand-alone text files, human-readable headers or
in the appropriate machine-readable metadata fields within text or
binary files as long as those fields can be easily viewed by the user.
3) No Modified Version of the Font Software may use the Reserved Font
Name(s) unless explicit written permission is granted by the corresponding
Copyright Holder. This restriction only applies to the primary font name as
presented to the users.
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
Software shall not be used to promote, endorse or advertise any
Modified Version, except to acknowledge the contribution(s) of the
Copyright Holder(s) and the Author(s) or with their explicit written
permission.
5) The Font Software, modified or unmodified, in part or in whole,
must be distributed entirely under this license, and must not be
distributed under any other license. The requirement for fonts to
remain under this license does not apply to any document created
using the Font Software.
TERMINATION
This license becomes null and void if any of the above conditions are
not met.
DISCLAIMER
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
OTHER DEALINGS IN THE FONT SOFTWARE.

View File

@ -0,0 +1,93 @@
Copyright 2020 The JetBrains Mono Project Authors (https://github.com/JetBrains/JetBrainsMono)
This Font Software is licensed under the SIL Open Font License, Version 1.1.
This license is copied below, and is also available with a FAQ at:
https://openfontlicense.org
-----------------------------------------------------------
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
-----------------------------------------------------------
PREAMBLE
The goals of the Open Font License (OFL) are to stimulate worldwide
development of collaborative font projects, to support the font creation
efforts of academic and linguistic communities, and to provide a free and
open framework in which fonts may be shared and improved in partnership
with others.
The OFL allows the licensed fonts to be used, studied, modified and
redistributed freely as long as they are not sold by themselves. The
fonts, including any derivative works, can be bundled, embedded,
redistributed and/or sold with any software provided that any reserved
names are not used by derivative works. The fonts and derivatives,
however, cannot be released under any other type of license. The
requirement for fonts to remain under this license does not apply
to any document created using the fonts or their derivatives.
DEFINITIONS
"Font Software" refers to the set of files released by the Copyright
Holder(s) under this license and clearly marked as such. This may
include source files, build scripts and documentation.
"Reserved Font Name" refers to any names specified as such after the
copyright statement(s).
"Original Version" refers to the collection of Font Software components as
distributed by the Copyright Holder(s).
"Modified Version" refers to any derivative made by adding to, deleting,
or substituting -- in part or in whole -- any of the components of the
Original Version, by changing formats or by porting the Font Software to a
new environment.
"Author" refers to any designer, engineer, programmer, technical
writer or other person who contributed to the Font Software.
PERMISSION & CONDITIONS
Permission is hereby granted, free of charge, to any person obtaining
a copy of the Font Software, to use, study, copy, merge, embed, modify,
redistribute, and sell modified and unmodified copies of the Font
Software, subject to the following conditions:
1) Neither the Font Software nor any of its individual components,
in Original or Modified Versions, may be sold by itself.
2) Original or Modified Versions of the Font Software may be bundled,
redistributed and/or sold with any software, provided that each copy
contains the above copyright notice and this license. These can be
included either as stand-alone text files, human-readable headers or
in the appropriate machine-readable metadata fields within text or
binary files as long as those fields can be easily viewed by the user.
3) No Modified Version of the Font Software may use the Reserved Font
Name(s) unless explicit written permission is granted by the corresponding
Copyright Holder. This restriction only applies to the primary font name as
presented to the users.
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
Software shall not be used to promote, endorse or advertise any
Modified Version, except to acknowledge the contribution(s) of the
Copyright Holder(s) and the Author(s) or with their explicit written
permission.
5) The Font Software, modified or unmodified, in part or in whole,
must be distributed entirely under this license, and must not be
distributed under any other license. The requirement for fonts to
remain under this license does not apply to any document created
using the Font Software.
TERMINATION
This license becomes null and void if any of the above conditions are
not met.
DISCLAIMER
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
OTHER DEALINGS IN THE FONT SOFTWARE.

View File

@ -0,0 +1,93 @@
Copyright 2010-2024 Adobe (http://www.adobe.com/), with Reserved Font Name 'Source'. All Rights Reserved. Source is a trademark of Adobe in the United States and/or other countries.
This Font Software is licensed under the SIL Open Font License, Version 1.1.
This license is copied below, and is also available with a FAQ at: http://scripts.sil.org/OFL
-----------------------------------------------------------
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
-----------------------------------------------------------
PREAMBLE
The goals of the Open Font License (OFL) are to stimulate worldwide
development of collaborative font projects, to support the font creation
efforts of academic and linguistic communities, and to provide a free and
open framework in which fonts may be shared and improved in partnership
with others.
The OFL allows the licensed fonts to be used, studied, modified and
redistributed freely as long as they are not sold by themselves. The
fonts, including any derivative works, can be bundled, embedded,
redistributed and/or sold with any software provided that any reserved
names are not used by derivative works. The fonts and derivatives,
however, cannot be released under any other type of license. The
requirement for fonts to remain under this license does not apply
to any document created using the fonts or their derivatives.
DEFINITIONS
"Font Software" refers to the set of files released by the Copyright
Holder(s) under this license and clearly marked as such. This may
include source files, build scripts and documentation.
"Reserved Font Name" refers to any names specified as such after the
copyright statement(s).
"Original Version" refers to the collection of Font Software components as
distributed by the Copyright Holder(s).
"Modified Version" refers to any derivative made by adding to, deleting,
or substituting -- in part or in whole -- any of the components of the
Original Version, by changing formats or by porting the Font Software to a
new environment.
"Author" refers to any designer, engineer, programmer, technical
writer or other person who contributed to the Font Software.
PERMISSION & CONDITIONS
Permission is hereby granted, free of charge, to any person obtaining
a copy of the Font Software, to use, study, copy, merge, embed, modify,
redistribute, and sell modified and unmodified copies of the Font
Software, subject to the following conditions:
1) Neither the Font Software nor any of its individual components,
in Original or Modified Versions, may be sold by itself.
2) Original or Modified Versions of the Font Software may be bundled,
redistributed and/or sold with any software, provided that each copy
contains the above copyright notice and this license. These can be
included either as stand-alone text files, human-readable headers or
in the appropriate machine-readable metadata fields within text or
binary files as long as those fields can be easily viewed by the user.
3) No Modified Version of the Font Software may use the Reserved Font
Name(s) unless explicit written permission is granted by the corresponding
Copyright Holder. This restriction only applies to the primary font name as
presented to the users.
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
Software shall not be used to promote, endorse or advertise any
Modified Version, except to acknowledge the contribution(s) of the
Copyright Holder(s) and the Author(s) or with their explicit written
permission.
5) The Font Software, modified or unmodified, in part or in whole,
must be distributed entirely under this license, and must not be
distributed under any other license. The requirement for fonts to
remain under this license does not apply to any document created
using the Font Software.
TERMINATION
This license becomes null and void if any of the above conditions are
not met.
DISCLAIMER
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
OTHER DEALINGS IN THE FONT SOFTWARE.

View File

@ -0,0 +1,93 @@
Copyright 2014 - 2023 Adobe (http://www.adobe.com/), with Reserved Font Name ‘Source’. All Rights Reserved. Source is a trademark of Adobe in the United States and/or other countries.
This Font Software is licensed under the SIL Open Font License, Version 1.1.
This license is copied below, and is also available with a FAQ at: http://scripts.sil.org/OFL
-----------------------------------------------------------
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
-----------------------------------------------------------
PREAMBLE
The goals of the Open Font License (OFL) are to stimulate worldwide
development of collaborative font projects, to support the font creation
efforts of academic and linguistic communities, and to provide a free and
open framework in which fonts may be shared and improved in partnership
with others.
The OFL allows the licensed fonts to be used, studied, modified and
redistributed freely as long as they are not sold by themselves. The
fonts, including any derivative works, can be bundled, embedded,
redistributed and/or sold with any software provided that any reserved
names are not used by derivative works. The fonts and derivatives,
however, cannot be released under any other type of license. The
requirement for fonts to remain under this license does not apply
to any document created using the fonts or their derivatives.
DEFINITIONS
"Font Software" refers to the set of files released by the Copyright
Holder(s) under this license and clearly marked as such. This may
include source files, build scripts and documentation.
"Reserved Font Name" refers to any names specified as such after the
copyright statement(s).
"Original Version" refers to the collection of Font Software components as
distributed by the Copyright Holder(s).
"Modified Version" refers to any derivative made by adding to, deleting,
or substituting -- in part or in whole -- any of the components of the
Original Version, by changing formats or by porting the Font Software to a
new environment.
"Author" refers to any designer, engineer, programmer, technical
writer or other person who contributed to the Font Software.
PERMISSION & CONDITIONS
Permission is hereby granted, free of charge, to any person obtaining
a copy of the Font Software, to use, study, copy, merge, embed, modify,
redistribute, and sell modified and unmodified copies of the Font
Software, subject to the following conditions:
1) Neither the Font Software nor any of its individual components,
in Original or Modified Versions, may be sold by itself.
2) Original or Modified Versions of the Font Software may be bundled,
redistributed and/or sold with any software, provided that each copy
contains the above copyright notice and this license. These can be
included either as stand-alone text files, human-readable headers or
in the appropriate machine-readable metadata fields within text or
binary files as long as those fields can be easily viewed by the user.
3) No Modified Version of the Font Software may use the Reserved Font
Name(s) unless explicit written permission is granted by the corresponding
Copyright Holder. This restriction only applies to the primary font name as
presented to the users.
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
Software shall not be used to promote, endorse or advertise any
Modified Version, except to acknowledge the contribution(s) of the
Copyright Holder(s) and the Author(s) or with their explicit written
permission.
5) The Font Software, modified or unmodified, in part or in whole,
must be distributed entirely under this license, and must not be
distributed under any other license. The requirement for fonts to
remain under this license does not apply to any document created
using the Font Software.
TERMINATION
This license becomes null and void if any of the above conditions are
not met.
DISCLAIMER
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
OTHER DEALINGS IN THE FONT SOFTWARE.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

4
clippy.toml Normal file
View File

@ -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

19
crates/doc/Cargo.toml Normal file
View File

@ -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 }

390
crates/doc/src/edit.rs Normal file
View File

@ -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());
}
}

651
crates/doc/src/lib.rs Normal file
View File

@ -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;

311
crates/doc/src/numbering.rs Normal file
View File

@ -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));
}
}

805
crates/doc/src/para.rs Normal file
View File

@ -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());
}
}
}
}

713
crates/doc/src/props.rs Normal file
View File

@ -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);
}
}

255
crates/doc/src/resolve.rs Normal file
View File

@ -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);
}
}

407
crates/doc/src/section.rs Normal file
View File

@ -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));
}
}

478
crates/doc/src/styles.rs Normal file
View File

@ -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");
}
}

340
crates/doc/src/table.rs Normal file
View File

@ -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);
}
}

76
crates/doc/src/tests.rs Normal file
View File

@ -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(_))));
}

16
crates/fonts/Cargo.toml Normal file
View File

@ -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 }

55
crates/fonts/build.rs Normal file
View File

@ -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)
}

896
crates/fonts/src/fontdb.rs Normal file
View File

@ -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;

330
crates/fonts/src/lib.rs Normal file
View File

@ -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");
}
}
}

View File

@ -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());
}

129
crates/fonts/src/word.rs Normal file
View File

@ -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);
}
}

View File

@ -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));
}

12
crates/geom/Cargo.toml Normal file
View File

@ -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 }

244
crates/geom/src/lib.rs Normal file
View File

@ -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);
}
}

19
crates/layout/Cargo.toml Normal file
View File

@ -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 }

View File

@ -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
}
}
}
}

View File

@ -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)");
}
}

266
crates/layout/src/hit.rs Normal file
View File

@ -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
}

724
crates/layout/src/lib.rs Normal file
View File

@ -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, &sections);
}
// 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;

863
crates/layout/src/para.rs Normal file
View File

@ -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)
}
}

241
crates/layout/src/table.rs Normal file
View File

@ -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 }
}

228
crates/layout/src/tests.rs Normal file
View File

@ -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 = &para.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);
}

21
crates/render/Cargo.toml Normal file
View File

@ -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"] }

438
crates/render/src/lib.rs Normal file
View File

@ -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());
}
}
}

View File

@ -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.

Binary file not shown.

After

Width:  |  Height:  |  Size: 31 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 5.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 31 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 5.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

View File

@ -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

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

View File

@ -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

2
rustfmt.toml Normal file
View File

@ -0,0 +1,2 @@
max_width = 150
use_small_heuristics = "Max"

9
xtask/Cargo.toml Normal file
View File

@ -0,0 +1,9 @@
[package]
name = "xtask"
version.workspace = true
edition.workspace = true
license.workspace = true
publish = false
[dependencies]
serde_json = { workspace = true }

83
xtask/src/assets.rs Normal file
View File

@ -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"));
}
}

104
xtask/src/ico.rs Normal file
View File

@ -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());
}
}

340
xtask/src/layers.rs Normal file
View File

@ -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());
}
}

236
xtask/src/main.rs Normal file
View File

@ -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(()) }
}

99
xtask/src/parity.rs Normal file
View File

@ -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: ."));
}
}

118
xtask/src/stats.rs Normal file
View File

@ -0,0 +1,118 @@
//! `cargo xtask stats`: tests and lines of Rust per crate.
use std::path::Path;
#[derive(Default, Clone, Copy)]
struct Counts {
files: usize,
lines: usize,
tests: usize,
proptests: usize,
}
fn walk(dir: &Path, c: &mut Counts) {
let Ok(rd) = std::fs::read_dir(dir) else {
return;
};
for e in rd.flatten() {
let p = e.path();
let name = e.file_name();
let name = name.to_string_lossy();
if p.is_dir() {
// Nested fuzz workspaces and build output are not part of the crate.
if name != "target" && name != "fuzz" && !name.starts_with('.') {
walk(&p, c);
}
} else if name.ends_with(".rs")
&& let Ok(s) = std::fs::read_to_string(&p)
{
c.files += 1;
c.lines += s.lines().count();
let (t, pt) = count_tests(&s);
c.tests += t;
c.proptests += pt;
}
}
}
/// `#[test]` attributes and `proptest!` blocks (each block may hold many
/// property tests, and macro-generated tests are only counted once — use
/// `--exact` for harness-accurate numbers).
fn count_tests(src: &str) -> (usize, usize) {
let mut tests = 0;
let mut props = 0;
for line in src.lines() {
let t = line.trim_start();
if t.starts_with("//") {
continue;
}
tests += t.matches("#[test]").count();
if t.starts_with("proptest!") {
props += 1;
}
}
(tests, props)
}
fn exact_tests(pkg: &str) -> Option<usize> {
let out = crate::cargo().args(["test", "-q", "-p", pkg, "--", "--list"]).output().ok()?;
if !out.status.success() {
return None;
}
Some(String::from_utf8_lossy(&out.stdout).lines().filter(|l| l.ends_with(": test")).count())
}
pub fn run(_root: &Path, exact: bool) -> Result<(), String> {
let meta = crate::metadata()?;
let mut rows = Vec::new();
for p in meta["packages"].as_array().into_iter().flatten() {
let name = p["name"].as_str().unwrap_or("?").to_owned();
let Some(dir) = p["manifest_path"].as_str().and_then(|m| Path::new(m).parent().map(Path::to_path_buf)) else {
continue;
};
let mut c = Counts::default();
walk(&dir, &mut c);
let ex = if exact { exact_tests(&name) } else { None };
rows.push((name, c, ex));
}
rows.sort_by(|a, b| a.0.cmp(&b.0));
let exact_col = if exact { format!(" {:>8}", "harness") } else { String::new() };
println!("{:<26} {:>6} {:>8} {:>7} {:>10}{exact_col}", "crate", "files", "lines", "#[test]", "proptest!");
println!("{}", "-".repeat(62 + if exact { 9 } else { 0 }));
let mut total = Counts::default();
let mut total_exact = 0;
for (name, c, ex) in &rows {
let ex_s = match (exact, ex) {
(false, _) => String::new(),
(true, Some(n)) => {
total_exact += n;
format!(" {n:>8}")
}
(true, None) => format!(" {:>8}", "error"),
};
println!("{name:<26} {:>6} {:>8} {:>7} {:>10}{ex_s}", c.files, c.lines, c.tests, c.proptests);
total.files += c.files;
total.lines += c.lines;
total.tests += c.tests;
total.proptests += c.proptests;
}
println!("{}", "-".repeat(62 + if exact { 9 } else { 0 }));
let ex_s = if exact { format!(" {total_exact:>8}") } else { String::new() };
println!("{:<26} {:>6} {:>8} {:>7} {:>10}{ex_s}", "TOTAL", total.files, total.lines, total.tests, total.proptests);
if !exact {
println!("\n(#[test] counts source attributes; macro-generated tests count once. Use --exact for harness counts.)");
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::count_tests;
#[test]
fn counts_attributes_and_proptest_blocks() {
let src = "#[test]\nfn a() {}\n// #[test] commented\n #[test]\nfn b() {}\nproptest! {\n}\n";
assert_eq!(count_tests(src), (2, 1));
}
}

152
xtask/src/version.rs Normal file
View File

@ -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();
}
}