mirror of
https://github.com/storytold/wordcraft.git
synced 2026-11-02 06:47:11 -06:00
Merge origin/main into feat/2738-underline-menu
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -366,7 +366,9 @@ Bundled fonts, icons, images and other assets keep their own open licenses; each
|
||||
with its author, source and license in [ATTRIBUTION.md](ATTRIBUTION.md).
|
||||
|
||||
The English spelling dictionary and hyphenation come from Grady Ward's public-domain Moby
|
||||
Hyphenator II word list. Polish spelling reads the Hunspell `pl_PL` dictionary installed on the system (not bundled) and
|
||||
Hyphenator II word list. Polish spelling reads the Hunspell `pl_PL` dictionary installed on the system (not bundled), as
|
||||
do Russian, Portuguese, Czech, Italian, Croatian and Turkish spelling (how to install them:
|
||||
[docs/spelling-dictionaries.md](docs/spelling-dictionaries.md)), and
|
||||
Polish hyphenation the TeX patterns by Hanna Kołodziejska, Bogusław Jackowski and Marek Ryćko (MIT
|
||||
option). The sample documents and templates are original text written for WordCraft.
|
||||
|
||||
|
||||
+13
-3
@@ -2,7 +2,7 @@
|
||||
|
||||
**Stage: alpha** · next: beta, ~15 points (60% → 75% ready for real work) and ~150–230 h away
|
||||
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-18 · **Change:** trivial (progress log: Home › Underline drop-down, #2738; previously trivial: progress log: Turkish case mapping in AutoCorrect, #1923; previously trivial: a mouse drag on a table leaves the view where it is, #1738; previously trivial: progress log: Hebrew field results and smart quotes, #1670; previously trivial: Backspace removes a selected table, rows or columns, #1844; previously trivial: progress log: mixed Hebrew/Latin text keeps its order in Word, #1543; previously trivial: progress log: web typing in non-Latin text no longer repeats, #1480; previously trivial: progress log: font family caches bounded and math font lookups follow font changes, #1354; previously trivial: `-->` AutoCorrect replacement, #2223; previously trivial: numbered headings, #1793; previously trivial: Navigation Find exposes Results, #2096; previously trivial: progress log: Columns on selected text, #2024; previously trivial: progress log: legacy outline, shadow, emboss and imprint text effects drawn, #2534; previously trivial: progress log: Indonesian proofing, #498; previously trivial: progress log: DOCX glossary documents (building blocks); previously trivial: progress log: Read Mode keeps the keyboard and shows whole, unobscured columns, #1772); previously trivial: progress log: font list entries of tall-ascent fonts no longer cut off, #2570); previously trivial: progress log: Split Cells and Convert Text to Table dialogs follow the Word check, #2592; previously trivial: a new source's Tag is made like Word's, three letters of the author and two digits of the year with a number added when taken, and an edit keeps the Tag, #2094; previously trivial: Web Layout fills the window width, #1876; previously trivial: progress log: Different First Page headers reachable from page 1, #2826; previously trivial: Language dialog keeps inherited proofing language and no-proof choices (#2717); previously trivial: progress log: web preferences kept between visits, #2132; previously trivial: progress log: undrawable private-use list bullets show the level's default bullet, #1683); previously trivial: table style editor formats every part, #714; previously trivial: VML freeforms read and kept on save; previously trivial: progress log: consistent preferred cell widths; previously trivial: multi-paragraph field instructions no longer add blank lines; previously trivial: page-break-only list paragraphs hide their labels; previously trivial: progress log: grouped `flowChartOr` shapes draw their circle and crossing spokes; previously trivial: progress log: copies keep their formatting in other programs, #1523; previously trivial: progress log: privacy options on save, #1062; previously trivial: progress log: equation pictures in theme colours on Windows, #1002; previously trivial: symbol fonts, #776; previously trivial: the save prompt answers from the keyboard, #861; previously trivial: table column and row markers on the rulers, #700; previously trivial: table borders and the resize handle drag on the page, #217; previously trivial: formatting revisions landed, #41; previously trivial: Draw tab ink, #307, and Draw Table and Eraser, #303, landed: catalog 402/434; previously minor: readiness table with hours per audience; full number back to the additive weighted sum, 60%; mainstream and essentials numbers added; alpha gate checked; previously major: full re-measure against Word for Mac 16.113.4; restructured to the craftrules progress-docs standard) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-18 · **Change:** trivial (progress log: Home › Underline drop-down, #2738; previously trivial: Read Aloud speaks each sentence in its own language, #1843; previously trivial: hint-reliant CJK outlines; previously trivial: Ctrl+wheel zoom no longer scrolls the document, #1743; previously trivial: progress log: Turkish case mapping in AutoCorrect, #1923; previously trivial: a mouse drag on a table leaves the view where it is, #1738; previously trivial: progress log: Hebrew field results and smart quotes, #1670; previously trivial: Backspace removes a selected table, rows or columns, #1844; previously trivial: progress log: mixed Hebrew/Latin text keeps its order in Word, #1543; previously trivial: progress log: web typing in non-Latin text no longer repeats, #1480; previously trivial: progress log: font family caches bounded and math font lookups follow font changes, #1354; previously trivial: `-->` AutoCorrect replacement, #2223; previously trivial: numbered headings, #1793; previously trivial: Navigation Find exposes Results, #2096; previously trivial: progress log: Columns on selected text, #2024; previously trivial: progress log: legacy outline, shadow, emboss and imprint text effects drawn, #2534; previously trivial: progress log: Indonesian proofing, #498; previously trivial: progress log: DOCX glossary documents (building blocks); previously trivial: progress log: Read Mode keeps the keyboard and shows whole, unobscured columns, #1772); previously trivial: progress log: font list entries of tall-ascent fonts no longer cut off, #2570); previously trivial: progress log: Split Cells and Convert Text to Table dialogs follow the Word check, #2592; previously trivial: a new source's Tag is made like Word's, three letters of the author and two digits of the year with a number added when taken, and an edit keeps the Tag, #2094; previously trivial: Web Layout fills the window width, #1876; previously trivial: progress log: Different First Page headers reachable from page 1, #2826; previously trivial: Language dialog keeps inherited proofing language and no-proof choices (#2717); previously trivial: progress log: web preferences kept between visits, #2132; previously trivial: progress log: undrawable private-use list bullets show the level's default bullet, #1683; previously trivial: progress log: `w:eastAsianLayout` kept on save; previously trivial: table style editor formats every part, #714); previously trivial: VML freeforms read and kept on save; previously trivial: progress log: consistent preferred cell widths; previously trivial: multi-paragraph field instructions no longer add blank lines; previously trivial: page-break-only list paragraphs hide their labels; previously trivial: progress log: grouped `flowChartOr` shapes draw their circle and crossing spokes; previously trivial: progress log: copies keep their formatting in other programs, #1523; previously trivial: progress log: privacy options on save, #1062; previously trivial: progress log: equation pictures in theme colours on Windows, #1002; previously trivial: symbol fonts, #776; previously trivial: the save prompt answers from the keyboard, #861; previously trivial: table column and row markers on the rulers, #700; previously trivial: table borders and the resize handle drag on the page, #217; previously trivial: formatting revisions landed, #41; previously trivial: Draw tab ink, #307, and Draw Table and Eraser, #303, landed: catalog 402/434; previously minor: readiness table with hours per audience; full number back to the additive weighted sum, 60%; mainstream and essentials numbers added; alpha gate checked; previously major: full re-measure against Word for Mac 16.113.4; restructured to the craftrules progress-docs standard) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-17 · **Change:** trivial (progress log: pasted blockquotes are indented paragraphs, #1270; previously trivial: progress log: print-area strings translated in every catalog; previously trivial: progress log: comments readable in the dark theme, #2045; previously trivial: progress log: ODT sections, #1379; previously trivial: progress log: table deletes, Change Case and Unlink Fields respect Track Changes and locked content controls, #1329; previously trivial: progress log: rounded rectangle corners, #1476; previously trivial: progress log: Save As type list, #1399; previously trivial: progress log: privacy options on save, #1062; previously trivial: progress log: equation pictures in theme colours on Windows, #1002; previously trivial: symbol fonts, #776; previously trivial: the save prompt answers from the keyboard, #861; previously trivial: table column and row markers on the rulers, #700; previously trivial: table borders and the resize handle drag on the page, #217; previously trivial: formatting revisions landed, #41; previously trivial: Draw tab ink, #307, and Draw Table and Eraser, #303, landed: catalog 402/434; previously minor: readiness table with hours per audience; full number back to the additive weighted sum, 60%; mainstream and essentials numbers added; alpha gate checked; previously major: full re-measure against Word for Mac 16.113.4; restructured to the craftrules progress-docs standard) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4
|
||||
> **Printing update:** 2026-10-12 · direct desktop printing through craft-print; details below.
|
||||
|
||||
@@ -16,7 +16,7 @@ agent control. This page is the summary; the assessment is
|
||||
|
||||
| Number | Value | Kind |
|
||||
| --- | --- | --- |
|
||||
| Ribbon/menu catalog coverage | **491 / 506 (97.0%)** | measured (`cargo xtask parity` → [`docs/parity-checklist.md`](docs/parity-checklist.md)) |
|
||||
| Ribbon/menu catalog coverage | **493 / 508 (97.0%)** | measured (`cargo xtask parity` → [`docs/parity-checklist.md`](docs/parity-checklist.md)) |
|
||||
| **Feature breadth** (weighted, incl. dialog options, styles, languages beyond the ribbon) | **~80%** | estimated |
|
||||
| **Ready for real work** (full target) | **~60%** (55–63%) | estimated, additive weighted sum over the dimensions |
|
||||
| **Mainstream practitioner** | **~55%** | estimated: weekly-work depth × discounts for interaction (×0.93), stability (×0.90), file exchange (×0.90) |
|
||||
@@ -137,8 +137,13 @@ Ranked, to beta (detail and remaining milestones in [docs/roadmap.md](docs/roadm
|
||||
- 2026-10-15: Find opens Navigation on Results; editing the query restores Results after manual Headings/Pages navigation. Typed and selected-text searches retain document contents (#2096).
|
||||
|
||||
| Date | What landed |
|
||||
| 2026-10-15 | AutoCorrect follows Turkish and Azerbaijani case mapping: `istanbul` → `İstanbul`, `ılık` → `Ilık`, `İNgilizce` → `İngilizce` with no combining dot (#1923) |
|
||||
| 2026-10-16 | The Spike, as in Word: Ctrl+F3 (⌘F3 on macOS) cuts the selection and adds it to the end of the Spike, each piece followed by a paragraph mark; Ctrl+Shift+F3 (⌘⇧F3) inserts everything collected, in order and with its formatting, and empties the Spike. The clipboard is left alone; each is one undo step. The Spike is the AutoText entry "Spike", so Insert › Quick Parts inserts it without emptying it. It lasts for the session (Word keeps it in Normal.dotm) |
|
||||
| 2026-10-17 | Home › Underline's drop-down shows each underline as a sample of its line, adds Dot-dot-dash, None and More Underlines…, and an Underline Color submenu backed by the new `format.underlineColor` command (catalog 490/505) (#2738) |
|
||||
| 2026-10-16 | DOCX two lines in one and horizontal in vertical (`w:eastAsianLayout`: id, combine, brackets, vert, vertCompress) are read and kept on save, in runs and character styles, instead of being dropped. Layout doesn't apply them yet. |
|
||||
| 2026-10-16 | Read Aloud speaks Russian, Ukrainian, Greek, Arabic, Hebrew, CJK and Thai text with a voice for its language instead of spelling out letters (#1843): the sentence's language comes from its run's tag (`w:bidi` for right-to-left text) or, when that disagrees with the script, from its letters, even when English letters outnumber them; Windows reads the text as UTF-8 and picks an installed voice of that language, macOS `say -v` and Linux `espeak -v` get the same language. The user's own voice stays whenever it already speaks the language (English documents read exactly as before), and a voice espeak lacks falls back to the default voice instead of silence. Not run on Windows or macOS here. |
|
||||
| 2026-10-16 | Hint-reliant CJK fonts execute TrueType contour assembly; Windows DFKai-SB no longer loses strokes. Synthetic local-font regression; no font binaries bundled. |
|
||||
| 2026-10-16 | Ctrl/⌘+wheel only zooms: a coast or wheel notch still gliding no longer scrolls the page on top of the zoom, which keeps the page point under the pointer (#1743). |
|
||||
| 2026-10-15 | AutoCorrect follows Turkish and Azerbaijani case mapping: `istanbul` → `İstanbul`, `ılık` → `Ilık`, `İNgilizce` → `İngilizce` with no combining dot (#1923) |
|
||||
| 2026-10-16 | Releasing a table drag with the mouse (column or row border, move handle, selection areas) leaves the view where it is instead of scrolling back to the caret (#1738) |
|
||||
| 2026-10-16 | Hebrew in fields and quotes (#1670): a STYLEREF (or any field) showing right-to-left text such as "פרק א" reads right to left in headers and the body instead of reversed; smart quotes in Hebrew pinned to Word's logical-order result (“ before, ” after, ” in acronyms like צה"ל) |
|
||||
| 2026-10-15 | Backspace with a whole table selected removes it, as in Word, and selected whole rows or columns go the same way; Delete clears them and keeps the table. A selection that covers a table and the paragraph before or after it removes the table, also when it starts inside the table or the table is nested (#1844) |
|
||||
@@ -444,6 +449,8 @@ Ranked, to beta (detail and remaining milestones in [docs/roadmap.md](docs/roadm
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-10-17 | trivial | Progress log: Home › Underline drop-down (#2738) |
|
||||
| 2026-10-16 | trivial | Progress log: the Spike |
|
||||
| 2026-10-16 | trivial | Progress log: `w:eastAsianLayout` kept on save |
|
||||
| 2026-10-15 | trivial | Progress log: undrawable private-use list bullets show the level's default bullet (#1683) |
|
||||
| 2026-10-15 | trivial | Progress log: web preferences kept between visits (#2132) |
|
||||
| 2026-10-15 | trivial | Progress log: Recently Used Fonts in the font box (#2068) |
|
||||
@@ -453,6 +460,7 @@ Ranked, to beta (detail and remaining milestones in [docs/roadmap.md](docs/roadm
|
||||
| 2026-10-17 | trivial | Progress log: DOCX glossary documents (building blocks) |
|
||||
| 2026-10-15 | trivial | Progress log: move options in Track Changes Options (gap #5) |
|
||||
| 2026-10-15 | trivial | Progress log: image size limits checked before allocation (#1321) |
|
||||
| 2026-10-17 | trivial | Progress log: Read Aloud speaks each sentence in its own language, #1843 |
|
||||
| 2026-10-17 | trivial | Language dialog keeps inherited proofing language and no-proof choices (#2717); readiness estimates unchanged |
|
||||
| 2026-10-17 | trivial | Explicit DrawingML preset/custom outline dashes survive import/save and paint in raster/PDF, scaled to the effective stroke width even for zero-width paths. Synthetic controls; theme-inherited dashes and line caps remain unverified. |
|
||||
| 2026-10-15 | trivial | `wordcraft-cli convert --update-fields` explicitly refreshes unlocked fields before conversion; ordinary conversion keeps cached results. Field refresh can change pagination |
|
||||
@@ -473,6 +481,8 @@ Ranked, to beta (detail and remaining milestones in [docs/roadmap.md](docs/roadm
|
||||
| 2026-10-15 | trivial | Progress log: web typing in non-Latin text no longer repeats (#1480) |
|
||||
| 2026-10-15 | trivial | Progress log: Backspace removes a selected table, rows or columns (#1844) |
|
||||
| 2026-10-16 | trivial | Progress log: Hebrew field results and smart quotes (#1670) |
|
||||
| 2026-10-16 | trivial | Progress log: Ctrl+wheel zoom no longer scrolls the document (#1743) |
|
||||
| 2026-10-16 | trivial | Hint-reliant CJK fonts execute TrueType contour assembly; Windows DFKai-SB no longer loses strokes. Synthetic local-font regression; no font binaries bundled. |
|
||||
| 2026-10-14 | trivial | Progress log: row table border exceptions |
|
||||
| 2026-10-12 | minor | Czech proofing review: short-word splits rank after deletion, NOSUGGEST excludes suggestions while accepting typed forms, affix comments apply only to headers, ratios keep spaces and repeated-word messages use Czech quotes; synthetic engine end-to-end and latency checks |
|
||||
| 2026-10-12 | minor | Czech spelling from the installed Hunspell dictionary, runtime-table suggestions and generic checks; Czech interface writes cs-CZ documents; no Czech hyphenation patterns bundled; estimates unchanged |
|
||||
|
||||
@@ -65,6 +65,51 @@ impl Fragment {
|
||||
pub fn clipboard_text(&self) -> String {
|
||||
self.to_document().shown_text(crate::StoryRef::Body)
|
||||
}
|
||||
/// Add `piece` at the end, followed by a paragraph mark unless it already ends with one: how
|
||||
/// Word's Spike collects what it cuts. The piece's text box stories get ids after this
|
||||
/// fragment's, so pieces copied from different documents (or after an id was freed and reused)
|
||||
/// never share one.
|
||||
pub fn append_piece(&mut self, mut piece: Fragment) {
|
||||
let Some(last) = piece.blocks.last() else { return };
|
||||
let ends_with_mark = piece.blocks.len() >= 2 && matches!(last, Block::Para(p) if p.is_empty());
|
||||
if !ends_with_mark {
|
||||
let mut mark = Paragraph::new();
|
||||
if let Block::Para(p) = last {
|
||||
mark.props = p.props.clone();
|
||||
}
|
||||
piece.blocks.push(Block::Para(mark));
|
||||
}
|
||||
let next = self.parts.keys().next_back().map_or(1, |k| k.saturating_add(1));
|
||||
let ids: BTreeMap<u32, u32> = piece.parts.keys().zip(next..=u32::MAX).map(|(old, new)| (*old, new)).collect();
|
||||
let renumber = |b: &mut Block| {
|
||||
each_para_mut(b, 0, &mut |p| {
|
||||
let mut changed = false;
|
||||
for o in p.objects.iter_mut() {
|
||||
for slot in o.text_box_slots() {
|
||||
if let Some(new) = slot.and_then(|old| ids.get(&old)) {
|
||||
*slot = Some(*new);
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
if changed {
|
||||
p.touch();
|
||||
}
|
||||
})
|
||||
};
|
||||
piece.blocks.iter_mut().for_each(renumber);
|
||||
for (old, mut part) in std::mem::take(&mut piece.parts) {
|
||||
part.blocks.iter_mut().map(Arc::make_mut).for_each(renumber);
|
||||
if let Some(new) = ids.get(&old) {
|
||||
self.parts.insert(*new, part);
|
||||
}
|
||||
}
|
||||
// The empty paragraph after the last mark is where the next piece starts.
|
||||
if self.blocks.len() >= 2 && matches!(self.blocks.last(), Some(Block::Para(p)) if p.is_empty()) {
|
||||
self.blocks.pop();
|
||||
}
|
||||
self.blocks.append(&mut piece.blocks);
|
||||
}
|
||||
pub fn from_text(s: &str) -> Fragment {
|
||||
let lines: Vec<&str> = s.split('\n').collect();
|
||||
Fragment {
|
||||
@@ -1021,6 +1066,42 @@ mod tests {
|
||||
assert_eq!(Path(vec![1, 0, 1, 0]).cell(), Some((Path::top(1), 0, 1)));
|
||||
}
|
||||
|
||||
fn para(text: &str) -> Block {
|
||||
Block::Para(Paragraph::with_text(text, CharProps::default()))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn appended_pieces_are_each_followed_by_one_paragraph_mark() {
|
||||
let mut spike = Fragment::default();
|
||||
spike.append_piece(Fragment { blocks: vec![para("a")], ..Default::default() });
|
||||
spike.append_piece(Fragment { blocks: vec![para("b")], ..Default::default() });
|
||||
assert_eq!(spike.plain_text(), "a\nb\n");
|
||||
// A piece that already ends with its paragraph mark gets no second one.
|
||||
let mut spike = Fragment::default();
|
||||
spike.append_piece(Fragment { blocks: vec![para("a"), para("")], ..Default::default() });
|
||||
assert_eq!(spike.plain_text(), "a\n");
|
||||
spike.append_piece(Fragment { blocks: vec![para("b"), para("c")], ..Default::default() });
|
||||
assert_eq!(spike.plain_text(), "a\nb\nc\n");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn appended_pieces_keep_their_own_text_boxes() {
|
||||
// Two documents give their first text box the same id.
|
||||
let mut one = doc(&["x"]);
|
||||
let id1 = text_box(&mut one, &Pos::body(0, 1), "One");
|
||||
let mut two = doc(&["y"]);
|
||||
let id2 = text_box(&mut two, &Pos::body(0, 1), "Two");
|
||||
assert_eq!(id1, id2);
|
||||
let mut spike = Fragment::default();
|
||||
spike.append_piece(one.copy_range(&Pos::body(0, 0), &Pos::body(0, 1 + 3)));
|
||||
spike.append_piece(two.copy_range(&Pos::body(0, 0), &Pos::body(0, 1 + 3)));
|
||||
let mut dst = doc(&[""]);
|
||||
dst.insert_fragment(&Pos::body(0, 0), &spike).unwrap();
|
||||
let texts: Vec<String> =
|
||||
[0, 1].iter().flat_map(|i| boxes_in(&dst, &Path::top(*i))).map(|b| dst.plain_text(StoryRef::Part(b.unwrap()))).collect();
|
||||
assert_eq!(texts, ["One", "Two"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bad_positions_error() {
|
||||
let mut d = doc(&["é"]);
|
||||
|
||||
@@ -316,6 +316,60 @@ pub enum NoteKind {
|
||||
Endnote,
|
||||
}
|
||||
|
||||
/// Where a positional tab ([`InlineObject::PositionalTab`]) lands on its line, and how the text
|
||||
/// after it lines up there (ECMA-376 `ST_PTabAlignment`).
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum PTabAlign {
|
||||
#[default]
|
||||
Left,
|
||||
Center,
|
||||
Right,
|
||||
}
|
||||
|
||||
impl PTabAlign {
|
||||
pub const ALL: [PTabAlign; 3] = [PTabAlign::Left, PTabAlign::Center, PTabAlign::Right];
|
||||
|
||||
/// The OOXML name (`left`, `center`, `right`).
|
||||
pub fn ooxml(self) -> &'static str {
|
||||
match self {
|
||||
PTabAlign::Left => "left",
|
||||
PTabAlign::Center => "center",
|
||||
PTabAlign::Right => "right",
|
||||
}
|
||||
}
|
||||
|
||||
pub fn from_ooxml(v: &str) -> Option<PTabAlign> {
|
||||
PTabAlign::ALL.into_iter().find(|a| a.ooxml() == v)
|
||||
}
|
||||
}
|
||||
|
||||
/// What a positional tab's place is measured against (ECMA-376 `ST_PTabRelativeTo`): the text
|
||||
/// margins, or the paragraph's indents.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum PTabBase {
|
||||
#[default]
|
||||
Margin,
|
||||
Indent,
|
||||
}
|
||||
|
||||
impl PTabBase {
|
||||
pub const ALL: [PTabBase; 2] = [PTabBase::Margin, PTabBase::Indent];
|
||||
|
||||
/// The OOXML name (`margin`, `indent`).
|
||||
pub fn ooxml(self) -> &'static str {
|
||||
match self {
|
||||
PTabBase::Margin => "margin",
|
||||
PTabBase::Indent => "indent",
|
||||
}
|
||||
}
|
||||
|
||||
pub fn from_ooxml(v: &str) -> Option<PTabBase> {
|
||||
PTabBase::ALL.into_iter().find(|a| a.ooxml() == v)
|
||||
}
|
||||
}
|
||||
|
||||
/// How ruby (phonetic guide) text lines up over its base text (ECMA-376 `ST_RubyAlign`).
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
@@ -549,6 +603,17 @@ pub enum InlineObject {
|
||||
#[serde(default, skip_serializing_if = "CharProps::is_empty")]
|
||||
props: Box<CharProps>,
|
||||
},
|
||||
/// A positional tab (Word's alignment tab, `w:ptab`): it goes to the left edge, centre or
|
||||
/// right edge of the margins or of the indents, whatever the paragraph's tab stops, and the
|
||||
/// text after it lines up there. Text already past that place sends it to the next line.
|
||||
PositionalTab {
|
||||
#[serde(default)]
|
||||
align: PTabAlign,
|
||||
#[serde(default)]
|
||||
relative_to: PTabBase,
|
||||
#[serde(default)]
|
||||
leader: crate::props::TabLeader,
|
||||
},
|
||||
/// Something we don't model, kept for round-trip (raw XML of the source format).
|
||||
Opaque {
|
||||
format: String,
|
||||
@@ -756,6 +821,7 @@ impl InlineObject {
|
||||
InlineObject::Field { result, .. } => result,
|
||||
InlineObject::Equation { linear, .. } => linear,
|
||||
InlineObject::Ruby { base, .. } => base,
|
||||
InlineObject::PositionalTab { .. } => "\t",
|
||||
InlineObject::Opaque { text, .. } => text,
|
||||
_ => "",
|
||||
}
|
||||
|
||||
+60
-2
@@ -372,6 +372,10 @@ pub struct CharProps {
|
||||
/// BCP 47 language of complex-script text (`w:lang/@w:bidi`, e.g. `fa-IR`).
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub lang_bidi: Option<String>,
|
||||
/// East Asian typography: two lines in one and horizontal in vertical (`w:eastAsianLayout`).
|
||||
/// Kept for round trip; layout doesn't apply it yet.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub east_asian_layout: Option<EastAsianLayout>,
|
||||
/// Hyperlink target: a URL, or `#bookmark` for an internal link.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub link: Option<String>,
|
||||
@@ -386,6 +390,60 @@ pub struct CharProps {
|
||||
pub fmt_change: Option<Box<PropChange<CharProps>>>,
|
||||
}
|
||||
|
||||
/// East Asian typography settings of a run (ISO/IEC 29500-1 §17.3.2.10, `w:eastAsianLayout`):
|
||||
/// *two lines in one* sets the run's text on two sub-lines inside one line (warichu), *horizontal
|
||||
/// in vertical* turns it 90° to read across a vertical line (tate-chu-yoko). Each attribute is
|
||||
/// `None` when the file doesn't say.
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Default, Serialize, Deserialize)]
|
||||
#[serde(default, rename_all = "camelCase")]
|
||||
pub struct EastAsianLayout {
|
||||
/// East Asian typography run id (`w:id`).
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub id: Option<i32>,
|
||||
/// Two lines in one (`w:combine`).
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub combine: Option<bool>,
|
||||
/// Brackets around two lines in one (`w:combineBrackets`).
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub combine_brackets: Option<CombineBrackets>,
|
||||
/// Horizontal in vertical (`w:vert`).
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub vert: Option<bool>,
|
||||
/// Compress horizontal-in-vertical text to the line height (`w:vertCompress`).
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub vert_compress: Option<bool>,
|
||||
}
|
||||
|
||||
/// Brackets around two lines in one (`ST_CombineBrackets`).
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum CombineBrackets {
|
||||
None,
|
||||
Round,
|
||||
Square,
|
||||
Angle,
|
||||
Curly,
|
||||
}
|
||||
|
||||
impl CombineBrackets {
|
||||
pub const ALL: [CombineBrackets; 5] =
|
||||
[CombineBrackets::None, CombineBrackets::Round, CombineBrackets::Square, CombineBrackets::Angle, CombineBrackets::Curly];
|
||||
/// OOXML `w:combineBrackets` value.
|
||||
pub fn ooxml(self) -> &'static str {
|
||||
match self {
|
||||
CombineBrackets::None => "none",
|
||||
CombineBrackets::Round => "round",
|
||||
CombineBrackets::Square => "square",
|
||||
CombineBrackets::Angle => "angle",
|
||||
CombineBrackets::Curly => "curly",
|
||||
}
|
||||
}
|
||||
/// The value for `s`, `None` for one the schema doesn't have.
|
||||
pub fn from_ooxml(s: &str) -> Option<CombineBrackets> {
|
||||
CombineBrackets::ALL.into_iter().find(|b| b.ooxml() == s)
|
||||
}
|
||||
}
|
||||
|
||||
/// A tracked formatting change (ECMA-376 §17.13.5: `w:rPrChange`, `w:pPrChange`,
|
||||
/// `w:tblPrChange`, `w:trPrChange`, `w:tcPrChange`, `w:sectPrChange`): the properties as they
|
||||
/// were before the change, and the revision (author, date) that made it.
|
||||
@@ -442,7 +500,7 @@ impl CharProps {
|
||||
pub fn minus(mut self, base: &CharProps) -> CharProps {
|
||||
drop_same_fields!(self, base; style, font, size, bold, italic, underline, underline_color, strike, double_strike, color, highlight,
|
||||
shading, border, vert_align, caps, small_caps, hidden, spacing, scale, position, kern, outline, shadow, emboss, engrave, lang, no_proof, rtl,
|
||||
cs, font_cs, size_cs, bold_cs, italic_cs, lang_bidi, link, ins, del);
|
||||
cs, font_cs, size_cs, bold_cs, italic_cs, lang_bidi, east_asian_layout, link, ins, del);
|
||||
self
|
||||
}
|
||||
|
||||
@@ -453,7 +511,7 @@ impl CharProps {
|
||||
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, border, vert_align, caps, small_caps, hidden, spacing, scale, position, kern, outline, shadow, emboss, engrave, lang, no_proof, rtl,
|
||||
cs, font_cs, font_ea, size_cs, bold_cs, italic_cs, lang_bidi, link, ins, del, fmt_change);
|
||||
cs, font_cs, font_ea, size_cs, bold_cs, italic_cs, lang_bidi, east_asian_layout, link, ins, del, fmt_change);
|
||||
if let Some(p) = &patch.text_effects {
|
||||
self.text_effects.get_or_insert_with(Default::default).overlay(p);
|
||||
}
|
||||
|
||||
@@ -68,13 +68,14 @@ impl SmartArtLayout {
|
||||
}
|
||||
}
|
||||
|
||||
/// The layout an id names (any ASCII case; `list` is the basic list).
|
||||
/// The layout an id names (any ASCII case, ignoring hyphens, underscores and spaces;
|
||||
/// `list` is the basic list; `basic process` or `process` is process, etc.).
|
||||
pub fn from_id(id: &str) -> Option<SmartArtLayout> {
|
||||
let id = id.trim();
|
||||
if id.eq_ignore_ascii_case("list") {
|
||||
let norm = id.chars().filter(|c| !c.is_whitespace() && *c != '-' && *c != '_').collect::<String>();
|
||||
if norm.eq_ignore_ascii_case("list") {
|
||||
return Some(SmartArtLayout::BasicList);
|
||||
}
|
||||
SmartArtLayout::ALL.into_iter().find(|l| l.id().eq_ignore_ascii_case(id))
|
||||
SmartArtLayout::ALL.into_iter().find(|l| l.id().eq_ignore_ascii_case(&norm) || l.label().replace(' ', "").eq_ignore_ascii_case(&norm))
|
||||
}
|
||||
|
||||
/// The name shown for it.
|
||||
@@ -159,7 +160,8 @@ impl SmartArtColors {
|
||||
}
|
||||
|
||||
pub fn from_id(id: &str) -> Option<SmartArtColors> {
|
||||
SmartArtColors::ALL.into_iter().find(|c| c.id().eq_ignore_ascii_case(id.trim()))
|
||||
let norm = id.chars().filter(|c| !c.is_whitespace() && *c != '-' && *c != '_').collect::<String>();
|
||||
SmartArtColors::ALL.into_iter().find(|c| c.id().eq_ignore_ascii_case(&norm) || c.label().replace(' ', "").eq_ignore_ascii_case(&norm))
|
||||
}
|
||||
|
||||
/// The name shown for it.
|
||||
@@ -287,8 +289,9 @@ impl SmartArtSpec {
|
||||
|
||||
/// `s` as item text: control characters as spaces, ends trimmed, at most [`MAX_TEXT`] characters.
|
||||
pub fn clean_text(s: &str) -> String {
|
||||
let s: String = s.chars().take(MAX_TEXT * 4).map(|c| if c.is_control() { ' ' } else { c }).collect();
|
||||
s.trim().chars().take(MAX_TEXT).collect::<String>().trim_end().to_string()
|
||||
let trimmed = s.trim_start_matches(|c: char| c.is_whitespace() || c.is_control());
|
||||
let s: String = trimmed.chars().take(MAX_TEXT).map(|c| if c.is_control() { ' ' } else { c }).collect();
|
||||
s.trim_end().to_string()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -319,6 +322,13 @@ mod tests {
|
||||
assert_eq!(s.descendants(5), 6..6);
|
||||
assert_eq!(s.descendants(99), 0..0);
|
||||
assert_eq!(SmartArtLayout::from_id("VENN"), Some(SmartArtLayout::Venn));
|
||||
assert_eq!(SmartArtLayout::from_id("basic-list"), Some(SmartArtLayout::BasicList));
|
||||
assert_eq!(SmartArtLayout::from_id("Basic Process"), Some(SmartArtLayout::Process));
|
||||
assert_eq!(SmartArtLayout::from_id("basic_cycle"), Some(SmartArtLayout::Cycle));
|
||||
assert_eq!(SmartArtLayout::from_id("list"), Some(SmartArtLayout::BasicList));
|
||||
assert_eq!(SmartArtColors::from_id("colorful"), Some(SmartArtColors::Colorful));
|
||||
assert_eq!(SmartArtColors::from_id("Accent 2"), Some(SmartArtColors::Accent2));
|
||||
assert_eq!(SmartArtColors::from_id("accent-4"), Some(SmartArtColors::Accent4));
|
||||
assert_eq!(clean_text(&format!("{}Valid Text", " ".repeat(1500))), "Valid Text");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,8 +4,9 @@ use std::collections::HashMap;
|
||||
|
||||
use wordcraft_doc::para::cap_alt;
|
||||
use wordcraft_doc::props::{
|
||||
Align, Border, BorderStyle, Borders, CellProps, CharProps, HeightRule, Highlight, LineSpacing, NumRef, ParaProps, Rgb, RowProps, TabAlign,
|
||||
TabLeader, TabStop, TableFloat, TableLook, TableProps, TextColor, TextDirection, Underline, VAlign, VMerge, VertAlign,
|
||||
Align, Border, BorderStyle, Borders, CellProps, CharProps, CombineBrackets, EastAsianLayout, HeightRule, Highlight, LineSpacing, NumRef,
|
||||
ParaProps, Rgb, RowProps, TabAlign, TabLeader, TabStop, TableFloat, TableLook, TableProps, TextColor, TextDirection, Underline, VAlign, VMerge,
|
||||
VertAlign,
|
||||
};
|
||||
use wordcraft_doc::section::{
|
||||
Columns, DocGrid, DocGridType, LineNumberRestart, LineNumbering, MAX_CHAR_SPACE, MAX_GRID_PITCH, NotePos, NoteProps, NoteRestart, NumFormat,
|
||||
@@ -224,6 +225,10 @@ impl PropCtx {
|
||||
_ => VertAlign::Baseline,
|
||||
})
|
||||
}
|
||||
"w:eastAsianLayout" => {
|
||||
let l = east_asian_layout(k);
|
||||
c.east_asian_layout = (l != EastAsianLayout::default()).then_some(l);
|
||||
}
|
||||
"w:lang" => {
|
||||
c.lang = k.attr("w:val").filter(|v| !v.is_empty() && v.len() < 64).map(str::to_string);
|
||||
c.lang_bidi = k.attr("w:bidi").filter(|v| !v.is_empty() && v.len() < 64).map(str::to_string);
|
||||
@@ -372,14 +377,18 @@ fn tab_stop(t: &El) -> Option<TabStop> {
|
||||
"clear" => TabAlign::Clear,
|
||||
_ => TabAlign::Left,
|
||||
};
|
||||
let leader = match t.attr("w:leader").unwrap_or("none") {
|
||||
Some(TabStop { pos, align, leader: tab_leader(t.attr("w:leader").unwrap_or("none")) })
|
||||
}
|
||||
|
||||
/// A tab stop's or positional tab's `w:leader` (`ST_TabTlc`, `ST_PTabLeader`).
|
||||
pub(crate) fn tab_leader(v: &str) -> TabLeader {
|
||||
match v {
|
||||
"dot" => TabLeader::Dot,
|
||||
"hyphen" => TabLeader::Hyphen,
|
||||
"underscore" | "heavy" => TabLeader::Underscore,
|
||||
"middleDot" => TabLeader::MiddleDot,
|
||||
_ => TabLeader::None,
|
||||
};
|
||||
Some(TabStop { pos, align, leader })
|
||||
}
|
||||
}
|
||||
|
||||
/// `w:shd/@w:fill` (a solid fill colour; `auto` = none), ignoring theme fills ([`PropCtx::shd_fill`]).
|
||||
@@ -742,3 +751,22 @@ fn table_float(e: &El) -> TableFloat {
|
||||
overlap: true,
|
||||
}
|
||||
}
|
||||
|
||||
/// `w:eastAsianLayout` (ISO/IEC 29500-1 §17.3.2.10). Attributes the schema doesn't allow (an id
|
||||
/// out of `xsd:int`, an unknown bracket, an on/off that isn't one) are left unset.
|
||||
fn east_asian_layout(e: &El) -> EastAsianLayout {
|
||||
let flag = |name: &str| {
|
||||
e.attr(name).and_then(|v| match v.trim() {
|
||||
"1" | "true" | "on" => Some(true),
|
||||
"0" | "false" | "off" => Some(false),
|
||||
_ => None,
|
||||
})
|
||||
};
|
||||
EastAsianLayout {
|
||||
id: e.attr("w:id").and_then(int).and_then(|v| i32::try_from(v).ok()),
|
||||
combine: flag("w:combine"),
|
||||
combine_brackets: e.attr("w:combineBrackets").and_then(|v| CombineBrackets::from_ooxml(v.trim())),
|
||||
vert: flag("w:vert"),
|
||||
vert_compress: flag("w:vertCompress"),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,7 +6,7 @@ use wordcraft_doc::connector::{ConnEnd, ShapeExtra};
|
||||
use wordcraft_doc::control::{ContentControl, ControlKind, ControlWrap};
|
||||
use wordcraft_doc::effects::{Glow, Shadow, ShapeEffects};
|
||||
use wordcraft_doc::graphic::{Embedded, Graphic, GraphicItem, GraphicKind};
|
||||
use wordcraft_doc::para::{AltText, Anchor, Float, FloatAlign, NoteKind, ShapeKind, Wrap, WrapText, cap_alt};
|
||||
use wordcraft_doc::para::{AltText, Anchor, Float, FloatAlign, NoteKind, PTabAlign, PTabBase, ShapeKind, Wrap, WrapText, cap_alt};
|
||||
use wordcraft_doc::props::{CharProps, NumChange, PropChange, Rgb, TextColor};
|
||||
use wordcraft_doc::table::{Cell, MAX_COLS, MAX_ROWS, Row, Table};
|
||||
use wordcraft_doc::{Block, Blocks, InlineObject, Paragraph, PartKind, RevisionKind, Run, para_block};
|
||||
@@ -628,7 +628,15 @@ impl Reader<'_> {
|
||||
f.instr.push_str(&k.text());
|
||||
}
|
||||
}
|
||||
"w:tab" | "w:ptab" => self.emit_text(sc, pb, "\t", props),
|
||||
"w:tab" => self.emit_text(sc, pb, "\t", props),
|
||||
// Alignment and base are required (ECMA-376 §17.3.3.23); without them it stays a plain tab.
|
||||
"w:ptab" => match (k.attr("w:alignment").and_then(PTabAlign::from_ooxml), k.attr("w:relativeTo").and_then(PTabBase::from_ooxml)) {
|
||||
(Some(align), Some(relative_to)) => {
|
||||
let leader = super::props::tab_leader(k.attr("w:leader").unwrap_or("none"));
|
||||
self.emit_obj(sc, pb, InlineObject::PositionalTab { align, relative_to, leader }, props)
|
||||
}
|
||||
_ => self.emit_text(sc, pb, "\t", props),
|
||||
},
|
||||
"w:br" => {
|
||||
if sc.fields.iter().any(|f| f.phase == Phase::Instr) {
|
||||
return;
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
//! Property writers, in ECMA-376 schema element order.
|
||||
|
||||
use wordcraft_doc::props::{
|
||||
Align, Border, BorderStyle, Borders, CellProps, CharProps, HeightRule, Highlight, LineSpacing, ParaProps, RowProps, TabAlign, TabLeader,
|
||||
TableFloat, TableLook, TableProps, TextColor, VAlign, VMerge, VertAlign,
|
||||
Align, Border, BorderStyle, Borders, CellProps, CharProps, EastAsianLayout, HeightRule, Highlight, LineSpacing, ParaProps, RowProps, TabAlign,
|
||||
TabLeader, TableFloat, TableLook, TableProps, TextColor, VAlign, VMerge, VertAlign,
|
||||
};
|
||||
|
||||
use crate::units::{n, twips};
|
||||
@@ -111,11 +111,38 @@ pub fn rpr_inner(w: &mut W, c: &CharProps) {
|
||||
(None, Some(b)) => w.empty("w:lang", &[("w:bidi", b)]),
|
||||
(None, None) => {}
|
||||
}
|
||||
// After w:lang (CT_RPr's sequence).
|
||||
if let Some(l) = &c.east_asian_layout {
|
||||
east_asian_layout(w, l);
|
||||
}
|
||||
if let Some(fx) = &c.text_effects {
|
||||
super::wordart::rpr_effects(w, fx);
|
||||
}
|
||||
}
|
||||
|
||||
/// `w:eastAsianLayout` with the attributes that are set (ISO/IEC 29500-1 §17.3.2.10).
|
||||
fn east_asian_layout(w: &mut W, l: &EastAsianLayout) {
|
||||
let on = |v: bool| if v { "1" } else { "0" };
|
||||
let id = l.id.map(|v| v.to_string());
|
||||
let mut a: Vec<(&str, &str)> = Vec::new();
|
||||
if let Some(id) = &id {
|
||||
a.push(("w:id", id));
|
||||
}
|
||||
if let Some(v) = l.combine {
|
||||
a.push(("w:combine", on(v)));
|
||||
}
|
||||
if let Some(b) = l.combine_brackets {
|
||||
a.push(("w:combineBrackets", b.ooxml()));
|
||||
}
|
||||
if let Some(v) = l.vert {
|
||||
a.push(("w:vert", on(v)));
|
||||
}
|
||||
if let Some(v) = l.vert_compress {
|
||||
a.push(("w:vertCompress", on(v)));
|
||||
}
|
||||
w.empty("w:eastAsianLayout", &a);
|
||||
}
|
||||
|
||||
/// Does `c` produce any `w:rPr` content (besides a tracked change)?
|
||||
pub fn has_rpr(c: &CharProps) -> bool {
|
||||
!c.formatting().is_empty()
|
||||
@@ -157,6 +184,17 @@ pub fn align_val(a: Align) -> &'static str {
|
||||
}
|
||||
}
|
||||
|
||||
/// A tab stop's or positional tab's `w:leader` value.
|
||||
pub fn tab_leader(l: TabLeader) -> &'static str {
|
||||
match l {
|
||||
TabLeader::None => "none",
|
||||
TabLeader::Dot => "dot",
|
||||
TabLeader::Hyphen => "hyphen",
|
||||
TabLeader::Underscore => "underscore",
|
||||
TabLeader::MiddleDot => "middleDot",
|
||||
}
|
||||
}
|
||||
|
||||
fn border_el(w: &mut W, name: &str, b: &Border) {
|
||||
let sz = n(round(b.width.clamp(0.0, 12.0) * 8.0));
|
||||
let space = n(round(b.space.clamp(0.0, 31.0)));
|
||||
@@ -273,15 +311,7 @@ pub fn ppr_inner(w: &mut W, p: &ParaProps, framed: bool, num_change: Option<&Cha
|
||||
let pos = twips(t.pos);
|
||||
match t.leader {
|
||||
TabLeader::None => w.empty("w:tab", &[("w:val", val), ("w:pos", &pos)]),
|
||||
l => {
|
||||
let lv = match l {
|
||||
TabLeader::Dot => "dot",
|
||||
TabLeader::Hyphen => "hyphen",
|
||||
TabLeader::Underscore => "underscore",
|
||||
_ => "middleDot",
|
||||
};
|
||||
w.empty("w:tab", &[("w:val", val), ("w:leader", lv), ("w:pos", &pos)]);
|
||||
}
|
||||
l => w.empty("w:tab", &[("w:val", val), ("w:leader", tab_leader(l)), ("w:pos", &pos)]),
|
||||
}
|
||||
}
|
||||
w.close("w:tabs");
|
||||
|
||||
@@ -794,6 +794,17 @@ impl Writer<'_> {
|
||||
w.close("w:r");
|
||||
self.rev_close(w, props);
|
||||
}
|
||||
InlineObject::PositionalTab { align, relative_to, leader } => {
|
||||
self.rev_open(w, props);
|
||||
w.open("w:r", &[]);
|
||||
self.rpr(w, props);
|
||||
w.empty(
|
||||
"w:ptab",
|
||||
&[("w:relativeTo", relative_to.ooxml()), ("w:alignment", align.ooxml()), ("w:leader", super::props::tab_leader(*leader))],
|
||||
);
|
||||
w.close("w:r");
|
||||
self.rev_close(w, props);
|
||||
}
|
||||
InlineObject::Opaque { format, xml, text } => {
|
||||
if format == "docx" && crate::xml::parse(xml.as_bytes()).is_ok() {
|
||||
w.raw(xml);
|
||||
|
||||
@@ -0,0 +1,121 @@
|
||||
//! East Asian typography settings of a run (`w:eastAsianLayout`, ISO/IEC 29500-1 §17.3.2.10):
|
||||
//! two lines in one and horizontal in vertical are read and saved again. Fixtures are
|
||||
//! hand-written from the spec.
|
||||
|
||||
use std::io::{Read, Write};
|
||||
|
||||
use wordcraft_doc::props::{CombineBrackets, EastAsianLayout};
|
||||
|
||||
fn docx(body: &str, styles: Option<&str>) -> Vec<u8> {
|
||||
let root = r#"<?xml version="1.0" encoding="UTF-8"?><Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument" Target="word/document.xml"/></Relationships>"#;
|
||||
let doc_rels = r#"<?xml version="1.0" encoding="UTF-8"?><Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/styles" Target="styles.xml"/></Relationships>"#;
|
||||
let doc = format!(
|
||||
r#"<?xml version="1.0" encoding="UTF-8" standalone="yes"?><w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"><w:body>{body}</w:body></w:document>"#
|
||||
);
|
||||
let mut parts = vec![("_rels/.rels", root.to_string()), ("word/document.xml", doc)];
|
||||
if let Some(s) = styles {
|
||||
parts.push(("word/_rels/document.xml.rels", doc_rels.to_string()));
|
||||
parts.push((
|
||||
"word/styles.xml",
|
||||
format!(r#"<?xml version="1.0" encoding="UTF-8" standalone="yes"?><w:styles xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main">{s}</w:styles>"#),
|
||||
));
|
||||
}
|
||||
let mut zw = zip::ZipWriter::new(std::io::Cursor::new(Vec::new()));
|
||||
for (name, xml) in &parts {
|
||||
zw.start_file(*name, zip::write::SimpleFileOptions::default()).unwrap();
|
||||
zw.write_all(xml.as_bytes()).unwrap();
|
||||
}
|
||||
zw.finish().unwrap().into_inner()
|
||||
}
|
||||
|
||||
fn part(bytes: &[u8], name: &str) -> String {
|
||||
let mut z = zip::ZipArchive::new(std::io::Cursor::new(bytes)).unwrap();
|
||||
let mut s = String::new();
|
||||
z.by_name(name).unwrap().read_to_string(&mut s).unwrap();
|
||||
s
|
||||
}
|
||||
|
||||
fn run(text: &str, rpr: &str) -> String {
|
||||
format!(r#"<w:r><w:rPr>{rpr}</w:rPr><w:t>{text}</w:t></w:r>"#)
|
||||
}
|
||||
|
||||
/// The East Asian layout of the run holding `text` in the first paragraph.
|
||||
fn layout_of(d: &wordcraft_doc::Document, text: &str) -> Option<EastAsianLayout> {
|
||||
let p = d.body.iter().find_map(|b| b.as_para()).unwrap();
|
||||
let start = p.text.find(text).unwrap();
|
||||
let mut at = 0;
|
||||
for r in &p.runs {
|
||||
if at == start {
|
||||
return r.props.east_asian_layout.clone();
|
||||
}
|
||||
at += r.len;
|
||||
}
|
||||
panic!("no run starts at {text:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn two_lines_in_one_and_horizontal_in_vertical_survive_a_save() {
|
||||
// Warichu with round brackets, tate-chu-yoko compressed to the line, and a plain run beside them.
|
||||
let body = format!(
|
||||
r#"<w:p>{}{}{}{}</w:p><w:sectPr><w:textDirection w:val="tbRl"/></w:sectPr>"#,
|
||||
run("本文", ""),
|
||||
run("二行の注", r#"<w:eastAsianLayout w:id="1" w:combine="on" w:combineBrackets="round"/>"#),
|
||||
run("12", r#"<w:eastAsianLayout w:id="2" w:vert="on" w:vertCompress="true"/>"#),
|
||||
run("月", "")
|
||||
);
|
||||
let d = wordcraft_docx::read(&docx(&body, None)).unwrap();
|
||||
let warichu = EastAsianLayout { id: Some(1), combine: Some(true), combine_brackets: Some(CombineBrackets::Round), ..Default::default() };
|
||||
let tcy = EastAsianLayout { id: Some(2), vert: Some(true), vert_compress: Some(true), ..Default::default() };
|
||||
assert_eq!(layout_of(&d, "二行の注"), Some(warichu.clone()));
|
||||
assert_eq!(layout_of(&d, "12"), Some(tcy.clone()));
|
||||
assert_eq!(layout_of(&d, "本文"), None);
|
||||
assert_eq!(layout_of(&d, "月"), None);
|
||||
|
||||
let out = wordcraft_docx::write(&d).unwrap();
|
||||
let xml = part(&out, "word/document.xml");
|
||||
assert_eq!(xml.matches("<w:eastAsianLayout ").count(), 2, "{xml}");
|
||||
assert!(xml.contains(r#"<w:eastAsianLayout w:id="1" w:combine="1" w:combineBrackets="round"/>"#), "{xml}");
|
||||
assert!(xml.contains(r#"<w:eastAsianLayout w:id="2" w:vert="1" w:vertCompress="1"/>"#), "{xml}");
|
||||
let back = wordcraft_docx::read(&out).unwrap();
|
||||
assert_eq!(layout_of(&back, "二行の注"), Some(warichu));
|
||||
assert_eq!(layout_of(&back, "12"), Some(tcy));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn east_asian_layout_is_written_after_lang() {
|
||||
// CT_RPr's sequence: … w:lang, w:eastAsianLayout, w:specVanish.
|
||||
let body = format!(r#"<w:p>{}</w:p>"#, run("12", r#"<w:eastAsianLayout w:vert="1"/><w:lang w:val="ja-JP"/><w:b/>"#));
|
||||
let d = wordcraft_docx::read(&docx(&body, None)).unwrap();
|
||||
let xml = part(&wordcraft_docx::write(&d).unwrap(), "word/document.xml");
|
||||
let lang = xml.find("<w:lang ").unwrap();
|
||||
let eal = xml.find("<w:eastAsianLayout ").unwrap();
|
||||
assert!(lang < eal, "{xml}");
|
||||
assert!(xml.contains(r#"<w:eastAsianLayout w:vert="1"/>"#), "{xml}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_character_style_keeps_its_east_asian_layout() {
|
||||
let styles = r#"<w:style w:type="character" w:styleId="TCY"><w:name w:val="Tate Chu Yoko"/><w:rPr><w:eastAsianLayout w:id="7" w:vert="on"/></w:rPr></w:style>"#;
|
||||
let body = format!(r#"<w:p>{}</w:p>"#, run("12", r#"<w:rStyle w:val="TCY"/>"#));
|
||||
let d = wordcraft_docx::read(&docx(&body, Some(styles))).unwrap();
|
||||
let out = wordcraft_docx::write(&d).unwrap();
|
||||
let xml = part(&out, "word/styles.xml");
|
||||
assert!(xml.contains(r#"<w:eastAsianLayout w:id="7" w:vert="1"/>"#), "{xml}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn values_outside_the_schema_are_dropped_not_guessed() {
|
||||
// An id past xsd:int, an unknown bracket and an on/off that isn't one: those attributes are
|
||||
// unset; the valid one beside them is kept. An element with nothing valid writes nothing.
|
||||
let body = format!(
|
||||
r#"<w:p>{}{}</w:p>"#,
|
||||
run("注", r#"<w:eastAsianLayout w:id="99999999999" w:combine="yes" w:combineBrackets="wavy" w:vert="off"/>"#),
|
||||
run("x", r#"<w:eastAsianLayout w:combineBrackets="wavy"/>"#)
|
||||
);
|
||||
let d = wordcraft_docx::read(&docx(&body, None)).unwrap();
|
||||
assert_eq!(layout_of(&d, "注"), Some(EastAsianLayout { vert: Some(false), ..Default::default() }));
|
||||
assert_eq!(layout_of(&d, "x"), None);
|
||||
let xml = part(&wordcraft_docx::write(&d).unwrap(), "word/document.xml");
|
||||
assert_eq!(xml.matches("<w:eastAsianLayout ").count(), 1, "{xml}");
|
||||
assert!(xml.contains(r#"<w:eastAsianLayout w:vert="0"/>"#), "{xml}");
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
//! Positional tabs (`w:ptab`, ECMA-376 Part 1 §17.3.3.23). Fixtures are hand-written from the spec.
|
||||
|
||||
use std::io::{Read, Write};
|
||||
|
||||
use wordcraft_doc::InlineObject;
|
||||
use wordcraft_doc::para::{PTabAlign, PTabBase};
|
||||
use wordcraft_doc::props::TabLeader;
|
||||
|
||||
fn docx(body: &str) -> Vec<u8> {
|
||||
let root = r#"<?xml version="1.0" encoding="UTF-8"?><Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument" Target="word/document.xml"/></Relationships>"#;
|
||||
let doc = format!(
|
||||
r#"<?xml version="1.0" encoding="UTF-8" standalone="yes"?><w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"><w:body>{body}</w:body></w:document>"#
|
||||
);
|
||||
let mut zw = zip::ZipWriter::new(std::io::Cursor::new(Vec::new()));
|
||||
for (name, bytes) in [("_rels/.rels", root.as_bytes()), ("word/document.xml", doc.as_bytes())] {
|
||||
zw.start_file(name, zip::write::SimpleFileOptions::default()).unwrap();
|
||||
zw.write_all(bytes).unwrap();
|
||||
}
|
||||
zw.finish().unwrap().into_inner()
|
||||
}
|
||||
|
||||
fn document_xml(bytes: &[u8]) -> String {
|
||||
let mut z = zip::ZipArchive::new(std::io::Cursor::new(bytes)).unwrap();
|
||||
let mut s = String::new();
|
||||
z.by_name("word/document.xml").unwrap().read_to_string(&mut s).unwrap();
|
||||
s
|
||||
}
|
||||
|
||||
fn ptab(relative_to: &str, alignment: &str, leader: &str) -> String {
|
||||
format!(r#"<w:r><w:ptab w:relativeTo="{relative_to}" w:alignment="{alignment}" w:leader="{leader}"/></w:r>"#)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn positional_tabs_keep_their_alignment_base_and_leader() {
|
||||
let body = format!(
|
||||
r#"<w:p><w:r><w:t>A</w:t></w:r>{}<w:r><w:t>B</w:t></w:r>{}<w:r><w:t>C</w:t></w:r>{}<w:r><w:t>D</w:t></w:r></w:p>"#,
|
||||
ptab("margin", "right", "dot"),
|
||||
ptab("indent", "center", "none"),
|
||||
ptab("margin", "left", "underscore"),
|
||||
);
|
||||
let d = wordcraft_docx::read(&docx(&body)).unwrap();
|
||||
let p = d.body.iter().find_map(|b| b.as_para()).unwrap();
|
||||
assert_eq!(p.plain_text(), "A\tB\tC\tD");
|
||||
assert_eq!(
|
||||
p.objects,
|
||||
vec![
|
||||
InlineObject::PositionalTab { align: PTabAlign::Right, relative_to: PTabBase::Margin, leader: TabLeader::Dot },
|
||||
InlineObject::PositionalTab { align: PTabAlign::Center, relative_to: PTabBase::Indent, leader: TabLeader::None },
|
||||
InlineObject::PositionalTab { align: PTabAlign::Left, relative_to: PTabBase::Margin, leader: TabLeader::Underscore },
|
||||
]
|
||||
);
|
||||
let xml = document_xml(&wordcraft_docx::write(&d).unwrap());
|
||||
for (rel, align, leader) in [("margin", "right", "dot"), ("indent", "center", "none"), ("margin", "left", "underscore")] {
|
||||
let want = format!(r#"<w:ptab w:relativeTo="{rel}" w:alignment="{align}" w:leader="{leader}"/>"#);
|
||||
assert!(xml.contains(&want), "{want} missing from {xml}");
|
||||
}
|
||||
assert!(!xml.contains("<w:tab/>"), "{xml}");
|
||||
}
|
||||
|
||||
/// A positional tab whose alignment or base isn't one the spec defines stays an ordinary tab.
|
||||
#[test]
|
||||
fn a_positional_tab_with_an_unknown_alignment_is_a_plain_tab() {
|
||||
let body = format!(r#"<w:p><w:r><w:t>A</w:t></w:r>{}<w:r><w:t>B</w:t></w:r></w:p>"#, ptab("margin", "decimal", "none"));
|
||||
let d = wordcraft_docx::read(&docx(&body)).unwrap();
|
||||
let p = d.body.iter().find_map(|b| b.as_para()).unwrap();
|
||||
assert_eq!(p.plain_text(), "A\tB");
|
||||
assert!(p.objects.is_empty(), "{:?}", p.objects);
|
||||
let xml = document_xml(&wordcraft_docx::write(&d).unwrap());
|
||||
assert!(xml.contains("<w:tab/>") && !xml.contains("w:ptab"), "{xml}");
|
||||
}
|
||||
@@ -110,6 +110,13 @@ fn every_char_prop_round_trips() {
|
||||
bold_cs: Some(false),
|
||||
italic_cs: Some(true),
|
||||
lang_bidi: Some("fa-IR".into()),
|
||||
east_asian_layout: Some(wordcraft_doc::props::EastAsianLayout {
|
||||
id: Some(3),
|
||||
combine: Some(true),
|
||||
combine_brackets: Some(wordcraft_doc::props::CombineBrackets::Square),
|
||||
vert: Some(false),
|
||||
vert_compress: Some(true),
|
||||
}),
|
||||
link: None,
|
||||
ins: None,
|
||||
del: None,
|
||||
|
||||
@@ -484,6 +484,8 @@ Editing|Keyboard|Move Paragraph Down|para.moveDown
|
||||
Editing|Keyboard|Promote|para.promote
|
||||
Editing|Keyboard|Demote|para.demote
|
||||
Editing|Keyboard|Toggle Unicode Character|text.toggleUnicode
|
||||
Editing|Keyboard|Spike|edit.spike
|
||||
Editing|Keyboard|Insert Spike|edit.insertSpike
|
||||
Editing|Navigation|Word Left|caret.wordLeft
|
||||
Editing|Navigation|Word Right|caret.wordRight
|
||||
Editing|Navigation|Line Start|caret.home
|
||||
|
||||
@@ -61,6 +61,18 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
.pure(),
|
||||
CommandSpec::new("edit.cut", "Cut", "Home › Clipboard", cut).key("Mod+X").when(has_sel),
|
||||
CommandSpec::new("edit.copy", "Copy", "Home › Clipboard", copy).key("Mod+C / Mod+Insert").pure(),
|
||||
// Word's Spike (Ctrl+F3, Ctrl+Shift+F3; ⌘ on the Mac): the AutoText entry "Spike", so Quick
|
||||
// Parts and AutoText insert it without emptying it, as in Word.
|
||||
CommandSpec::new("edit.spike", "Spike", "Editing", spike).key("Mod+F3").when(has_sel),
|
||||
CommandSpec::new("edit.insertSpike", "Insert Spike", "Editing", insert_spike).key("Mod+Shift+F3").when(|s| {
|
||||
if s.math.is_some() {
|
||||
Some("leave the equation first")
|
||||
} else if s.building_blocks.contains_key(SPIKE) {
|
||||
None
|
||||
} else {
|
||||
Some("the Spike is empty")
|
||||
}
|
||||
}),
|
||||
CommandSpec::new("edit.paste", "Paste", "Home › Clipboard", paste).key("Mod+V / Shift+Insert").params(r#"{"text"?: string, "html"?: string, "rtf"?: string}"#),
|
||||
CommandSpec::new("edit.pasteText", "Paste: Keep Text Only", "Home › Clipboard › Paste", paste_text)
|
||||
.params(r#"{"text"?: string}"#)
|
||||
@@ -198,6 +210,34 @@ fn cut(s: &mut Session, v: &Value) -> CmdResult {
|
||||
Ok(r)
|
||||
}
|
||||
|
||||
/// The AutoText entry the Spike collects into.
|
||||
const SPIKE: &str = "Spike";
|
||||
|
||||
/// Ctrl+F3: cut the selection to the end of the Spike. The clipboard is left alone.
|
||||
fn spike(s: &mut Session, _: &Value) -> CmdResult {
|
||||
let (a, b) = s.sel.ordered();
|
||||
// Column selection collapses the selection before the command runs.
|
||||
if a == b {
|
||||
return Err(CmdError::Disabled("edit.spike: nothing is selected".into()));
|
||||
}
|
||||
let piece = s.doc.copy_range(&a, &b);
|
||||
if piece.blocks.is_empty() {
|
||||
return Err(CmdError::Disabled("edit.spike: this selection can't be collected".into()));
|
||||
}
|
||||
delete_selection(s)?;
|
||||
super::controls::restore_placeholder(s)?;
|
||||
s.building_blocks.entry(SPIKE.to_string()).or_default().append_piece(piece);
|
||||
sel_result(s)
|
||||
}
|
||||
|
||||
/// Ctrl+Shift+F3: insert everything the Spike collected, in order, and empty it.
|
||||
fn insert_spike(s: &mut Session, _: &Value) -> CmdResult {
|
||||
let frag = s.building_blocks.get(SPIKE).cloned().ok_or_else(|| CmdError::Disabled("edit.insertSpike: the Spike is empty".into()))?;
|
||||
super::paste::insert(s, frag)?;
|
||||
s.building_blocks.remove(SPIKE);
|
||||
sel_result(s)
|
||||
}
|
||||
|
||||
/// Paste keeps the source formatting: our own copy while the system clipboard still holds it,
|
||||
/// else another program's HTML or RTF (#687), else the system clipboard's text.
|
||||
fn paste(s: &mut Session, v: &Value) -> CmdResult {
|
||||
|
||||
@@ -232,7 +232,7 @@ struct TocEntry {
|
||||
/// breaks): what Insert › Link › Place in This Document lists.
|
||||
pub(crate) fn headings(s: &Session) -> Vec<(usize, u8, String, usize)> {
|
||||
let f = FieldCode::parse("TOC \\o \"1-9\" \\u");
|
||||
toc_entries(s, &f).into_iter().map(|e| (e.path.last(), e.level.saturating_add(1), e.text, e.lead)).collect()
|
||||
toc_entries(s, &f).into_iter().filter(|e| e.path.depth() == 0).map(|e| (e.path.last(), e.level.saturating_add(1), e.text, e.lead)).collect()
|
||||
}
|
||||
|
||||
/// The heading level (1–9) of a built-in heading style.
|
||||
@@ -273,9 +273,6 @@ fn toc_entries(s: &Session, f: &FieldCode) -> Vec<TocEntry> {
|
||||
LevelSuffix::Space => format!("{label} "),
|
||||
LevelSuffix::Nothing => label,
|
||||
});
|
||||
if path.depth() > 0 {
|
||||
continue;
|
||||
}
|
||||
let style = p.props.style.as_deref().unwrap_or("Normal");
|
||||
if style.starts_with("TOC") {
|
||||
continue;
|
||||
@@ -462,8 +459,12 @@ fn insert_toc(s: &mut Session, v: &Value) -> CmdResult {
|
||||
/// A hidden `_Toc` bookmark around the heading at body block `i` (after its leading page breaks),
|
||||
/// reusing one already there; its name.
|
||||
pub(crate) fn toc_bookmark(s: &mut Session, i: usize, lead: usize) -> Result<String, CmdError> {
|
||||
bookmark_heading(s, &Path::top(i), lead)
|
||||
}
|
||||
|
||||
fn bookmark_heading(s: &mut Session, path: &Path, lead: usize) -> Result<String, CmdError> {
|
||||
const MARK: usize = wordcraft_doc::para::OBJ.len_utf8();
|
||||
let para = s.doc.para(StoryRef::Body, &Path::top(i)).ok_or_else(|| CmdError::Failed("heading is gone".into()))?;
|
||||
let para = s.doc.para(StoryRef::Body, path).ok_or_else(|| CmdError::Failed("heading is gone".into()))?;
|
||||
if let Some(InlineObject::BookmarkStart { name }) = para.object_at(lead)
|
||||
&& name.starts_with("_Toc")
|
||||
{
|
||||
@@ -474,12 +475,12 @@ pub(crate) fn toc_bookmark(s: &mut Session, i: usize, lead: usize) -> Result<Str
|
||||
let name = format!("_Toc{next:09}");
|
||||
let end = para.len();
|
||||
let props = CharProps::default();
|
||||
let pos = |off| Pos { story: StoryRef::Body, path: Path::top(i), off };
|
||||
let pos = |off| Pos { story: StoryRef::Body, path: path.clone(), off };
|
||||
s.doc.insert_object(&pos(end), InlineObject::BookmarkEnd { name: name.clone() }, &props)?;
|
||||
s.doc.insert_object(&pos(lead), InlineObject::BookmarkStart { name: name.clone() }, &props)?;
|
||||
// The caret in that paragraph stays on the same text.
|
||||
let shift = |p: &mut Pos| {
|
||||
if p.story == StoryRef::Body && p.path == Path::top(i) {
|
||||
if p.story == StoryRef::Body && &p.path == path {
|
||||
if p.off > end {
|
||||
p.off = p.off.saturating_add(2 * MARK);
|
||||
} else if p.off >= lead && p.off > 0 {
|
||||
@@ -539,7 +540,7 @@ fn regenerate_toc(s: &mut Session, leader: Option<TabLeader>) -> Result<(), CmdE
|
||||
for e in &entries {
|
||||
// TC fields link to their paragraph's start.
|
||||
let lead = s.doc.para(StoryRef::Body, &e.path).map_or(0, |p| p.text.bytes().take_while(|b| *b == 0x0C).count());
|
||||
v.push(Some(format!("#{}", toc_bookmark(s, e.path.last(), lead)?)));
|
||||
v.push(Some(format!("#{}", bookmark_heading(s, &e.path, lead)?)));
|
||||
}
|
||||
v
|
||||
} else {
|
||||
@@ -560,7 +561,12 @@ fn regenerate_toc(s: &mut Session, leader: Option<TabLeader>) -> Result<(), CmdE
|
||||
}
|
||||
let shift = if pass == 0 { 0 } else { entries.len() };
|
||||
for (k, e) in entries.iter().enumerate() {
|
||||
let target = Path::top(e.path.last() + if e.path.last() > start { shift } else { 0 });
|
||||
let mut target = e.path.clone();
|
||||
if let Some(top) = target.0.first_mut()
|
||||
&& (*top as usize) > start
|
||||
{
|
||||
*top = top.saturating_add(u32::try_from(shift).unwrap_or(u32::MAX));
|
||||
}
|
||||
let page =
|
||||
l.caret(&Pos { story: StoryRef::Body, path: target, off: e.lead }).and_then(|c| l.pages.get(c.page)).map(|pg| pg.number).unwrap_or(1);
|
||||
let show_page = no_pages.is_none_or(|(a, b)| !(a..=b).contains(&(e.level + 1)));
|
||||
|
||||
@@ -191,7 +191,7 @@ fn read_bytes(name: &str, bytes: &[u8], password: Option<&str>, page: &wordcraft
|
||||
let mut doc = match ext.as_str() {
|
||||
"doc" | "dot" => {
|
||||
let (mut doc, encrypted) = open_binary_doc(bytes, password)?;
|
||||
doc.ensure_nonempty();
|
||||
opened(&mut doc);
|
||||
return Ok((doc, encrypted));
|
||||
}
|
||||
// Plain text goes through the formats importer: BOMs, unmarked UTF-16, Windows-1252.
|
||||
|
||||
@@ -1216,6 +1216,8 @@ impl Session {
|
||||
}
|
||||
// Restrict Editing: refused outright, or allowed as long as it only edits the exceptions.
|
||||
let guard = if spec.mutates { crate::cmd::protect::check_before(self, id, params)? } else { false };
|
||||
// Quick Parts aren't part of the document, so a refused edit puts them back itself (the Spike).
|
||||
let blocks_before = guard.then(|| self.building_blocks.clone());
|
||||
if spec.mutates && self.doc.settings.protection.as_deref() == Some("trackedChanges") {
|
||||
self.doc.settings.track_changes = true;
|
||||
}
|
||||
@@ -1340,6 +1342,9 @@ impl Session {
|
||||
self.undo_evicted = ev;
|
||||
self.column = c;
|
||||
}
|
||||
if let Some(b) = blocks_before {
|
||||
self.building_blocks = b;
|
||||
}
|
||||
self.status = e.to_string();
|
||||
}
|
||||
}
|
||||
|
||||
+469
-25
@@ -6,6 +6,12 @@
|
||||
//! `BIBLIOGRAPHY` fields. The citation's text simply isn't in the queue, so every citation
|
||||
//! style is skipped the same way: "(Smith et al., 2020)", "[3]" or a superscript number.
|
||||
//!
|
||||
//! Each sentence is spoken in its own language: the run's language tag, or else the script of its
|
||||
//! letters (Cyrillic, Greek, Arabic, Hebrew, CJK, Thai…), so a Russian sentence gets a Russian voice
|
||||
//! where one is installed instead of being spelled out letter by letter. The system's own voice
|
||||
//! stays whenever it already speaks the language (English text on an English system keeps the
|
||||
//! voice the user chose), and without a voice for the language the default voice speaks.
|
||||
//!
|
||||
//! The speech itself runs on a worker thread (desktop only): it starts `say` (macOS), `espeak`
|
||||
//! / `spd-say` (Linux, BSD) or the Windows speech synthesizer (PowerShell) for the current
|
||||
//! sentence, and moves on when it finishes. Commands only change the shared state and wake the
|
||||
@@ -55,6 +61,8 @@ pub struct Utterance {
|
||||
pub text: String,
|
||||
pub start: Pos,
|
||||
pub end: Pos,
|
||||
/// The language to speak it in (a BCP 47 tag), or `None` for the system's default voice.
|
||||
pub lang: Option<String>,
|
||||
}
|
||||
|
||||
impl Utterance {
|
||||
@@ -130,12 +138,249 @@ pub fn sentences(doc: &Document, from: &Pos, to: Option<&Pos>, skip_citations: b
|
||||
continue;
|
||||
}
|
||||
let (Some(first), Some(last)) = (map.get(a), map.get(b.saturating_sub(1))) else { continue };
|
||||
out.push(Utterance { text: spoken, start: Pos::new(story, path.clone(), first.0), end: Pos::new(story, path.clone(), last.1) });
|
||||
// The language of the sentence's first character, through styles and the defaults.
|
||||
let run = para.props_of_char(first.0);
|
||||
let explicit = doc.styles.resolve_char(para.props.style.as_deref(), run).lang;
|
||||
let lang = speech_language(&spoken, explicit.as_deref(), run.lang_bidi.as_deref());
|
||||
out.push(Utterance { text: spoken, start: Pos::new(story, path.clone(), first.0), end: Pos::new(story, path.clone(), last.1), lang });
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// The language of `text` from its script, as a BCP 47 primary tag: Cyrillic is Russian (Ukrainian
|
||||
/// with one of its letters `іїєґ`), Greek, Arabic, Hebrew, Thai and Hangul map to `el`, `ar`, `he`, `th`
|
||||
/// and `ko`, kana to Japanese, Han letters alone to Chinese. `None` for Latin text (the script
|
||||
/// doesn't say which language) and text without letters. Mixed text goes to its largest non-Latin
|
||||
/// script once that script has a real share of the letters (a fifth), even when Latin letters
|
||||
/// outnumber it ("Я открыл Microsoft Word and PowerPoint"): the default voice would spell those
|
||||
/// letters out, while a Russian voice reads the English words well enough. Ties go to the script
|
||||
/// listed first.
|
||||
pub fn detect_language(text: &str) -> Option<&'static str> {
|
||||
// Letters per script: Cyrillic, Greek, Arabic, Hebrew, Thai, Hangul, kana, Han, Latin and others.
|
||||
let mut n = [0usize; 10];
|
||||
let mut ukrainian = false;
|
||||
for c in text.chars().filter(|c| c.is_alphabetic()) {
|
||||
let i = match c {
|
||||
'\u{0400}'..='\u{052F}' | '\u{1C80}'..='\u{1C8F}' | '\u{2DE0}'..='\u{2DFF}' | '\u{A640}'..='\u{A69F}' => {
|
||||
ukrainian |= matches!(c, 'і' | 'ї' | 'є' | 'ґ' | 'І' | 'Ї' | 'Є' | 'Ґ');
|
||||
0
|
||||
}
|
||||
'\u{0370}'..='\u{03FF}' | '\u{1F00}'..='\u{1FFF}' => 1,
|
||||
'\u{0600}'..='\u{06FF}' | '\u{0750}'..='\u{077F}' | '\u{08A0}'..='\u{08FF}' | '\u{FB50}'..='\u{FDFF}' | '\u{FE70}'..='\u{FEFF}' => 2,
|
||||
'\u{0590}'..='\u{05FF}' | '\u{FB1D}'..='\u{FB4F}' => 3,
|
||||
'\u{0E00}'..='\u{0E7F}' => 4,
|
||||
'\u{1100}'..='\u{11FF}' | '\u{3130}'..='\u{318F}' | '\u{AC00}'..='\u{D7AF}' => 5,
|
||||
'\u{3040}'..='\u{30FF}' | '\u{31F0}'..='\u{31FF}' => 6,
|
||||
'\u{3400}'..='\u{4DBF}' | '\u{4E00}'..='\u{9FFF}' | '\u{F900}'..='\u{FAFF}' | '\u{20000}'..='\u{2FFFF}' => 7,
|
||||
_ => 8,
|
||||
};
|
||||
if let Some(k) = n.get_mut(i) {
|
||||
*k += 1;
|
||||
}
|
||||
}
|
||||
// Japanese text mixes kana and Han letters; any kana makes it Japanese.
|
||||
if n[6] > 0 {
|
||||
n[6] += n[7];
|
||||
n[7] = 0;
|
||||
}
|
||||
// The first script with the most letters (`max_by_key` would keep the last of a tie).
|
||||
let (best, count) = n.iter().take(8).copied().enumerate().fold((0, 0), |b, (i, k)| if k > b.1 { (i, k) } else { b });
|
||||
let total: usize = n.iter().sum();
|
||||
if count == 0 || count.saturating_mul(5) < total {
|
||||
return None;
|
||||
}
|
||||
Some(match best {
|
||||
0 if ukrainian => "uk",
|
||||
0 => "ru",
|
||||
1 => "el",
|
||||
2 => "ar",
|
||||
3 => "he",
|
||||
4 => "th",
|
||||
5 => "ko",
|
||||
6 => "ja",
|
||||
_ => "zh",
|
||||
})
|
||||
}
|
||||
|
||||
/// A language tag that is safe to hand to a speech program or put in a script: letters, digits
|
||||
/// and hyphens only, starting with a letter, at most 12 characters (a tag from a document is
|
||||
/// hostile input).
|
||||
fn plain_tag(tag: &str) -> Option<&str> {
|
||||
let t = tag.trim();
|
||||
(!t.is_empty() && t.len() <= 12 && t.starts_with(|c: char| c.is_ascii_alphabetic()) && t.chars().all(|c| c.is_ascii_alphanumeric() || c == '-'))
|
||||
.then_some(t)
|
||||
}
|
||||
|
||||
/// The script group a language is written in, for the languages [`detect_language`] can tell
|
||||
/// apart by script (the primary subtag in, the detected tag out).
|
||||
fn script_language(primary: &str) -> Option<&'static str> {
|
||||
Some(match primary {
|
||||
"ru" | "uk" | "be" | "bg" | "sr" | "mk" | "kk" | "ky" | "mn" | "tg" => "ru",
|
||||
"el" => "el",
|
||||
"ar" | "fa" | "ur" | "ps" | "ku" | "sd" | "ug" => "ar",
|
||||
"he" | "yi" => "he",
|
||||
"th" => "th",
|
||||
"ko" => "ko",
|
||||
"ja" => "ja",
|
||||
"zh" => "zh",
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
|
||||
/// The language to speak `text` in: the document's `explicit` tag when it fits the text, else the
|
||||
/// language of its script, else `None` (the default voice). When the text is in a script
|
||||
/// [`detect_language`] recognises, a tag written in another script (the `en-US` Word writes on
|
||||
/// every run, on Cyrillic text) gives way to the script, or the voice would spell the letters out;
|
||||
/// right-to-left text tries the run's complex-script tag (`w:bidi`, e.g. `fa-IR`) first, and Han
|
||||
/// letters alone keep a Japanese or Korean tag. Latin text keeps any tag, as in Word, which reads
|
||||
/// text in its proofing language: English words in a run tagged `ru-RU` get the Russian voice.
|
||||
pub fn speech_language(text: &str, explicit: Option<&str>, bidi: Option<&str>) -> Option<String> {
|
||||
let explicit = explicit.and_then(plain_tag);
|
||||
let Some(d) = detect_language(text) else { return explicit.map(str::to_string) };
|
||||
let fits = |tag: &&str| {
|
||||
let primary = tag.split('-').next().unwrap_or("").to_ascii_lowercase();
|
||||
let script = script_language(&primary);
|
||||
script == script_language(d) || (d == "zh" && matches!(script, Some("ja" | "ko")))
|
||||
};
|
||||
let bidi = bidi.and_then(plain_tag).filter(|_| matches!(d, "ar" | "he"));
|
||||
Some(bidi.into_iter().chain(explicit).find(fits).unwrap_or(d).to_string())
|
||||
}
|
||||
|
||||
/// The primary language subtag of a plain tag, lowercased (`ru-RU` gives `ru`). `None` for tags
|
||||
/// without a two- or three-letter language, such as Word's `x-none`.
|
||||
fn primary_tag(tag: &str) -> Option<String> {
|
||||
let t = plain_tag(tag)?;
|
||||
t.split('-').next().filter(|p| (2..=3).contains(&p.len()) && p.chars().all(|c| c.is_ascii_alphabetic())).map(str::to_ascii_lowercase)
|
||||
}
|
||||
|
||||
/// The language to ask the speech program for, or `None` to keep its default voice: only when
|
||||
/// `lang` isn't the language the default voice speaks (`default`, a primary subtag) already.
|
||||
#[cfg(any(not(target_os = "windows"), test))]
|
||||
fn voice_language(lang: Option<&str>, default: &str) -> Option<String> {
|
||||
lang.and_then(primary_tag).filter(|p| !p.eq_ignore_ascii_case(default))
|
||||
}
|
||||
|
||||
/// The arguments of `espeak-ng`/`espeak`: the speed, the voice for `lang` unless it's English
|
||||
/// (espeak's default voice), and `--stdin`.
|
||||
#[cfg(any(all(unix, not(target_os = "macos")), test))]
|
||||
fn espeak_args(wpm: &str, lang: Option<&str>) -> Vec<String> {
|
||||
let mut a = vec!["-s".to_string(), wpm.to_string()];
|
||||
if let Some(p) = voice_language(lang, "en") {
|
||||
a.push("-v".into());
|
||||
a.push(p);
|
||||
}
|
||||
a.push("--stdin".into());
|
||||
a
|
||||
}
|
||||
|
||||
/// The voices in `say -v '?'`'s `listing`: each line is `Name ll_RR # sample sentence`, and
|
||||
/// names may contain spaces. Gives (name, lowercased `ll_rr` code) pairs.
|
||||
#[cfg(any(target_os = "macos", test))]
|
||||
fn mac_voices(listing: &str) -> impl Iterator<Item = (String, String)> + '_ {
|
||||
listing.lines().filter_map(|line| {
|
||||
let line = line.split('#').next().unwrap_or("");
|
||||
let words: Vec<&str> = line.split_whitespace().collect();
|
||||
let at = words.iter().skip(1).position(|w| {
|
||||
let mut p = w.split('_');
|
||||
p.next().is_some_and(|l| (2..=3).contains(&l.len()) && l.chars().all(|c| c.is_ascii_lowercase()))
|
||||
&& p.next().is_some_and(|r| !r.is_empty())
|
||||
})? + 1;
|
||||
Some((words.get(..at)?.join(" "), words.get(at)?.to_ascii_lowercase()))
|
||||
})
|
||||
}
|
||||
|
||||
/// macOS's novelty voices (sound effects and singing), never chosen to read a document.
|
||||
#[cfg(any(target_os = "macos", test))]
|
||||
const MAC_NOVELTY_VOICES: &[&str] = &[
|
||||
"Albert",
|
||||
"Bad News",
|
||||
"Bahh",
|
||||
"Bells",
|
||||
"Boing",
|
||||
"Bubbles",
|
||||
"Cellos",
|
||||
"Deranged",
|
||||
"Good News",
|
||||
"Hysterical",
|
||||
"Jester",
|
||||
"Organ",
|
||||
"Pipe Organ",
|
||||
"Superstar",
|
||||
"Trinoids",
|
||||
"Whisper",
|
||||
"Wobble",
|
||||
"Zarvox",
|
||||
];
|
||||
|
||||
/// The voice `say` should use for `lang`, or `None` to keep the system voice: `None` when the
|
||||
/// system voice already speaks the language (`default`, a primary subtag) or no voice does, else
|
||||
/// the first voice in `listing` for the language, one of the same region first, never a novelty voice.
|
||||
#[cfg(any(target_os = "macos", test))]
|
||||
fn mac_voice(lang: &str, listing: &str, default: &str) -> Option<String> {
|
||||
let primary = voice_language(Some(lang), default)?;
|
||||
let tag = plain_tag(lang)?.replace('-', "_").to_ascii_lowercase();
|
||||
let mut first = None;
|
||||
for (name, code) in mac_voices(listing) {
|
||||
if code.split('_').next() != Some(primary.as_str()) || MAC_NOVELTY_VOICES.contains(&name.as_str()) {
|
||||
continue;
|
||||
}
|
||||
if code == tag {
|
||||
return Some(name);
|
||||
}
|
||||
first.get_or_insert(name);
|
||||
}
|
||||
first
|
||||
}
|
||||
|
||||
/// The language (primary subtag) of the system voice: the `selected` voice's in `listing`, else
|
||||
/// the `locale`'s (`ru_RU`, `ru_RU.UTF-8`), else English.
|
||||
#[cfg(any(target_os = "macos", test))]
|
||||
fn mac_default_language(selected: Option<&str>, listing: &str, locale: Option<&str>) -> String {
|
||||
let voice = selected
|
||||
.and_then(|v| mac_voices(listing).find(|(name, _)| name == v.trim()))
|
||||
.and_then(|(_, code)| code.split('_').next().map(str::to_string));
|
||||
let locale = || locale.and_then(|l| l.split(['_', '-', '.', '@']).next()).and_then(primary_tag);
|
||||
voice.or_else(locale).unwrap_or_else(|| "en".into())
|
||||
}
|
||||
|
||||
/// The language of the voice `say` uses by default (asked once).
|
||||
#[cfg(target_os = "macos")]
|
||||
fn mac_default_voice_language() -> &'static str {
|
||||
static LANG: std::sync::OnceLock<String> = std::sync::OnceLock::new();
|
||||
LANG.get_or_init(|| {
|
||||
let read = |args: &[&str]| {
|
||||
std::process::Command::new("defaults")
|
||||
.args(args)
|
||||
.output()
|
||||
.ok()
|
||||
.filter(|o| o.status.success())
|
||||
.map(|o| String::from_utf8_lossy(&o.stdout).trim().to_string())
|
||||
};
|
||||
let voice = read(&["read", "com.apple.speech.voice.prefs", "SelectedVoiceName"]);
|
||||
let locale = read(&["read", "-g", "AppleLocale"]).or_else(|| std::env::var("LANG").ok());
|
||||
mac_default_language(voice.as_deref(), mac_voice_listing(), locale.as_deref())
|
||||
})
|
||||
}
|
||||
|
||||
/// Should `speak` say the sentence again with the default voice? When it gave the program a voice
|
||||
/// and the program then exited with a failure: espeak-ng does that, silently, for a voice it
|
||||
/// doesn't know, and a missing voice must never mean silence. `exited_ok` is `None` when the
|
||||
/// program didn't exit on its own (stopped, or never started).
|
||||
#[cfg(any(not(target_arch = "wasm32"), test))]
|
||||
fn retry_without_voice(gave_voice: bool, exited_ok: Option<bool>) -> bool {
|
||||
gave_voice && exited_ok == Some(false)
|
||||
}
|
||||
|
||||
/// The names `say` can voice, as `say -v '?'` lists them (asked once).
|
||||
#[cfg(target_os = "macos")]
|
||||
fn mac_voice_listing() -> &'static str {
|
||||
static LISTING: std::sync::OnceLock<String> = std::sync::OnceLock::new();
|
||||
LISTING.get_or_init(|| {
|
||||
std::process::Command::new("say").args(["-v", "?"]).output().map(|o| String::from_utf8_lossy(&o.stdout).into_owned()).unwrap_or_default()
|
||||
})
|
||||
}
|
||||
|
||||
/// Sentence spans in `text` as char index ranges, without surrounding whitespace.
|
||||
fn split_sentences(text: &str) -> Vec<(usize, usize)> {
|
||||
let chars: Vec<char> = text.chars().collect();
|
||||
@@ -443,7 +688,7 @@ enum Spoken {
|
||||
fn worker(shared: &(Mutex<Shared>, Condvar), backend: Backend) {
|
||||
let (m, cv) = shared;
|
||||
loop {
|
||||
let (text, generation, rate) = {
|
||||
let (text, lang, generation, rate) = {
|
||||
let mut s = lock(m);
|
||||
while s.state == State::Paused {
|
||||
s = cv.wait(s).unwrap_or_else(PoisonError::into_inner);
|
||||
@@ -453,7 +698,7 @@ fn worker(shared: &(Mutex<Shared>, Condvar), backend: Backend) {
|
||||
return;
|
||||
}
|
||||
match s.queue.get(s.index) {
|
||||
Some(u) => (u.text.clone(), s.generation, s.rate),
|
||||
Some(u) => (u.text.clone(), u.lang.clone(), s.generation, s.rate),
|
||||
None => {
|
||||
s.state = State::Stopped;
|
||||
s.running = false;
|
||||
@@ -463,7 +708,7 @@ fn worker(shared: &(Mutex<Shared>, Condvar), backend: Backend) {
|
||||
};
|
||||
let changed = || lock(m).generation != generation;
|
||||
let outcome = match backend {
|
||||
Backend::System => speak(&text, rate, &changed),
|
||||
Backend::System => speak(&text, lang.as_deref(), rate, &changed),
|
||||
Backend::Instant => Spoken::Done,
|
||||
Backend::Hold => {
|
||||
let mut s = lock(m);
|
||||
@@ -521,11 +766,23 @@ struct WindowsSpeech {
|
||||
}
|
||||
|
||||
#[cfg(any(target_os = "windows", test))]
|
||||
fn windows_speech(system_root: Option<std::ffi::OsString>, rate: f32) -> WindowsSpeech {
|
||||
fn windows_speech(system_root: Option<std::ffi::OsString>, rate: f32, lang: Option<&str>) -> WindowsSpeech {
|
||||
// SAPI rate: -10 (a third of normal speed) … 10 (three times).
|
||||
let r = ((rate.ln() / 3f32.ln()) * 10.0).round().clamp(-10.0, 10.0) as i64;
|
||||
// The voice of the sentence's language, when one is installed (the default voice would spell
|
||||
// out Cyrillic, Greek… letters one by one), but only when the user's default voice doesn't
|
||||
// speak that language already, in any region. The tag is `primary_tag`-clean, so it can't end
|
||||
// the quote.
|
||||
let voice = lang.and_then(primary_tag).map_or_else(String::new, |p| {
|
||||
format!(
|
||||
"if ($s.Voice.Culture.TwoLetterISOLanguageName -ne '{p}') {{ $v = $s.GetInstalledVoices() | Where-Object {{ $_.Enabled -and $_.VoiceInfo.Culture.TwoLetterISOLanguageName -eq '{p}' }} | Select-Object -First 1; if ($v) {{ $s.SelectVoice($v.VoiceInfo.Name) }} }}; "
|
||||
)
|
||||
});
|
||||
// The sentence arrives on stdin as UTF-8 bytes; `[Console]::In` would decode them with the
|
||||
// console's code page (Cyrillic became `?` and the voice read single letters), so read the raw
|
||||
// stream and decode it as UTF-8. The text itself is never part of the script.
|
||||
let script = format!(
|
||||
"Add-Type -AssemblyName System.Speech; $s = New-Object System.Speech.Synthesis.SpeechSynthesizer; $s.Rate = {r}; $s.Speak([Console]::In.ReadToEnd())"
|
||||
"Add-Type -AssemblyName System.Speech; $s = New-Object System.Speech.Synthesis.SpeechSynthesizer; $s.Rate = {r}; {voice}$m = New-Object System.IO.MemoryStream; [Console]::OpenStandardInput().CopyTo($m); $s.Speak([System.Text.Encoding]::UTF8.GetString($m.ToArray()))"
|
||||
);
|
||||
WindowsSpeech {
|
||||
program: windows_powershell(system_root),
|
||||
@@ -536,50 +793,69 @@ fn windows_speech(system_root: Option<std::ffi::OsString>, rate: f32) -> Windows
|
||||
}
|
||||
}
|
||||
|
||||
/// Speak one sentence with the system's speech program, stopping early when `changed()`.
|
||||
/// Speak one sentence with the system's speech program, stopping early when `changed()`. When
|
||||
/// the program fails with the sentence's voice, the default voice says it instead.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn speak(text: &str, rate: f32, changed: &dyn Fn() -> bool) -> Spoken {
|
||||
fn speak(text: &str, lang: Option<&str>, rate: f32, changed: &dyn Fn() -> bool) -> Spoken {
|
||||
let (spoken, gave_voice, exited_ok) = speak_with(text, lang, rate, changed);
|
||||
if retry_without_voice(gave_voice, exited_ok) {
|
||||
return speak_with(text, None, rate, changed).0;
|
||||
}
|
||||
spoken
|
||||
}
|
||||
|
||||
/// [`speak`] in `lang`'s voice once: how it ended, whether the program was given a voice, and
|
||||
/// whether it exited successfully (`None` when it didn't exit on its own).
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn speak_with(text: &str, lang: Option<&str>, rate: f32, changed: &dyn Fn() -> bool) -> (Spoken, bool, Option<bool>) {
|
||||
use std::io::Write;
|
||||
use std::process::{Command, Stdio};
|
||||
let wpm = ((BASE_WPM * rate).round() as i64).to_string();
|
||||
// The text goes in on stdin so it can never be taken for an option.
|
||||
let mut tries: Vec<Command> = Vec::new();
|
||||
// The text goes in on stdin so it can never be taken for an option. Each try is a program
|
||||
// and whether it was given a voice.
|
||||
let mut tries: Vec<(Command, bool)> = Vec::new();
|
||||
#[cfg(target_os = "macos")]
|
||||
{
|
||||
let mut c = Command::new("say");
|
||||
c.args(["-r", &wpm]);
|
||||
tries.push(c);
|
||||
let voice = lang.and_then(|l| mac_voice(l, mac_voice_listing(), mac_default_voice_language()));
|
||||
if let Some(voice) = &voice {
|
||||
c.args(["-v", voice.as_str()]);
|
||||
}
|
||||
tries.push((c, voice.is_some()));
|
||||
}
|
||||
#[cfg(target_os = "windows")]
|
||||
{
|
||||
let w = windows_speech(std::env::var_os("SystemRoot"), rate);
|
||||
let w = windows_speech(std::env::var_os("SystemRoot"), rate, lang);
|
||||
let mut c = Command::new(&w.program);
|
||||
c.args(&w.args);
|
||||
std::os::windows::process::CommandExt::creation_flags(&mut c, w.creation_flags);
|
||||
tries.push(c);
|
||||
tries.push((c, lang.and_then(primary_tag).is_some()));
|
||||
}
|
||||
#[cfg(all(unix, not(target_os = "macos")))]
|
||||
{
|
||||
for prog in ["espeak-ng", "espeak"] {
|
||||
let args = espeak_args(&wpm, lang);
|
||||
let voiced = args.iter().any(|a| a == "-v");
|
||||
let mut c = Command::new(prog);
|
||||
c.args(["-s", &wpm, "--stdin"]);
|
||||
tries.push(c);
|
||||
c.args(args);
|
||||
tries.push((c, voiced));
|
||||
}
|
||||
}
|
||||
let _ = &wpm;
|
||||
let _ = (&wpm, lang);
|
||||
let mut child = None;
|
||||
let mut last_err = String::from("no speech program on this system");
|
||||
for mut c in tries {
|
||||
for (mut c, voiced) in tries {
|
||||
match c.stdin(Stdio::piped()).stdout(Stdio::null()).stderr(Stdio::null()).spawn() {
|
||||
Ok(ch) => {
|
||||
child = Some(ch);
|
||||
child = Some((ch, voiced));
|
||||
break;
|
||||
}
|
||||
Err(e) => last_err = e.to_string(),
|
||||
}
|
||||
}
|
||||
let Some(mut child) = child else {
|
||||
return Spoken::Failed(format!("Read Aloud couldn't start the speech program: {last_err}"));
|
||||
let Some((mut child, voiced)) = child else {
|
||||
return (Spoken::Failed(format!("Read Aloud couldn't start the speech program: {last_err}")), false, None);
|
||||
};
|
||||
if let Some(mut stdin) = child.stdin.take() {
|
||||
let _ = stdin.write_all(text.as_bytes());
|
||||
@@ -587,14 +863,14 @@ fn speak(text: &str, rate: f32, changed: &dyn Fn() -> bool) -> Spoken {
|
||||
}
|
||||
loop {
|
||||
match child.try_wait() {
|
||||
Ok(Some(_)) => return Spoken::Done,
|
||||
Ok(Some(status)) => return (Spoken::Done, voiced, Some(status.success())),
|
||||
Ok(None) => {}
|
||||
Err(e) => return Spoken::Failed(e.to_string()),
|
||||
Err(e) => return (Spoken::Failed(e.to_string()), voiced, None),
|
||||
}
|
||||
if changed() {
|
||||
let _ = child.kill();
|
||||
let _ = child.wait();
|
||||
return Spoken::Interrupted;
|
||||
return (Spoken::Interrupted, voiced, None);
|
||||
}
|
||||
std::thread::sleep(std::time::Duration::from_millis(30));
|
||||
}
|
||||
@@ -625,12 +901,12 @@ mod tests {
|
||||
// The flags `speak` passes to `creation_flags` (std can't read them back from the
|
||||
// `Command`): Win32's CREATE_NO_WINDOW (0x0800_0000), or every sentence flashes a
|
||||
// console (#1122).
|
||||
let w = windows_speech(Some("D:\\Win".into()), 1.0);
|
||||
let w = windows_speech(Some("D:\\Win".into()), 1.0, None);
|
||||
assert_eq!(w.creation_flags, 0x0800_0000);
|
||||
assert_eq!(w.program, windows_powershell(Some("D:\\Win".into())));
|
||||
assert_eq!(w.args[..3], ["-NoProfile", "-NonInteractive", "-Command"]);
|
||||
assert!(w.args[3].contains("$s.Rate = 0;"), "{}", w.args[3]);
|
||||
assert!(windows_speech(None, 3.0).args[3].contains("$s.Rate = 10;"));
|
||||
assert!(windows_speech(None, 3.0, None).args[3].contains("$s.Rate = 10;"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -740,4 +1016,172 @@ mod tests {
|
||||
r.play_pause();
|
||||
assert_eq!(r.state(), State::Stopped);
|
||||
}
|
||||
#[test]
|
||||
fn languages_are_detected_from_the_script() {
|
||||
for (text, tag) in [
|
||||
("Привет как твои дела?", "ru"),
|
||||
("Привіт, як справи? Їжак і ґанок", "uk"),
|
||||
("Γειά σου κόσμε", "el"),
|
||||
("مرحبا بالعالم", "ar"),
|
||||
("שלום עולם", "he"),
|
||||
("こんにちは、世界", "ja"),
|
||||
("你好,世界", "zh"),
|
||||
("안녕하세요 세계", "ko"),
|
||||
("สวัสดีชาวโลก", "th"),
|
||||
("Hello Привет мир как дела", "ru"),
|
||||
("Hello world and Привет", "ru"),
|
||||
] {
|
||||
assert_eq!(detect_language(text), Some(tag), "{text}");
|
||||
}
|
||||
// Latin text, nothing to read, or only punctuation: no guess.
|
||||
for text in ["Hello, world", "", " ", "?!…", "123"] {
|
||||
assert_eq!(detect_language(text), None, "{text:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_explicit_language_wins_over_detection() {
|
||||
// The document says so (a Russian run, even of Latin letters).
|
||||
assert_eq!(speech_language("Privet", Some("ru-RU"), None).as_deref(), Some("ru-RU"));
|
||||
assert_eq!(speech_language("Привет", Some("uk-UA"), None).as_deref(), Some("uk-UA"));
|
||||
assert_eq!(speech_language("Hallo Welt", Some("de-DE"), None).as_deref(), Some("de-DE"));
|
||||
// The default English tag on Cyrillic text (Word writes it on every run): the script wins.
|
||||
assert_eq!(speech_language("Привет", Some("en-US"), None).as_deref(), Some("ru"));
|
||||
// Nothing to go on: the default voice.
|
||||
assert_eq!(speech_language("Hello", None, None), None);
|
||||
assert_eq!(speech_language("Привет", None, None).as_deref(), Some("ru"));
|
||||
// A hostile tag is never passed on.
|
||||
assert_eq!(speech_language("Hello", Some("en'; calc; '"), None), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sentences_carry_the_run_language() {
|
||||
let mut doc = Document::from_text("Привет как твои дела?\nPrivet.");
|
||||
let s = sentences(&doc, &Pos::body(0, 0), None, true);
|
||||
assert_eq!(s.iter().map(|u| u.lang.as_deref()).collect::<Vec<_>>(), [Some("ru"), Some("en-US")]);
|
||||
if let wordcraft_doc::Block::Para(p) = Arc::make_mut(&mut doc.body[1]) {
|
||||
for r in &mut p.runs {
|
||||
r.props.lang = Some("ru-RU".into());
|
||||
}
|
||||
}
|
||||
let s = sentences(&doc, &Pos::body(0, 0), None, true);
|
||||
assert_eq!(s.iter().map(|u| u.lang.as_deref()).collect::<Vec<_>>(), [Some("ru"), Some("ru-RU")]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn windows_script_reads_utf8_and_picks_the_voice() {
|
||||
let w = windows_speech(None, 1.0, Some("ru"));
|
||||
let script = &w.args[3];
|
||||
assert!(script.contains("UTF8"), "{script}");
|
||||
assert!(script.contains("GetInstalledVoices") && script.contains("SelectVoice"), "{script}");
|
||||
assert!(script.contains("'ru'"), "{script}");
|
||||
// The default voice when the language is unknown, and no voice code at all.
|
||||
let d = windows_speech(None, 1.0, None);
|
||||
assert!(d.args[3].contains("UTF8") && !d.args[3].contains("SelectVoice"), "{}", d.args[3]);
|
||||
// The sentence is never in the script: it arrives on stdin.
|
||||
assert!(!windows_speech(None, 1.0, Some("ru-RU")).args[3].contains("Привет"));
|
||||
// A tag that isn't a plain language tag is dropped, not put in the script.
|
||||
let h = windows_speech(None, 1.0, Some("ru'; Remove-Item x; '"));
|
||||
assert!(!h.args[3].contains("Remove-Item"), "{}", h.args[3]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn espeak_gets_the_language() {
|
||||
assert_eq!(espeak_args("180", Some("ru-RU")), ["-s", "180", "-v", "ru", "--stdin"]);
|
||||
assert_eq!(espeak_args("180", Some("uk")), ["-s", "180", "-v", "uk", "--stdin"]);
|
||||
assert_eq!(espeak_args("180", None), ["-s", "180", "--stdin"]);
|
||||
assert_eq!(espeak_args("180", Some("--evil")), ["-s", "180", "--stdin"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn english_text_keeps_the_default_voice() {
|
||||
// Word writes `en-US` on almost every run: espeak's default voice is English already, so
|
||||
// no `-v` (as before #1843), and Word's language-less `x-none` is no voice either.
|
||||
assert_eq!(espeak_args("180", Some("en-US")), ["-s", "180", "--stdin"]);
|
||||
assert_eq!(espeak_args("180", Some("en-GB")), ["-s", "180", "--stdin"]);
|
||||
assert_eq!(espeak_args("180", Some("x-none")), ["-s", "180", "--stdin"]);
|
||||
// Windows: the user's default voice stays when it already speaks the language, any region.
|
||||
let script = &windows_speech(None, 1.0, Some("en-US")).args[3];
|
||||
let guard = script.find("if ($s.Voice.Culture.TwoLetterISOLanguageName -ne 'en')");
|
||||
let pick = script.find("GetInstalledVoices");
|
||||
assert!(guard.is_some() && pick.is_some() && guard < pick, "{script}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn macos_keeps_the_system_voice_for_its_language() {
|
||||
// Current macOS lists its novelty voices first, in alphabetical order.
|
||||
let list = "Albert en_US # Hello! My name is Albert.\n\
|
||||
Bad News en_US # The light you see at the end of the tunnel.\n\
|
||||
Bahh en_US # Do not pull the wool over my eyes.\n\
|
||||
Samantha en_US # Hello! My name is Samantha.\n\
|
||||
Milena ru_RU # Здравствуйте!\n";
|
||||
// English text with an English system voice: no `-v`, so `say` keeps the user's voice.
|
||||
assert_eq!(mac_voice("en-US", list, "en"), None);
|
||||
assert_eq!(mac_voice("en", list, "en"), None);
|
||||
// English text on a Russian system: an English voice, never a novelty one.
|
||||
assert_eq!(mac_voice("en-US", list, "ru").as_deref(), Some("Samantha"));
|
||||
assert_eq!(mac_voice("en-US", "Albert en_US # Hi\nBahh en_US # Hi\n", "ru"), None);
|
||||
// The system voice's language: the chosen voice's, else the locale's, else English.
|
||||
assert_eq!(mac_default_language(Some("Milena"), list, Some("en_US")), "ru");
|
||||
assert_eq!(mac_default_language(Some("Gone"), list, Some("de_DE")), "de");
|
||||
assert_eq!(mac_default_language(None, list, Some("ru_RU.UTF-8")), "ru");
|
||||
assert_eq!(mac_default_language(None, list, Some("C")), "en");
|
||||
assert_eq!(mac_default_language(None, "", None), "en");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_voice_the_program_lacks_falls_back_to_the_default() {
|
||||
// espeak-ng exits with a failure, saying nothing, for a `-v` it doesn't know.
|
||||
assert!(retry_without_voice(true, Some(false)));
|
||||
// It spoke, it was given no voice, or it was stopped (or never exited on its own).
|
||||
assert!(!retry_without_voice(true, Some(true)));
|
||||
assert!(!retry_without_voice(false, Some(false)));
|
||||
assert!(!retry_without_voice(true, None));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn right_to_left_text_uses_the_complex_script_tag() {
|
||||
// Persian tagged `w:bidi="fa-IR"` on a `w:val="en-US"` run: Persian, not Arabic.
|
||||
assert_eq!(speech_language("سلام دنیا", Some("en-US"), Some("fa-IR")).as_deref(), Some("fa-IR"));
|
||||
assert_eq!(speech_language("שלום עולם", Some("en-US"), Some("he-IL")).as_deref(), Some("he-IL"));
|
||||
// The complex-script tag is for complex-script text only.
|
||||
assert_eq!(speech_language("Hello", Some("en-US"), Some("fa-IR")).as_deref(), Some("en-US"));
|
||||
assert_eq!(speech_language("سلام", Some("en-US"), Some("he-IL")).as_deref(), Some("ar"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mixed_sentences_follow_the_other_script() {
|
||||
// More English letters than Cyrillic ones: the English voice would spell the Russian out.
|
||||
assert_eq!(detect_language("Я открыл Microsoft Word and PowerPoint"), Some("ru"));
|
||||
// A single foreign word in an English sentence isn't a real share: the default voice.
|
||||
assert_eq!(detect_language("The word дом means house"), None);
|
||||
// Ties between scripts always go the same way, the first script in the list.
|
||||
assert_eq!(detect_language("αβ дг"), Some("ru"));
|
||||
assert_eq!(detect_language("дг αβ"), Some("ru"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn han_letters_keep_a_japanese_or_korean_tag() {
|
||||
assert_eq!(speech_language("東京都。", Some("ja-JP"), None).as_deref(), Some("ja-JP"));
|
||||
assert_eq!(speech_language("東京都。", Some("ko-KR"), None).as_deref(), Some("ko-KR"));
|
||||
assert_eq!(speech_language("東京都。", Some("en-US"), None).as_deref(), Some("zh"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn macos_voice_matches_the_language() {
|
||||
let list = "Alex en_US # Most people recognize me by my voice.\n\
|
||||
Eddy (Russian (Russia)) ru_RU # Привет!\n\
|
||||
Milena ru_RU # Здравствуйте!\n\
|
||||
Samantha en_US # Hello!\n\
|
||||
Lesya uk_UA # Привіт!\n\
|
||||
Daniel en_GB # Hello!\n";
|
||||
assert_eq!(mac_voice("ru", list, "en").as_deref(), Some("Eddy (Russian (Russia))"));
|
||||
assert_eq!(mac_voice("ru-RU", list, "en").as_deref(), Some("Eddy (Russian (Russia))"));
|
||||
assert_eq!(mac_voice("uk", list, "en").as_deref(), Some("Lesya"));
|
||||
assert_eq!(mac_voice("en-GB", list, "ru").as_deref(), Some("Daniel"));
|
||||
// No voice for the language, or nothing listed: the default voice.
|
||||
assert_eq!(mac_voice("el", list, "en"), None);
|
||||
assert_eq!(mac_voice("ru", "", "en"), None);
|
||||
assert_eq!(mac_voice("ru", "garbage line\n\n \n", "en"), None);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -163,6 +163,11 @@ fn word_default_shortcuts_resolve() {
|
||||
assert_eq!(id("Alt+F1"), Some("fields.next"));
|
||||
}
|
||||
assert_eq!(id("Mod+PageUp"), Some("edit.findPrevious"));
|
||||
// The Spike: Word's Ctrl+F3 and Ctrl+Shift+F3 (⌘F3 and ⌘⇧F3 in Word for Mac).
|
||||
assert_eq!(id("Mod+F3"), Some("edit.spike"));
|
||||
assert_eq!(id("Mod+Shift+F3"), Some("edit.insertSpike"));
|
||||
assert_eq!(id("F3"), Some("edit.findNext"));
|
||||
assert_eq!(id("Shift+F3"), Some("format.changeCase"));
|
||||
assert_eq!(id("F12"), Some("file.saveAs"));
|
||||
assert_eq!(id("Mod+Alt+M"), Some("review.newComment"));
|
||||
assert_eq!(id("Mod+H"), Some("edit.findReplace"));
|
||||
@@ -1438,6 +1443,102 @@ fn clipboard_pane_collects_copies_and_pastes_one_undoably() {
|
||||
assert!(s.run("edit.pasteClipboardItem", &json!({"index": 0})).is_err());
|
||||
}
|
||||
|
||||
fn spiked(s: &Session) -> Option<String> {
|
||||
s.building_blocks.get("Spike").map(|f| f.plain_text())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn spike_collects_cuts_and_insert_spike_pastes_them_and_empties_it() {
|
||||
let mut s = Session::new(wordcraft_doc::Document::from_text("Alpha beta gamma.\n"));
|
||||
run(&mut s, "select.text", json!({"text": "beta"}));
|
||||
run(&mut s, "edit.copy", json!({}));
|
||||
run(&mut s, "select.text", json!({"text": "Alpha"}));
|
||||
run(&mut s, "edit.spike", json!({}));
|
||||
run(&mut s, "select.text", json!({"text": "gamma"}));
|
||||
run(&mut s, "edit.spike", json!({}));
|
||||
assert_eq!(text(&s), " beta .\n", "Ctrl+F3 cuts");
|
||||
assert_eq!(spiked(&s).as_deref(), Some("Alpha\ngamma\n"), "each piece is followed by a paragraph mark");
|
||||
assert_eq!((s.clipboard_text.as_str(), s.clip_history.len()), ("beta", 1), "the clipboard is left alone");
|
||||
// Ctrl+Shift+F3 on the empty last paragraph: everything, in the order it was collected.
|
||||
run(&mut s, "caret.docEnd", json!({}));
|
||||
run(&mut s, "edit.insertSpike", json!({}));
|
||||
assert_eq!(text(&s), " beta .\nAlpha\ngamma\n");
|
||||
assert_eq!(spiked(&s), None, "Ctrl+Shift+F3 empties the Spike");
|
||||
assert!(matches!(s.run("edit.insertSpike", &json!({})), Err(crate::CmdError::Disabled(_))));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn spike_keeps_character_formatting() {
|
||||
let mut s = Session::new(wordcraft_doc::Document::from_text("Bold plain\n"));
|
||||
run(&mut s, "select.text", json!({"text": "Bold"}));
|
||||
run(&mut s, "format.bold", json!({}));
|
||||
run(&mut s, "edit.spike", json!({}));
|
||||
run(&mut s, "caret.docEnd", json!({}));
|
||||
run(&mut s, "edit.insertSpike", json!({}));
|
||||
assert_eq!(text(&s), " plain\nBold\n");
|
||||
let p = s.doc.para_at(&Pos::body(1, 0)).unwrap();
|
||||
assert_eq!(p.props_of_char(0).bold, Some(true));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn spike_and_insert_spike_are_one_undo_step_each() {
|
||||
let mut s = Session::new(wordcraft_doc::Document::from_text("one two\n"));
|
||||
run(&mut s, "select.text", json!({"text": "one"}));
|
||||
run(&mut s, "edit.spike", json!({}));
|
||||
run(&mut s, "edit.undo", json!({}));
|
||||
assert_eq!(text(&s), "one two\n");
|
||||
assert_eq!(spiked(&s).as_deref(), Some("one\n"), "like Cut and the clipboard, undo leaves the Spike as it is");
|
||||
run(&mut s, "caret.docEnd", json!({}));
|
||||
let before = text(&s);
|
||||
run(&mut s, "edit.insertSpike", json!({}));
|
||||
assert_eq!(text(&s), "one two\none\n");
|
||||
run(&mut s, "edit.undo", json!({}));
|
||||
assert_eq!(text(&s), before);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn spike_needs_a_selection() {
|
||||
let mut s = Session::new(wordcraft_doc::Document::from_text("one two"));
|
||||
let r = s.run("edit.spike", &json!({}));
|
||||
assert!(matches!(r, Err(crate::CmdError::Disabled(_))), "{r:?}");
|
||||
assert_eq!(spiked(&s), None);
|
||||
assert_eq!(text(&s), "one two");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn autotext_inserts_the_spike_without_emptying_it() {
|
||||
let mut s = Session::new(wordcraft_doc::Document::from_text("Alpha beta\n"));
|
||||
run(&mut s, "select.text", json!({"text": "Alpha"}));
|
||||
run(&mut s, "edit.spike", json!({}));
|
||||
run(&mut s, "caret.docEnd", json!({}));
|
||||
run(&mut s, "insert.autoText", json!({"insert": "Spike"}));
|
||||
run(&mut s, "insert.autoText", json!({"insert": "Spike"}));
|
||||
assert_eq!(text(&s), " beta\nAlpha\nAlpha\n");
|
||||
assert_eq!(spiked(&s).as_deref(), Some("Alpha\n"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn spike_outside_the_editable_regions_changes_nothing() {
|
||||
let mut s = Session::new(wordcraft_doc::Document::from_text("Locked intro.\nFill in here.\n"));
|
||||
run(&mut s, "select.text", json!({"text": "Fill in here."}));
|
||||
run(&mut s, "review.restrict", json!({"exception": "add"}));
|
||||
run(&mut s, "review.restrict", json!({"mode": "readOnly"}));
|
||||
// Collected inside the exception: allowed.
|
||||
run(&mut s, "select.text", json!({"text": "here"}));
|
||||
run(&mut s, "edit.spike", json!({}));
|
||||
assert_eq!(spiked(&s).as_deref(), Some("here\n"));
|
||||
// Outside it: refused, and neither the document nor the Spike changes.
|
||||
let before = text(&s);
|
||||
run(&mut s, "select.text", json!({"text": "intro"}));
|
||||
assert!(s.run("edit.spike", &json!({})).is_err());
|
||||
assert_eq!(text(&s), before);
|
||||
assert_eq!(spiked(&s).as_deref(), Some("here\n"));
|
||||
// Inserting outside it: refused, and the Spike keeps what it holds.
|
||||
assert!(s.run("edit.insertSpike", &json!({})).is_err());
|
||||
assert_eq!(text(&s), before);
|
||||
assert_eq!(spiked(&s).as_deref(), Some("here\n"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn clipboard_pane_is_capped_and_rejects_bad_indexes() {
|
||||
let mut s = s();
|
||||
@@ -7943,3 +8044,74 @@ fn quick_parts_use_the_documents_building_blocks() {
|
||||
assert!(s.run("insert.quickParts", &json!({"save": " ", "in": "document"})).is_err());
|
||||
assert!(s.run("insert.quickParts", &json!({"insert": "Closing"})).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn toc_collects_cell_headings() {
|
||||
use std::sync::Arc;
|
||||
use wordcraft_doc::{Block, CharProps, InlineObject, Paragraph, Path, Table, para_block};
|
||||
let mut earlier = Table::new(1, 1, 180.0);
|
||||
earlier.rows[0].cells[0].blocks = vec![para_block(Paragraph::with_text("Earlier", CharProps::default()).styled("Heading1"))];
|
||||
let mut nested = Table::new(1, 1, 180.0);
|
||||
nested.rows[0].cells[0].blocks = vec![para_block(Paragraph::with_text("Nested", CharProps::default()).styled("Heading3"))];
|
||||
let mut later = Paragraph::with_text("Later", CharProps::default());
|
||||
later.props.outline_level = Some(1);
|
||||
let mut table = Table::new(1, 1, 180.0);
|
||||
table.rows[0].cells[0].blocks =
|
||||
vec![para_block(later), Arc::new(Block::Table(nested)), para_block(Paragraph::with_text("Unlisted", CharProps::default()))];
|
||||
let mut toc = Paragraph::with_text("Contents", CharProps::default()).styled("TOCHeading");
|
||||
toc.insert_object(
|
||||
toc.len(),
|
||||
InlineObject::Field {
|
||||
instr: r#"TOC \o "1-3" \h \u"#.into(),
|
||||
result: String::new(),
|
||||
locked: false,
|
||||
dirty: true,
|
||||
code: false,
|
||||
form: String::new(),
|
||||
},
|
||||
&CharProps::default(),
|
||||
)
|
||||
.unwrap();
|
||||
let mut d = wordcraft_doc::Document::new();
|
||||
d.body = vec![
|
||||
Arc::new(Block::Table(earlier)),
|
||||
para_block(toc),
|
||||
para_block(Paragraph::with_text("\u{000C}", CharProps::default())),
|
||||
Arc::new(Block::Table(table)),
|
||||
];
|
||||
let mut s = Session::new(wordcraft_docx::read(&wordcraft_docx::write(&d).unwrap()).unwrap());
|
||||
for command in ["references.updateFields", "references.updateToc"] {
|
||||
run(&mut s, command, json!({}));
|
||||
let entries: Vec<_> = s
|
||||
.doc
|
||||
.body
|
||||
.iter()
|
||||
.filter_map(|b| b.as_para())
|
||||
.filter(|p| p.props.style.as_deref().is_some_and(|st| st.starts_with("TOC") && st != "TOCHeading"))
|
||||
.cloned()
|
||||
.collect();
|
||||
assert_eq!(entries.len(), 3, "{command}: {}", text(&s));
|
||||
let layout = s.layout();
|
||||
let bookmarks = s.doc.bookmarks();
|
||||
let mut pages = Vec::new();
|
||||
for (p, title) in entries.iter().zip(["Earlier", "Later", "Nested"]) {
|
||||
let value = p.plain_text();
|
||||
assert!(value.starts_with(title), "{value}");
|
||||
let shown: u32 = value.rsplit_once('\t').unwrap().1.parse().unwrap();
|
||||
let name = p.props_of_char(0).link.as_ref().unwrap().strip_prefix('#').unwrap();
|
||||
let (_, target) = bookmarks.iter().find(|(n, _)| n == name).unwrap();
|
||||
assert!(target.path.depth() > 0);
|
||||
let paragraph = s.doc.para(StoryRef::Body, &target.path).unwrap();
|
||||
assert_eq!(paragraph.plain_text(), title);
|
||||
let caret = layout.caret(target).unwrap();
|
||||
assert_eq!(shown, layout.pages[caret.page].number, "{title}");
|
||||
pages.push(caret.page);
|
||||
}
|
||||
assert!(pages[1] > pages[0]);
|
||||
assert_eq!(bookmarks.iter().filter(|(n, _)| n.starts_with("_Toc")).count(), 3);
|
||||
// The block-index-only link API must not misidentify a cell paragraph as a body block.
|
||||
let targets = s.run("link.targets", &json!({})).unwrap();
|
||||
assert_eq!(targets["headings"].as_array().unwrap().len(), 0);
|
||||
assert!(s.doc.para(StoryRef::Body, &Path::top(1)).is_some());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -129,3 +129,60 @@ fn a_date_field_in_a_header_is_refreshed_too() {
|
||||
.collect();
|
||||
assert_eq!(found, [today], "the header's field is refreshed as well as the body's");
|
||||
}
|
||||
|
||||
/// A minimal Word 97-2003 file whose only paragraph holds a `DATE` field cached as " 2001":
|
||||
/// FIB, one compressed text piece, and the Clx that points at it.
|
||||
fn binary_doc_with_stale_date() -> Vec<u8> {
|
||||
const TEXT_FC: usize = 0x600;
|
||||
let text = "\x13DATE \\@ \"yyyy\"\x14 2001\x15\r";
|
||||
let n = text.chars().count() as u32;
|
||||
let mut plc = Vec::new();
|
||||
for cp in [0u32, n] {
|
||||
plc.extend_from_slice(&cp.to_le_bytes());
|
||||
}
|
||||
plc.extend_from_slice(&[0, 0]);
|
||||
plc.extend_from_slice(&((2 * TEXT_FC as u32) | 0x4000_0000).to_le_bytes());
|
||||
plc.extend_from_slice(&0u16.to_le_bytes());
|
||||
let mut clx = vec![0x02u8];
|
||||
clx.extend_from_slice(&(plc.len() as u32).to_le_bytes());
|
||||
clx.extend_from_slice(&plc);
|
||||
let clx_at = 0x40usize;
|
||||
let mut table = vec![0u8; clx_at];
|
||||
table.extend_from_slice(&clx);
|
||||
|
||||
let mut word = vec![0u8; TEXT_FC];
|
||||
word.extend(text.chars().map(|c| c as u8));
|
||||
let put16 = |v: &mut Vec<u8>, at: usize, x: u16| v[at..at + 2].copy_from_slice(&x.to_le_bytes());
|
||||
let put32 = |v: &mut Vec<u8>, at: usize, x: u32| v[at..at + 4].copy_from_slice(&x.to_le_bytes());
|
||||
put16(&mut word, 0x00, 0xA5EC);
|
||||
put16(&mut word, 0x02, 0x00C1);
|
||||
put16(&mut word, 0x0A, 0x1000);
|
||||
put16(&mut word, 0x0C, 0x00BF);
|
||||
put16(&mut word, 0x20, 0x000E);
|
||||
put16(&mut word, 0x3E, 0x0016);
|
||||
put32(&mut word, 0x40, 0x1000);
|
||||
put32(&mut word, 0x4C, n);
|
||||
put16(&mut word, 0x98, 0x005D);
|
||||
put32(&mut word, 0x9A + 33 * 8, clx_at as u32);
|
||||
put32(&mut word, 0x9A + 33 * 8 + 4, clx.len() as u32);
|
||||
|
||||
let mut comp = cfb::CompoundFile::create(std::io::Cursor::new(Vec::new())).unwrap();
|
||||
for (name, data) in [("WordDocument", &word), ("0Table", &table)] {
|
||||
let mut s = comp.create_stream(format!("/{name}")).unwrap();
|
||||
s.write_all(data).unwrap();
|
||||
}
|
||||
comp.into_inner().into_inner()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn binary_doc_opened_by_extension_refreshes_date_fields() {
|
||||
let bytes = binary_doc_with_stale_date();
|
||||
// The same bytes under a `.docx` name are read by content, which refreshes the field.
|
||||
let expected = fields(&wordcraft_engine::io::open_bytes("d.docx", &bytes).expect("opens by content"));
|
||||
assert_eq!(expected.len(), 1);
|
||||
assert!(!expected[0].1.contains("2001"), "reference should be refreshed: {expected:?}");
|
||||
for name in ["d.doc", "d.dot"] {
|
||||
let got = fields(&wordcraft_engine::io::open_bytes(name, &bytes).expect("opens"));
|
||||
assert_eq!(got, expected, "{name}: DATE field stale after opening");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,7 +7,7 @@ use std::sync::{Arc, Mutex, RwLock};
|
||||
|
||||
use kurbo::BezPath;
|
||||
use skrifa::instance::{Location, LocationRef, Size};
|
||||
use skrifa::outline::{DrawSettings, OutlinePen};
|
||||
use skrifa::outline::{DrawSettings, Engine, HintingInstance, HintingOptions, OutlinePen, Target};
|
||||
use skrifa::raw::FileRef;
|
||||
use skrifa::string::StringId;
|
||||
use skrifa::{GlyphId, MetadataProvider};
|
||||
@@ -84,6 +84,8 @@ pub struct FontFace {
|
||||
pub instance: Option<harfrust::ShaperInstance>,
|
||||
/// Basic Multilingual Plane coverage bitset, built on first use.
|
||||
bmp: std::sync::OnceLock<Box<[u64]>>,
|
||||
// Some legacy CJK fonts assemble their contours with TrueType instructions.
|
||||
outline_hinter: std::sync::OnceLock<Option<HintingInstance>>,
|
||||
}
|
||||
|
||||
/// A cheap `Copy` handle to a face. Faces are never unloaded, so the handle lives for the rest of
|
||||
@@ -177,7 +179,28 @@ impl FontFace {
|
||||
if let Some(f) = self.skrifa()
|
||||
&& let Some(g) = f.outline_glyphs().get(GlyphId::new(gid))
|
||||
{
|
||||
let _ = g.draw(DrawSettings::unhinted(Size::unscaled(), self.location()), &mut pen);
|
||||
let hinter = self.outline_hinter.get_or_init(|| {
|
||||
let outlines = f.outline_glyphs();
|
||||
if !outlines.require_interpreter() {
|
||||
return None;
|
||||
}
|
||||
// At UPEM pixels, hinted coordinates remain in font units. This
|
||||
// executes contour assembly without tying the cache to view zoom.
|
||||
HintingInstance::new(
|
||||
&outlines,
|
||||
Size::new(self.upem as f32),
|
||||
self.location(),
|
||||
HintingOptions { engine: Engine::Interpreter, target: Target::Mono },
|
||||
)
|
||||
.map_err(|e| log::warn!("Cannot initialize outline interpreter for {}: {e}", self.family))
|
||||
.ok()
|
||||
});
|
||||
let settings =
|
||||
hinter.as_ref().map_or_else(|| DrawSettings::unhinted(Size::unscaled(), self.location()), |h| DrawSettings::hinted(h, false));
|
||||
if let Err(e) = g.draw(settings, &mut pen) {
|
||||
log::warn!("Cannot draw glyph {gid} in {}: {e}", self.family);
|
||||
return BezPath::new();
|
||||
}
|
||||
}
|
||||
pen.0
|
||||
}
|
||||
@@ -862,6 +885,7 @@ fn make_face(bytes: FontBytes, index: u32, family: String, style: String, legacy
|
||||
bytes,
|
||||
index,
|
||||
bmp: std::sync::OnceLock::new(),
|
||||
outline_hinter: std::sync::OnceLock::new(),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1761,6 +1785,26 @@ mod tests_sysfonts;
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
#[test]
|
||||
#[ignore = "requires a locally licensed kaiu.ttf; set WORDCRAFT_TEST_KAI_FONT"]
|
||||
fn kai_contours_include_complete_character_body() {
|
||||
use kurbo::Shape;
|
||||
let path = std::env::var_os("WORDCRAFT_TEST_KAI_FONT").expect("set WORDCRAFT_TEST_KAI_FONT");
|
||||
let db = super::FontDb::with_font_dirs(vec![]);
|
||||
db.set_system_fallback(false);
|
||||
assert!(db.add_font(std::fs::read(path).unwrap()) > 0);
|
||||
let face = db.face("DFKai-SB", "Regular");
|
||||
assert_eq!(face.family, "DFKai-SB");
|
||||
for ch in ['中', '文', '契', '約'] {
|
||||
let outline = face.glyph_outline(face.glyph_for(ch));
|
||||
let bounds = outline.bounding_box();
|
||||
eprintln!("{ch}: {bounds:?}, upem={}", face.upem);
|
||||
assert!(bounds.width() > face.upem * 0.5, "{ch}: missing horizontal strokes");
|
||||
assert!(bounds.height() > face.upem * 0.5, "{ch}: missing vertical strokes");
|
||||
assert!(std::sync::Arc::ptr_eq(&db.outline(&face, face.glyph_for(ch)), &db.outline(&face, face.glyph_for(ch))));
|
||||
}
|
||||
}
|
||||
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
|
||||
+172
-17
@@ -41,18 +41,19 @@ pub fn decode_entity(name: &str) -> Option<String> {
|
||||
"lt" | "LT" => '<',
|
||||
"gt" | "GT" => '>',
|
||||
"quot" | "QUOT" => '"',
|
||||
"apos" => '\'',
|
||||
"nbsp" => '\u{00A0}',
|
||||
"apos" | "APOS" => '\'',
|
||||
"nbsp" | "NBSP" => '\u{00A0}',
|
||||
"ensp" => '\u{2002}',
|
||||
"emsp" => '\u{2003}',
|
||||
"thinsp" => '\u{2009}',
|
||||
"shy" => '\u{00AD}',
|
||||
"copy" => '©',
|
||||
"reg" => '®',
|
||||
"trade" => '™',
|
||||
"copy" | "COPY" => '©',
|
||||
"reg" | "REG" => '®',
|
||||
"trade" | "TRADE" => '™',
|
||||
"hellip" => '…',
|
||||
"mdash" => '—',
|
||||
"ndash" => '–',
|
||||
"minus" => '−',
|
||||
"lsquo" => '‘',
|
||||
"rsquo" => '’',
|
||||
"sbquo" => '‚',
|
||||
@@ -70,12 +71,28 @@ pub fn decode_entity(name: &str) -> Option<String> {
|
||||
"frac12" => '½',
|
||||
"frac14" => '¼',
|
||||
"frac34" => '¾',
|
||||
"frac13" => '⅓',
|
||||
"frac23" => '⅔',
|
||||
"frac18" => '⅛',
|
||||
"frac38" => '⅜',
|
||||
"frac58" => '⅝',
|
||||
"frac78" => '⅞',
|
||||
"sup1" => '¹',
|
||||
"sup2" => '²',
|
||||
"sup3" => '³',
|
||||
"micro" => 'µ',
|
||||
"permil" => '‰',
|
||||
"prime" => '′',
|
||||
"Prime" => '″',
|
||||
"oline" => '‾',
|
||||
"frasl" => '⁄',
|
||||
"sect" => '§',
|
||||
"para" => '¶',
|
||||
"euro" => '€',
|
||||
"pound" => '£',
|
||||
"yen" => '¥',
|
||||
"cent" => '¢',
|
||||
"euro" | "EURO" => '€',
|
||||
"pound" | "POUND" => '£',
|
||||
"yen" | "YEN" => '¥',
|
||||
"cent" | "CENT" => '¢',
|
||||
"curren" => '¤',
|
||||
"dagger" => '†',
|
||||
"Dagger" => '‡',
|
||||
"larr" => '←',
|
||||
@@ -83,10 +100,53 @@ pub fn decode_entity(name: &str) -> Option<String> {
|
||||
"uarr" => '↑',
|
||||
"darr" => '↓',
|
||||
"harr" => '↔',
|
||||
"lArr" => '⇐',
|
||||
"rArr" => '⇒',
|
||||
"uArr" => '⇑',
|
||||
"dArr" => '⇓',
|
||||
"hArr" => '⇔',
|
||||
"crarr" => '↵',
|
||||
"forall" => '∀',
|
||||
"part" => '∂',
|
||||
"exist" => '∃',
|
||||
"empty" => '∅',
|
||||
"nabla" => '∇',
|
||||
"isin" => '∈',
|
||||
"notin" => '∉',
|
||||
"ni" => '∋',
|
||||
"prod" => '∏',
|
||||
"sum" => '∑',
|
||||
"radic" => '√',
|
||||
"prop" => '∝',
|
||||
"ang" => '∠',
|
||||
"and" => '∧',
|
||||
"or" => '∨',
|
||||
"cap" => '∩',
|
||||
"cup" => '∪',
|
||||
"int" => '∫',
|
||||
"there4" => '∴',
|
||||
"sim" => '∼',
|
||||
"cong" => '≅',
|
||||
"asymp" | "approx" => '≈',
|
||||
"ne" => '≠',
|
||||
"equiv" => '≡',
|
||||
"le" => '≤',
|
||||
"ge" => '≥',
|
||||
"ne" => '≠',
|
||||
"sub" => '⊂',
|
||||
"sup" => '⊃',
|
||||
"nsub" => '⊄',
|
||||
"sube" => '⊆',
|
||||
"supe" => '⊇',
|
||||
"oplus" => '⊕',
|
||||
"otimes" => '⊗',
|
||||
"perp" => '⊥',
|
||||
"sdot" => '⋅',
|
||||
"infin" => '∞',
|
||||
"loz" => '◊',
|
||||
"spades" => '♠',
|
||||
"clubs" => '♣',
|
||||
"hearts" => '♥',
|
||||
"diams" => '♦',
|
||||
"check" => '✓',
|
||||
"iexcl" => '¡',
|
||||
"iquest" => '¿',
|
||||
@@ -100,13 +160,44 @@ pub fn decode_entity(name: &str) -> Option<String> {
|
||||
"eacute" => 'é',
|
||||
"egrave" => 'è',
|
||||
"ecirc" => 'ê',
|
||||
"euml" => 'ë',
|
||||
"Eacute" => 'É',
|
||||
"Egrave" => 'È',
|
||||
"Ecirc" => 'Ê',
|
||||
"Euml" => 'Ë',
|
||||
"aacute" => 'á',
|
||||
"agrave" => 'à',
|
||||
"acirc" => 'â',
|
||||
"atilde" => 'ã',
|
||||
"Agrave" => 'À',
|
||||
"Aacute" => 'Á',
|
||||
"Acirc" => 'Â',
|
||||
"Atilde" => 'Ã',
|
||||
"iacute" => 'í',
|
||||
"igrave" => 'ì',
|
||||
"icirc" => 'î',
|
||||
"iuml" => 'ï',
|
||||
"Iacute" => 'Í',
|
||||
"Igrave" => 'Ì',
|
||||
"Icirc" => 'Î',
|
||||
"Iuml" => 'Ï',
|
||||
"oacute" => 'ó',
|
||||
"ograve" => 'ò',
|
||||
"ocirc" => 'ô',
|
||||
"otilde" => 'õ',
|
||||
"Oacute" => 'Ó',
|
||||
"Ograve" => 'Ò',
|
||||
"Ocirc" => 'Ô',
|
||||
"Otilde" => 'Õ',
|
||||
"uacute" => 'ú',
|
||||
"ugrave" => 'ù',
|
||||
"ucirc" => 'û',
|
||||
"Uacute" => 'Ú',
|
||||
"Ugrave" => 'Ù',
|
||||
"Ucirc" => 'Û',
|
||||
"yacute" => 'ý',
|
||||
"yuml" => 'ÿ',
|
||||
"Yacute" => 'Ý',
|
||||
"ntilde" => 'ñ',
|
||||
"Ntilde" => 'Ñ',
|
||||
"ccedil" => 'ç',
|
||||
@@ -121,11 +212,36 @@ pub fn decode_entity(name: &str) -> Option<String> {
|
||||
"beta" => 'β',
|
||||
"gamma" => 'γ',
|
||||
"delta" => 'δ',
|
||||
"pi" => 'π',
|
||||
"sigma" => 'σ',
|
||||
"epsilon" => 'ε',
|
||||
"zeta" => 'ζ',
|
||||
"eta" => 'η',
|
||||
"theta" => 'θ',
|
||||
"iota" => 'ι',
|
||||
"kappa" => 'κ',
|
||||
"lambda" => 'λ',
|
||||
"mu" => 'μ',
|
||||
"Omega" => 'Ω',
|
||||
"nu" => 'ν',
|
||||
"xi" => 'ξ',
|
||||
"pi" => 'π',
|
||||
"rho" => 'ρ',
|
||||
"sigma" => 'σ',
|
||||
"tau" => 'τ',
|
||||
"upsilon" => 'υ',
|
||||
"phi" => 'φ',
|
||||
"chi" => 'χ',
|
||||
"psi" => 'ψ',
|
||||
"omega" => 'ω',
|
||||
"Gamma" => 'Γ',
|
||||
"Delta" => 'Δ',
|
||||
"Theta" => 'Θ',
|
||||
"Lambda" => 'Λ',
|
||||
"Xi" => 'Ξ',
|
||||
"Pi" => 'Π',
|
||||
"Sigma" => 'Σ',
|
||||
"Upsilon" => 'Υ',
|
||||
"Phi" => 'Φ',
|
||||
"Psi" => 'Ψ',
|
||||
"Omega" => 'Ω',
|
||||
"zwj" => '\u{200D}',
|
||||
"zwnj" => '\u{200C}',
|
||||
_ => return None,
|
||||
@@ -347,6 +463,7 @@ struct El {
|
||||
align: Option<Align>,
|
||||
preserve: bool,
|
||||
page_break: bool,
|
||||
page_break_after: bool,
|
||||
/// Li: a paragraph of this item was already emitted.
|
||||
used: bool,
|
||||
}
|
||||
@@ -389,6 +506,8 @@ struct Builder<'r> {
|
||||
para: Option<Para>,
|
||||
/// The current paragraph continues a list item (joins the previous paragraph).
|
||||
para_cont: bool,
|
||||
/// Pending page break from preceding elements with page-break-after or unconsumed break.
|
||||
pending_page_break: bool,
|
||||
/// Whether the current paragraph's first / last text was read with preserved white space
|
||||
/// (CSS `white-space: pre…`): those edge spaces are kept when the paragraph ends.
|
||||
pre_lead: Option<bool>,
|
||||
@@ -508,7 +627,7 @@ pub fn css_len_pt(v: &str) -> Option<f32> {
|
||||
}
|
||||
|
||||
/// Apply a `style` attribute to formatting / block properties.
|
||||
fn apply_style(css: &str, f: &mut Fmt, align: &mut Option<Align>, preserve: &mut bool, page_break: &mut bool) {
|
||||
fn apply_style(css: &str, f: &mut Fmt, align: &mut Option<Align>, preserve: &mut bool, page_break: &mut bool, page_break_after: &mut bool) {
|
||||
for decl in css.split(';').take(64) {
|
||||
let Some((k, v)) = decl.split_once(':') else { continue };
|
||||
let k = k.trim().to_ascii_lowercase();
|
||||
@@ -567,6 +686,7 @@ fn apply_style(css: &str, f: &mut Fmt, align: &mut Option<Align>, preserve: &mut
|
||||
}
|
||||
"white-space" => *preserve = lv.starts_with("pre"),
|
||||
"page-break-before" | "break-before" => *page_break = lv == "always" || lv == "page",
|
||||
"page-break-after" | "break-after" => *page_break_after = lv == "always" || lv == "page",
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
@@ -586,6 +706,7 @@ impl<'r> Builder<'r> {
|
||||
stack: Vec::new(),
|
||||
para: None,
|
||||
para_cont: false,
|
||||
pending_page_break: false,
|
||||
pre_lead: None,
|
||||
pre_tail: false,
|
||||
meta: Meta::default(),
|
||||
@@ -655,7 +776,8 @@ impl<'r> Builder<'r> {
|
||||
} else {
|
||||
self.para_cont = false;
|
||||
}
|
||||
p.page_break = self.stack.iter().rev().take_while(|e| e.kind != ElKind::Cell).any(|e| e.page_break);
|
||||
let pb_before = self.stack.iter_mut().rev().take_while(|e| e.kind != ElKind::Cell).any(|e| std::mem::take(&mut e.page_break));
|
||||
p.page_break = pb_before || std::mem::take(&mut self.pending_page_break);
|
||||
self.para = Some(p);
|
||||
}
|
||||
self.para.get_or_insert_with(Para::default)
|
||||
@@ -805,6 +927,9 @@ impl<'r> Builder<'r> {
|
||||
}
|
||||
|
||||
fn close(&mut self, e: &El) {
|
||||
if e.page_break_after || e.page_break {
|
||||
self.pending_page_break = true;
|
||||
}
|
||||
if e.kind != ElKind::Inline {
|
||||
self.flush();
|
||||
}
|
||||
@@ -954,6 +1079,11 @@ impl<'r> Builder<'r> {
|
||||
self.container().push(FBlock::Para(Para::new(Kind::Rule)));
|
||||
return;
|
||||
}
|
||||
"wbr" => {
|
||||
let f = self.fmt();
|
||||
self.start_para().push_text("\u{200B}", &f);
|
||||
return;
|
||||
}
|
||||
"img" => {
|
||||
let alt = attr(attrs, "alt").unwrap_or("").to_string();
|
||||
let dim = |k: &str| attr(attrs, k).and_then(|v| v.trim().trim_end_matches("px").trim().parse::<f32>().ok()).map(|p| p * 0.75);
|
||||
@@ -1003,6 +1133,7 @@ impl<'r> Builder<'r> {
|
||||
let mut align = None;
|
||||
let mut preserve = self.preserve();
|
||||
let mut page_break = false;
|
||||
let mut page_break_after = false;
|
||||
match name {
|
||||
// A header cell is bold, as in browsers and Word, unless its own CSS says otherwise.
|
||||
"b" | "strong" | "th" => fmt.bold = true,
|
||||
@@ -1051,7 +1182,7 @@ impl<'r> Builder<'r> {
|
||||
align = Some(Align::Center);
|
||||
}
|
||||
if let Some(st) = attr(attrs, "style") {
|
||||
apply_style(st, &mut fmt, &mut align, &mut preserve, &mut page_break);
|
||||
apply_style(st, &mut fmt, &mut align, &mut preserve, &mut page_break, &mut page_break_after);
|
||||
}
|
||||
if kind == ElKind::Pre {
|
||||
preserve = true;
|
||||
@@ -1095,7 +1226,7 @@ impl<'r> Builder<'r> {
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
self.stack.push(El { name: name.to_string(), kind, fmt, align, preserve, page_break, used: false });
|
||||
self.stack.push(El { name: name.to_string(), kind, fmt, align, preserve, page_break, page_break_after, used: false });
|
||||
}
|
||||
|
||||
fn end(&mut self, name: &str) {
|
||||
@@ -1785,5 +1916,29 @@ mod tests {
|
||||
#[test]
|
||||
fn entities() {
|
||||
assert_eq!(unescape("a <b> AB &bogus; &"), "a <b> AB &bogus; &");
|
||||
assert_eq!(unescape("− © &EURO; ™ ½ ¾"), "− © € ™ ½ ¾");
|
||||
assert_eq!(unescape("θ λ Δ ∇ ∑ √ ≠ ≤ ≥ ♥"), "θ λ Δ ∇ ∑ √ ≠ ≤ ≥ ♥");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wbr_word_break_opportunity() {
|
||||
let f = parse("<p>Super<wbr>cali<wbr>fragilistic</p>");
|
||||
let Some(FBlock::Para(p)) = f.blocks.first() else { panic!("expected para") };
|
||||
assert_eq!(p.text(), "Super\u{200B}cali\u{200B}fragilistic");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn page_break_after_and_before() {
|
||||
let f = parse("<p style='page-break-after: always'>First</p><p>Second</p>");
|
||||
let paras: Vec<_> = f.blocks.iter().filter_map(|b| if let FBlock::Para(p) = b { Some(p) } else { None }).collect();
|
||||
assert_eq!(paras.len(), 2);
|
||||
assert!(!paras[0].page_break);
|
||||
assert!(paras[1].page_break);
|
||||
|
||||
let f2 = parse("<div style='break-after: page'><p>Part 1</p></div><p>Part 2</p>");
|
||||
let paras2: Vec<_> = f2.blocks.iter().filter_map(|b| if let FBlock::Para(p) = b { Some(p) } else { None }).collect();
|
||||
assert_eq!(paras2.len(), 2);
|
||||
assert!(!paras2[0].page_break);
|
||||
assert!(paras2[1].page_break);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,6 +6,8 @@
|
||||
//! breaks; the lists make sure of it for the standard Japanese/Chinese sets.
|
||||
//! - Kinsoku off: a line may break between any two East Asian characters, punctuation included.
|
||||
//! - Word wrap off: Latin words may break at any character.
|
||||
//! - Automatic spacing (`autoSpaceDE`, `autoSpaceDN`): which neighbouring characters get room
|
||||
//! between Asian text and Latin text or digits ([`auto_space`]).
|
||||
//!
|
||||
//! Only East Asian characters are in the lists, and the extra breaks need letters on both sides,
|
||||
//! so text with no East Asian characters breaks exactly as before unless word wrap is off.
|
||||
@@ -69,9 +71,56 @@ pub fn apply(text: &str, opps: &mut Vec<usize>, kinsoku: bool, word_wrap: bool)
|
||||
opps.dedup();
|
||||
}
|
||||
|
||||
/// Which of two adjacent characters is the Asian one, when the paragraph's automatic spacing puts
|
||||
/// room between them.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum AsianSide {
|
||||
Left,
|
||||
Right,
|
||||
}
|
||||
|
||||
/// Whether the paragraph's `autoSpaceDE` (Asian and Latin text) or `autoSpaceDN` (Asian text and
|
||||
/// digits) spaces `a` from the `b` right after it, and which side is Asian. Asian text is
|
||||
/// ideographs, kana and bopomofo, not their punctuation: Word leaves "5、" and "、8" alone. Hangul
|
||||
/// is neither Asian nor Latin here, and Latin punctuation such as `%` or `(` is not Latin.
|
||||
pub fn auto_space(a: char, b: char, de: bool, dn: bool) -> Option<AsianSide> {
|
||||
let spaced = |other: char| (de && is_latin_letter(other)) || (dn && other.is_ascii_digit());
|
||||
if is_asian_text(a) && spaced(b) {
|
||||
Some(AsianSide::Left)
|
||||
} else if is_asian_text(b) && spaced(a) {
|
||||
Some(AsianSide::Right)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
fn is_asian_text(c: char) -> bool {
|
||||
is_east_asian(c) && c.is_alphanumeric() && !is_hangul(c)
|
||||
}
|
||||
|
||||
fn is_latin_letter(c: char) -> bool {
|
||||
c.is_alphabetic() && !is_east_asian(c) && !is_hangul(c)
|
||||
}
|
||||
|
||||
/// Hangul jamo, compatibility jamo, extended jamo, syllables and halfwidth Hangul.
|
||||
fn is_hangul(c: char) -> bool {
|
||||
matches!(c as u32, 0x1100..=0x11FF | 0x3130..=0x318F | 0xA960..=0xA97F | 0xAC00..=0xD7FF | 0xFFA0..=0xFFDC)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::apply;
|
||||
use super::{AsianSide, apply, auto_space};
|
||||
|
||||
#[test]
|
||||
fn auto_space_pairs() {
|
||||
assert_eq!(auto_space('院', '9', true, true), Some(AsianSide::Left));
|
||||
assert_eq!(auto_space('A', 'カ', true, true), Some(AsianSide::Right));
|
||||
assert_eq!(auto_space('院', '9', true, false), None);
|
||||
assert_eq!(auto_space('A', '字', false, true), None);
|
||||
for (a, b) in [('5', '、'), ('、', '8'), ('漢', '%'), ('漢', '한'), ('한', 'A'), ('a', 'b'), ('漢', '字'), ('漢', '1')] {
|
||||
assert_eq!(auto_space(a, b, true, true), None, "{a}{b}");
|
||||
}
|
||||
}
|
||||
|
||||
/// Where a line may start, as the text from there to the next opportunity.
|
||||
fn breaks(text: &str, kinsoku: bool, word_wrap: bool) -> Vec<String> {
|
||||
|
||||
@@ -3680,6 +3680,43 @@ fn next_first_line(ctx: &mut Ctx, block: usize, width: f32, depth: usize) -> f32
|
||||
}
|
||||
}
|
||||
|
||||
fn row_keeps_next(row: &table::RowLayout) -> bool {
|
||||
let Some(items) = row.cells.first().and_then(|cell| row.items.get(cell.items.clone())) else { return false };
|
||||
let Some((depth, story)) = items
|
||||
.iter()
|
||||
.filter_map(|item| match item {
|
||||
Placed::Cell { table, story, .. } => Some((table.0.len() + 3, *story)),
|
||||
_ => None,
|
||||
})
|
||||
.min_by_key(|(depth, _)| *depth)
|
||||
else {
|
||||
return false;
|
||||
};
|
||||
// Word takes the row's keep flag from the first paragraph of its first cell.
|
||||
items
|
||||
.iter()
|
||||
.find_map(|item| match item {
|
||||
Placed::Lines { path, story: own, para, .. } if path.0.len() == depth && path.0.last() == Some(&0) && *own == story => {
|
||||
Some(para.rp.keep_next)
|
||||
}
|
||||
_ => None,
|
||||
})
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
fn row_can_start(row: &wordcraft_doc::Row, laid: &table::RowLayout, room: f32, page_height: f32) -> bool {
|
||||
if laid.height <= room + 0.01 {
|
||||
return true;
|
||||
}
|
||||
if row.props.header || row.props.cant_split || (row.props.height_rule == HeightRule::Exact && row.props.height.is_some_and(|h| h > 0.0)) {
|
||||
return false;
|
||||
}
|
||||
if row.props.height_rule == HeightRule::AtLeast && row.props.height.is_some_and(|h| h > room + 0.01 && h <= page_height + 0.01) {
|
||||
return false;
|
||||
}
|
||||
table::split_row(laid, room).is_some()
|
||||
}
|
||||
|
||||
fn place_table(ctx: &mut Ctx, pb: &mut PageBuilder, t: &wordcraft_doc::Table, block: usize, body_top: f32) {
|
||||
pb.prev = None;
|
||||
let width = pb.col_w();
|
||||
@@ -3707,6 +3744,19 @@ fn place_table(ctx: &mut Ctx, pb: &mut PageBuilder, t: &wordcraft_doc::Table, bl
|
||||
// table (e.g. 3 header rows over 30 000 pages).
|
||||
const MAX_HEADER_REPEATS: usize = 200_000;
|
||||
let mut header_repeats = 0usize;
|
||||
// Resolve kept chains once; nested paragraphs cannot keep their enclosing row.
|
||||
let mut kept: Vec<(usize, f32)> = (0..tl.rows.len()).map(|i| (i, 0.0)).collect();
|
||||
for i in (0..tl.rows.len().saturating_sub(1)).rev() {
|
||||
let Some(row) = tl.rows.get(i) else { continue };
|
||||
if i < header_rows.len() || !row_keeps_next(row) {
|
||||
continue;
|
||||
}
|
||||
let Some((end, height)) = kept.get(i + 1).copied() else { continue };
|
||||
if let Some(group) = kept.get_mut(i) {
|
||||
*group = (end, row.height + height);
|
||||
}
|
||||
}
|
||||
let repeated_height: f32 = header_rows.iter().filter_map(|i| tl.rows.get(*i)).map(|r| r.height).sum();
|
||||
let place = |pb: &mut PageBuilder, row: &table::RowLayout| {
|
||||
let (x, y) = (pb.col_x() + tl.x, pb.y);
|
||||
if let Some(pg) = pb.page() {
|
||||
@@ -3745,7 +3795,23 @@ fn place_table(ctx: &mut Ctx, pb: &mut PageBuilder, t: &wordcraft_doc::Table, bl
|
||||
// Each pass places the part of the row that fits, then breaks the page.
|
||||
for _ in 0..1000 {
|
||||
let cur = rest.as_ref().unwrap_or(row);
|
||||
if pb.y + cur.height <= pb.bottom + 0.01 {
|
||||
// A fitting kept row follows the next row when that row cannot start below it.
|
||||
// Move only the prefix's start; an oversized follower cannot separate its kept rows.
|
||||
let defer_kept = pb.y + cur.height <= pb.bottom + 0.01
|
||||
&& !pb.web
|
||||
&& !pb.at_top()
|
||||
&& !fresh
|
||||
&& rest.is_none()
|
||||
&& !ri.checked_sub(1).and_then(|prev| kept.get(prev)).is_some_and(|&(end, _)| end >= ri)
|
||||
&& kept.get(ri).is_some_and(|&(end, height)| {
|
||||
if end == ri {
|
||||
return false;
|
||||
}
|
||||
let (Some(next), Some(laid)) = (t.rows.get(end), tl.rows.get(end)) else { return false };
|
||||
let page_height = pb.bottom - body_top - repeated_height;
|
||||
height <= page_height + 0.01 && !row_can_start(next, laid, pb.bottom - pb.y - height, page_height)
|
||||
});
|
||||
if pb.y + cur.height <= pb.bottom + 0.01 && !defer_kept {
|
||||
break;
|
||||
}
|
||||
// Measured in Word: a row with an at-least height doesn't split where less than that
|
||||
@@ -3756,7 +3822,7 @@ fn place_table(ctx: &mut Ctx, pb: &mut PageBuilder, t: &wordcraft_doc::Table, bl
|
||||
r.props.height_rule == wordcraft_doc::props::HeightRule::AtLeast
|
||||
&& r.props.height.is_some_and(|h| h > room + 0.01 && h <= pb.bottom - body_top + 0.01)
|
||||
});
|
||||
let split = if splittable && !short_of_min { table::split_row(cur, room) } else { None };
|
||||
let split = if splittable && !short_of_min && !defer_kept { table::split_row(cur, room) } else { None };
|
||||
match split {
|
||||
Some((a, b)) => {
|
||||
place(pb, &a);
|
||||
@@ -4158,6 +4224,8 @@ pub fn now_ms() -> f64 {
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
#[cfg(test)]
|
||||
mod tests_autospace;
|
||||
#[cfg(test)]
|
||||
mod tests_bidi;
|
||||
#[cfg(test)]
|
||||
mod tests_complex;
|
||||
|
||||
@@ -6,7 +6,7 @@ use std::sync::Arc;
|
||||
|
||||
use wordcraft_doc::math::MathJc;
|
||||
use wordcraft_doc::numbering::{Level, LevelSuffix};
|
||||
use wordcraft_doc::para::{COLUMN_BREAK, InlineObject, LINE_BREAK, NoteKind, OBJ, PAGE_BREAK, RubyAlign, SOFT_HYPHEN, WrapText};
|
||||
use wordcraft_doc::para::{COLUMN_BREAK, InlineObject, LINE_BREAK, NoteKind, OBJ, PAGE_BREAK, PTabAlign, PTabBase, RubyAlign, SOFT_HYPHEN, WrapText};
|
||||
use wordcraft_doc::props::{Align, CharProps, LineSpacing, ParaProps, TabAlign, TabLeader, TabStop};
|
||||
use wordcraft_doc::resolve::{ResolvedChar, ResolvedPara};
|
||||
use wordcraft_doc::section::NumFormat;
|
||||
@@ -362,6 +362,8 @@ pub struct ParaLayout {
|
||||
pub rubies: Vec<RubyLayout>,
|
||||
/// Display equations (on lines of their own): cluster index and justification.
|
||||
pub displays: Vec<(usize, wordcraft_doc::math::MathJc)>,
|
||||
/// Positional tabs (laid out as tab clusters): cluster index, alignment, base and leader.
|
||||
pub ptabs: Vec<(usize, PTabAlign, PTabBase, TabLeader)>,
|
||||
/// Byte ranges of the text laid out as nothing ([`left_out`]): hidden text that is not shown,
|
||||
/// tracked deletions in the final text. Resolved as laid out, table formatting included.
|
||||
pub left_out: Vec<std::ops::Range<usize>>,
|
||||
@@ -371,6 +373,9 @@ pub struct ParaLayout {
|
||||
pub shown: Vec<(usize, String, Vec<std::ops::Range<usize>>)>,
|
||||
/// Shapes drawn around overstruck field text (enclosed characters, `EQ \o`), by cluster index.
|
||||
pub enclosures: Vec<(usize, Enclosed)>,
|
||||
/// Room after a cluster between Asian text and Latin text or digits ([`auto_space`]), by
|
||||
/// cluster index, sorted. It is part of the cluster's advance, but takes no room at a line's end.
|
||||
pub auto_space: Vec<(usize, f32)>,
|
||||
}
|
||||
|
||||
/// A shape drawn over a cluster (an enclosed character's circle, square, triangle or diamond),
|
||||
@@ -498,6 +503,54 @@ fn snap_chars(b: &mut Builder, text: &str, grid: &Grid) {
|
||||
}
|
||||
}
|
||||
|
||||
/// Automatic spacing (`w:autoSpaceDE`, `w:autoSpaceDN`): a quarter of the Asian character's font
|
||||
/// size between Asian text and Latin text or digits that touch, across runs. Measured on Word's
|
||||
/// PDF of a Chinese document at 16 pt (#2358), as JIS X 4051 asks. A field or note number is
|
||||
/// classed by the text it shows. The room goes after the first of the two characters; returns it
|
||||
/// by cluster index.
|
||||
fn auto_space(b: &mut Builder, text: &str, de: bool, dn: bool) -> Vec<(usize, f32)> {
|
||||
let mut out = Vec::new();
|
||||
if !de && !dn {
|
||||
return out;
|
||||
}
|
||||
let mut shown = b.shown.iter().map(|s| (s.0, s.1.as_str())).peekable();
|
||||
// The previous text cluster: index, its last character, its font size.
|
||||
let mut prev: Option<(usize, char, f32)> = None;
|
||||
for k in 0..b.clusters.len() {
|
||||
let Some(c) = b.clusters.get(k) else { break };
|
||||
match c.kind {
|
||||
ClKind::Text => {}
|
||||
ClKind::Marker if c.adv == 0.0 => continue,
|
||||
_ => {
|
||||
prev = None;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
while shown.next_if(|s| s.0 < k).is_some() {}
|
||||
let s = match shown.peek() {
|
||||
Some(&(i, t)) if i == k => t,
|
||||
_ => text.get(c.start..c.end).unwrap_or(""),
|
||||
};
|
||||
let (Some(first), Some(last)) = (s.chars().next(), s.chars().next_back()) else {
|
||||
prev = None;
|
||||
continue;
|
||||
};
|
||||
let size = b.styles.get(c.style as usize).map_or(0.0, |st| st.size);
|
||||
if let Some((pk, pc, psize)) = prev
|
||||
&& let Some(side) = crate::kinsoku::auto_space(pc, first, de, dn)
|
||||
{
|
||||
let asian = if side == crate::kinsoku::AsianSide::Left { psize } else { size };
|
||||
let gap = if asian.is_finite() { (asian / 4.0).clamp(0.0, 400.0) } else { 0.0 };
|
||||
if let Some(pc) = b.clusters.get_mut(pk) {
|
||||
pc.adv += gap;
|
||||
out.push((pk, gap));
|
||||
}
|
||||
}
|
||||
prev = Some((k, last, size));
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// An area text wraps around.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct Exclusion {
|
||||
@@ -1350,6 +1403,7 @@ pub fn layout_para(p: &Paragraph, env: &ParaEnv) -> ParaLayout {
|
||||
let mut enclosures = Vec::new();
|
||||
let mut rubies = Vec::new();
|
||||
let mut displays = Vec::new();
|
||||
let mut ptabs = Vec::new();
|
||||
let mut eq_counter = env.eq_number;
|
||||
let math_props = doc.settings.math.clone().unwrap_or_default();
|
||||
// The byte ranges of runs left out of the layout, in order, adjacent runs merged.
|
||||
@@ -1552,6 +1606,10 @@ pub fn layout_para(p: &Paragraph, env: &ParaEnv) -> ParaLayout {
|
||||
}
|
||||
}
|
||||
Some(InlineObject::Opaque { text, .. }) => b.shape_atomic(text, start, end, &rc),
|
||||
Some(InlineObject::PositionalTab { align, relative_to, leader }) => {
|
||||
push(&mut b, ClKind::Tab, 0.0, 0.0);
|
||||
ptabs.push((b.clusters.len() - 1, *align, *relative_to, *leader));
|
||||
}
|
||||
_ => push(&mut b, ClKind::Marker, 0.0, 0.0),
|
||||
}
|
||||
}
|
||||
@@ -1564,6 +1622,9 @@ pub fn layout_para(p: &Paragraph, env: &ParaEnv) -> ParaLayout {
|
||||
if rp.snap_to_grid {
|
||||
snap_chars(&mut b, &p.text, &env.grid);
|
||||
}
|
||||
// Text on a character grid keeps to its cells.
|
||||
let auto_space =
|
||||
if rp.snap_to_grid && env.grid.chars.is_some() { Vec::new() } else { auto_space(&mut b, &p.text, rp.auto_space_de, rp.auto_space_dn) };
|
||||
// Break opportunities and hyphenation points come from the text as laid out: hidden text that is
|
||||
// not shown and, without markup, tracked deletions are left out, so a space or soft hyphen there
|
||||
// is no place to break, and words are hyphenated as they read.
|
||||
@@ -1688,8 +1749,10 @@ pub fn layout_para(p: &Paragraph, env: &ParaEnv) -> ParaLayout {
|
||||
maths,
|
||||
rubies,
|
||||
displays,
|
||||
ptabs,
|
||||
left_out: Vec::new(),
|
||||
enclosures,
|
||||
auto_space,
|
||||
};
|
||||
let auto = env.doc.settings.auto_hyphenation && !pl.rp.suppress_hyphens;
|
||||
pl.hyph_after = hyphenation_points(&text, to_para, &pl, auto, env.doc.settings.hyphenate_caps, &runs);
|
||||
@@ -1748,6 +1811,21 @@ fn next_tab(x: f32, tabs: &[TabStop], default_tab: f32, hanging_at: Option<f32>)
|
||||
})
|
||||
}
|
||||
|
||||
/// A positional tab's stop (column coordinates): the left edge, centre or right edge of the
|
||||
/// column (`margin`) or of the space between the indents (`indent`), whatever the tab stops.
|
||||
fn ptab_stop(align: PTabAlign, base: PTabBase, leader: TabLeader, width: f32, indents: (f32, f32)) -> TabStop {
|
||||
let (lo, hi) = match base {
|
||||
PTabBase::Margin => (0.0, width),
|
||||
PTabBase::Indent => (indents.0, width - indents.1),
|
||||
};
|
||||
let (pos, align) = match align {
|
||||
PTabAlign::Left => (lo, TabAlign::Left),
|
||||
PTabAlign::Center => ((lo + hi) / 2.0, TabAlign::Center),
|
||||
PTabAlign::Right => (hi, TabAlign::Right),
|
||||
};
|
||||
TabStop { pos, align, leader }
|
||||
}
|
||||
|
||||
fn break_lines(pl: &mut ParaLayout, env: &ParaEnv, mark_style: u32, level: Option<&Level>) {
|
||||
let suffix = level.map(|l| l.suffix);
|
||||
let rp = pl.rp.clone();
|
||||
@@ -1901,9 +1979,16 @@ fn break_lines(pl: &mut ParaLayout, env: &ParaEnv, mark_style: u32, level: Optio
|
||||
ClKind::Tab => {
|
||||
has_tab = true;
|
||||
resolve_tab(pl, &mut pending_tab, &mut xs, c0, x, &mut x);
|
||||
let stop = next_tab(x, &rp.tabs, default_tab, hanging_at);
|
||||
let ptab = pl.ptabs.iter().find(|t| t.0 == j).map(|&(_, a, b, l)| ptab_stop(a, b, l, width, (rp.indent_left, rp.indent_right)));
|
||||
let stop = ptab.unwrap_or_else(|| next_tab(x, &rp.tabs, default_tab, hanging_at));
|
||||
// A positional tab whose place the text has passed goes there on the next line
|
||||
// (ECMA-376 §17.3.3.23); a line with nothing but markers on it is empty.
|
||||
if ptab.is_some() && stop.pos < x - 0.01 && (c0..j).any(|k| pl.clusters.get(k).is_some_and(|c| c.kind != ClKind::Marker)) {
|
||||
end = LineEnd::Wrap;
|
||||
break;
|
||||
}
|
||||
xs.push(x);
|
||||
if stop.pos > right_edge + 0.01 && j > c0 && stop.align == TabAlign::Left {
|
||||
if ptab.is_none() && 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;
|
||||
@@ -1953,7 +2038,8 @@ fn break_lines(pl: &mut ParaLayout, env: &ParaEnv, mark_style: u32, level: Optio
|
||||
}
|
||||
});
|
||||
let shrink = if shrink_spaces && !has_tab { space_w * MAX_SPACE_SHRINK } else { 0.0 };
|
||||
let fits = absorbs || x + c.adv <= right_edge + shrink + 0.01 || c.kind == ClKind::Space || c.kind == ClKind::Marker;
|
||||
let fits =
|
||||
absorbs || x + c.adv - pl.auto_space_after(j) <= right_edge + shrink + 0.01 || c.kind == ClKind::Space || c.kind == ClKind::Marker;
|
||||
// A line's first item stays on it even when it doesn't fit; markers before it
|
||||
// (a content control's start, a bookmark) take no room and don't count (Word).
|
||||
if !fits && (c0..j).any(|k| pl.clusters.get(k).is_some_and(|c| c.kind != ClKind::Marker)) {
|
||||
@@ -2186,7 +2272,11 @@ fn break_lines(pl: &mut ParaLayout, env: &ParaEnv, mark_style: u32, level: Optio
|
||||
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,
|
||||
_ => {
|
||||
// The automatic space after the line's last character goes with the wrap.
|
||||
content_end -= pl.auto_space_after(k);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
let slack = right_edge - content_end;
|
||||
@@ -2637,6 +2727,10 @@ fn resolve_tab(pl: &mut ParaLayout, pending: &mut Option<(usize, TabStop, f32)>,
|
||||
}
|
||||
|
||||
impl ParaLayout {
|
||||
/// The automatic space in cluster `k`'s advance ([`auto_space`]), 0 without one.
|
||||
fn auto_space_after(&self, k: usize) -> f32 {
|
||||
self.auto_space.binary_search_by_key(&k, |a| a.0).ok().and_then(|i| self.auto_space.get(i)).map_or(0.0, |a| a.1)
|
||||
}
|
||||
/// The line the list label is drawn on: the first, unless the paragraph opens with a page
|
||||
/// break, when Word draws it with the text on the next page.
|
||||
pub fn label_line(&self) -> usize {
|
||||
|
||||
@@ -1217,9 +1217,49 @@ fn autofit(grid: &mut [f32], content: &[(f32, f32)], t: &Table, avail: f32) {
|
||||
if short < 0.01 {
|
||||
return;
|
||||
}
|
||||
let spare: f32 = grid.iter().zip(content).map(|(w, c)| (*w - c.0).max(0.0)).sum();
|
||||
let give = (short / spare.max(0.01)).min(1.0);
|
||||
// Donors give equal widths until they reach their own content minimum.
|
||||
let mut rooms: Vec<_> = grid.iter().zip(content).map(|(w, c)| *w - c.0).filter(|room| *room > 0.0).collect();
|
||||
rooms.sort_by(f32::total_cmp);
|
||||
let mut remaining = short;
|
||||
let mut share = 0.0;
|
||||
for (i, room) in rooms.iter().enumerate() {
|
||||
share = remaining / (rooms.len() - i) as f32;
|
||||
if share <= *room {
|
||||
break;
|
||||
}
|
||||
remaining -= room;
|
||||
}
|
||||
for (w, c) in grid.iter_mut().zip(content) {
|
||||
*w = if *w < c.0 { c.0 } else { *w - (*w - c.0) * give };
|
||||
*w = if *w < c.0 { c.0 } else { *w - (*w - c.0).min(share) };
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod width_tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn autofit_shares_donor_loss() {
|
||||
let table = Table::new(1, 4, 260.0);
|
||||
let mut grid = [30.0, 120.0, 20.0, 90.0];
|
||||
let content = [(28.0, 28.0), (40.0, 120.0), (40.0, 40.0), (30.0, 90.0)];
|
||||
autofit(&mut grid, &content, &table, 260.0);
|
||||
assert_eq!(grid, [28.0, 111.0, 40.0, 81.0]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn autofit_exhausts_donors() {
|
||||
let table = Table::new(1, 2, 50.0);
|
||||
let mut grid = [30.0, 20.0];
|
||||
autofit(&mut grid, &[(25.0, 25.0), (40.0, 40.0)], &table, 50.0);
|
||||
assert_eq!(grid, [25.0, 40.0]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn autofit_keeps_fitting_widths() {
|
||||
let table = Table::new(1, 3, 120.0);
|
||||
let mut grid = [30.0, 40.0, 50.0];
|
||||
autofit(&mut grid, &[(10.0, 10.0), (20.0, 20.0), (30.0, 30.0)], &table, 120.0);
|
||||
assert_eq!(grid, [30.0, 40.0, 50.0]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -879,6 +879,79 @@ fn right_tab_aligns_text_end() {
|
||||
assert!((end - 472.0).abs() < 1.0, "{end}");
|
||||
}
|
||||
|
||||
/// A paragraph `before` + positional tab + `after`, with left and right indents and a custom
|
||||
/// left tab stop at 50 pt that the positional tab must ignore.
|
||||
fn ptab_doc(before: &str, after: &str, ptab: InlineObject, indents: (f32, f32)) -> Document {
|
||||
let mut d = Document::from_text(&format!("{before}{after}"));
|
||||
d.format_paragraphs(&Pos::body(0, 0), &Pos::body(0, 0), &|p| {
|
||||
p.indent_left = Some(indents.0);
|
||||
p.indent_right = Some(indents.1);
|
||||
p.tabs = Some(vec![wordcraft_doc::props::TabStop { pos: 50.0, align: Default::default(), leader: Default::default() }]);
|
||||
})
|
||||
.unwrap();
|
||||
d.insert_object(&Pos::body(0, before.len()), ptab, &Default::default()).unwrap();
|
||||
d
|
||||
}
|
||||
|
||||
fn ptab(align: wordcraft_doc::para::PTabAlign, relative_to: wordcraft_doc::para::PTabBase, leader: wordcraft_doc::props::TabLeader) -> InlineObject {
|
||||
InlineObject::PositionalTab { align, relative_to, leader }
|
||||
}
|
||||
|
||||
/// ECMA-376 §17.3.3.23: a right positional tab relative to the margin ends the text after it on
|
||||
/// the right margin, past the right indent, whatever the paragraph's tab stops; its leader fills
|
||||
/// the gap.
|
||||
#[test]
|
||||
fn right_positional_tab_ends_text_on_the_right_margin() {
|
||||
use wordcraft_doc::para::{PTabAlign, PTabBase};
|
||||
let d = ptab_doc("Left", "Right", ptab(PTabAlign::Right, PTabBase::Margin, wordcraft_doc::props::TabLeader::Dot), (0.0, 36.0));
|
||||
let l = lay(&d);
|
||||
let end = l.caret(&Pos::body(0, d.body[0].as_para().unwrap().len())).unwrap().x;
|
||||
assert!((end - 540.0).abs() < 1.0, "end of the text after the tab: {end}");
|
||||
let para = l.pages[0].items.iter().find_map(|it| if let Placed::Lines { para, .. } = it { Some(para) } else { None }).unwrap();
|
||||
assert!(para.lines[0].leaders.iter().any(|(_, ld)| *ld == wordcraft_doc::props::TabLeader::Dot), "{:?}", para.lines[0].leaders);
|
||||
}
|
||||
|
||||
/// Relative to the indents, a centre positional tab centres the text after it between the left
|
||||
/// and right indents, not between the margins.
|
||||
#[test]
|
||||
fn center_positional_tab_relative_to_indents_centres_between_them() {
|
||||
use wordcraft_doc::para::{PTabAlign, PTabBase};
|
||||
let d = ptab_doc("a", "Mid", ptab(PTabAlign::Center, PTabBase::Indent, wordcraft_doc::props::TabLeader::None), (36.0, 108.0));
|
||||
let l = lay(&d);
|
||||
let from = l.caret(&Pos::body(0, 1 + wordcraft_doc::para::OBJ.len_utf8())).unwrap().x;
|
||||
let to = l.caret(&Pos::body(0, d.body[0].as_para().unwrap().len())).unwrap().x;
|
||||
// Indents at 36 and 468 - 108 = 360 from the margin: centre 198, page x 270.
|
||||
assert!(((from + to) / 2.0 - 270.0).abs() < 1.0, "centre of the text after the tab: {}", (from + to) / 2.0);
|
||||
}
|
||||
|
||||
/// Text already past the positional tab's place sends it to the next line, where it goes to
|
||||
/// that place.
|
||||
#[test]
|
||||
fn positional_tab_behind_the_text_moves_to_the_next_line() {
|
||||
use wordcraft_doc::para::{PTabAlign, PTabBase};
|
||||
let before = "word ".repeat(14);
|
||||
let d = ptab_doc(&before, "X", ptab(PTabAlign::Left, PTabBase::Margin, wordcraft_doc::props::TabLeader::None), (0.0, 0.0));
|
||||
let l = lay(&d);
|
||||
let first = l.caret(&Pos::body(0, 0)).unwrap();
|
||||
let x = l.caret(&Pos::body(0, before.len() + wordcraft_doc::para::OBJ.len_utf8())).unwrap();
|
||||
assert!(x.top > first.top + 1.0, "X on the next line: {} vs {}", x.top, first.top);
|
||||
assert!((x.x - 72.0).abs() < 0.5, "X at the left margin: {}", x.x);
|
||||
}
|
||||
|
||||
/// A line holding only markers (a bookmark) before the positional tab is still empty: the tab
|
||||
/// doesn't leave it blank to go to the next line, even when its place is behind the indent.
|
||||
#[test]
|
||||
fn positional_tab_after_only_a_bookmark_stays_on_its_line() {
|
||||
use wordcraft_doc::para::{PTabAlign, PTabBase};
|
||||
let mut d = ptab_doc("", "X", ptab(PTabAlign::Left, PTabBase::Margin, wordcraft_doc::props::TabLeader::None), (36.0, 0.0));
|
||||
d.insert_object(&Pos::body(0, 0), InlineObject::BookmarkStart { name: "top".into() }, &Default::default()).unwrap();
|
||||
let l = lay(&d);
|
||||
let para = l.pages[0].items.iter().find_map(|it| if let Placed::Lines { para, .. } = it { Some(para) } else { None }).unwrap();
|
||||
assert_eq!(para.lines.len(), 1);
|
||||
let x = l.caret(&Pos::body(0, 2 * wordcraft_doc::para::OBJ.len_utf8())).unwrap().x;
|
||||
assert!((x - 108.0).abs() < 0.5, "X at the indent: {x}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lists_get_labels_and_indent() {
|
||||
let mut d = Document::from_text("one\ntwo\nthree");
|
||||
@@ -7554,3 +7627,179 @@ fn split_cell_moves_image_frame() {
|
||||
assert!((rest.height - (50.0 + top)).abs() < 0.01);
|
||||
}
|
||||
}
|
||||
|
||||
fn row_keep_doc() -> Document {
|
||||
use wordcraft_doc::props::{HeightRule, LineSpacing};
|
||||
let plain = ParaProps {
|
||||
space_before: Some(0.0),
|
||||
space_after: Some(0.0),
|
||||
line_spacing: Some(LineSpacing::Exactly(12.0)),
|
||||
widow_control: Some(false),
|
||||
..Default::default()
|
||||
};
|
||||
let para = |text: &str, props: ParaProps| {
|
||||
let mut p = Paragraph::with_text(text, Default::default());
|
||||
p.props = props;
|
||||
wordcraft_doc::para_block(p)
|
||||
};
|
||||
let mut d = Document::new();
|
||||
d.last_section.page_w = 400.0;
|
||||
d.last_section.page_h = 360.0;
|
||||
d.last_section.margin_top = 36.0;
|
||||
d.last_section.margin_bottom = 36.0;
|
||||
d.last_section.margin_left = 36.0;
|
||||
d.last_section.margin_right = 36.0;
|
||||
d.styles.upsert(wordcraft_doc::Style {
|
||||
id: "KeptRow".into(),
|
||||
name: "Kept row".into(),
|
||||
para: ParaProps { keep_next: Some(true), keep_lines: Some(true), ..Default::default() },
|
||||
..Default::default()
|
||||
});
|
||||
let mut t = Table::new(2, 1, 328.0);
|
||||
t.props.style = None;
|
||||
t.props.cell_margins = Some([0.0; 4]);
|
||||
t.rows[0].props.height = Some(24.0);
|
||||
t.rows[0].props.height_rule = HeightRule::Exact;
|
||||
t.rows[0].cells[0].blocks = vec![para("Heading", ParaProps { style: Some("KeptRow".into()), ..plain.clone() })];
|
||||
t.rows[1].props.height = Some(180.0);
|
||||
t.rows[1].props.height_rule = HeightRule::AtLeast;
|
||||
t.rows[1].cells[0].blocks = vec![para("", plain.clone()), para("Following row", plain.clone())];
|
||||
d.body = vec![para("Filler", ParaProps { line_spacing: Some(LineSpacing::Exactly(190.0)), ..plain }), Arc::new(Block::Table(t))];
|
||||
d
|
||||
}
|
||||
|
||||
fn row_keep_pages(d: &Document) -> Vec<usize> {
|
||||
let l = lay(d);
|
||||
(0..d.table(StoryRef::Body, &Path::top(1)).unwrap().rows.len())
|
||||
.map(|row| {
|
||||
l.pages
|
||||
.iter()
|
||||
.position(|page| {
|
||||
page.items.iter().any(|item| matches!(item, Placed::Lines { path, .. } if path.0.len() == 4 && path.0[..3] == [1, row as u32, 0]))
|
||||
})
|
||||
.unwrap()
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn kept_row_moves_with_next() {
|
||||
for rule in [wordcraft_doc::props::HeightRule::AtLeast, wordcraft_doc::props::HeightRule::Exact] {
|
||||
let mut d = row_keep_doc();
|
||||
d.table_mut(StoryRef::Body, &Path::top(1)).unwrap().rows[1].props.height_rule = rule;
|
||||
d.para_mut(StoryRef::Body, &Path(vec![1, 0, 0, 0])).unwrap().props.keep_next = Some(false);
|
||||
assert_eq!(row_keep_pages(&d), [0, 1], "unkept control, {rule:?}");
|
||||
d.para_mut(StoryRef::Body, &Path(vec![1, 0, 0, 0])).unwrap().props.keep_next = None;
|
||||
assert_eq!(row_keep_pages(&d), [1, 1], "inherited keep, {rule:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn kept_row_allows_next_split() {
|
||||
let mut d = row_keep_doc();
|
||||
let t = d.table_mut(StoryRef::Body, &Path::top(1)).unwrap();
|
||||
t.rows[1].props.height = None;
|
||||
let p = d.para_mut(StoryRef::Body, &Path(vec![1, 1, 0, 0])).unwrap();
|
||||
let props = p.props.clone();
|
||||
*p = Paragraph::with_text(&"Next\n".repeat(30), Default::default());
|
||||
p.props = props;
|
||||
assert_eq!(row_keep_pages(&d), [0, 0]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn kept_rows_move_as_chain() {
|
||||
let mut d = row_keep_doc();
|
||||
d.para_mut(StoryRef::Body, &Path::top(0)).unwrap().props.line_spacing = Some(wordcraft_doc::props::LineSpacing::Exactly(170.0));
|
||||
let t = d.table_mut(StoryRef::Body, &Path::top(1)).unwrap();
|
||||
let mut middle = t.rows[0].clone();
|
||||
middle.props.height = Some(80.0);
|
||||
middle.props.height_rule = wordcraft_doc::props::HeightRule::AtLeast;
|
||||
let mut p = Paragraph::with_text("Middle", Default::default());
|
||||
p.props = middle.cells[0].blocks[0].as_para().unwrap().props.clone();
|
||||
middle.cells[0].blocks = vec![wordcraft_doc::para_block(p)];
|
||||
t.rows.insert(1, middle);
|
||||
assert_eq!(row_keep_pages(&d), [1, 1, 1]);
|
||||
d.para_mut(StoryRef::Body, &Path(vec![1, 0, 0, 0])).unwrap().props.keep_next = Some(false);
|
||||
assert_eq!(row_keep_pages(&d), [0, 1, 1]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn oversized_kept_rows_finish() {
|
||||
let mut d = row_keep_doc();
|
||||
d.table_mut(StoryRef::Body, &Path::top(1)).unwrap().rows[1].props.height = Some(400.0);
|
||||
let l = lay(&d);
|
||||
assert!(l.pages.len() <= 4, "{} pages", l.pages.len());
|
||||
assert_eq!(row_keep_pages(&d), [1, 2], "Word starts the oversized kept group on a fresh page");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn kept_row_can_split_itself() {
|
||||
let mut d = row_keep_doc();
|
||||
let t = d.table_mut(StoryRef::Body, &Path::top(1)).unwrap();
|
||||
t.rows[0].props.height = None;
|
||||
t.rows[0].props.height_rule = wordcraft_doc::props::HeightRule::Auto;
|
||||
t.rows[1].props.height = Some(20.0);
|
||||
let p = d.para_mut(StoryRef::Body, &Path(vec![1, 0, 0, 0])).unwrap();
|
||||
let mut props = p.props.clone();
|
||||
props.keep_lines = Some(false);
|
||||
*p = Paragraph::with_text(&"Heading\n".repeat(10), Default::default());
|
||||
p.props = props;
|
||||
assert_eq!(row_keep_pages(&d), [0, 1]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn first_cell_controls_keep() {
|
||||
for (first, last, pages) in [(true, false, [1, 1]), (false, true, [0, 1]), (true, true, [1, 1]), (false, false, [0, 1])] {
|
||||
let mut d = row_keep_doc();
|
||||
let t = d.table_mut(StoryRef::Body, &Path::top(1)).unwrap();
|
||||
t.grid = vec![164.0, 164.0];
|
||||
for (ri, row) in t.rows.iter_mut().enumerate() {
|
||||
row.cells.push(row.cells[0].clone());
|
||||
for (ci, cell) in row.cells.iter_mut().enumerate() {
|
||||
let mut p = Paragraph::with_text("Cell", Default::default());
|
||||
p.props = cell.blocks[0].as_para().unwrap().props.clone();
|
||||
p.props.keep_next = Some(ri == 0 && if ci == 0 { first } else { last });
|
||||
cell.blocks = vec![wordcraft_doc::para_block(p)];
|
||||
}
|
||||
}
|
||||
assert_eq!(row_keep_pages(&d), pages, "first={first}, last={last}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn first_para_controls_row_keep() {
|
||||
let mut d = row_keep_doc();
|
||||
let mut p = Paragraph::with_text("Last paragraph", Default::default());
|
||||
p.props = d.para(StoryRef::Body, &Path(vec![1, 0, 0, 0])).unwrap().props.clone();
|
||||
p.props.keep_next = Some(false);
|
||||
d.table_mut(StoryRef::Body, &Path::top(1)).unwrap().rows[0].cells[0].blocks.push(wordcraft_doc::para_block(p));
|
||||
assert_eq!(row_keep_pages(&d), [1, 1], "Word honors the first paragraph even when the last opts out");
|
||||
d.para_mut(StoryRef::Body, &Path(vec![1, 0, 0, 0])).unwrap().props.keep_next = Some(false);
|
||||
d.para_mut(StoryRef::Body, &Path(vec![1, 0, 0, 1])).unwrap().props.keep_next = Some(true);
|
||||
assert_eq!(row_keep_pages(&d), [0, 1], "a later paragraph cannot keep its row");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nested_keep_leaves_outer_row() {
|
||||
let mut d = row_keep_doc();
|
||||
let t = d.table_mut(StoryRef::Body, &Path::top(1)).unwrap();
|
||||
t.rows[0].props.height = Some(60.0);
|
||||
let mut nested = Table::new(1, 1, 328.0);
|
||||
nested.rows[0].cells[0].blocks = vec![t.rows[0].cells[0].blocks[0].clone()];
|
||||
let mut p = Paragraph::with_text("Outer tail", Default::default());
|
||||
p.props.keep_next = Some(false);
|
||||
t.rows[0].cells[0].blocks = vec![Arc::new(Block::Table(nested)), wordcraft_doc::para_block(p)];
|
||||
assert_eq!(row_keep_pages(&d), [0, 1]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn kept_prefix_stays_together() {
|
||||
let mut d = row_keep_doc();
|
||||
let t = d.table_mut(StoryRef::Body, &Path::top(1)).unwrap();
|
||||
t.rows[1].props.height = Some(400.0);
|
||||
let mut middle = t.rows[0].clone();
|
||||
middle.props.height = Some(80.0);
|
||||
middle.props.height_rule = wordcraft_doc::props::HeightRule::AtLeast;
|
||||
t.rows.insert(1, middle);
|
||||
assert_eq!(row_keep_pages(&d), [1, 1, 2], "an oversized follower cannot separate an already placed kept prefix");
|
||||
}
|
||||
|
||||
@@ -0,0 +1,130 @@
|
||||
//! Automatic space between Asian text and Latin text or digits (`w:autoSpaceDE`, `w:autoSpaceDN`,
|
||||
//! #2358). Word's PDF of the issue's file shows about a quarter of the font size between 院 and 94
|
||||
//! at 16 pt. Assertions compare a layout with the flags on to the same layout with them off, never
|
||||
//! font metrics (fonts differ between machines; CJK glyphs may be missing).
|
||||
|
||||
use super::*;
|
||||
use crate::para::ClKind;
|
||||
use wordcraft_doc::props::Align;
|
||||
use wordcraft_doc::section::{DocGrid, DocGridType};
|
||||
|
||||
fn lay(doc: &Document) -> DocLayout {
|
||||
layout(doc, &mut LayoutCache::new(), &LayoutOptions::default())
|
||||
}
|
||||
|
||||
fn first_para(l: &DocLayout) -> Arc<ParaLayout> {
|
||||
l.pages[0].items.iter().find_map(|it| if let Placed::Lines { para, .. } = it { Some(para.clone()) } else { None }).unwrap()
|
||||
}
|
||||
|
||||
fn para0(d: &mut Document) -> &mut Paragraph {
|
||||
d.para_mut(StoryRef::Body, &Path::top(0)).unwrap()
|
||||
}
|
||||
|
||||
/// The room each cluster gains with the flags as `d` sets them, against both flags off, in points
|
||||
/// rounded to a hundredth.
|
||||
fn gaps(d: &Document) -> Vec<f32> {
|
||||
let mut off = d.clone();
|
||||
let p = para0(&mut off);
|
||||
p.props.auto_space_de = Some(false);
|
||||
p.props.auto_space_dn = Some(false);
|
||||
let (on, off) = (first_para(&lay(d)), first_para(&lay(&off)));
|
||||
assert_eq!(on.clusters.len(), off.clusters.len());
|
||||
on.clusters.iter().zip(&off.clusters).map(|(a, b)| ((a.adv - b.adv) * 100.0).round() / 100.0).collect()
|
||||
}
|
||||
|
||||
/// The default font size of `d`'s first paragraph, a quarter of it.
|
||||
fn quarter(d: &Document) -> f32 {
|
||||
let pl = first_para(&lay(d));
|
||||
let size = pl.clusters.first().and_then(|c| pl.styles.get(c.style as usize)).unwrap().size;
|
||||
(size / 4.0 * 100.0).round() / 100.0
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn digits_and_latin_beside_asian_text_get_a_quarter_of_the_font_size() {
|
||||
let mut d = Document::from_text("法院94年A字");
|
||||
let q = quarter(&d);
|
||||
assert!(q > 0.0);
|
||||
// 院|9 and 4|年 (DN), 年|A and A|字 (DE): the space follows the first of the two.
|
||||
assert_eq!(gaps(&d), [0.0, q, 0.0, q, q, q, 0.0]);
|
||||
para0(&mut d).props.auto_space_dn = Some(false);
|
||||
assert_eq!(gaps(&d), [0.0, 0.0, 0.0, 0.0, q, q, 0.0], "numbers off");
|
||||
let p = para0(&mut d);
|
||||
p.props.auto_space_dn = None;
|
||||
p.props.auto_space_de = Some(false);
|
||||
assert_eq!(gaps(&d), [0.0, q, 0.0, q, 0.0, 0.0, 0.0], "Latin off");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_space_crosses_runs_and_takes_the_asian_size() {
|
||||
let mut d = Document::from_text("漢A漢");
|
||||
let size = |s: f32| move |c: &mut CharProps| c.size = Some(s);
|
||||
let p = para0(&mut d);
|
||||
p.format(0, 3, &size(20.0)).unwrap();
|
||||
p.format(3, 4, &size(10.0)).unwrap();
|
||||
p.format(4, 7, &size(20.0)).unwrap();
|
||||
assert_eq!(gaps(&d), [5.0, 5.0, 0.0]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn spaces_punctuation_hangul_and_latin_alone_get_nothing() {
|
||||
for text in ["例 a", "5 例", "5、", "、8", "abc 123", "漢한", "한A", "漢%", "(A)"] {
|
||||
let d = Document::from_text(text);
|
||||
assert!(gaps(&d).iter().all(|g| *g == 0.0), "{text}: {:?}", gaps(&d));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_field_result_is_classed_by_the_text_it_shows() {
|
||||
let mut d = Document::from_text("漢漢");
|
||||
let field =
|
||||
InlineObject::Field { instr: "MERGEFIELD n".into(), result: "12".into(), locked: false, dirty: false, code: false, form: String::new() };
|
||||
para0(&mut d).insert_object(3, field, &CharProps::default()).unwrap();
|
||||
let pl = first_para(&lay(&d));
|
||||
let at = pl.clusters.iter().position(|c| c.start == 3).unwrap();
|
||||
assert!(pl.shown.iter().any(|s| s.0 == at && s.1 == "12"), "the field shows its result: {:?}", pl.shown);
|
||||
let q = quarter(&d);
|
||||
assert_eq!(pl.clusters[at].kind, ClKind::Text);
|
||||
// 漢|1 and 2|漢: the field's first and last characters, not the object it stands for.
|
||||
assert_eq!(gaps(&d), [q, q, 0.0]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_character_grid_the_paragraph_snaps_to_turns_it_off() {
|
||||
let with = |kind: DocGridType, snap: bool| {
|
||||
let mut d = Document::from_text("法院94年");
|
||||
d.last_section.doc_grid = Some(DocGrid { kind, line_pitch: 18.0, char_space: 0 });
|
||||
para0(&mut d).props.snap_to_grid = Some(snap);
|
||||
d
|
||||
};
|
||||
let d = with(DocGridType::LinesAndChars, true);
|
||||
assert!(gaps(&d).iter().all(|g| *g == 0.0), "{:?}", gaps(&d));
|
||||
// A line grid alone, or a paragraph that doesn't snap, keeps it.
|
||||
for d in [with(DocGridType::Lines, true), with(DocGridType::LinesAndChars, false)] {
|
||||
let q = quarter(&d);
|
||||
assert_eq!(gaps(&d), [0.0, q, 0.0, q, 0.0]);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_space_takes_no_room_at_the_end_of_a_justified_line() {
|
||||
let text = "abcdef 1漢漢漢";
|
||||
let mut d = Document::from_text(text);
|
||||
let pl = first_para(&lay(&d));
|
||||
let line = &pl.lines[0];
|
||||
let k = pl.clusters.iter().position(|c| c.start == text.find('1').unwrap()).unwrap();
|
||||
let gap = gaps(&d)[k];
|
||||
assert!(gap > 0.0);
|
||||
let plain = pl.clusters[k].adv - gap;
|
||||
// The right edge falls a quarter of the way into the space after the 1: the 1 fits, 漢 does
|
||||
// not. Counted, the space would overflow by more than the line's one space may shrink.
|
||||
let right = line.xs[k - line.c0] + plain + gap / 4.0;
|
||||
let p = para0(&mut d);
|
||||
p.props.indent_right = Some(line.right - right);
|
||||
p.props.align = Some(Align::Justify);
|
||||
let pl = first_para(&lay(&d));
|
||||
let line = &pl.lines[0];
|
||||
assert!(pl.lines.len() > 1);
|
||||
assert_eq!(line.c1 - 1, k, "the 1 ends the first line");
|
||||
let end = line.xs[k - line.c0] + plain;
|
||||
assert!((end - line.right).abs() < 0.01, "the 1 ends at {end}, the line at {}", line.right);
|
||||
}
|
||||
+24
-4
@@ -48,6 +48,7 @@ use wordcraft_doc::{Document, Path as DocPath, Rgb, StoryRef};
|
||||
use wordcraft_fonts::FaceRef;
|
||||
use wordcraft_geom::Rect;
|
||||
use wordcraft_layout::display::{DisplayOptions, Draw, Stroke as LineStyle, page_display};
|
||||
use wordcraft_layout::para::{ClKind, LineEnd};
|
||||
use wordcraft_layout::wordart::ArtPaint;
|
||||
use wordcraft_layout::{DocLayout, LayoutCache, LayoutOptions, Page, Placed, layout};
|
||||
use wordcraft_metafile::{Picture, PlacedItem};
|
||||
@@ -414,6 +415,7 @@ type Civil = (i64, u8, u8, u8, u8, u8);
|
||||
|
||||
/// `YYYY-MM-DD[THH:MM[:SS]]…` → civil time.
|
||||
fn parse_iso(s: &str) -> Option<Civil> {
|
||||
let s = s.trim();
|
||||
let n = |a: usize, b: usize| s.get(a..b).and_then(|x| x.parse::<i64>().ok());
|
||||
let (y, mo, d) = (n(0, 4)?, n(5, 7)?, n(8, 10)?);
|
||||
if !(1..=12).contains(&mo) || !(1..=31).contains(&d) {
|
||||
@@ -1418,7 +1420,7 @@ impl Exporter<'_> {
|
||||
}
|
||||
m
|
||||
});
|
||||
let pos = marks.get(name)?.clone();
|
||||
let pos = marks.get(name).or_else(|| marks.iter().find(|(k, _)| k.eq_ignore_ascii_case(name)).map(|(_, v)| v))?.clone();
|
||||
if let Some(c) = self.lay.caret(&pos)
|
||||
&& let Some(out) = self.out_index.get(&c.page)
|
||||
{
|
||||
@@ -1435,7 +1437,8 @@ impl Exporter<'_> {
|
||||
_ => None,
|
||||
})
|
||||
.unwrap_or(c.x);
|
||||
return Some((*out, x, c.top));
|
||||
let (x, y) = self.lay.pages.get(c.page).map(|p| Fit::of(p).point(x, c.top)).unwrap_or((x, c.top));
|
||||
return Some((*out, x, y));
|
||||
}
|
||||
self.locate(pos.story, &pos.path)
|
||||
}
|
||||
@@ -1483,14 +1486,31 @@ impl Exporter<'_> {
|
||||
for (out, pi) in pages.iter().enumerate() {
|
||||
let Some(page) = self.lay.pages.get(*pi) else { continue };
|
||||
for it in &page.items {
|
||||
let Placed::Lines { story: StoryRef::Body, path, para, l0: 0, x, y, .. } = it else { continue };
|
||||
let Placed::Lines { story: StoryRef::Body, path, para, l0, l1, x, y, .. } = it else { continue };
|
||||
let Some(p) = self.doc.para(StoryRef::Body, path) else { continue };
|
||||
let Some(level) = self.doc.styles.resolve_para(&p.props).outline_level else { continue };
|
||||
// A leading page break can be placed on the previous page without the heading.
|
||||
let first = para
|
||||
.lines
|
||||
.iter()
|
||||
.take_while(|line| {
|
||||
line.end == LineEnd::PageBreak
|
||||
&& para
|
||||
.clusters
|
||||
.get(line.c0..line.c1)
|
||||
.is_some_and(|cs| cs.iter().all(|c| matches!(c.kind, ClKind::PageBreak | ClKind::Marker)))
|
||||
})
|
||||
.count();
|
||||
if !(*l0..*l1).contains(&first) {
|
||||
continue;
|
||||
}
|
||||
let Some(line) = para.lines.get(first) else { continue };
|
||||
let Some(piece) = para.lines.get(*l0) else { continue };
|
||||
let title: String = self.title_text(p, para).split_whitespace().collect::<Vec<_>>().join(" ").chars().take(200).collect();
|
||||
if title.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let (x, y) = Fit::of(page).point(*x, *y);
|
||||
let (x, y) = Fit::of(page).point(*x, *y + line.top - piece.top);
|
||||
flat.push((level.min(8), title, out, x, y));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1027,6 +1027,14 @@ fn bookmark_destination_is_the_page_of_its_start() {
|
||||
assert_eq!(page, 1, "the bookmark is on the second page");
|
||||
assert!((x - 72.0).abs() < 1.0 && y < 150.0, "{x} {y}");
|
||||
assert_eq!(ex.bookmark("first").map(|b| b.0), Some(0));
|
||||
|
||||
// Case-insensitive lookup matches
|
||||
let (page_ci, x_ci, y_ci) = ex.bookmark("TARGET").unwrap();
|
||||
assert_eq!(page_ci, 1);
|
||||
assert_eq!((x_ci, y_ci), (x, y));
|
||||
|
||||
// Whitespace in ISO date string is parsed successfully
|
||||
assert!(parse_iso(" 2024-05-06T07:08:09Z \n").is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -1077,6 +1085,71 @@ fn ref_fields_export_cached_links() {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn outline_after_leading_breaks() {
|
||||
use hayro_syntax::object::{Array, Dict};
|
||||
|
||||
for (lead, marker) in [
|
||||
("\u{c}", None),
|
||||
("\u{c}\u{c}", None),
|
||||
("\u{c}", Some(InlineObject::BookmarkStart { name: "leading".into() })),
|
||||
("\u{c}\u{c}", Some(InlineObject::CommentStart { id: 0 })),
|
||||
] {
|
||||
let mut d = Document::from_text("Earlier page");
|
||||
let mut heading = Paragraph::with_text(&format!("{lead}Navigation target"), CharProps::default()).styled("Heading1");
|
||||
heading.insert_object(lead.len(), InlineObject::BookmarkStart { name: "target".into() }, &CharProps::default()).unwrap();
|
||||
if let Some(marker) = marker {
|
||||
heading.insert_object(0, marker, &CharProps::default()).unwrap();
|
||||
}
|
||||
d.body.push(para_block(heading));
|
||||
let bytes = export(&d, &PdfOptions::default()).unwrap();
|
||||
let text = extract_text(&bytes);
|
||||
let target = text.iter().position(|t| squash(t).contains("Navigation target")).unwrap();
|
||||
assert!(target > 0, "the heading follows a page break");
|
||||
for pages in [None, Some(vec![target]), Some(vec![target, 0]), Some(vec![0])] {
|
||||
let expected = pages.as_ref().map_or(Some(target), |p| p.iter().position(|i| *i == target));
|
||||
let bytes = export(&d, &PdfOptions { pages, ..Default::default() }).unwrap();
|
||||
let pdf = hayro_syntax::Pdf::new(bytes).unwrap();
|
||||
let catalog = pdf.xref().get::<Dict<'_>>(pdf.xref().root_id()).unwrap();
|
||||
let outlines = catalog.get::<Dict<'_>>(b"Outlines");
|
||||
let Some(expected) = expected else {
|
||||
assert!(outlines.is_none(), "a page containing only the break has no heading outline");
|
||||
continue;
|
||||
};
|
||||
let first = outlines.unwrap().get::<Dict<'_>>(b"First").unwrap();
|
||||
let dest = first.get::<Array<'_>>(b"Dest").unwrap();
|
||||
assert_eq!(dest.iter::<Dict<'_>>().next().unwrap(), *pdf.pages()[expected].raw(), "outline must reach the visible heading");
|
||||
assert!(first.get::<Dict<'_>>(b"Next").is_none(), "one outline entry per heading");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn outline_keeps_visible_objects() {
|
||||
use hayro_syntax::object::{Array, Dict};
|
||||
|
||||
let mut base = Document::from_text("Earlier text");
|
||||
let media = base.add_media(png(), "png");
|
||||
for object in [
|
||||
InlineObject::Field { instr: "QUOTE Visible".into(), result: "Visible".into(), locked: true, dirty: false, code: false, form: String::new() },
|
||||
InlineObject::Image { media, w: 16.0, h: 8.0, alt: String::new(), float: Default::default(), crop: [0.0; 4], ole: None },
|
||||
] {
|
||||
let mut d = base.clone();
|
||||
let mut heading = Paragraph::with_text("\u{c}After break", CharProps::default()).styled("Heading1");
|
||||
heading.insert_object(0, object, &CharProps::default()).unwrap();
|
||||
heading.insert_object(0, InlineObject::BookmarkStart { name: "leading".into() }, &CharProps::default()).unwrap();
|
||||
d.body.push(para_block(heading));
|
||||
let bytes = export(&d, &PdfOptions::default()).unwrap();
|
||||
assert!(squash(&extract_text(&bytes)[1]).contains("After break"));
|
||||
let pdf = hayro_syntax::Pdf::new(bytes).unwrap();
|
||||
let catalog = pdf.xref().get::<Dict<'_>>(pdf.xref().root_id()).unwrap();
|
||||
let outlines = catalog.get::<Dict<'_>>(b"Outlines").unwrap();
|
||||
let first = outlines.get::<Dict<'_>>(b"First").unwrap();
|
||||
let dest = first.get::<Array<'_>>(b"Dest").unwrap();
|
||||
assert_eq!(dest.iter::<Dict<'_>>().next().unwrap(), *pdf.pages()[0].raw(), "visible objects before the break anchor the heading");
|
||||
}
|
||||
}
|
||||
|
||||
/// Print Markup (#589): the comments and their replies print beside each page, which is scaled
|
||||
/// down to keep its paper size; without it they stay out of the PDF.
|
||||
#[test]
|
||||
|
||||
@@ -974,6 +974,7 @@ pub fn show(app: &mut WordApp, ui: &mut Ui) {
|
||||
}
|
||||
let pct = (app.canvas.scale / PX_PER_PT * zoom_delta * 100.0).clamp(if reading { 75.0 } else { 10.0 }, if reading { 150.0 } else { 500.0 });
|
||||
let id = if reading { "view.readTextSize" } else { "view.zoom" };
|
||||
app.canvas.wheel.cancel();
|
||||
let _ = app.run(id, json!({"value": pct}));
|
||||
}
|
||||
let avail = if app.session.view.read_mode { area.size() } else { area.size() - vec2(SCROLLBAR, 0.0) };
|
||||
|
||||
@@ -3871,7 +3871,9 @@ mod tests {
|
||||
("tr", "İTALİK", "İtalik — Giriş › Yazı Tipi"),
|
||||
("tr", "içindekiler", "İçindekiler Tablosu — Başvurular › İçindekiler Tablosu"),
|
||||
("tr", "YAZI TİPİ", "İtalik — Giriş › Yazı Tipi"),
|
||||
("tr", "INSERT", "Tablo — Ekle › Tablolar"),
|
||||
// Dozens of commands match a lone INSERT and the box shows 14, in registry order: the
|
||||
// witness is an early one; Table is checked below, past the box's limit.
|
||||
("tr", "INSERT", "Bölünemez Boşluk — Ekle › Semboller"),
|
||||
("tr", "INSERT.TABLE", "Tablo — Ekle › Tablolar"),
|
||||
("tr", "FORMAT.ITALIC", "İtalik — Giriş › Yazı Tipi"),
|
||||
("tr", "TABLE OF CONTENTS", "İçindekiler Tablosu — Başvurular › İçindekiler Tablosu"),
|
||||
@@ -3890,6 +3892,9 @@ mod tests {
|
||||
assert_eq!(crate::i18n::lowercase("Iİıi"), if language == "tr" { "ıiıi" } else { "ii\u{307}ıi" });
|
||||
}
|
||||
assert_eq!(serde_json::to_value(&h.state().session.doc).unwrap(), before, "search does not change the document");
|
||||
crate::i18n::set_current(crate::i18n::Lang::from_pref("tr"));
|
||||
let reg = h.state().session.registry.clone();
|
||||
assert!(command_matches(reg.get("insert.table").unwrap(), "INSERT"), "tr: INSERT finds Table");
|
||||
crate::i18n::set_current(crate::i18n::Lang::EN);
|
||||
}
|
||||
|
||||
|
||||
@@ -2646,3 +2646,5 @@
|
||||
The measurement must be between {min} and {max}. Размерът трябва да е между {min} и {max}.
|
||||
Recently Used Fonts Наскоро използвани шрифтове
|
||||
All Fonts Всички шрифтове
|
||||
Spike Натрупване
|
||||
Insert Spike Вмъкване на натрупаното
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 7 and column 25.
|
@@ -2649,3 +2649,5 @@ preview Title Ukázka
|
||||
The measurement must be between {min} and {max}. Míra musí být mezi {min} a {max}.
|
||||
Recently Used Fonts Naposledy použitá písma
|
||||
All Fonts Všechna písma
|
||||
Spike Sběrač
|
||||
Insert Spike Vložit ze sběrače
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 7 and column 25.
|
@@ -2257,3 +2257,5 @@
|
||||
The measurement must be between {min} and {max}. Das Maß muss zwischen {min} und {max} liegen.
|
||||
Recently Used Fonts Zuletzt verwendete Schriftarten
|
||||
All Fonts Alle Schriftarten
|
||||
Spike Sammlung
|
||||
Insert Spike Sammlung einfügen
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 6 and column 25.
|
@@ -2624,3 +2624,5 @@ preview Title Título
|
||||
The measurement must be between {min} and {max}. La medida debe estar entre {min} y {max}.
|
||||
Recently Used Fonts Fuentes usadas recientemente
|
||||
All Fonts Todas las fuentes
|
||||
Spike Acumulador
|
||||
Insert Spike Insertar acumulador
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 6 and column 25.
|
@@ -2292,3 +2292,5 @@
|
||||
The measurement must be between {min} and {max}. Mõõt peab olema vahemikus {min} kuni {max}.
|
||||
Recently Used Fonts Viimati kasutatud fondid
|
||||
All Fonts Kõik fondid
|
||||
Spike Koguja
|
||||
Insert Spike Lisa kogujast
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 845 and column 23.
|
@@ -2265,3 +2265,5 @@
|
||||
The measurement must be between {min} and {max}. La mesure doit être comprise entre {min} et {max}.
|
||||
Recently Used Fonts Polices récemment utilisées
|
||||
All Fonts Toutes les polices
|
||||
Spike Pique-notes
|
||||
Insert Spike Insérer le pique-notes
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 7 and column 25.
|
@@ -2657,3 +2657,5 @@ preview Title Naslov
|
||||
All Fonts Svi fontovi
|
||||
Moved from: Premješteno iz:
|
||||
Moved to: Premješteno u:
|
||||
Spike Skupljač
|
||||
Insert Spike Umetni iz skupljača
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 6 and column 25.
|
@@ -2653,3 +2653,5 @@
|
||||
The measurement must be between {min} and {max}. A méretnek {min} és {max} között kell lennie.
|
||||
Recently Used Fonts Legutóbb használt betűtípusok
|
||||
All Fonts Összes betűtípus
|
||||
Spike Gyűjtő
|
||||
Insert Spike Beszúrás a gyűjtőből
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 6 and column 25.
|
@@ -2666,3 +2666,5 @@ preview Title Esempio
|
||||
The measurement must be between {min} and {max}. La misura deve essere compresa tra {min} e {max}.
|
||||
Recently Used Fonts Caratteri usati di recente
|
||||
All Fonts Tutti i caratteri
|
||||
Spike Raccoglitore
|
||||
Insert Spike Inserisci raccoglitore
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 6 and column 25.
|
@@ -2258,3 +2258,5 @@
|
||||
The measurement must be between {min} and {max}. {min} から {max} までの値を指定してください。
|
||||
Recently Used Fonts 最近使用したフォント
|
||||
All Fonts すべてのフォント
|
||||
Spike スパイク
|
||||
Insert Spike スパイクの挿入
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 923 and column 23.
|
@@ -2511,3 +2511,5 @@ preview Title 제목
|
||||
The measurement must be between {min} and {max}. 측정값은 {min}에서 {max} 사이여야 합니다.
|
||||
Recently Used Fonts 최근 사용한 글꼴
|
||||
All Fonts 모든 글꼴
|
||||
Spike 스파이크
|
||||
Insert Spike 스파이크 삽입
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 929 and column 23.
|
@@ -2662,3 +2662,5 @@ preview Title Tytuł
|
||||
The measurement must be between {min} and {max}. Miara musi być z zakresu od {min} do {max}.
|
||||
Recently Used Fonts Ostatnio używane czcionki
|
||||
All Fonts Wszystkie czcionki
|
||||
Spike Zbieracz
|
||||
Insert Spike Wstaw ze zbieracza
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 6 and column 25.
|
@@ -2270,3 +2270,5 @@
|
||||
The measurement must be between {min} and {max}. A medida deve estar entre {min} e {max}.
|
||||
Recently Used Fonts Fontes usadas recentemente
|
||||
All Fonts Todas as fontes
|
||||
Spike Coletor
|
||||
Insert Spike Inserir coletor
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 6 and column 25.
|
@@ -2655,3 +2655,5 @@ preview Title Пример
|
||||
The measurement must be between {min} and {max}. Размер должен быть от {min} до {max}.
|
||||
Recently Used Fonts Недавно использованные шрифты
|
||||
All Fonts Все шрифты
|
||||
Spike Копилка
|
||||
Insert Spike Вставить из копилки
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 7 and column 25.
|
@@ -2268,3 +2268,5 @@
|
||||
The measurement must be between {min} and {max}. Mera mora biti između {min} i {max}.
|
||||
Recently Used Fonts Nedavno korišćeni fontovi
|
||||
All Fonts Svi fontovi
|
||||
Spike Sakupljač
|
||||
Insert Spike Umetni iz sakupljača
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 6 and column 25.
|
@@ -2272,3 +2272,5 @@
|
||||
The measurement must be between {min} and {max}. Мера мора бити између {min} и {max}.
|
||||
Recently Used Fonts Недавно коришћени фонтови
|
||||
All Fonts Сви фонтови
|
||||
Spike Сакупљач
|
||||
Insert Spike Уметни из сакупљача
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 11 and column 25.
|
@@ -2672,3 +2672,5 @@ preview Title Titel
|
||||
All Fonts Alla typsnitt
|
||||
Moved from: Flyttat från:
|
||||
Moved to: Flyttat till:
|
||||
Spike Samlare
|
||||
Insert Spike Infoga samlare
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 7 and column 25.
|
@@ -2654,3 +2654,5 @@ preview Title Örnek
|
||||
The measurement must be between {min} and {max}. Ölçü {min} ile {max} arasında olmalıdır.
|
||||
Recently Used Fonts Son Kullanılan Yazı Tipleri
|
||||
All Fonts Tüm Yazı Tipleri
|
||||
Spike Biriktirici
|
||||
Insert Spike Biriktiriciyi Ekle
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 797 and column 23.
|
@@ -2270,3 +2270,5 @@
|
||||
The measurement must be between {min} and {max}. Розмір має бути від {min} до {max}.
|
||||
Recently Used Fonts Нещодавно використані шрифти
|
||||
All Fonts Усі шрифти
|
||||
Spike Накопичувач
|
||||
Insert Spike Вставити з накопичувача
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 6 and column 31.
|
@@ -2697,3 +2697,5 @@ template Letter Thư
|
||||
The measurement must be between {min} and {max}. Số đo phải nằm trong khoảng từ {min} đến {max}.
|
||||
Recently Used Fonts Phông chữ dùng gần đây
|
||||
All Fonts Tất cả phông chữ
|
||||
Spike Bộ gom
|
||||
Insert Spike Chèn bộ gom
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 7 and column 25.
|
@@ -2270,3 +2270,5 @@
|
||||
The measurement must be between {min} and {max}. 度量值必须介于 {min} 和 {max} 之间。
|
||||
Recently Used Fonts 最近使用的字体
|
||||
All Fonts 所有字体
|
||||
Spike 图文场
|
||||
Insert Spike 插入图文场
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 7 and column 25.
|
@@ -2259,3 +2259,5 @@
|
||||
The measurement must be between {min} and {max}. 度量值必須介於 {min} 和 {max} 之間。
|
||||
Recently Used Fonts 最近使用的字型
|
||||
All Fonts 所有字型
|
||||
Spike 收集區
|
||||
Insert Spike 插入收集區
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 924 and column 23.
|
@@ -3659,6 +3659,67 @@ mod tests {
|
||||
assert_eq!((a.canvas.scale, &a.canvas.page_rects), (scale, &pages));
|
||||
}
|
||||
|
||||
/// Issue #1743: Ctrl/⌘ + wheel only zooms. The one view movement is the anchor correction that
|
||||
/// keeps the page point under the pointer there; the touchpad coast still running when the
|
||||
/// wheel starts zooming (animation effects off) doesn't scroll on top of it.
|
||||
#[test]
|
||||
fn ctrl_wheel_zoom_does_not_scroll_the_document() {
|
||||
for modifiers in [egui::Modifiers::CTRL, egui::Modifiers::COMMAND] {
|
||||
let ctx = egui::Context::default();
|
||||
let system = std::rc::Rc::new(std::cell::Cell::new(Some(false)));
|
||||
let mut a = app_with_system_animations(&system);
|
||||
a.run("text.insert", json!({"text": "lorem ipsum dolor sit amet consectetur ".repeat(1200)})).unwrap();
|
||||
let screen = egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(1280.0, 800.0));
|
||||
let mut t = 0.0;
|
||||
let mut frame = |a: &mut WordApp, events: Vec<egui::Event>| {
|
||||
t += 1.0 / 60.0;
|
||||
let input = egui::RawInput { events, time: Some(t), screen_rect: Some(screen), ..Default::default() };
|
||||
ctx.run_ui(input, |ui| {
|
||||
a.logic(ui.ctx());
|
||||
a.ui(ui);
|
||||
})
|
||||
.drop_without_applying_deltas();
|
||||
};
|
||||
for _ in 0..3 {
|
||||
frame(&mut a, Vec::new());
|
||||
}
|
||||
let rect = a.canvas.canvas_rect.unwrap();
|
||||
let pointer = rect.center() + egui::vec2(30.0, 40.0);
|
||||
frame(&mut a, vec![egui::Event::PointerMoved(pointer)]);
|
||||
let wheel = |delta: f32, modifiers| egui::Event::MouseWheel {
|
||||
unit: egui::MouseWheelUnit::Point,
|
||||
delta: egui::vec2(0.0, delta),
|
||||
phase: egui::TouchPhase::Move,
|
||||
modifiers,
|
||||
};
|
||||
// Scroll down a little, so the pointer is well inside the document.
|
||||
frame(&mut a, vec![wheel(-3.0, egui::Modifiers::NONE)]);
|
||||
for _ in 0..40 {
|
||||
frame(&mut a, Vec::new());
|
||||
}
|
||||
let layout = a.session.layout();
|
||||
let under = |a: &WordApp| canvas::page_at(&a.canvas.page_rects, &layout, a.canvas.scale, pointer);
|
||||
let (page, x, y) = under(&a).unwrap();
|
||||
let scale = a.canvas.scale;
|
||||
frame(&mut a, vec![wheel(50.0, modifiers)]);
|
||||
let mut last = a.canvas.scroll_offset;
|
||||
for n in 0..60 {
|
||||
last = a.canvas.scroll_offset;
|
||||
if n > 0 {
|
||||
frame(&mut a, Vec::new());
|
||||
}
|
||||
let (p, px, py) = under(&a).unwrap();
|
||||
assert_eq!(p, page);
|
||||
assert!(
|
||||
((px - x).abs() < 1.0 || a.canvas.scroll_offset.x == 0.0) && (py - y).abs() < 1.0,
|
||||
"the page point stays under the pointer ({modifiers:?} frame {n}): ({x},{y}) -> ({px},{py})"
|
||||
);
|
||||
}
|
||||
assert_eq!(a.canvas.scroll_offset, last, "nothing scrolls once the zoom has settled ({modifiers:?})");
|
||||
assert!(a.canvas.scale > scale, "zoomed ({modifiers:?})");
|
||||
}
|
||||
}
|
||||
|
||||
/// Issue #688: dragging a selection past the viewport's bottom edge scrolls on, and the
|
||||
/// selection keeps growing as the content moves under the stationary pointer.
|
||||
#[test]
|
||||
|
||||
@@ -174,11 +174,16 @@ impl CanvasScroll {
|
||||
let mut touchpad = Vec2::ZERO;
|
||||
let mut lifted = false;
|
||||
let mut stop = false;
|
||||
let mut zooming = false;
|
||||
for event in &input.events {
|
||||
match event {
|
||||
Event::MouseWheel { unit, delta, phase, modifiers } => {
|
||||
// Ctrl/⌘+wheel zooms (egui turns it into a zoom factor).
|
||||
if !hovered || modifiers.matches_any(opts.zoom_modifier) {
|
||||
if !hovered {
|
||||
continue;
|
||||
}
|
||||
if modifiers.matches_any(opts.zoom_modifier) {
|
||||
zooming = true;
|
||||
continue;
|
||||
}
|
||||
let d = axis_lock(if delta.is_finite() { *delta } else { Vec2::ZERO }, *modifiers, opts);
|
||||
@@ -202,6 +207,13 @@ impl CanvasScroll {
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
// The wheel zooms this frame: whatever was still gliding (a coast, a notch easing in)
|
||||
// stops rather than scroll the page on top of the zoom's anchor correction (#1743). The early
|
||||
// return deliberately drops this frame's other deltas too.
|
||||
if zooming {
|
||||
self.hit_edge();
|
||||
return Vec2::ZERO;
|
||||
}
|
||||
now += touchpad;
|
||||
let fed = touchpad != Vec2::ZERO;
|
||||
if SYNTH_MOMENTUM {
|
||||
@@ -237,6 +249,12 @@ impl CanvasScroll {
|
||||
self.pending = Vec2::ZERO;
|
||||
}
|
||||
|
||||
/// A zoom took over the wheel: drop any coast or queued notches so they don't scroll the page
|
||||
/// on top of the anchor correction that keeps the pointer's page point in place (#1743).
|
||||
pub fn cancel(&mut self) {
|
||||
self.hit_edge();
|
||||
}
|
||||
|
||||
/// Scrolling continues without new input: keep repainting.
|
||||
pub fn is_animating(&self) -> bool {
|
||||
self.pending != Vec2::ZERO || self.momentum.is_active()
|
||||
@@ -425,6 +443,47 @@ mod tests {
|
||||
seen
|
||||
}
|
||||
|
||||
/// One frame of [`CanvasScroll::frame`] with the pointer on a 400x300 screen.
|
||||
fn canvas_frame(ctx: &egui::Context, scroll: &mut CanvasScroll, t: f64, events: Vec<Event>) -> Vec2 {
|
||||
let screen = egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(400.0, 300.0));
|
||||
let input = egui::RawInput { events, time: Some(t), screen_rect: Some(screen), ..Default::default() };
|
||||
let mut out = Vec2::ZERO;
|
||||
ctx.run_ui(input, |ui| {
|
||||
let opts = ui.ctx().options(|o| o.input_options);
|
||||
out = ui.input(|i| scroll.frame(i, &opts, true, NOTCH_PX, 300.0, true));
|
||||
})
|
||||
.drop_without_applying_deltas();
|
||||
out
|
||||
}
|
||||
|
||||
/// Issue #1743: a Ctrl/⌘+wheel event drops queued wheel notches instead of scrolling them on top
|
||||
/// of the zoom. (The touchpad-coast case is covered by `ctrl_wheel_zoom_does_not_scroll_the_document`.)
|
||||
#[test]
|
||||
fn zoom_modifier_wheel_clears_queued_notches() {
|
||||
for modifiers in [Modifiers::CTRL, Modifiers::COMMAND] {
|
||||
let ctx = egui::Context::default();
|
||||
let mut scroll = CanvasScroll::default();
|
||||
let mut t = 0.0;
|
||||
let mut next = || {
|
||||
t += f64::from(FRAME);
|
||||
t
|
||||
};
|
||||
canvas_frame(&ctx, &mut scroll, next(), vec![Event::PointerMoved(egui::pos2(50.0, 50.0))]);
|
||||
// A fresh fling and a queued notch.
|
||||
for _ in 0..6 {
|
||||
canvas_frame(&ctx, &mut scroll, next(), vec![wheel(MouseWheelUnit::Point, Vec2::new(0.0, -30.0), Modifiers::NONE)]);
|
||||
}
|
||||
canvas_frame(&ctx, &mut scroll, next(), vec![wheel(MouseWheelUnit::Line, Vec2::new(0.0, -5.0), Modifiers::NONE)]);
|
||||
assert!(scroll.is_animating(), "something is still gliding");
|
||||
let moved = canvas_frame(&ctx, &mut scroll, next(), vec![wheel(MouseWheelUnit::Line, Vec2::new(0.0, 1.0), modifiers)]);
|
||||
assert_eq!(moved, Vec2::ZERO, "the zoom frame scrolls nothing ({modifiers:?})");
|
||||
assert!(!scroll.is_animating(), "nothing glides on after the zoom ({modifiers:?})");
|
||||
for _ in 0..5 {
|
||||
assert_eq!(canvas_frame(&ctx, &mut scroll, next(), Vec::new()), Vec2::ZERO, "no leftover scroll ({modifiers:?})");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// egui's own scroll areas ease a wheel notch in over several frames; with animation effects
|
||||
/// off the notch lands whole in its frame, and nothing more arrives afterwards.
|
||||
#[test]
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# File-format parity with Microsoft Word
|
||||
|
||||
> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-18 · **Change:** trivial (DOCX: mixed right-to-left runs split and marked `w:rtl` with their language, #1543; previously trivial: `w:doNotTrackMoves` round-trips and turns move tracking off; previously trivial: legacy outline, shadow, emboss and imprint text effects drawn, #2534; previously trivial: DOCX glossary documents (building blocks) read, kept and written; previously trivial: cached field results keep their visible run formatting; previously trivial: cached REF results retain hyperlink destinations; previously trivial: CLI conversions can explicitly refresh fields; previously trivial: a copied equation also goes in the clipboard's MathML formats, #1297; previously trivial: a pasted HTML `<blockquote>` is an indented paragraph, not the Quote style, #1270); previously trivial: every table style part round-trips, #714; previously trivial: VML freeforms read and kept on save; previously trivial: multi-paragraph field instructions no longer add blank lines; previously trivial: `flowChartOr` draws its circle and cross spokes, as in Word; previously trivial: RTF: Mac OS Roman and DOS text, #1653; previously trivial: VML text boxes keep their internal margins, fit to their text and vertical alignment; hidden drawings not shown; previously trivial: PDF export draws horizontal and vertical lines inside groups, #1474; previously trivial: symbol-font text exported as Unicode, #776; previously trivial: text-box margins, Resize shape to fit text and freeforms in groups, #743; previously trivial: formatted paste from the system clipboard's HTML and RTF, #687; previously trivial: independent complex-script formatting inheritance and round-trip regression tests; previously trivial: import/export fixes for TXT, RTF, ODT, HTML and LaTeX, #433–#440; previously major: first version; every format Word reads or writes, measured from the readers and writers on origin/main) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4, plus Word for Windows' extra formats
|
||||
> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-18 · **Change:** trivial (two lines in one and horizontal in vertical (`w:eastAsianLayout`) kept on save; previously trivial: PDF outline targets follow leading page breaks; previously trivial: positional tabs `w:ptab` read and kept on save, #2367; previously trivial: DOCX: mixed right-to-left runs split and marked `w:rtl` with their language, #1543; previously trivial: `w:doNotTrackMoves` round-trips and turns move tracking off; previously trivial: legacy outline, shadow, emboss and imprint text effects drawn, #2534; previously trivial: DOCX glossary documents (building blocks) read, kept and written; previously trivial: cached field results keep their visible run formatting; previously trivial: cached REF results retain hyperlink destinations; previously trivial: CLI conversions can explicitly refresh fields; previously trivial: a copied equation also goes in the clipboard's MathML formats, #1297; previously trivial: a pasted HTML `<blockquote>` is an indented paragraph, not the Quote style, #1270); previously trivial: every table style part round-trips, #714; previously trivial: VML freeforms read and kept on save; previously trivial: multi-paragraph field instructions no longer add blank lines; previously trivial: `flowChartOr` draws its circle and cross spokes, as in Word; previously trivial: RTF: Mac OS Roman and DOS text, #1653; previously trivial: VML text boxes keep their internal margins, fit to their text and vertical alignment; hidden drawings not shown; previously trivial: PDF export draws horizontal and vertical lines inside groups, #1474; previously trivial: symbol-font text exported as Unicode, #776; previously trivial: text-box margins, Resize shape to fit text and freeforms in groups, #743; previously trivial: formatted paste from the system clipboard's HTML and RTF, #687; previously trivial: independent complex-script formatting inheritance and round-trip regression tests; previously trivial: import/export fixes for TXT, RTF, ODT, HTML and LaTeX, #433–#440; previously major: first version; every format Word reads or writes, measured from the readers and writers on origin/main) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4, plus Word for Windows' extra formats
|
||||
|
||||
Every format Word opens or saves, what WordCraft does with it, and how it's tested. Fidelity is the
|
||||
share of a typical real-world file's content and formatting that survives (read: shows correctly;
|
||||
@@ -39,7 +39,7 @@ Weights are the share of a Word user's file traffic (estimate). Read/write: ✅
|
||||
| `.docx` Strict Open XML | 1% | R/W | ✅ (namespace mapping in `xml.rs`) | ❌ | ~60% | 2–4 | one fixture | Writing Strict is rare |
|
||||
| `.docm` / `.dotx` / `.dotm` | 3% | R/W | ✅ | ✅ | ~65% | incl. above | `macro_packages.rs` | Macros and signatures kept on save (#172); macros never run |
|
||||
| `.doc` Word 97-2003 (and `.dot`) | 8% | R/W | 🟡 | ❌ | ~60% | 25–40 | `crates/docbin/src/tests.rs`, `tests/drawing.rs` | Spec-based reader ([MS-DOC]): text, formatting, styles, sections, headers/footers, tables, lists, notes, fields, bookmarks; inline and floating pictures (PNG, JPEG, DIB, TIFF, EMF, WMF; Macintosh PICT as a sized placeholder) with crop, alt text, position and wrap; basic shapes, lines, groups and text boxes ([MS-ODRAW]). Table cell shading from every SHD record (`sprmTDefTableShd`, `sprmTDefTableShdRaw` and their 2nd/3rd ranges, with the Word 97 `Shd80` as fallback; a clear pattern shows its background colour); the row-end mark is not a cell, and each table has its own grid and properties (#1380). Horizontally merged rows stored as fewer, wider cells come with #1123. Freeform shapes, z-order and Word 6/95 files are dropped or refused. Password-protected files (XOR obfuscation, RC4, RC4 CryptoAPI) open after a password prompt, from the public specs; not yet tried on a file Word wrote (AES-encrypted ones are refused). No writer. DIB conversion caps BMP bytes (header included) and encoded PNG at the supplied BLIP byte limit, and uses it as the decoder's allocation limit; a BMP fallback is kept if it fits (related #1334 and #1554). |
|
||||
| PDF export | 8% | W | — | ✅ | ~80% | 6–10 | `crates/pdf/src/tests.rs` | krilla: embedded subsets, selectable text incl. field results (#133); Hebrew and Arabic copy and search as whole words (right-to-left glyphs written in visual order, #1383); All Caps and Small Caps text extracts as the capitals shown, as from Word's PDFs, and never changes the case of other text (#1473), links, outline, tagging. Fonts it can't subset become outlines (#132). Print Markup (#589): comments in balloons beside each page (scaled to its paper) and tracked changes; HTML/Markdown/text copies end with a comment list. Horizontal and vertical lines inside groups are drawn (#1474). No PDF/A option, no "best for printing/online" choice |
|
||||
| PDF export | 8% | W | — | ✅ | ~80% | 6–10 | `crates/pdf/src/tests.rs` | krilla: embedded subsets, selectable text incl. field results (#133); Hebrew and Arabic copy and search as whole words (right-to-left glyphs written in visual order, #1383); All Caps and Small Caps text extracts as the capitals shown, as from Word's PDFs, and never changes the case of other text (#1473), links, outline, tagging. Outline destinations follow leading page breaks to the heading text. Fonts it can't subset become outlines (#132). Print Markup (#589): comments in balloons beside each page (scaled to its paper) and tracked changes; HTML/Markdown/text copies end with a comment list. Horizontal and vertical lines inside groups are drawn (#1474). No PDF/A option, no "best for printing/online" choice |
|
||||
| PDF open (PDF Reflow, Word for Windows) | 1% | R | ❌ | — | 0% | 30–50 | — | Converting a PDF into an editable document. A sibling app (PdfCraft) may supply the parser |
|
||||
| `.rtf` Rich Text Format | 3% | R/W | 🟡 | 🟡 | ~50% | 10–15 | `crates/formats/src/tests.rs` | ~1,500 lines: text, character/paragraph formatting, styles, lists, tables, pictures (`\shppict`, #154), fields, bookmarks. 8-bit text in the document's code page (`\ansicpg`) or the current font's (`\fcharset`, `\cpg`): Cyrillic, Central European, Greek, Turkish, Hebrew, Arabic, Baltic, Vietnamese, Thai (was Windows-1252 only), Mac OS Roman (`\mac`, `\fcharset77`) and DOS code pages (`\pc` 437, `\pca` 850, `\cpg` 437/737/850/852/857/866, `\fcharset254`) (#1653). Double-byte East Asian code pages (Japanese `\fcharset128`, Chinese, Korean) not decoded yet. Pasted from the system clipboard where the platform offers it (Windows, Wayland) when there is no HTML (#687). Tracked insertions and deletions read and written (`\revtbl`, `\revised`/`\deleted`, authors, `\revdttm` dates), so Accept/Reject All work after opening or a round trip; paragraph-mark and formatting revisions not yet. Sections on import (#1378): each `\sect` ends a section with its own page size and orientation, margins, header/footer distance, columns, break type, page-number restart, start and format, title page and headers/footers (those it leaves out link to the previous); export still writes one section. Thin: comments, notes, section export, RTL |
|
||||
| `.odt` OpenDocument Text | 2% | R/W | 🟡 | 🟡 | ~50% | 8–12 | `crates/formats/src/tests.rs`, `crates/formats/src/odt.rs` unit tests, `crates/engine/tests/odt_sections.rs` | Styles, lists (#87), tables, pictures, headers/footers, page breaks (#86), page size, orientation and margins (#810), sections: one per master-page change on a paragraph or table (next page, with its own page size, orientation, margins, columns and page-numbering restart) and per columned `text:section` (continuous, with its columns and separator line) (#1379); tracked insertions and deletions with author and date, inserted and deleted paragraph marks and text inserted then deleted included (ODF change tracking; format changes read as none; pictures and tables inside a tracked deletion are dropped). No footnotes, comments, fields or RTL |
|
||||
@@ -68,6 +68,7 @@ Measured by grepping `crates/docx/src/read` and `src/write` for the OOXML elemen
|
||||
| Text, runs, paragraph and character properties, styles, numbering, sections | ✅ | ✅ | Including a Styles gallery for files that mark no style `w:qFormat` (LibreOffice: Word's usual quick styles are added, #1238), complex-script props (`w:rtl`, `w:cs`, `w:szCs`, `w:bCs`…); runs that mix right-to-left and left-to-right text are split on save where the direction changes, and the right-to-left pieces get `w:rtl` and a bidi language (`w:lang w:bidi`, e.g. he-IL), so Word lays Hebrew around Latin names, numbers and e-mail addresses out as WordCraft does and doesn't call it Arabic (#1543, checked in Word for Mac 16.113.4), East Asian fonts kept apart from Latin (#111); independent complex-script formatting inherited through defaults/styles and preserved over repeated saves (`complex_script_inheritance.rs`), indents in characters (`w:firstLineChars`, `w:hangingChars`, `w:leftChars`, `w:rightChars`, #534); every ST_NumberFormat value of `w:numFmt` is kept on save, Chinese/Japanese/Korean, enclosed and full-width formats are drawn, the rest (Hebrew, Arabic, Hindi, Thai…) show decimal (#534). `w:beforeLines`/`w:afterLines` not kept (the points are) |
|
||||
| Tables incl. floating (`w:tblpPr`), table styles and conditional formatting | ✅ | ✅ | Custom table styles round-trip (#256); right-to-left tables (`w:bidiVisual`) and sections (`w:bidi`) round-trip (#362) |
|
||||
| Text, runs, paragraph and character properties, styles, numbering, sections | ✅ | ✅ | Including complex-script props (`w:rtl`, `w:cs`, `w:szCs`, `w:bCs`…), East Asian fonts kept apart from Latin (#111); independent complex-script formatting inherited through defaults/styles and preserved over repeated saves (`complex_script_inheritance.rs`), indents in characters (`w:firstLineChars`, `w:hangingChars`, `w:leftChars`, `w:rightChars`, #534); every ST_NumberFormat value of `w:numFmt` is kept on save, Chinese/Japanese/Korean, enclosed and full-width formats are drawn, the rest (Hebrew, Arabic, Hindi, Thai…) show decimal (#534). `w:beforeLines`/`w:afterLines` not kept (the points are) |
|
||||
| Positional tabs (`w:ptab`: alignment, margin or indent, leader) | ✅ | ✅ | Laid out at their place whatever the tab stops; one with an alignment or base the spec doesn't define reads as a plain tab (#2367). `crates/docx/tests/positional_tabs.rs` |
|
||||
| Tables incl. floating (`w:tblpPr`), table styles and conditional formatting | ✅ | ✅ | Custom table styles round-trip (#256), every `w:tblStylePr` part (wholeTable, firstRow, lastRow, firstCol, lastCol, band1/2Vert, band1/2Horz, ne/nw/se/swCell) with run formatting, `w:jc`, cell borders, shading and `w:vAlign` (#714); a cell's own `w:vAlign w:val="top"` is kept and beats the style's (#1288); right-to-left tables (`w:bidiVisual`) and sections (`w:bidi`) round-trip (#362) |
|
||||
| Text, runs, paragraph and character properties, styles, numbering, sections | ✅ | ✅ | Including a Styles gallery for files that mark no style `w:qFormat` (LibreOffice: Word's usual quick styles are added, #1238), complex-script props (`w:rtl`, `w:cs`, `w:szCs`, `w:bCs`…), East Asian fonts kept apart from Latin (#111); independent complex-script formatting inherited through defaults/styles and preserved over repeated saves (`complex_script_inheritance.rs`), indents in characters (`w:firstLineChars`, `w:hangingChars`, `w:leftChars`, `w:rightChars`, #534); every ST_NumberFormat value of `w:numFmt` is kept on save, Chinese/Japanese/Korean, enclosed and full-width formats are drawn, the rest (Hebrew, Arabic, Hindi, Thai…) show decimal (#534). `w:beforeLines`/`w:afterLines` not kept (the points are) |
|
||||
| Tables incl. floating (`w:tblpPr`), table styles and conditional formatting | ✅ | ✅ | Row table border exceptions (`w:tblPrEx/w:tblBorders`) round-trip (`row_table_borders_round_trip`); other row table exceptions are not preserved. Custom table styles round-trip (#256); right-to-left tables (`w:bidiVisual`) and sections (`w:bidi`) round-trip (#362) |
|
||||
@@ -102,7 +103,8 @@ Measured by grepping `crates/docx/src/read` and `src/write` for the OOXML elemen
|
||||
| Section vertical alignment (`w:vAlign` in `w:sectPr`, §17.6.23) | ✅ | ✅ | `top`, `center`, `bottom` and `both` (Justified, read as Center before #1320); unknown values read as top, top writes nothing (`crates/docx/tests/section_valign.rs`). ODT/RTF don't carry it |
|
||||
| Ruby (`w:ruby`: `w:rubyPr`, `w:rt`, `w:rubyBase`) | ✅ | ✅ | #288; base and ruby text with their run formatting, alignment, sizes, raise, language |
|
||||
| Document grid (`w:docGrid`: type, line pitch, `w:charSpace`), paragraph `w:snapToGrid` | ✅ | ✅ | #391; run-level `w:snapToGrid` not read |
|
||||
| `w:eastAsianLayout`, `w:fitText` | ❌ | ❌ | East Asian layout |
|
||||
| Two lines in one and horizontal in vertical (`w:eastAsianLayout`, §17.3.2.10) | ✅ | ✅ | Every attribute (`w:id`, `w:combine`, `w:combineBrackets`, `w:vert`, `w:vertCompress`) read into the run's formatting and written back after `w:lang`, in runs and styles; values outside the schema are dropped (`crates/docx/tests/east_asian_layout.rs`). Not laid out yet: the text shows as ordinary text |
|
||||
| `w:fitText` | ❌ | ❌ | East Asian layout |
|
||||
| Glossary document (building blocks) | ✅ | ✅ | `w:docPart` entries (name, style, gallery, category, types, behaviours, description, guid, body) are read into `Document::glossary` and offered by Insert › Quick Parts and AutoText; `save` with `"in": "document"` stores one in the file. An untouched glossary is written back byte for byte with its own parts (styles, settings, fonts…); an edited one, or one whose parts weren't kept, is written from the model as `word/glossary1.xml` with copies of the document's styles and numbering. The glossary's own styles, settings and fonts, text boxes inside entries, and the notes and comments of a selection saved into the document don't survive an edit. Tests: `crates/docx/tests/glossary.rs` |
|
||||
| `w:altChunk`, sub-documents | ❌ | ❌ | |
|
||||
| Hostile input | ✅ | — | `malformed.rs`, capped allocations, never-crash standard |
|
||||
@@ -228,6 +230,9 @@ Imported text fields use the first nonempty cached result run's formatting on th
|
||||
| 2026-10-17 | trivial | Glossary document (building blocks) read into the model, kept verbatim while unchanged and written from the model once edited; split from the `w:altChunk`/sub-documents row |
|
||||
| 2026-10-16 | trivial | Legacy `w:outline`, `w:shadow`, `w:emboss` and `w:imprint` run effects drawn on screen and in PDFs (#2534) |
|
||||
| 2026-10-15 | trivial | `w:doNotTrackMoves` read and written (Track moves off: a tracked cut and paste is a deletion and an insertion) |
|
||||
| 2026-10-16 | trivial | Positional tabs (`w:ptab`) keep their alignment, base and leader through DOCX saves instead of becoming plain tabs (#2367) |
|
||||
| 2026-10-16 | trivial | PDF outline destinations point to heading text after leading page breaks, including selected-page exports |
|
||||
| 2026-10-16 | trivial | `w:eastAsianLayout` (two lines in one, horizontal in vertical) is read and written; not laid out yet |
|
||||
| 2026-10-13 | trivial | A cell's explicit top vertical alignment (`w:vAlign w:val="top"`) is kept on read and write and beats a table style that centres or bottom-aligns (#1288) |
|
||||
| 2026-10-14 | trivial | Row table border exceptions survive DOCX saves |
|
||||
| 2026-10-12 | trivial | Fields keep `w:dirty` through DOCX round trips — on buffered fields and range-field begin markers alike — and computing a result (Update Field, Update Table) clears it like Word's F9, so a generator's placeholder TOC still refreshes when Word opens the file until WordCraft replaces the placeholder (#877) |
|
||||
|
||||
+6
-3
@@ -1,6 +1,6 @@
|
||||
# Where WordCraft falls short of Word
|
||||
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-18 · **Change:** trivial (numbered headings, #1793; previously trivial: legacy outline, shadow, emboss and imprint text effects drawn, #2534; previously trivial: #8 narrowed: Indonesian proofing from the installed Hunspell dictionary, #498; previously trivial: glossary/building blocks closed; previously trivial: Split Cells and Convert Text to Table follow the Word check, #2592; previously trivial: Language dialog keeps inherited proofing language and no-proof choices (#2717); previously trivial: #29: a copied equation also goes in the MathML clipboard formats, #1297; previously trivial: VML freeforms read and kept on save; previously trivial: consistent preferred cell widths; previously trivial: multi-paragraph field instructions no longer add blank lines; previously trivial: page-break-only list paragraphs hide their labels; previously trivial: floating-table overlap ignores empty line-break lines; previously trivial: copies keep their formatting on the clipboard as HTML, #1523; previously trivial: sample-text formulas on Enter, #1067; previously trivial: #29: copied equations as MathML, OMML open as #1297, #816; previously trivial: the Font list no longer keeps every previewed font in memory, #848; previously trivial: table column and row markers on the rulers, #700; previously trivial: table borders and the resize handle drag on the page, #217; previously trivial: formatting revisions landed, #41; previously trivial: Draw Table and Eraser landed, #303; previously minor: beta distance restated: ~15 points, ~150–230 h; previously: first version; every known shortfall from the 2026-10-10 re-measure, ranked) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-18 · **Change:** trivial (automatic Asian/Latin and Asian/number spacing laid out, #2358; previously trivial: hint-reliant CJK outlines; previously trivial: numbered headings, #1793; previously trivial: legacy outline, shadow, emboss and imprint text effects drawn, #2534; previously trivial: #8 narrowed: Indonesian proofing from the installed Hunspell dictionary, #498; previously trivial: glossary/building blocks closed; previously trivial: Split Cells and Convert Text to Table follow the Word check, #2592; previously trivial: Language dialog keeps inherited proofing language and no-proof choices (#2717); previously trivial: #29: a copied equation also goes in the MathML clipboard formats, #1297; previously trivial: #24: the Spike, Ctrl+F3 and Ctrl+Shift+F3; previously trivial: VML freeforms read and kept on save; previously trivial: consistent preferred cell widths; previously trivial: multi-paragraph field instructions no longer add blank lines; previously trivial: page-break-only list paragraphs hide their labels; previously trivial: floating-table overlap ignores empty line-break lines; previously trivial: copies keep their formatting on the clipboard as HTML, #1523; previously trivial: sample-text formulas on Enter, #1067; previously trivial: #29: copied equations as MathML, OMML open as #1297, #816; previously trivial: the Font list no longer keeps every previewed font in memory, #848; previously trivial: table column and row markers on the rulers, #700; previously trivial: table borders and the resize handle drag on the page, #217; previously trivial: formatting revisions landed, #41; previously trivial: Draw Table and Eraser landed, #303; previously minor: beta distance restated: ~15 points, ~150–230 h; previously: first version; every known shortfall from the 2026-10-10 re-measure, ranked) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4
|
||||
|
||||
> **Printing update:** 2026-10-12 · direct desktop printing through craft-print; details below.
|
||||
|
||||
@@ -40,14 +40,14 @@ Document renaming: desktop renaming requires a filesystem that supports hard lin
|
||||
| 14 | **`.doc` can't be written; Word 6/95 files refused** | | `crates/docbin` is read-only | Users exchanging files with old Word or legacy systems | 25–40 | [file-format-parity.md](file-format-parity.md) |
|
||||
| 15 | **References depth**: no EndNote/Mendeley desktop integration (bibliography sources are saved in DOCX, #790; Zotero landed, #189) | | #790 stores sources in `customXml` and reads them back; `crates/zotero` | Students and researchers | 3–6 | [target-app-parity.md](target-app-parity.md) |
|
||||
| 16 | **Right-to-left completeness**: kashida, RTL in HTML/ODT/RTF; six still-open older RTL bug reports to re-verify after #207 (#211, #199, #66, #63, #48, #19; #215 is closed). RTL tables and sections landed (#362); PDF export's Hebrew and Arabic text copies and searches as whole words (#1383) | | [layout-parity.md](layout-parity.md) | Arabic, Persian, Hebrew writers | 7–11 | [layout-parity.md](layout-parity.md) |
|
||||
| 17 | **East Asian typography**: Asian Typography options (Phonetic Guide landed, #288; Enclose Characters landed, #297; document grid landed, #391; vertical text landed, #491) | | `para.asianTypography` is live (Paragraph dialog Asian Typography tab, #323) and no East Asian item is left in the catalog's missing list; the remaining depth is not measured | Chinese, Japanese and Korean documents | 11–19 | [layout-parity.md](layout-parity.md) |
|
||||
| 17 | **East Asian typography**: hint-reliant DFKai-SB contours repaired (local Windows regression; no readiness estimate change); Asian Typography options (Phonetic Guide landed, #288; Enclose Characters landed, #297; document grid landed, #391; vertical text landed, #491) | | `para.asianTypography` is live (Paragraph dialog Asian Typography tab, #323), automatic Asian/Latin and Asian/number spacing is laid out (#2358), and no East Asian item is left in the catalog's missing list; the remaining depth is not measured | Chinese, Japanese and Korean documents | 11–19 | [layout-parity.md](layout-parity.md) |
|
||||
| 19 | **RTF and ODT depth**: notes, comments, revisions (RTF and ODT insertions and deletions done), sections (RTF import done, #1378; RTF export and ODT left), RTL. ODT sections landed on import (#1379): page-style changes and columned `text:section`s | | ~2,100 (RTF) and ~1,600 (ODT) lines in `crates/formats`; RTF code pages (#896) and page setup (#902), ODT page setup (#829) landed; open PRs: RTF notes and comments (#1056), ODT notes and comments (#1057), ODT headers and footers (#873) | Users exchanging with LibreOffice or older tools | 18–27 | [file-format-parity.md](file-format-parity.md) |
|
||||
| 18 | **Screen-reader access** to the document canvas: exposed, not yet tried with real screen readers | | The canvas describes paragraphs, headings, lists, tables, links, pictures and the caret to AccessKit (#488), tested headlessly; Windows Magnifier follows the caret, onto a new line too (#1119); no NVDA, VoiceOver or Orca run yet | Blind and low-vision users; public-sector procurement | 3–6 | [ui-parity.md](ui-parity.md) |
|
||||
| 20 | **Performance on large real-world documents** unmeasured (500+ pages, many pictures) | | Only the 188-page sample benchmark; scrolling a 1,096-page one-table document went from ~600 ms to ~2–5 ms a frame (#811), but opening it still lays out for seconds | Thesis and book authors | 10–20 | [layout-parity.md](layout-parity.md) |
|
||||
| 21 | **Mail merge data sources**: no Outlook/Contacts, Access or database sources, no email merge; CSV, typed lists (#247) and Excel/OpenDocument spreadsheets (#334, legacy .xls #778) only | | Select Recipients reads .csv/.tsv/.txt in any common encoding (UTF-8/16, GBK, Big5, Shift_JIS…), .xlsx/.xlsm, .xls and .ods; Edit Recipient List filters and sorts (#778) | Office administrators | 4–8 | [target-app-parity.md](target-app-parity.md) |
|
||||
| 22 | **Ecosystem**: no VBA execution (macros preserved only), no Office add-ins, no cloud storage, no real-time co-authoring | | `.docm` keeps macros (#172) | Enterprise users with macro templates; teams | 40–80 + owner decisions | [target-app-parity.md](target-app-parity.md) |
|
||||
| 23 | **PDF Reflow** (open a PDF as an editable document, Word for Windows) | | | Users who edit PDFs they receive | 30–50 | [file-format-parity.md](file-format-parity.md) |
|
||||
| 24 | **Default shortcuts**: 140 (macOS) / 158 (Linux/Windows) of Word's ~250 (#330, #339, #343; Customize Keyboard #368, Customize Ribbon and Quick Access Toolbar #379; inspector, macros, strikethrough, file/edit keys) | | [ui-parity.md](ui-parity.md) | Power users | 1–3 | [ui-parity.md](ui-parity.md) |
|
||||
| 24 | **Default shortcuts**: 140 (macOS) / 158 (Linux/Windows) of Word's ~250 (#330, #339, #343; Customize Keyboard #368, Customize Ribbon and Quick Access Toolbar #379; inspector, macros, strikethrough, file/edit keys, the Spike) | | [ui-parity.md](ui-parity.md) | Power users | 1–3 | [ui-parity.md](ui-parity.md) |
|
||||
| 25 | **In-app AI assistant** (Copilot-style draft, rewrite, summarize) | | Chat PR #178 and AI assistant PR #815 open | Users expecting Copilot | 15–30 + owner (provider) | [target-app-parity.md](target-app-parity.md) |
|
||||
| 27 | **View windows**: Side by Side, Synchronous Scrolling, Arrange All, Switch Windows | | [parity-checklist.md](parity-checklist.md) (View 25/29); PR #331 open | Comparing documents | 3–5 | [ui-parity.md](ui-parity.md) |
|
||||
| 26 | **Minor formats**: MHT, Works/WordPerfect | | Word XML Document (Flat OPC) opens and saves, Word 2003 XML opens (#548) and saves (#1081, not yet checked in Word) | Rare | 14–26 | [file-format-parity.md](file-format-parity.md) |
|
||||
@@ -90,6 +90,7 @@ The Language dialog reads effective character formatting, including document def
|
||||
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-10-16 | trivial | #24: the Spike (Ctrl+F3, Ctrl+Shift+F3) |
|
||||
| 2026-10-17 | trivial | Glossary/building blocks closed (DOCX glossary read, kept and written); `w:altChunk` and sub-documents remain |
|
||||
| 2026-10-15 | trivial | #5 narrowed: Track Changes Options › Moves (Moved from / Moved to marks and colours) and Track moves (`w:doNotTrackMoves`) landed; drag-and-drop moves remain |
|
||||
| 2026-10-17 | trivial | Language dialog keeps inherited proofing language and no-proof choices (#2717); readiness estimates unchanged |
|
||||
@@ -101,6 +102,8 @@ The Language dialog reads effective character formatting, including document def
|
||||
| 2026-10-16 | trivial | #8 narrowed: Indonesian proofing (id-ID spelling from the installed Hunspell dictionary, generic checks, #498) |
|
||||
| 2026-10-16 | trivial | Legacy outline, shadow, emboss and imprint text effects drawn (#2534) |
|
||||
| 2026-10-15 | trivial | Numbered headings (#1793): heading-linked Multilevel List presets; numbering keeps heading styles. Not a ranked gap; style gallery previews without numbers remain |
|
||||
| 2026-10-16 | trivial | Hint-reliant CJK fonts execute TrueType contour assembly; Windows DFKai-SB no longer loses strokes. Synthetic local-font regression; no font binaries bundled. |
|
||||
| 2026-10-16 | trivial | #17: automatic space between Asian text and Latin text or digits laid out (#2358) |
|
||||
| 2026-10-14 | trivial | Row table border exceptions fixed; other row table exceptions remain unsupported |
|
||||
| 2026-10-12 | minor | Czech proofing review: short-word splits rank after deletion, NOSUGGEST excludes suggestions while accepting typed forms, affix comments apply only to headers, ratios keep spaces and repeated-word messages use Czech quotes; synthetic engine end-to-end and latency checks |
|
||||
| 2026-10-12 | minor | Czech spelling from the installed Hunspell dictionary, runtime-table suggestions and generic checks; Czech interface writes cs-CZ documents; no Czech hyphenation patterns bundled; estimates unchanged |
|
||||
|
||||
+13
-3
@@ -1,6 +1,6 @@
|
||||
# Layout and pagination parity with Microsoft Word
|
||||
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-17 · **Change:** trivial (field results with right-to-left text read right to left, #1670; previously trivial: a list bullet no installed font can draw shows the level's default bullet, #1683; previously trivial: a missing Thai font is drawn in one Thai face for the whole run, #1549; previously trivial: the caret after spaces hanging past the margin stays at the margin, #2146; previously trivial: covered black-circle fallback before UI sans; previously trivial: resolved split-cell continuation top insets; previously trivial: Symbol PCL6 alias uses Symbol encoding; previously trivial: exact full font names select their installed face; previously trivial: Arial Unicode MS uses proportional fallbacks; previously trivial: DrawingML outline dashes, including zero-width paths; previously trivial: consistent cell widths override a stale table grid; previously trivial: multi-paragraph field instructions no longer add blank lines; previously trivial: page-break-only list paragraphs hide their labels; previously trivial: floating-table overlap ignores empty line-break lines; previously trivial: paragraph spacing adds up when the document turns HTML auto spacing off; previously trivial: an inherited footer sizes its percentage tables by its own section; previously trivial: an empty section mark stays on a full page; previously trivial: a fitting row with less room than its minimum height moves on whole; oversized rows still split; previously trivial: text past an exact-height row is cut off; previously trivial: a list number stays with text after a leading page break; previously trivial: a bookmark after a page break takes no line; previously trivial: a page break followed by a page break before or a next-page section starts one page; previously trivial: All Caps and Small Caps draw ß as SS, #1581; previously trivial: justified vertical page alignment, #1320; previously trivial: page numbers restart at 0; previously trivial: a list label moves with its first line when the paragraph is centred or end-aligned (#1159); previously trivial: PAGE fields in a paragraph split across pages show the page they are on, #1352; previously trivial: a dragged floating object anchors in the column it lands in, #884; previously trivial: a paragraph ending on a page with footnotes keeps its space after above the separator, #1116; previously trivial: symbol-font text drawn as Unicode symbols, #776; previously trivial: large-table scrolling measured and fixed, #811; previously trivial: text boxes use their own margins; Resize shape to fit text never cuts text, #743; previously trivial: complex-script shaping and Myanmar line breaking, #604; previously trivial: body page totals, table-cell footnotes and bookmark link pages fixed, #450 #451 #455; previously major: first version; line breaking, pagination and object placement checked against Word's behaviour from the layout source on origin/main) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-17 · **Change:** trivial (AutoFit donors lose equal widths down to their content minima; previously trivial: automatic space between Asian text and Latin text or digits, #2358; previously trivial: hint-reliant CJK outlines; previously trivial: positional tabs go to the margins or indents, #2367; previously trivial: field results with right-to-left text read right to left, #1670; previously trivial: a list bullet no installed font can draw shows the level's default bullet, #1683; previously trivial: a missing Thai font is drawn in one Thai face for the whole run, #1549; previously trivial: the caret after spaces hanging past the margin stays at the margin, #2146; previously trivial: covered black-circle fallback before UI sans; previously trivial: resolved split-cell continuation top insets; previously trivial: Symbol PCL6 alias uses Symbol encoding; previously trivial: exact full font names select their installed face; previously trivial: Arial Unicode MS uses proportional fallbacks; previously trivial: DrawingML outline dashes, including zero-width paths; previously trivial: TOCs include table-cell headings and retain full targets; previously trivial: kept table rows follow the next row; previously trivial: consistent cell widths override a stale table grid; previously trivial: multi-paragraph field instructions no longer add blank lines; previously trivial: page-break-only list paragraphs hide their labels; previously trivial: floating-table overlap ignores empty line-break lines; previously trivial: paragraph spacing adds up when the document turns HTML auto spacing off; previously trivial: an inherited footer sizes its percentage tables by its own section; previously trivial: an empty section mark stays on a full page; previously trivial: a fitting row with less room than its minimum height moves on whole; oversized rows still split; previously trivial: text past an exact-height row is cut off; previously trivial: a list number stays with text after a leading page break; previously trivial: a bookmark after a page break takes no line; previously trivial: a page break followed by a page break before or a next-page section starts one page; previously trivial: All Caps and Small Caps draw ß as SS, #1581; previously trivial: justified vertical page alignment, #1320; previously trivial: page numbers restart at 0; previously trivial: a list label moves with its first line when the paragraph is centred or end-aligned (#1159); previously trivial: PAGE fields in a paragraph split across pages show the page they are on, #1352; previously trivial: a dragged floating object anchors in the column it lands in, #884; previously trivial: a paragraph ending on a page with footnotes keeps its space after above the separator, #1116; previously trivial: symbol-font text drawn as Unicode symbols, #776; previously trivial: large-table scrolling measured and fixed, #811; previously trivial: text boxes use their own margins; Resize shape to fit text never cuts text, #743; previously trivial: complex-script shaping and Myanmar line breaking, #604; previously trivial: body page totals, table-cell footnotes and bookmark link pages fixed, #450 #451 #455; previously major: first version; line breaking, pagination and object placement checked against Word's behaviour from the layout source on origin/main) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4
|
||||
|
||||
A document only "opens correctly" if its pages break where Word breaks them: the same lines, the
|
||||
same page count, footnotes and pictures on the same pages. This checklist covers line breaking,
|
||||
@@ -19,6 +19,7 @@ font can't match Word's metrics.
|
||||
| Feature | State | Evidence / gap | Hours |
|
||||
|---|---|---|---|
|
||||
| 2026-10-16 | trivial | Split-cell content retains its resolved top margin and border band on continuation pages, matching original Word Mac16.113.2 controls. Synthetic regressions cover margins, borders, line spacing and image frames. |
|
||||
| Hint-reliant CJK glyph outlines (DFKai-SB / 標楷體) | 🟡 | Required TrueType interpreter runs at units-per-em size; local Windows glyph-bounds regression and synthetic DOCX page/window comparison pass. Other platforms and hint-reliant families remain unmeasured; no native Word comparison. | — |
|
||||
| Line breaking (UAX #14 with Word's exceptions: no break after `/`) | ✅ | `layout::tests::no_line_break_right_after_a_slash` | — |
|
||||
| Justification, Word 2013+ space shrinking (compat mode 15) | ✅ | #109 | — |
|
||||
| Spaces at the end of a line hang past the right margin instead of wrapping; the caret after them stays at the margin, in the body and in headers and footers | ✅ | `layout::tests::caret_after_hanging_spaces_stays_at_the_margin` (#2146): a letter typed after them wraps to the next line | — |
|
||||
@@ -37,7 +38,7 @@ font can't match Word's metrics.
|
||||
| Automatic (HTML-style) paragraph spacing (`w:beforeAutospacing`, `w:afterAutospacing`) | ✅ | Measured in Word for Windows: 14 pt at any size, none at the top of the document or a cell or after a cell's last paragraph, the larger of two neighbouring spaces. `layout::tests::automatic_paragraph_spacing_like_word`, `…_drops_at_cell_edges` | — |
|
||||
| Paragraph boundaries inside field instructions | ✅ | Synthetic DOCX observed in Word: instruction-only paragraphs add no height; the empty result keeps a line. `docx::fixtures::field_code_spans_paragraphs`, `layout::tests::hidden_marks_take_no_space`. Other field-code display modes remain unverified | — |
|
||||
| Widow/orphan control, keep with next, keep lines together, page break before | ✅ | `lib.rs` keep_next/keep_lines; a line holding only a page break stays at the bottom of a full page instead of leaving a blank page (#534); a paragraph ending with a page break keeps its space after on the old page, so the next page starts at the top (compatibility modes 12 to 15, `layout::tests::space_before_at_the_top_of_a_page_like_word`); a three-line paragraph with room for two moves whole (neither a widow nor an orphan is allowed), as in Word | — |
|
||||
| Tabs (left, center, right, decimal, bar), leaders | 🟡 | Bar tabs untested | 1–2 |
|
||||
| Tabs (left, center, right, decimal, bar), leaders | 🟡 | Bar tabs untested. Positional tabs (`w:ptab`, ECMA-376 §17.3.3.23) go to the left edge, centre or right edge of the margins or indents whatever the tab stops, and to the next line when the text has passed that place (#2367, `layout::tests::right_positional_tab_ends_text_on_the_right_margin` and the three after it); not compared with Word on lines narrowed by floating objects or right-to-left paragraphs | 1–2 |
|
||||
| Hyphenation (automatic, zone from the document, words in capitals, soft hyphens, consecutive limit, Manual Hyphenation, #407) | 🟡 | English patterns only; other languages hyphenate wrongly or not at all | in localization |
|
||||
| Drop caps | ✅ | 2026-10-06 | — |
|
||||
| All Caps and Small Caps: the full capital of each letter, so ß draws as SS (ff as FF); the capitals drawn for one character are one cluster, so the caret, selection, hit testing and line breaking treat ß as one character | ✅ | `layout::tests::caps_draw_sharp_s_as_two_capitals_in_one_cluster` (#1581). The PDF text layer still reads the document's lowercase letters until #1473 | — |
|
||||
@@ -64,6 +65,7 @@ font can't match Word's metrics.
|
||||
| Vertical text (`tbRl`) in sections and text boxes; cell text direction | ✅ | cell text direction (#245); text-box text direction (#369); vertical sections (`tbRl`/`btLr`, #491): the page's text area turned, lines top to bottom stacking right to left, ideographs, kana and Hangul upright, Latin on its side, caret and clicks through the turn. Missing: OpenType `vert` alternates (punctuation is moved, not substituted), arrow keys along vertical lines, upright pictures take turned room | 2–4 |
|
||||
| Document grid (`w:docGrid`, lines per page, characters per line) | 🟡 | #391, #401: line grid snaps line heights to whole pitches (text centred in them; `w:snapToGrid` off opts a paragraph out); character grid puts East Asian characters on the character pitch (`linesAndChars`) or all text (`snapToChars`), Latin text proportional; View › Gridlines draws it. Not yet: run-level `w:snapToGrid`, grid in headers/footers, paragraph spacing in grid lines, Word's exact glyph placement in a cell | 1–2 |
|
||||
| Ruby / phonetic guide, enclose characters, combined characters | 🟡 | ruby (Phonetic Guide) landed (#288): ruby text over its base by alignment, line grows to fit, in PDF; enclose and combined characters missing; ruby not yet compared with Word page by page | 2–4 |
|
||||
| Automatic space between Asian text and Latin text or digits (`w:autoSpaceDE`, `w:autoSpaceDN`) | 🟡 | #2358: a quarter of the Asian character's font size after the first of two touching characters, across runs (measured on Word's PDF of a Chinese document at 16 pt, as JIS X 4051 asks); not beside East Asian punctuation, Hangul, Latin punctuation or an existing space; a field is classed by the text it shows; none at a line's end, none on a character grid the paragraph snaps to (`layout::tests_autospace`). Not yet: justification widening the space as Word does, `autoSpaceLikeWord95` | 1–2 |
|
||||
| Bidirectional paragraphs, mixed-direction lines; RTL tables and sections | 🟡 | #207; RTL tables (#362): grid columns from the right edge, start alignment and indent from the right margin, cell margins and left/right borders mirrored, merged and turned cells and nested tables included; RTL sections (#362): newspaper columns right to left with the line between mirrored. Right-hand page gutter (`w:rtlGutter`) missing | 1 |
|
||||
| Equations: display layout, numbering, wrapping long display equations | ✅ | #191; long display equations break at top-level operators (`m:brkBin`, `m:brkBinSub`), manual breaks and `m:alnAt`, continuation lines indented by `m:wrapIndent` or set right (`m:wrapRight`) (#326) | — |
|
||||
| Hidden text excluded from breaking and hyphenation | ✅ | #173 | — |
|
||||
@@ -173,15 +175,19 @@ No overall parity estimate changes from these probes.
|
||||
| Large real-world documents (500+ pages, many pictures) | not measured | | gap |
|
||||
| Scrolling a 1,096-page document that is one 5,300-row table (#811) | ~2.4 ms idle, ~4.5 ms median scroll frame (was ~600 ms); opening still lays out for ~4–8 s | not measured | measured 2026-10-12, release, headless (`tests_perf::big_table_scroll_bench`) |
|
||||
|
||||
TOC refresh includes heading and outline paragraphs inside tables, including nested cells. Links and page numbers retain their full paragraph paths when entries move body blocks. The existing link picker still accepts top-level heading block indices.
|
||||
|
||||
Explicit DrawingML preset and custom outline dashes retain their stroke-width units on import
|
||||
and save, in raster output and in PDF. Synthetic controls cover red rectangles, ellipses and
|
||||
connectors. Custom lists are capped at 64 pairs; painted spans are bounded to 0.01–20000 pt.
|
||||
Theme-inherited dash styles, line caps, ink and open freeform strokes are not covered by this change.
|
||||
|
||||
When content exceeds a preferred column width, AutoFit donors lose equal widths until they reach their content minima. Original synthetic Word probes cover donor reductions; cached field refresh and wrapping remain separate gaps.
|
||||
## Revision history
|
||||
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-10-16 | trivial | Table row keep-next uses the first paragraph of the first cell, including inherited flags. Kept row chains start on a fresh page when the following row cannot start below them. Synthetic Word export covers mixed-cell flags, multiple paragraphs and kept prefixes before oversized followers. |
|
||||
| 2026-10-16 | trivial | Include table-cell headings in regenerated TOCs with full target paths |
|
||||
| 2026-10-17 | trivial | Explicit DrawingML preset/custom outline dashes survive import/save and paint in raster/PDF, scaled to the effective stroke width even for zero-width paths. Synthetic controls; theme-inherited dashes and line caps remain unverified. |
|
||||
| 2026-10-17 | trivial | Arial Unicode MS uses Arial and its proportional substitutes; glyph coverage and exact Word line height remain font-dependent. |
|
||||
| 2026-10-16 | trivial | Exact OpenType full names (name ID4) select installed static faces after family names; variable-instance aliases remain unsupported |
|
||||
@@ -191,6 +197,10 @@ Theme-inherited dash styles, line caps, ink and open freeform strokes are not co
|
||||
| 2026-10-15 | trivial | A missing Thai document font is substituted by one Thai face for the whole run, Latin letters and digits included (#1549) |
|
||||
| 2026-10-15 | trivial | A list bullet no installed font can draw (a private-use code) shows the level's default bullet instead of an icon from an unrelated font (#1683) |
|
||||
| 2026-10-16 | trivial | Field results (STYLEREF, REF, PAGE text…) with right-to-left text are reordered like ordinary text, so a Hebrew heading in a header reads correctly (#1670) |
|
||||
| 2026-10-16 | trivial | Positional tabs (`w:ptab`) are laid out at the margins or indents they name, not at the next tab stop (#2367) |
|
||||
| 2026-10-16 | trivial | Hint-reliant CJK fonts execute TrueType contour assembly; Windows DFKai-SB no longer loses strokes. Synthetic local-font regression; no font binaries bundled. |
|
||||
| 2026-10-16 | trivial | `w:autoSpaceDE` and `w:autoSpaceDN` are laid out: a quarter em between Asian text and Latin text or digits (#2358) |
|
||||
| 2026-10-16 | trivial | AutoFit donors lose equal widths, bounded by content minima |
|
||||
| 2026-10-14 | trivial | Row table border exceptions draw without leaking into other rows |
|
||||
| 2026-10-14 | trivial | Consistent cell widths override a stale table grid |
|
||||
| 2026-10-14 | trivial | Paragraphs inside complex-field instructions take no flow height; empty result paragraphs keep their line. |
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Localization parity with Microsoft Word
|
||||
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-16 · **Change:** minor (Indonesian proofing: id-ID spelling from the installed Hunspell dictionary, dictionary-driven suggestions, generic checks with Indonesian messages, #498; previously trivial: print-area strings translated in every catalog; previously trivial: Italian: the interface audit checks the Italian catalog, `Privacy Settings` wording; previously trivial: Italian grammar: `po'`, `qual è`, `È`, `un`/`un'` rules; more elisions and truncations in spelling; previously trivial: Czech interface catalog completed, 2,447 entries; previously trivial: interface audit runs in CI again: size labels translated, 20 strings in every catalog, Read Aloud error race fixed, #1128; previously trivial: Review › Language reaches every language again — Set Proofing Language… and Set As Default, #860; previously minor: Italian proofing: it-IT spelling from the installed Hunspell dictionary, accent-first suggestions, conservative grammar with Italian messages; previously minor: Portuguese proofing; previously trivial: Turkish interface catalog completed, 2,325 entries, #774; previously minor: Hungarian interface added; previously trivial: sorting and table numbers follow the text's language, #836, #837; previously minor: Polish interface; Polish proofing — spelling, grammar, hyphenation — and a proofing language per run; previously minor: Turkish interface added; previously major: first version; catalogs measured from `crates/ui-egui/src/i18n`, Word's languages from its bundle listing) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-16 · **Change:** minor (how to install the Hunspell dictionaries on Windows, macOS and Linux: `docs/spelling-dictionaries.md`; previously minor: Read Aloud speaks each sentence in its own language, #1843; previously minor: Indonesian proofing: id-ID spelling from the installed Hunspell dictionary, dictionary-driven suggestions, generic checks with Indonesian messages, #498; previously trivial: print-area strings translated in every catalog; previously trivial: Italian: the interface audit checks the Italian catalog, `Privacy Settings` wording; previously trivial: Italian grammar: `po'`, `qual è`, `È`, `un`/`un'` rules; more elisions and truncations in spelling; previously trivial: Czech interface catalog completed, 2,447 entries; previously trivial: interface audit runs in CI again: size labels translated, 20 strings in every catalog, Read Aloud error race fixed, #1128; previously trivial: Review › Language reaches every language again — Set Proofing Language… and Set As Default, #860; previously minor: Italian proofing: it-IT spelling from the installed Hunspell dictionary, accent-first suggestions, conservative grammar with Italian messages; previously minor: Portuguese proofing; previously trivial: Turkish interface catalog completed, 2,325 entries, #774; previously minor: Hungarian interface added; previously trivial: sorting and table numbers follow the text's language, #836, #837; previously minor: Polish interface; Polish proofing — spelling, grammar, hyphenation — and a proofing language per run; previously minor: Turkish interface added; previously major: first version; catalogs measured from `crates/ui-egui/src/i18n`, Word's languages from its bundle listing) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4
|
||||
|
||||
Interface languages, script support and proofing per language. How translation works (catalog
|
||||
format, adding a language, clean-room rule): `crates/ui-egui/src/i18n/mod.rs`.
|
||||
@@ -113,6 +113,9 @@ other than envelopes (Letter, Legal, A4, B5 (JIS)) stay as they are (#1128).
|
||||
| Indonesian (`id`, `id-ID`, `ind`, legacy `in`) | The system Hunspell `id_ID` dictionary, read at runtime (Debian/Ubuntu: `hunspell-id`; LibreOffice's `dict-id`; the same system, LibreOffice, Dictionaries folder and `WORDCRAFT_DICTIONARIES` search paths as Polish). No dictionary data is bundled; without an installed dictionary spelling stays off. Hyphenated reduplication is accepted whole (`kupu-kupu`) or by parts (`anak-anak`); hyphenated clitics after capitals and abbreviations (`KTP-nya`, `se-Indonesia`) are accepted | the same dictionary-driven ranking as Czech: MAP/REP/TRY/KEY tables of the loaded dictionary, missing spaces and a stem scan; nothing Indonesian hard-coded | Indonesian messages for repeated words (offering the hyphenated reduplication `anak-anak` first, then the single word), space before punctuation and double spaces only; no Indonesian-specific grammar rules | existing English fallback, as for `Other`; no Indonesian patterns bundled |
|
||||
| Any other | — (no marks) | — | — | the English patterns, as before |
|
||||
|
||||
The dictionary file names each language looks for, the folders searched on each system and how to
|
||||
install one (`pt_BR` as the example): [spelling-dictionaries.md](spelling-dictionaries.md).
|
||||
|
||||
Measured with the SJP.PL dictionary: every one of the ~4.5 million forms an independent expansion of its affix file
|
||||
produces is accepted, and 200,000 one-edit mutations that aren't words are all rejected; a check
|
||||
takes ~1 µs, a suggestion list 1–35 ms (release build). Not yet: Czech hyphenation patterns or Czech-specific grammar, other languages' dictionaries,
|
||||
@@ -194,6 +197,8 @@ WordCraft keeps 2,54 cm).
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-10-16 | minor | Indonesian proofing (#498): id-ID spelling from the installed Hunspell `id_ID` dictionary (not bundled), Czech's dictionary-driven suggestions, repeated-word (hyphenated reduplication offered) and spacing checks with Indonesian messages; Review › Language, the status bar, Set Proofing Language and File › Options › Proofing list Indonesian; estimates unchanged |
|
||||
| 2026-10-16 | trivial | Read Aloud picks a voice for the sentence's language (run tag, else script) when the default voice doesn't speak it, reads Windows text as UTF-8 |
|
||||
| 2026-10-16 | trivial | Linked the new guide to installing the Hunspell dictionaries on Windows, macOS and Linux (`docs/spelling-dictionaries.md`, `pt_BR` as the example); estimates unchanged |
|
||||
| 2026-10-12 | minor | Czech proofing review: short-word splits rank after deletion, NOSUGGEST excludes suggestions while accepting typed forms, affix comments apply only to headers, ratios keep spaces and repeated-word messages use Czech quotes; synthetic engine end-to-end and latency checks |
|
||||
| 2026-10-12 | minor | Czech spelling from the installed Hunspell dictionary, runtime-table suggestions and generic checks; Czech interface writes cs-CZ documents; no Czech hyphenation patterns bundled; estimates unchanged |
|
||||
| 2026-10-13 | minor | Swedish interface catalog (2,504 entries), locale selection, saved preference, audit coverage and font checks (#1219) |
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
Generated by `cargo xtask parity` (`wordcraft-cli parity --markdown`): the word-processor feature catalog (`crates/engine/src/catalog.rs`) compared with the live command registry.
|
||||
|
||||
**491 of 506 catalog features have commands (97.0%).**
|
||||
**493 of 508 catalog features have commands (97.0%).**
|
||||
|
||||
| Tab | Live | Total |
|
||||
|---|---|---|
|
||||
@@ -28,7 +28,7 @@ Generated by `cargo xtask parity` (`wordcraft-cli parity --markdown`): the word-
|
||||
| SmartArt Design | 7 | 7 |
|
||||
| Chart Design | 5 | 5 |
|
||||
| Header & Footer | 5 | 5 |
|
||||
| Editing | 38 | 38 |
|
||||
| Editing | 40 | 40 |
|
||||
| Tools | 6 | 6 |
|
||||
|
||||
## Missing
|
||||
|
||||
@@ -0,0 +1,125 @@
|
||||
# Installing spelling dictionaries
|
||||
|
||||
> **Last reviewed:** 2026-10-16 · **Last updated:** 2026-10-16 · **Change:** major (first version)
|
||||
|
||||
English spelling works out of the box. Polish, Russian, Portuguese, Czech, Italian, Croatian and
|
||||
Turkish spelling use a standard **Hunspell** dictionary (an `.aff` and a `.dic` file) that
|
||||
WordCraft reads at runtime. These dictionaries are not bundled: they are large, and several are
|
||||
GPL or LGPL licensed. Without one, the language isn't checked. The status bar then shows the
|
||||
language with **(no dictionary)**, for example *Português (Brasil) (no dictionary)*.
|
||||
|
||||
Install a dictionary once, restart WordCraft, and the language is checked as you type.
|
||||
|
||||
## Which files each language needs
|
||||
|
||||
WordCraft looks for the first of these file names (each name means a `<name>.aff` and
|
||||
`<name>.dic` pair in the same folder):
|
||||
|
||||
| Language | File names | Common source |
|
||||
|---|---|---|
|
||||
| Polish | `pl_PL`, `pl-PL`, `pl` | SJP.PL |
|
||||
| Russian | `ru_RU`, `ru-RU`, `ru` | LibreOffice `dict-ru` |
|
||||
| Portuguese (Brazil and Portugal) | `pt_BR`, `pt-BR`, `pt` | VERO (LibreOffice `dict-pt-BR`) |
|
||||
| Czech | `cs_CZ`, `cs-CZ`, `cs` | LibreOffice `dict-cs` |
|
||||
| Italian | `it_IT`, `it_CH`, `it-IT`, `it` | LibreOffice `dict-it` |
|
||||
| Croatian | `hr_HR`, `hr-HR`, `hr` | LibreOffice `dict-hr` |
|
||||
| Turkish | `tr_TR`, `tr-TR`, `tr` | LibreOffice `dict-tr` |
|
||||
|
||||
Every Portuguese region uses the same `pt_BR` dictionary, so text marked *Português (Portugal)*
|
||||
is checked with it too.
|
||||
|
||||
The files may be UTF-8 or the encoding named on the `.aff`'s `SET` line (ISO 8859-2, KOI8-R and
|
||||
Windows-1251 are read as well).
|
||||
|
||||
## Where WordCraft looks
|
||||
|
||||
The first folder holding a matching pair wins:
|
||||
|
||||
1. The folder set in **File › Options › Proofing › Dictionaries folder**. This is the easiest
|
||||
way on every system, and it works for an app started from the Start menu, Finder or the Dock.
|
||||
The same pane lists the dictionary file each language found.
|
||||
2. The folder in the `WORDCRAFT_DICTIONARIES` environment variable.
|
||||
3. The system's usual places:
|
||||
|
||||
| System | Folders |
|
||||
|---|---|
|
||||
| Windows | `%LOCALAPPDATA%\WordCraft\dictionaries`, `%LOCALAPPDATA%\hunspell`, `%APPDATA%\WordCraft\dictionaries`, `%APPDATA%\hunspell`, LibreOffice's `share\extensions\dict-*` under `Program Files` (on any drive) |
|
||||
| macOS | `~/Library/Spelling`, `/Library/Spelling`, `/opt/homebrew/share/hunspell`, `/usr/local/share/hunspell`, `/Applications/LibreOffice.app/Contents/Resources/extensions/dict-*` |
|
||||
| Linux and BSD | `~/.local/share/hunspell`, `/usr/share/hunspell`, `/usr/share/myspell`, `/usr/share/myspell/dicts`, `/usr/local/share/hunspell`, `/usr/local/share/myspell`, LibreOffice's `share/extensions/dict-*` under `/usr/lib/libreoffice` or `/opt/libreoffice` |
|
||||
|
||||
The web version can't read these folders, so these languages aren't spell-checked there.
|
||||
|
||||
## Example: Brazilian Portuguese (`pt_BR`)
|
||||
|
||||
VERO, the Brazilian Portuguese dictionary LibreOffice ships, is available from the
|
||||
[LibreOffice dictionaries repository](https://github.com/LibreOffice/dictionaries/tree/master/pt_BR)
|
||||
(LGPLv3 / MPL). Any of these ways works.
|
||||
|
||||
### If LibreOffice is installed
|
||||
|
||||
Nothing to do: WordCraft finds LibreOffice's `dict-pt-BR` extension on its own. Restart
|
||||
WordCraft after installing LibreOffice.
|
||||
|
||||
### Windows
|
||||
|
||||
In PowerShell:
|
||||
|
||||
```powershell
|
||||
$d = "$env:LOCALAPPDATA\WordCraft\dictionaries"
|
||||
New-Item -ItemType Directory -Force $d | Out-Null
|
||||
$base = "https://raw.githubusercontent.com/LibreOffice/dictionaries/master/pt_BR"
|
||||
Invoke-WebRequest "$base/pt_BR.aff" -OutFile "$d\pt_BR.aff" -UseBasicParsing
|
||||
Invoke-WebRequest "$base/pt_BR.dic" -OutFile "$d\pt_BR.dic" -UseBasicParsing
|
||||
```
|
||||
|
||||
### macOS
|
||||
|
||||
```sh
|
||||
mkdir -p ~/Library/Spelling
|
||||
cd ~/Library/Spelling
|
||||
curl -fLO https://raw.githubusercontent.com/LibreOffice/dictionaries/master/pt_BR/pt_BR.aff
|
||||
curl -fLO https://raw.githubusercontent.com/LibreOffice/dictionaries/master/pt_BR/pt_BR.dic
|
||||
```
|
||||
|
||||
### Linux
|
||||
|
||||
From the distribution's packages:
|
||||
|
||||
```sh
|
||||
sudo apt install hunspell-pt-br # Debian, Ubuntu
|
||||
```
|
||||
|
||||
Or, without root, into your own folder:
|
||||
|
||||
```sh
|
||||
mkdir -p ~/.local/share/hunspell
|
||||
cd ~/.local/share/hunspell
|
||||
curl -fLO https://raw.githubusercontent.com/LibreOffice/dictionaries/master/pt_BR/pt_BR.aff
|
||||
curl -fLO https://raw.githubusercontent.com/LibreOffice/dictionaries/master/pt_BR/pt_BR.dic
|
||||
```
|
||||
|
||||
Other languages work the same way: replace `pt_BR` with the language's folder and file names
|
||||
from the LibreOffice repository (for example `it_IT/it_IT.aff`).
|
||||
|
||||
## Checking that it works
|
||||
|
||||
- Restart WordCraft. The status bar no longer says **(no dictionary)**, and a misspelled word
|
||||
such as `impramir` is underlined, with `imprimir` among its suggestions.
|
||||
- **File › Options › Proofing** lists the file each language found.
|
||||
- From the command line:
|
||||
|
||||
```sh
|
||||
wordcraft-cli run --cmd 'text.insert={"text":"Vou impramir o documento."}' --cmd select.all --cmd 'review.language={"lang":"pt-BR"}' --cmd review.issues --print
|
||||
```
|
||||
|
||||
The `review.issues` line lists `impramir` as a spelling issue with its suggestions.
|
||||
- The log file (`%APPDATA%\WordCraft\logs\wordcraft.log` on Windows) says
|
||||
`Portuguese spelling: no Hunspell pt_BR dictionary installed` when none was found.
|
||||
|
||||
Never commit dictionary files to this repository: they stay on the user's system.
|
||||
|
||||
## Revision history
|
||||
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-10-16 | major | First version: the file names per language, the search order and folders on Windows, macOS and Linux, installing `pt_BR` on each, and how to check it works |
|
||||
@@ -1,6 +1,6 @@
|
||||
# WordCraft parity with Microsoft Word
|
||||
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-14 · **Change:** trivial (Ink to Shape recognises arrows; previously trivial: Windows Magnifier follows the caret onto a new line, #1119; previously trivial: table border drags with Ctrl and Alt, from the left edge and of selected cells, #711; previously trivial: table column and row markers on the rulers, #700; previously trivial: table borders and the resize handle drag on the page, #217; previously trivial: all 12 bibliography styles, #383; previously trivial: formatting revisions landed, #41; previously trivial: Draw Table and Eraser landed, #303; previously minor: readiness table with hours per audience; full number back to the additive weighted sum, 60%; mainstream and essentials numbers added; alpha gate checked; previously major: full re-measure against Word for Mac 16.113.4; replaces the parity tables that lived in ROADMAP.md) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-16 · **Change:** trivial (East Asian typography: `w:eastAsianLayout` kept on save, not laid out; previously trivial: Ink to Shape recognises arrows; previously trivial: Windows Magnifier follows the caret onto a new line, #1119; previously trivial: table border drags with Ctrl and Alt, from the left edge and of selected cells, #711; previously trivial: table column and row markers on the rulers, #700; previously trivial: table borders and the resize handle drag on the page, #217; previously trivial: all 12 bibliography styles, #383; previously trivial: formatting revisions landed, #41; previously trivial: Draw Table and Eraser landed, #303; previously minor: readiness table with hours per audience; full number back to the additive weighted sum, 60%; mainstream and essentials numbers added; alpha gate checked; previously major: full re-measure against Word for Mac 16.113.4; replaces the parity tables that lived in ROADMAP.md) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4
|
||||
|
||||
The authoritative assessment of how close WordCraft is to Microsoft Word. [`ROADMAP.md`](../ROADMAP.md)
|
||||
summarizes it; [`gaps.md`](gaps.md) itemizes every shortfall; the deep checklists are
|
||||
@@ -76,7 +76,7 @@ full parity for the area.
|
||||
| View (modes, zoom, panes, windows) | 3 | 72% | 70% | 6–10 | Print/web/draft/read/focus, dark page (#194), pages side by side when zoomed out (#251), navigation pane; Outline view with the Outlining tab: levels, promote/demote, moving with collapsed content, expand/collapse, Show Level, first line only, text formatting (#493). Missing: View Side by Side, Synchronous Scrolling, Arrange All, Switch Windows, Master Document, Immersive Reader depth. |
|
||||
| Backstage, printing, options | 3 | 58% | 55% | 12–20 | New/Home pages (#254), save prompts (#151), web printing via the browser (#209). Desktop printing still goes through PDF; Options has a fraction of Word's panes. |
|
||||
| Right-to-left and complex scripts | 1 | 50% | 55% | 15–25 | UAX #9, Arabic shaping, RTL paragraphs (#207). No RTL sections/tables, kashida justification or mirrored UI; open bug reports (#215, #211, #199, #66, #63, #48, #19) need re-testing against #207. |
|
||||
| East Asian typography | 1 | 25% | not rated | 13–22 | IME input works (winit fixes pending, #155–#164). Vertical text in sections, text boxes and cells with upright ideographs (#491; no `vert` glyph alternates). Ruby/Phonetic Guide with DOCX round trip (#288); document grid (lines per page, characters per line, #391). No `w:eastAsianLayout`, enclose characters, Asian line-break options. |
|
||||
| East Asian typography | 1 | 25% | not rated | 13–22 | IME input works (winit fixes pending, #155–#164). Vertical text in sections, text boxes and cells with upright ideographs (#491; no `vert` glyph alternates). Ruby/Phonetic Guide with DOCX round trip (#288); document grid (lines per page, characters per line, #391). Two lines in one and horizontal in vertical (`w:eastAsianLayout`) are kept on save but not laid out. No enclose characters, Asian line-break options. |
|
||||
| Accessibility | 1 | 50% | not rated | 10–18 | AccessKit enabled in eframe. Alt Text pane for pictures, shapes, text boxes, groups, charts and tables with Mark as decorative, all round-tripping in DOCX (#386). The Accessibility pane groups errors, warnings and tips with jump-to and one-click fixes; rules for alt text, heading order, header rows, blank table rows/columns, merged cells, low contrast (WCAG ratio), floating objects and blank paragraphs. Screen-reader access to the document canvas is still untested (#18); no reading-order tools. The document canvas describes its text, headings, lists, tables, links, pictures and caret to screen readers through AccessKit (#488); Windows Magnifier follows the caret, onto a new line too (#1119). |
|
||||
| Agent control (CLI, MCP, control channel, macro record/playback) | 0 | beyond Word | 100%+ | — | Every action is a command id usable from CLI, MCP and a keyed control port. Word has VBA and Office.js instead (see Ecosystem). |
|
||||
|
||||
@@ -251,6 +251,7 @@ can be used without committing Word output.
|
||||
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-10-16 | trivial | East Asian typography row: `w:eastAsianLayout` kept on save, not laid out; no percentage change |
|
||||
| 2026-10-12 | minor | Czech proofing review: short-word splits rank after deletion, NOSUGGEST excludes suggestions while accepting typed forms, affix comments apply only to headers, ratios keep spaces and repeated-word messages use Czech quotes; synthetic engine end-to-end and latency checks |
|
||||
| 2026-10-12 | minor | Czech spelling from the installed Hunspell dictionary, runtime-table suggestions and generic checks; Czech interface writes cs-CZ documents; no Czech hyphenation patterns bundled; estimates unchanged |
|
||||
| 2026-10-13 | trivial | Windows Magnifier follows the caret onto a new line (#1119): Accessibility row updated; no percentage change |
|
||||
|
||||
+6
-4
@@ -1,6 +1,6 @@
|
||||
# UI parity with Microsoft Word
|
||||
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-18 · **Change:** trivial (Home › Underline drop-down shows its lines, #2738; previously trivial: a mouse drag on a table leaves the view where it is, #1738; previously trivial: numbered headings, #1793; previously trivial: Navigation Find exposes Results, #2096; previously trivial: the tab strip never draws tabs over Comments / Editing / Share, #2026; previously trivial: Columns on selected text, #2024; previously trivial: Different First Page headers reachable from page 1, #2826; previously trivial: a copied equation also goes in the clipboard's MathML formats, #1297; previously trivial: Read Mode keeps the keyboard, shows whole columns and keeps text clear of the page-turn bar, #1772); previously trivial: font list entries of tall-ascent fonts sit in their row, not cut off, #2570); previously trivial: Split Cells and Convert Text to Table dialogs follow Word's merged-cell limits and Initial column width, #2592; previously trivial: a new source's Tag is made like Word's, three letters of the author and two digits of the year with a number added when taken, and an edit keeps the Tag, #2094; previously trivial: Web Layout text fills the window width, #1876; previously trivial: Language dialog keeps inherited proofing language and no-proof choices (#2717)); previously trivial: comment balloons readable in the dark theme, #2045; previously trivial: Recently Used Fonts section at the top of the font list, #2068; previously trivial: web preferences kept between visits, #2132; previously trivial: dialog close control: a Windows-style box at the title bar's right end, the whole box clickable, red on hover, #1885); previously trivial (Modify Table Style formats every table style part, #714); previously trivial: a click on the ribbon chrome hands the keyboard to the page in the same frame, #1093 follow-up; previously trivial: Page Setup vertical alignment, #1320; previously trivial: the app opens on the Home tab every launch, as in Word; previously trivial: no document behind the Start screen, #1129; previously trivial: equation pictures share a few textures, #1002; previously trivial: selection bar in the left margin, #856; previously trivial: table column and row markers on the rulers, #700; previously trivial: table borders and the resize handle drag on the page, #217; previously major: first version; ribbon, dialogs, shortcuts and on-canvas interaction measured from `crates/ui-egui` and the command registry) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-18 · **Change:** trivial (Home › Underline drop-down shows its lines, #2738; previously trivial: the Spike: Ctrl+F3 and Ctrl+Shift+F3; previously trivial: Ctrl+wheel zoom no longer scrolls the document, #1743; previously trivial: a mouse drag on a table leaves the view where it is, #1738; previously trivial: numbered headings, #1793; previously trivial: Navigation Find exposes Results, #2096; previously trivial: the tab strip never draws tabs over Comments / Editing / Share, #2026; previously trivial: Columns on selected text, #2024; previously trivial: Different First Page headers reachable from page 1, #2826; previously trivial: a copied equation also goes in the clipboard's MathML formats, #1297; previously trivial: Read Mode keeps the keyboard, shows whole columns and keeps text clear of the page-turn bar, #1772); previously trivial: font list entries of tall-ascent fonts sit in their row, not cut off, #2570); previously trivial: Split Cells and Convert Text to Table dialogs follow Word's merged-cell limits and Initial column width, #2592; previously trivial: a new source's Tag is made like Word's, three letters of the author and two digits of the year with a number added when taken, and an edit keeps the Tag, #2094; previously trivial: Web Layout text fills the window width, #1876; previously trivial: Language dialog keeps inherited proofing language and no-proof choices (#2717)); previously trivial: comment balloons readable in the dark theme, #2045; previously trivial: Recently Used Fonts section at the top of the font list, #2068; previously trivial: web preferences kept between visits, #2132; previously trivial: dialog close control: a Windows-style box at the title bar's right end, the whole box clickable, red on hover, #1885); previously trivial (Modify Table Style formats every table style part, #714); previously trivial: a click on the ribbon chrome hands the keyboard to the page in the same frame, #1093 follow-up; previously trivial: Page Setup vertical alignment, #1320; previously trivial: the app opens on the Home tab every launch, as in Word; previously trivial: no document behind the Start screen, #1129; previously trivial: equation pictures share a few textures, #1002; previously trivial: selection bar in the left margin, #856; previously trivial: table column and row markers on the rulers, #700; previously trivial: table borders and the resize handle drag on the page, #217; previously major: first version; ribbon, dialogs, shortcuts and on-canvas interaction measured from `crates/ui-egui` and the command registry) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4
|
||||
|
||||
How WordCraft looks and feels next to Word: ribbon, dialogs, panes, keyboard, mouse and touch.
|
||||
Typing behaviour has its own checklist ([`typing-parity.md`](typing-parity.md)). All assets are our
|
||||
@@ -25,7 +25,7 @@ Document title opens Rename Document; the account badge opens Options › Genera
|
||||
| Modal dialogs | **54** (`Dialog` enum: Font, Enclose Characters, Paragraph, Tabs, Borders and Shading, Find, Go To, Insert Table, Table Properties, Page Setup, Link, Bookmark, Word Count, Zoom, Watermark, New Style, Modify Style, Manage Styles, Table Style, Commands, Icons, Templates and Add-ins, Organizer, Compare and Combine, AutoCorrect Options, Paste Special, About, Save Changes, Password, Encrypt with Password, Define New Multilevel List, Track Changes Options; Columns, Symbol with Special Characters, Field (#321); Field code (#339); mail merge Recipients, Insert Merge Field, Find Recipient, Rules, Match Fields, Check for Errors; Caption with New Label and Numbering, Index, Mark Index Entry, Custom Table of Contents with Options and TOC styles, Source Manager, Create/Edit Source (#402); Line Numbers, Hyphenation with Manual Hyphenation, Language, Envelopes, Labels (#407)) plus panes and menus for the rest | ~100 (Options panes…) | ~45% | 11–19 |
|
||||
| Modal dialogs | **55** (`Dialog` enum: Font, Enclose Characters, Paragraph, Tabs, Borders and Shading, Find, Go To, Insert Table, Table Properties, Page Setup, Link, Bookmark, Word Count, Zoom, Watermark, New Style, Modify Style, Manage Styles, Table Style, Commands, Icons, Templates and Add-ins, Organizer, Compare and Combine, AutoCorrect Options, Paste Special, About, Save Changes, Password, Encrypt with Password, Define New Multilevel List, Track Changes Options; Columns, Symbol with Special Characters, Field (#321); Field code (#339); mail merge Recipients, Insert Merge Field, Find Recipient, Rules, Match Fields, Check for Errors; Caption with New Label and Numbering, Index, Mark Index Entry, Custom Table of Contents with Options and TOC styles, Source Manager, Create/Edit Source (#402); Cross-reference (#916); Line Numbers, Hyphenation with Manual Hyphenation, Language, Envelopes, Labels (#407); Page Setup has a Paper size list and custom width and height, opened from Layout › Size › More Paper Sizes… (#1433)) plus panes and menus for the rest | ~100 (Options panes…) | ~45% | 11–19 |
|
||||
| Keyboard shortcuts | **152** commands bound in `CommandSpec::key`, 172 key combinations (counted from the registry on Linux, 2026-10-13); styles, spacing, symbols, fields, views, mail merge (#330); field keys Alt+F9, Shift+F9, Ctrl+Shift+F9, Ctrl+F11, Ctrl+Shift+F11, Shift+F11 (#339), Next Field on Alt+F1 and Alt+Shift+F1 (Word's other keys for it) since F11 went to full screen (#980; GNOME and KDE may keep Alt+F1 for their own overview or application menu, and then Next Field has no key there; Previous Field keeps Shift+F11); Alt+Shift+arrows (move paragraphs, promote/demote), Shift+F5 Go Back and Alt+X (#343); Outline view: Alt+Shift+Plus/Minus expand/collapse, Alt+Shift+1–9 and Alt+Shift+A Show Level (#493); Alt+Q to "Tell me" command search (#819, not on macOS). Missing: en dash on the numeric keypad | ~250 default shortcuts | ~70% | 2–4 |
|
||||
| Keyboard shortcuts | **152** commands bound in `CommandSpec::key`, 172 key combinations (counted from the registry on Linux, 2026-10-13); styles, spacing, symbols, fields, views, mail merge (#330); field keys Alt+F9, Shift+F9, Ctrl+Shift+F9, Ctrl+F11, Ctrl+Shift+F11, Shift+F11 (#339), Next Field on Alt+F1 and Alt+Shift+F1 (Word's other keys for it) since F11 went to full screen (#980; GNOME and KDE may keep Alt+F1 for their own overview or application menu, and then Next Field has no key there; Previous Field keeps Shift+F11); Alt+Shift+arrows (move paragraphs, promote/demote), Shift+F5 Go Back and Alt+X (#343); Outline view: Alt+Shift+Plus/Minus expand/collapse, Alt+Shift+1–9 and Alt+Shift+A Show Level (#493); Alt+Q to "Tell me" command search (#819, not on macOS); on macOS, Word for Mac's own keys: ⌘⇧S Save As, ⌘⌥E Insert Endnote, ⌘⌥A New Comment, ⌃H Replace, ⌘⌥R collapse the ribbon. Missing: en dash on the numeric keypad Insert Hyperlink has Word's Link to: Existing File or Web Page / Place in This Document (top of the document, headings, bookmarks) (#1234). | ~250 default shortcuts | ~70% | 2–4 |
|
||||
| Keyboard shortcuts | **152** commands bound in `CommandSpec::key`, 172 key combinations (counted from the registry on Linux, 2026-10-13); styles, spacing, symbols, fields, views, mail merge (#330); field keys Alt+F9, Shift+F9, Ctrl+Shift+F9, Ctrl+F11, Ctrl+Shift+F11, Shift+F11 (#339), Next Field on Alt+F1 and Alt+Shift+F1 (Word's other keys for it) since F11 went to full screen (#980; GNOME and KDE may keep Alt+F1 for their own overview or application menu, and then Next Field has no key there; Previous Field keeps Shift+F11); Alt+Shift+arrows (move paragraphs, promote/demote), Shift+F5 Go Back and Alt+X (#343); Outline view: Alt+Shift+Plus/Minus expand/collapse, Alt+Shift+1–9 and Alt+Shift+A Show Level (#493); Alt+Q to "Tell me" command search (#819, not on macOS); on macOS, Word for Mac's own keys: ⌘⇧S Save As, ⌘⌥E Insert Endnote, ⌘⌥A New Comment, ⌃H Replace, ⌘⌥R collapse the ribbon. The Spike: Ctrl+F3 cuts to it, Ctrl+Shift+F3 inserts it and empties it (⌘F3, ⌘⇧F3 on macOS). Missing: en dash on the numeric keypad Insert Hyperlink has Word's Link to: Existing File or Web Page / Place in This Document (top of the document, headings, bookmarks) (#1234). | ~250 default shortcuts | ~70% | 2–4 |
|
||||
| Modal dialogs | **58** (`Dialog` enum: Discard Recovery, Font, Enclose Characters, Paragraph, Tabs, Borders and Shading, Find, Go To, Insert Table, Table Properties, Page Setup, Link, Bookmark, Word Count, Zoom, Watermark, New Style, Modify Style, Manage Styles, Table Style, Commands, Icons, Templates and Add-ins, Organizer, Compare and Combine, AutoCorrect Options, Paste Special, About, Save Changes, Password, Encrypt with Password, Define New Multilevel List, Track Changes Options; Columns, Symbol with Special Characters, Field (#321); Field code (#339); mail merge Recipients, Insert Merge Field, Find Recipient, Rules, Match Fields, Check for Errors; Caption with New Label and Numbering, Index, Mark Index Entry, Custom Table of Contents with Options and TOC styles, Source Manager, Create/Edit Source (#402); Cross-reference (#916); Line Numbers, Hyphenation with Manual Hyphenation, Language, Envelopes, Labels (#407); Page Setup has a Paper size list and custom width and height, opened from Layout › Size › More Paper Sizes… (#1433); every dialog's close control is a box at the title bar's right end, the full title-bar height and 1.6× as wide, all of it clickable, red with a white ✕ on hover as a Windows caption button (#1885); Split Cells (columns, rows, merge before split; defaults follow the selection as in Word: twice its columns, its rows, Merge offered for several cells or one merged across columns; a merged cell splits into 1 to its height rows, its height or 1 × 1 cutting it back into rows, more refused with Word's message; parts split across columns are equal and other rows keep their grid lines) and Convert Text to Table (live column count, read-only rows; Initial column width Auto or 0 to 22 in, AutoFit to contents/window; Paragraphs/Commas/Tabs/Other "-" separators) (#2592)) plus panes and menus for the rest | ~100 (Options panes…) | ~45% | 11–19 |
|
||||
| Task panes (Navigation, Styles, Comments, Reviewing, Format Picture/Shape, Thesaurus, Accessibility, Clipboard, Selection) | Navigation (long headings truncate with an ellipsis instead of expanding the pane, #759), Styles, Comments (balloons and cards read in both themes, #2045), Thesaurus, Restrict Editing, Accessibility (grouped findings, jump-to, fixes) and Alt Text (#386), Read Aloud, Zotero; Style Inspector (#236); Clipboard pane (#264) open | all | 70% | 4–6 |
|
||||
| Context menus (right-click) | text, spelling suggestions, fields (Update Field, Edit Field…, Toggle Field Codes, #339; Update Field on a table of contents entry updates the table, #1197); right-click selects a picture, shape or text box and offers Wrap Text (styles, Move with Text / Fix Position on Page, #513); tables, the items following the selection as in Word (Insert, Delete › Cells, Columns, Rows or Table whatever is selected (#1198), Merge and Split Cells, Distribute, AutoFit, Text Direction, Insert Caption…, Table Properties…; no Border Styles yet) | rich per context | 65% | 2–4 |
|
||||
@@ -36,10 +36,10 @@ Document title opens Rename Document; the account badge opens Options › Genera
|
||||
| Rotation handle, crop handles on canvas, alignment guides, smart guides | rotation handle (Shift: 15° steps), turned frames resize along their axes (#332); crop handles: Picture Format › Crop puts the picture in crop mode, black crop handles trim a side (a corner, two) keeping the scale and the visible part in place, the cropped-away part shows dimmed, Esc/Enter/a click outside/Crop again keep the crop, one undo step per drag (unturned pictures; no Crop to Shape, Aspect Ratio or Fill/Fit); alignment guides, our own design: a moved picture, shape or text box snaps its left, centre or right (top, middle or bottom) to the page's edges and centre, the margins and the other floating objects on the page within 6 screen points, x and y independently, turned objects by their rotated bounds, with a thin line across the page for each guide while dragging; Alt moves freely; File › Options › Advanced › Editing (`ui.alignmentGuides`) turns them off. Smart guides missing | Word for Mac 16.113.4 shows no guide lines and snaps inconsistently (to the page's left edge, not its top, right or bottom; Word check K); Word for Windows' guides not observed | 75% | 1–2 |
|
||||
| Tables on the page: drag column and row borders, resize handle past the bottom-right corner; select columns, rows and cells; "+" insert buttons; move handle | ✅ resize cursors, dashed guide, one undo step per drag, selection kept; Shift moves one column; Esc cancels (#217); Ctrl shares the change among the columns on the right, the left edge moves a left-aligned table's indent (a centred or right-aligned one keeps its alignment), a border of cells selected across moves only in their rows, Alt shows the column widths on the ruler (#711); double-click a column border to AutoFit it (a nested table's within the cell around it, #971); selection arrows above a column, left of a row and along a cell's left edge (a drag selects more; the cells are shaded and a column is edited cell by cell); "+" left of a row border and above a column border; move handle past the top-left corner (a click selects the table, a drag floats it where it's dropped); an ordinary drag, Shift+arrows or Shift+click from one cell into another select the rectangle of whole cells between them, and into or out of a table whole rows, their cells shaded whole (#971). Missing: a single selected cell's border; The Cell Size Height and Width boxes set every selected row and column (#1242). | ✅ | 95% | 1 |
|
||||
| Selection: word/sentence/paragraph clicks, dragging after a double-click/triple-click/Ctrl+click extends by whole words/paragraphs/sentences (#549), Shift extend, F8 extend mode, column (Alt+drag, #237) | ✅ | ✅ | 90% | 1 |
|
||||
| Zoom: Ctrl+scroll, pinch (#179), zoom buttons (#232), pages side by side when zoomed out (#251), still one per row after One Page or Page Width (#768) | ✅ | ✅ | 85% | — |
|
||||
| Zoom: Ctrl+scroll (only zooms; a coast or notch still gliding stops, #1743), pinch (#179), zoom buttons (#232), pages side by side when zoomed out (#251), still one per row after One Page or Page Width (#768) | ✅ | ✅ | 85% | — |
|
||||
| Selection: word/sentence/paragraph clicks, dragging after a double-click/triple-click/Ctrl+click extends by whole words/paragraphs/sentences (#549), Shift extend, F8 extend mode, column (Alt+drag, #237), selection bar in the left margin (click a line, double-click a paragraph, triple-click or Ctrl+click everything, drag by lines, Shift+click extends; #856) | ✅ Missing: the selection bar in the right margin for right-to-left paragraphs, beside table rows, and Word's right-pointing arrow cursor (egui has none; the plain arrow shows) | ✅ | 92% | 1 |
|
||||
| Headers and footers on the page: double-click the top or bottom margin to edit them, the body to leave | ✅ in Print Layout, also on a page with none yet: the header or footer that page shows is created (first page, even page or the default) and the caret put in it; one left empty is taken away again on leaving and is never saved (#960). Making one is an undo step, refused in a protected document. On a later section's page with no header anywhere it goes in the first section and shows on every section linked to it, as Word links to the previous section by default. With Different First Page (or Odd & Even) on, a double-click goes into the header that page shows even from another header being edited, and Insert › Header/Footer and Edit Header with the caret on page 1 use page 1's own (#2826). Web Layout, Draft and Read Mode show no margins and leave it alone | ✅ | 95% | 0.5 |
|
||||
| Zoom: Ctrl+scroll, pinch (#179), zoom buttons (#232), pages side by side when zoomed out (#251) | ✅ | ✅ | 85% | — |
|
||||
| Zoom: Ctrl+scroll (only zooms; a coast or notch still gliding stops, #1743), pinch (#179), zoom buttons (#232), pages side by side when zoomed out (#251) | ✅ | ✅ | 85% | — |
|
||||
| Touchpad smooth/inertial scroll | ✅ (#252) | ✅ | 90% | — |
|
||||
| Web Layout width | Text wraps to the whole work area (window width at the current zoom, 144–20,000 pt) and re-wraps once a window resize has settled (not on every frame of a drag) or at once on a zoom, keeping the caret in view; the sheet runs edge to edge with no centred page. Draft and Outline keep their 800 pt column; headless sessions keep 800 pt (#1876) | text across the window | 95% | — |
|
||||
| Web and Draft Layout rendering | Very long pages render only visible full-resolution raster tiles instead of shrinking one huge texture to the GPU size limit; nearby tiles are cached for scrolling | sharp document text | 95% | — |
|
||||
@@ -85,6 +85,8 @@ Find selects the Results tab, including when it prefills selected text. Editing
|
||||
| 2026-10-16 | trivial | Multilevel List heading presets: selected paragraphs already in a Heading style keep their style and level, every other selected paragraph becomes Heading 1, as Word for Mac does (#1793) |
|
||||
| 2026-10-15 | trivial | Multilevel List gallery: four lists linked to Heading 1–9 (1 / 1.1 / 1.1.1; Article I. / Section 1.01 / (a); I. / A. / 1.; Chapter 1), `list.multilevelPreset`; headings keep their styles and new headings are numbered (#1793). Style gallery previews don't show the numbers yet |
|
||||
| 2026-10-16 | trivial | A mouse drag that edits a table (column or row border, move handle) leaves the view where it is; typing still follows the caret (#1738) |
|
||||
| 2026-10-16 | trivial | Ctrl/⌘+wheel only zooms: a coast or wheel notch still gliding stops instead of scrolling the page on top of the zoom (#1743) |
|
||||
| 2026-10-16 | trivial | The Spike: Ctrl+F3 (⌘F3 on macOS) cuts the selection to the end of the Spike, Ctrl+Shift+F3 (⌘⇧F3) inserts everything collected and empties it; it is the AutoText entry "Spike", so Quick Parts inserts it without emptying it. Per session, not saved between launches |
|
||||
| 2026-10-13 | trivial | New/Modify Table Style: No Border on a part that inherits its lines hides them; emptying the font box makes the part's font inherited again (#1288) |
|
||||
| 2026-10-14 | trivial | Tab stops edited on the ruler: corner kind selector, click to set, drag to move or off to clear, per-kind markers mirrored for RTL; each gesture one `para.tabs` call and one undo step; Rulers 85% → 90% |
|
||||
| 2026-10-12 | trivial | Tables on the page: one selected cell (Select Cell, or a click along its left edge) is a cell selection when its border is dragged: the border moves in its row only (#962) |
|
||||
|
||||
Reference in New Issue
Block a user