mirror of
https://github.com/storytold/wordcraft.git
synced 2026-11-11 16:51:21 +00:00
Show fresh SEQ, STYLEREF and REF results in PDF export and printing (#5162)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
958867b7e8
commit
2df2bfc30e
10 files changed
+692
-70
No files matched your search
+3
-1
@@ -4,7 +4,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-11-03 · **Change:** trivial (Print Markup balloon anchors use one object walk per paragraph (#3614)); previously trivial (N-up and booklet printing, #641; readiness unchanged); previously trivial (Print Markup comment discovery walks paragraph objects once, #3614); previously trivial (progress log: the dialogs of the mirrored Arabic interface read right to left: label-and-field rows, tables (list columns, the Models table), tile galleries and the Add/Remove arrows follow the reading direction, through one `rtl::Table` helper that draws exactly as before in English; no ranked gap closed, readiness estimates unchanged); previously trivial (progress log: fonts that aren't installed are flagged in the font box and status bar); previously trivial (progress log: Options › Language redesign, CP4.3); previously trivial (progress log: the missing-pack bar on a fresh install, the caret's typing language, and the spelling menu heading in the interface language, CP4.3); previously trivial (progress log: Language pack languages in every language list, CP4.3); previously trivial (progress log: craft-proof pin, French elision and Valencian, CP4.3); previously trivial (progress log: a long comment's card and balloon stop at ten lines, #3382); previously trivial (progress log: a pasted text box story is charged for the formatting it copies); previously trivial (progress log: a .doc table row keeps one override per cell); previously trivial (progress log: a .doc file's sections find their paragraphs by index); previously minor (Arabic interface language with mirrored UI, #1195); previously trivial (progress log: Replace and Remove Table of Contents with Word's title above a section break, #4041); previously trivial (progress log: a password-protected file named on the command line asks for its password after the startup document is replaced, #5299); previously trivial (progress log: language-packs-2 published and pinned, 75 Language packs); previously trivial (progress log: language-packs-1 published and pinned, CP4.4); previously trivial (progress log: Language packs, CP4);; previously trivial (DOCX table rows and cells inside selected alternate-content branches survive open and repeated saves (#5219); broader corpus qualification and readiness estimates unchanged); previously trivial (progress log: a list inside a Markdown block quote stays quoted); previously trivial (progress log: arrowheads keep their kind, width and length and are drawn at Word's sizes); previously trivial (progress log: the line between columns for selected text, #5178); previously trivial (progress log: .doc standard font, #5066); previously trivial (progress log: a table row doesn't split off a picture that doesn't fit, #4272); previously trivial (progress log: a middle or bottom cell of a row split across pages lines up on the first page); previously trivial (progress log: tab leader dots spaced and placed as in Word); previously trivial (progress log: a list level above counts its first number at the first deeper item); previously trivial (progress log: paragraph side borders lie outside the text, #5036); previously trivial (progress log: a paragraph naming an undefined style is formatted as Normal, #5077); previously trivial (progress log: TOC page numbers as PAGEREF fields, #5097); previously trivial (progress log: Backstage keytips, #4960); previously trivial (Progress log: only the printer's PDF leaves out links; Save these pages as PDF keeps them, #4734); previously trivial (progress log: Chinese and Japanese text counts a word per character, #4547; previously trivial: aligned-copy review fixes retain the document axis through scrolling and include secondary selections (#4764)); previously trivial (connector rerouting indexes laid-out object positions once per batch (#3631); previously trivial (progress log: corporate authors in bibliographies, #4848); previously trivial (custom-property bookmark-link preservation, #4767); previously trivial (progress log: Table Properties follows the selection, #4857); previously trivial (Relative font-size commands preserve mixed sizes in the selection (#4903); existing button steps and one-point shortcuts are unchanged; readiness estimates unchanged); previously trivial (Arabic Backspace removes one combining mark and keeps its base letter (#4757); previously trivial (note conversion walks reference offsets and formatting runs together, #4750); previously trivial (DOCX comment-ID save lookups); previously trivial (progress log: RTF pictures stored as Windows or Enhanced Metafiles are imported, #4259); previously trivial (#4658 bounds nested Markdown reference-label work); previously trivial (HTML import keeps preformatted lines with CR or CRLF input newlines, #4810); previously trivial (progress log: Insert › Shapes icon grid, #4349); previously trivial (progress log: AutoCorrect Options with no document open, #4585); previously trivial (progrLine truncated
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-03 · **Change:** trivial (progress log: SEQ, STYLEREF and REF fields show fresh results in output); previously trivial (Print Markup balloon anchors use one object walk per paragraph (#3614)); previously trivial (N-up and booklet printing, #641; readiness unchanged); previously trivial (Print Markup comment discovery walks paragraph objects once, #3614); previously trivial (progress log: the dialogs of the mirrored Arabic interface read right to left: label-and-field rows, tables (list columns, the Models table), tile galleries and the Add/Remove arrows follow the reading direction, through one `rtl::Table` helper that draws exactly as before in English; no ranked gap closed, readiness estimates unchanged); previously trivial (progress log: fonts that aren't installed are flagged in the font box and status bar); previously trivial (progress log: Options › Language redesign, CP4.3); previously trivial (progress log: the missing-pack bar on a fresh install, the caret's typing language, and the spelling menu heading in the interface language, CP4.3); previously trivial (progress log: Language pack languages in every language list, CP4.3); previously trivial (progress log: craft-proof pin, French elision and Valencian, CP4.3); previously trivial (progress log: a long comment's card and balloon stop at ten lines, #3382); previously trivial (progress log: a pasted text box story is charged for the formatting it copies); previously trivial (progress log: a .doc table row keeps one override per cell); previously trivial (progress log: a .doc file's sections find their paragraphs by index); previously minor (Arabic interface language with mirrored UI, #1195); previously trivial (progress log: Replace and Remove Table of Contents with Word's title above a section break, #4041); previously trivial (progress log: a password-protected file named on the command line asks for its password after the startup document is replaced, #5299); previously trivial (progress log: language-packs-2 published and pinned, 75 Language packs); previously trivial (progress log: language-packs-1 published and pinned, CP4.4); previously trivial (progress log: Language packs, CP4);; previously trivial (DOCX table rows and cells inside selected alternate-content branches survive open and repeated saves (#5219); broader corpus qualification and readiness estimates unchanged); previously trivial (progress log: a list inside a Markdown block quote stays quoted); previously trivial (progress log: arrowheads keep their kind, width and length and are drawn at Word's sizes); previously trivial (progress log: the line between columns for selected text, #5178); previously trivial (progress log: .doc standard font, #5066); previously trivial (progress log: a table row doesn't split off a picture that doesn't fit, #4272); previously trivial (progress log: a middle or bottom cell of a row split across pages lines up on the first page); previously trivial (progress log: tab leader dots spaced and placed as in Word); previously trivial (progress log: a list level above counts its first number at the first deeper item); previously trivial (progress log: paragraph side borders lie outside the text, #5036); previously trivial (progress log: a paragraph naming an undefined style is formatted as Normal, #5077); previously trivial (progress log: TOC page numbers as PAGEREF fields, #5097); previously trivial (progress log: Backstage keytips, #4960); previously trivial (Progress log: only the printer's PDF leaves out links; Save these pages as PDF keeps them, #4734); previously trivial (progress log: Chinese and Japanese text counts a word per character, #4547; previously trivial: aligned-copy review fixes retain the document axis through scrolling and include secondary selections (#4764)); previously trivial (connector rerouting indexes laid-out object positions once per batch (#3631); previously trivial (progress log: corporate authors in bibliographies, #4848); previously trivial (custom-property bookmark-link preservation, #4767); previously trivial (progress log: Table Properties follows the selection, #4857); previously trivial (Relative font-size commands preserve mixed sizes in the selection (#4903); existing button steps and one-point shortcuts are unchanged; readiness estimates unchanged); previously trivial (Arabic Backspace removes one combining mark and keeps its base letter (#4757); previously trivial (note conversion walks reference offsets and formatting runs together, #4750); previously trivial (DOCX comment-ID save lookups); previously trivial (progress log: RTF pictures stored as Windows or Enhanced Metafiles are imported, #4259); previously trivial (#4658 bounds nested Markdown reference-label work); previously trivial (HTML import keeps preformatted lines with CR or CRLF input newlines, #4810); previously trivial (progress log: Insert › Shapes icon grid, #4349); previously triviLine truncated
|
||||
> **Printing update:** 2026-10-12 · direct desktop printing through craft-print; details below.
|
||||
|
||||
WordCraft aims for complete parity with Microsoft Word, then goes further on speed, openness and
|
||||
@@ -139,6 +139,7 @@ Ranked, to beta (detail and remaining milestones in [docs/roadmap.md](docs/roadm
|
||||
|
||||
## Progress log
|
||||
|
||||
- 2026-11-01: PDF export, printing and page images show the body's SEQ, STYLEREF and REF fields with their current results, as Word fills them in when it prints or exports even with Update fields before printing off: a caption numbered 7 when it is the first shows 1 on paper, and a REF to a bookmark the document lacks prints Word's "Error! Reference source not found.". NUMWORDS, FILENAME, the document properties, formulas and locked fields keep their stored results there, as in Word; the editor and the saved document keep every stored result.
|
||||
- 2026-10-28: Print Markup HTML, Markdown and text copies discover comment anchors in one paragraph walk, so unrelated bookmark markers no longer cause repeated prefix scans (#3614). Existing quotes, replies, order and export limits are unchanged; original synthetic semantic and scaling regressions cover the traversal.
|
||||
- 2026-10-30: Layout resource caps (#3867, #4385) and linear-time passes (#3866, #4392): repeated table-header rows are capped by item counts, a clipped group counting every nested member, the wrap cache by decoded pixels and the widths a linked text box chain lays out by their lines and items; PDF export, hit testing, tracked-change and clipped-table costs, and long endnote selections no longer grow quadratically. Synthetic regression tests only; no new Word comparison.
|
||||
- 2026-11-03: Proofing (CP4.3): craft-proof moves to craft-libs main with #589 and #590. French spelling suggestions offer the elided form first (`Jadore` → `J'adore`, before `J adore`), and Valencian text (`ca-ES-valencia`) is proofed with the `ca-valencia` pack instead of plain Catalan. Readiness unchanged.
|
||||
@@ -722,6 +723,7 @@ Footer page-table height and body wrapping are corrected. Other pagination gaps
|
||||
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-11-01 | trivial | Progress log: SEQ, STYLEREF and REF fields show fresh results in output |
|
||||
| 2026-10-29 | trivial | Progress log: Print Markup balloon anchors use one object walk per paragraph (#3614); readiness estimates unchanged |
|
||||
| 2026-10-28 | trivial | Print Markup text-copy comment discovery walks paragraph objects once (#3614); quotes, order, limits and readiness estimates unchanged |
|
||||
| 2026-11-03 | trivial | Progress log: Language pack languages in every language list (CP4.3) |
|
||||
|
||||
@@ -16,7 +16,9 @@ mod properties;
|
||||
mod toc;
|
||||
|
||||
use fields::caption;
|
||||
pub use fields::{CHAPTER_SEPARATORS, refresh_date_fields, seq_format, update_fields, update_fields_in, update_seq};
|
||||
pub use fields::{
|
||||
CHAPTER_SEPARATORS, REFERENCE_NOT_FOUND, output_fields, refresh_date_fields, seq_format, update_fields, update_fields_in, update_seq,
|
||||
};
|
||||
pub(super) use notes::note_options;
|
||||
use notes::{note, note_refs};
|
||||
pub use properties::{bookmark_text, date_picture};
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
use super::properties::{
|
||||
bookmark_text, count_text, date_picture, doc_property, doc_property_arg, new_property_value, set_summary_property, summary_property, text_result,
|
||||
bookmark_ends_in_its_paragraph, bookmark_text, bookmark_text_at, count_text, date_picture, doc_property, doc_property_arg, new_property_value,
|
||||
set_summary_property, summary_property, text_result,
|
||||
};
|
||||
use super::toc::{heading_style_level, update_toc};
|
||||
use super::{
|
||||
@@ -76,66 +77,219 @@ pub(super) fn caption(s: &mut Session, v: &Value) -> CmdResult {
|
||||
/// the last level-n heading's number, or its count when the heading isn't numbered) and the
|
||||
/// text or number of the nearest paragraph of a style.
|
||||
pub fn update_seq(s: &mut Session) -> Result<(), CmdError> {
|
||||
let mut headings = [0u32; 9];
|
||||
let mut seq: std::collections::HashMap<String, (u32, u32)> = Default::default();
|
||||
// Built on the first STYLEREF (field results don't change what it finds).
|
||||
let mut styles: Option<wordcraft_layout::styleref::StyleIndex> = None;
|
||||
for path in s.doc.para_paths(StoryRef::Body) {
|
||||
let Some(p) = s.doc.para(StoryRef::Body, &path) else { continue };
|
||||
// Numbers first: a STYLEREF to a heading that holds a SEQ field shows its new number.
|
||||
let numbers = seq_results(&s.doc);
|
||||
apply_results(&mut s.doc, numbers)?;
|
||||
let refs = styleref_results(&s.doc);
|
||||
apply_results(&mut s.doc, refs)
|
||||
}
|
||||
|
||||
/// A field's fresh result: its story and paragraph, its object index there and the text.
|
||||
type FieldResult = (StoryRef, Path, usize, String);
|
||||
|
||||
/// The SEQ numbering so far while the document is walked in order.
|
||||
#[derive(Default)]
|
||||
struct SeqCount {
|
||||
/// How many headings of each level there have been (for `\s`).
|
||||
headings: [u32; 9],
|
||||
/// Per sequence: its number, and the heading count it was last restarted at.
|
||||
seq: std::collections::HashMap<String, (u32, u32)>,
|
||||
/// Text boxes already counted: a story shown twice, or inside itself, counts once.
|
||||
boxes: std::collections::HashSet<u32>,
|
||||
out: Vec<FieldResult>,
|
||||
}
|
||||
|
||||
/// The fresh results of the unlocked SEQ fields in the body and in the text boxes anchored there
|
||||
/// ([`update_seq`]). A text box's fields count where the box is anchored, before the fields after
|
||||
/// the anchor in its paragraph and after those before it (measured in Word for Windows).
|
||||
fn seq_results(doc: &wordcraft_doc::Document) -> Vec<FieldResult> {
|
||||
let mut count = SeqCount::default();
|
||||
for path in doc.para_paths(StoryRef::Body) {
|
||||
let Some(p) = doc.para(StoryRef::Body, &path) else { continue };
|
||||
if let Some(l) = heading_style_level(p)
|
||||
&& let Some(k) = (l as usize).checked_sub(1)
|
||||
&& let Some(count) = headings.get_mut(k)
|
||||
&& let Some(n) = count.headings.get_mut(k)
|
||||
{
|
||||
*count = count.saturating_add(1);
|
||||
*n = n.saturating_add(1);
|
||||
}
|
||||
let mut updates: Vec<(usize, String)> = Vec::new();
|
||||
for (k, o) in p.objects.iter().enumerate() {
|
||||
let InlineObject::Field { instr, locked: false, .. } = o else { continue };
|
||||
let f = FieldCode::parse(instr);
|
||||
match f.name.as_str() {
|
||||
"SEQ" => {
|
||||
let Some(id) = f.args.first() else { continue };
|
||||
let restart =
|
||||
f.arg('s').and_then(|l| l.parse::<usize>().ok()).and_then(|l| l.checked_sub(1)).and_then(|l| headings.get(l)).copied();
|
||||
let n = seq.entry(id.clone()).or_insert((0, 0));
|
||||
if let Some(epoch) = restart
|
||||
&& n.1 != epoch
|
||||
{
|
||||
*n = (0, epoch);
|
||||
}
|
||||
if let Some(r) = f.arg('r').and_then(|r| r.parse::<u32>().ok()) {
|
||||
n.0 = r;
|
||||
} else if !f.has('c') {
|
||||
n.0 = n.0.saturating_add(1);
|
||||
}
|
||||
let fmt = wordcraft_layout::fields::format_switch(instr).unwrap_or_default();
|
||||
updates.push((k, if f.has('h') { String::new() } else { wordcraft_layout::fields::field_number(instr, fmt, n.0) }));
|
||||
}
|
||||
"STYLEREF" => {
|
||||
let ix = styles.get_or_insert_with(|| wordcraft_layout::styleref::StyleIndex::build(&s.doc));
|
||||
if let Some(i) = ix.index_of(&path) {
|
||||
updates.push((k, text_result(instr, ix.eval(instr, wordcraft_layout::styleref::At::Body(i)))));
|
||||
count_seq(doc, StoryRef::Body, &path, p, &mut count);
|
||||
}
|
||||
count.out
|
||||
}
|
||||
|
||||
/// Number the SEQ fields of paragraph `p` (at `path` in `story`) and of the text boxes anchored in it.
|
||||
fn count_seq(doc: &wordcraft_doc::Document, story: StoryRef, path: &Path, p: &Paragraph, count: &mut SeqCount) {
|
||||
for (k, o) in p.objects.iter().enumerate() {
|
||||
for id in o.text_boxes() {
|
||||
if count.boxes.insert(id) {
|
||||
let part = StoryRef::Part(id);
|
||||
for bp in doc.para_paths(part) {
|
||||
if let Some(boxed) = doc.para(part, &bp) {
|
||||
count_seq(doc, part, &bp, boxed, count);
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
if updates.is_empty() {
|
||||
let InlineObject::Field { instr, locked: false, .. } = o else { continue };
|
||||
let f = FieldCode::parse(instr);
|
||||
if f.name != "SEQ" {
|
||||
continue;
|
||||
}
|
||||
let para = s.doc.para_mut(StoryRef::Body, &path)?;
|
||||
for (k, r) in updates {
|
||||
if let Some(InlineObject::Field { result, dirty, .. }) = para.objects.get_mut(k) {
|
||||
// A computed result is no longer stale: Word's F9 clears `w:dirty` too.
|
||||
*result = r;
|
||||
*dirty = false;
|
||||
let Some(id) = f.args.first() else { continue };
|
||||
let restart = f.arg('s').and_then(|l| l.parse::<usize>().ok()).and_then(|l| l.checked_sub(1)).and_then(|l| count.headings.get(l)).copied();
|
||||
let n = count.seq.entry(id.clone()).or_insert((0, 0));
|
||||
if let Some(epoch) = restart
|
||||
&& n.1 != epoch
|
||||
{
|
||||
*n = (0, epoch);
|
||||
}
|
||||
if let Some(r) = f.arg('r').and_then(|r| r.parse::<u32>().ok()) {
|
||||
n.0 = r;
|
||||
} else if !f.has('c') {
|
||||
n.0 = n.0.saturating_add(1);
|
||||
}
|
||||
let fmt = wordcraft_layout::fields::format_switch(instr).unwrap_or_default();
|
||||
let r = if f.has('h') { String::new() } else { wordcraft_layout::fields::field_number(instr, fmt, n.0) };
|
||||
count.out.push((story, path.clone(), k, r));
|
||||
}
|
||||
}
|
||||
|
||||
/// The fresh results of the body's unlocked STYLEREF fields ([`update_seq`]), from the text
|
||||
/// `doc` holds: the SEQ fields in the headings they find should already show their numbers.
|
||||
fn styleref_results(doc: &wordcraft_doc::Document) -> Vec<FieldResult> {
|
||||
let mut out = Vec::new();
|
||||
// Built on the first STYLEREF.
|
||||
let mut styles: Option<wordcraft_layout::styleref::StyleIndex> = None;
|
||||
for path in doc.para_paths(StoryRef::Body) {
|
||||
let Some(p) = doc.para(StoryRef::Body, &path) else { continue };
|
||||
for (k, o) in p.objects.iter().enumerate() {
|
||||
let InlineObject::Field { instr, locked: false, .. } = o else { continue };
|
||||
if FieldCode::parse(instr).name != "STYLEREF" {
|
||||
continue;
|
||||
}
|
||||
let ix = styles.get_or_insert_with(|| wordcraft_layout::styleref::StyleIndex::build(doc));
|
||||
if let Some(i) = ix.index_of(&path) {
|
||||
out.push((StoryRef::Body, path.clone(), k, text_result(instr, ix.eval(instr, wordcraft_layout::styleref::At::Body(i)))));
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// What Word prints for a `REF` to a bookmark the document doesn't have.
|
||||
pub const REFERENCE_NOT_FOUND: &str = "Error! Reference source not found.";
|
||||
|
||||
/// The fresh results of the body's unlocked REF fields as output shows them: the bookmark's
|
||||
/// text, or Word's error when the document lacks the bookmark (measured in Word for Windows);
|
||||
/// `\p` ("above" or "below") and the paragraph number switches `\r`, `\n` and `\w` keep the
|
||||
/// stored result, and so does a REF to a bookmark that ends in a later paragraph: Word shows such
|
||||
/// a bookmark's paragraphs as paragraphs of the result, which a field's one-paragraph result
|
||||
/// can't hold (measured in Word for Windows). A bookmark in a header, footer, footnote, endnote,
|
||||
/// comment or text box is found too, and a REF to it shows that text as `stored` holds it, its
|
||||
/// own fields not yet refreshed, as Word's output does (measured in Word for Windows). The
|
||||
/// document's bookmarks are looked up once, and each bookmark's text read once, however many
|
||||
/// fields refer to it.
|
||||
fn ref_results(doc: &wordcraft_doc::Document, stored: &wordcraft_doc::Document) -> Vec<FieldResult> {
|
||||
let mut out = Vec::new();
|
||||
let mut starts: std::collections::HashMap<String, Pos> = std::collections::HashMap::new();
|
||||
for (name, start) in doc.bookmarks().into_iter().chain(part_bookmarks(doc)) {
|
||||
// The first of two bookmarks with one name, as `bookmark_text` takes.
|
||||
starts.entry(name).or_insert(start);
|
||||
}
|
||||
// A bookmark's text, `None` when it ends in a later paragraph.
|
||||
let mut texts: std::collections::HashMap<String, Option<String>> = std::collections::HashMap::new();
|
||||
for path in doc.para_paths(StoryRef::Body) {
|
||||
let Some(p) = doc.para(StoryRef::Body, &path) else { continue };
|
||||
for (k, o) in p.objects.iter().enumerate() {
|
||||
let InlineObject::Field { instr, locked: false, .. } = o else { continue };
|
||||
// `\r`, `\n` and `\w` show the bookmarked paragraph's number, which isn't computed
|
||||
// here: those keep their stored result rather than print the paragraph's text.
|
||||
if wordcraft_layout::fields::field_name(instr) != "REF" || ['p', 'r', 'n', 'w'].iter().any(|&sw| has_switch(instr, sw)) {
|
||||
continue;
|
||||
}
|
||||
let f = FieldCode::parse(instr);
|
||||
let target = f.args.first().map(|s| s.split('\\').next().unwrap_or("").trim_matches('"')).unwrap_or("");
|
||||
let Some(start) = starts.get(target) else {
|
||||
out.push((StoryRef::Body, path.clone(), k, REFERENCE_NOT_FOUND.to_string()));
|
||||
continue;
|
||||
};
|
||||
let text = texts.entry(target.to_string()).or_insert_with(|| {
|
||||
let source = if start.story == StoryRef::Body { doc } else { stored };
|
||||
bookmark_ends_in_its_paragraph(source, target, start).then(|| bookmark_text_at(source, target, start)).flatten()
|
||||
});
|
||||
if let Some(r) = text.clone() {
|
||||
out.push((StoryRef::Body, path.clone(), k, text_result(instr, r)));
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// The bookmarks outside the body (headers, footers, notes, comments and text boxes), in story
|
||||
/// order.
|
||||
fn part_bookmarks(doc: &wordcraft_doc::Document) -> Vec<(String, Pos)> {
|
||||
let mut out = Vec::new();
|
||||
for id in doc.parts.keys() {
|
||||
let story = StoryRef::Part(*id);
|
||||
for path in doc.para_paths(story) {
|
||||
let Some(p) = doc.para(story, &path) else { continue };
|
||||
for (off, o) in p.objects_with_offsets() {
|
||||
if let InlineObject::BookmarkStart { name } = o {
|
||||
out.push((name.clone(), Pos { story, path: path.clone(), off }));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Put `results` into their fields, clearing `w:dirty` (a computed result is no longer stale:
|
||||
/// Word's F9 clears it too).
|
||||
fn apply_results(doc: &mut wordcraft_doc::Document, results: Vec<FieldResult>) -> Result<(), CmdError> {
|
||||
for (story, path, k, r) in results {
|
||||
let para = doc.para_mut(story, &path)?;
|
||||
if let Some(InlineObject::Field { result, dirty, .. }) = para.objects.get_mut(k) {
|
||||
*result = r;
|
||||
*dirty = false;
|
||||
}
|
||||
para.touch();
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// `results` less those its fields already show.
|
||||
fn changed(doc: &wordcraft_doc::Document, mut results: Vec<FieldResult>) -> Vec<FieldResult> {
|
||||
results.retain(|(story, path, k, r)| {
|
||||
!matches!(doc.para(*story, path).and_then(|p| p.objects.get(*k)), Some(InlineObject::Field { result, .. }) if result == r)
|
||||
});
|
||||
results
|
||||
}
|
||||
|
||||
/// The document as Word prints and exports it (PDF, print jobs, page images). Laying a document
|
||||
/// out for output, Word fills in the body's unlocked SEQ, STYLEREF and REF fields, even with
|
||||
/// "Update fields before printing" off, while NUMWORDS, FILENAME, the summary properties and
|
||||
/// formulas keep their stored results (measured with Word for Windows, printing and exporting
|
||||
/// fields whose stored results were wrong). The document itself keeps its stored results.
|
||||
/// `None` when every such field already shows its fresh result, so nothing is copied then.
|
||||
pub fn output_fields(doc: &wordcraft_doc::Document) -> Option<wordcraft_doc::Document> {
|
||||
// Numbers first, then the fields that read them: a STYLEREF to a heading holding a SEQ field,
|
||||
// and a REF to a bookmarked caption, show the new number.
|
||||
let seq = changed(doc, seq_results(doc));
|
||||
let mut out = (!seq.is_empty()).then(|| doc.clone());
|
||||
if let Some(d) = out.as_mut() {
|
||||
apply_results(d, seq).ok()?;
|
||||
}
|
||||
let styles = changed(out.as_ref().unwrap_or(doc), styleref_results(out.as_ref().unwrap_or(doc)));
|
||||
if !styles.is_empty() {
|
||||
apply_results(out.get_or_insert_with(|| doc.clone()), styles).ok()?;
|
||||
}
|
||||
let refs = changed(out.as_ref().unwrap_or(doc), ref_results(out.as_ref().unwrap_or(doc), doc));
|
||||
if !refs.is_empty() {
|
||||
apply_results(out.get_or_insert_with(|| doc.clone()), refs).ok()?;
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Whether `instr` has the one-letter switch `\sw`, also run together with another (`\p\h`).
|
||||
fn has_switch(instr: &str, sw: char) -> bool {
|
||||
instr.split('\\').skip(1).any(|s| {
|
||||
|
||||
@@ -92,11 +92,26 @@ pub fn date_picture(instr: &str) -> Option<String> {
|
||||
/// the paragraph's end. `None` when there is no such bookmark.
|
||||
pub fn bookmark_text(doc: &wordcraft_doc::Document, name: &str) -> Option<String> {
|
||||
let (_, start) = doc.bookmarks().into_iter().find(|(n, _)| n == name)?;
|
||||
bookmark_text_at(doc, name, &start)
|
||||
}
|
||||
|
||||
/// Does the bookmark `name` that starts at `start` end in the paragraph it starts in?
|
||||
pub(super) fn bookmark_ends_in_its_paragraph(doc: &wordcraft_doc::Document, name: &str, start: &Pos) -> bool {
|
||||
doc.para(start.story, &start.path).is_some_and(|para| {
|
||||
para.object_offsets()
|
||||
.into_iter()
|
||||
.any(|o| o > start.off && matches!(para.object_at(o), Some(InlineObject::BookmarkEnd { name: n }) if n == name))
|
||||
})
|
||||
}
|
||||
|
||||
/// [`bookmark_text`] for the bookmark `name` that starts at `start`, for a caller that has
|
||||
/// looked the document's bookmarks up once.
|
||||
pub(super) fn bookmark_text_at(doc: &wordcraft_doc::Document, name: &str, start: &Pos) -> Option<String> {
|
||||
let para = doc.para(start.story, &start.path)?;
|
||||
let end = para
|
||||
.object_offsets()
|
||||
.into_iter()
|
||||
.find(|o| *o > start.off && matches!(para.object_at(*o), Some(InlineObject::BookmarkEnd { name: n }) if n == name))
|
||||
.unwrap_or(para.len());
|
||||
Some(doc.copy_range(&start, &Pos { off: end, ..start.clone() }).plain_text().trim().to_string())
|
||||
Some(doc.copy_range(start, &Pos { off: end, ..start.clone() }).plain_text().trim().to_string())
|
||||
}
|
||||
@@ -511,3 +511,353 @@ fn caption_for_an_object_in_a_cell_stays_in_the_cell() {
|
||||
let texts: Vec<String> = s.doc.body.iter().filter_map(|b| b.as_para()).map(|p| p.plain_text()).collect();
|
||||
assert_eq!(texts.get(1).map(String::as_str), Some("Figure 1"), "{texts:?}");
|
||||
}
|
||||
|
||||
/// Output (PDF, print, page images) shows the body's unlocked SEQ, STYLEREF and REF fields with
|
||||
/// their fresh results, as Word fills them in when it prints or exports even with "Update fields
|
||||
/// before printing" off; NUMWORDS and a locked field keep their stored results there, and the
|
||||
/// editor and the document keep every stored result.
|
||||
#[test]
|
||||
fn output_shows_fresh_seq_styleref_and_ref_results() {
|
||||
let mut s = Session::new(wordcraft_doc::Document::from_text("Refs\nChapter One\nTarget words"));
|
||||
s.run("select.text", &json!({"text": "Chapter One"})).unwrap();
|
||||
s.run("styles.apply", &json!({"style": "Heading 1"})).unwrap();
|
||||
s.run("select.text", &json!({"text": "Target words"})).unwrap();
|
||||
s.run("insert.bookmark", &json!({"name": "Mark"})).unwrap();
|
||||
s.run("caret.docEnd", &json!({})).unwrap();
|
||||
for (instr, result) in [
|
||||
(r#"SEQ Figure \* ARABIC"#, "7"),
|
||||
(r#"STYLEREF "Heading 1""#, "stale style"),
|
||||
("REF Mark \\h", "stale text"),
|
||||
("NUMWORDS", "999"),
|
||||
(r#"SEQ Table \* ARABIC"#, "9"),
|
||||
] {
|
||||
s.run("insert.field", &json!({"instr": instr, "result": result})).unwrap();
|
||||
s.run("text.insert", &json!({"text": " "})).unwrap();
|
||||
}
|
||||
// Stale stored results (inserting a field computes its result), the last field locked.
|
||||
let stored = ["7", "stale style", "stale text", "999", "9"];
|
||||
let last = s.doc.para_paths(StoryRef::Body).pop().unwrap();
|
||||
let para = s.doc.para_mut(StoryRef::Body, &last).unwrap();
|
||||
let mut stale = stored.iter();
|
||||
for o in &mut para.objects {
|
||||
if let InlineObject::Field { result, locked, .. } = o {
|
||||
*result = stale.next().unwrap().to_string();
|
||||
*locked = stale.len() == 0;
|
||||
}
|
||||
}
|
||||
para.touch();
|
||||
let shown = |l: &wordcraft_layout::DocLayout| -> Vec<String> {
|
||||
l.pages
|
||||
.iter()
|
||||
.flat_map(|pg| &pg.items)
|
||||
.flat_map(|it| match it {
|
||||
wordcraft_layout::Placed::Lines { para, .. } => para.shown.iter().map(|s| s.1.clone()).collect(),
|
||||
_ => vec![],
|
||||
})
|
||||
.collect()
|
||||
};
|
||||
assert_eq!(shown(&s.export_layout()), ["1", "Chapter One", "Target words", "999", "9"]);
|
||||
assert_eq!(shown(&s.layout()), stored, "the editor shows the stored results");
|
||||
assert_eq!(fields(&s).iter().map(|f| f.1.as_str()).collect::<Vec<_>>(), stored, "the document keeps them");
|
||||
let pdf = s.print_pdf(&json!({})).unwrap().0;
|
||||
assert!(pdf.len() > 1000);
|
||||
// Once the fields are current, output copies nothing.
|
||||
update_seq(&mut s).unwrap();
|
||||
update_fields(&mut s).unwrap();
|
||||
assert!(output_fields(&s.doc).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn output_numbers_text_box_captions_where_their_boxes_are_anchored() {
|
||||
// Measured in Word for Windows, every SEQ result stored as a stale 9: output numbers a text
|
||||
// box's SEQ field where the box is anchored (box B before C in its paragraph, box F after E),
|
||||
// and a REF to a bookmark in a box shows the box's text as stored.
|
||||
let ns = r#"xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main" xmlns:v="urn:schemas-microsoft-com:vml""#;
|
||||
let run = |t: &str| format!(r#"<w:r><w:t xml:space="preserve">{t}</w:t></w:r>"#);
|
||||
let field = |instr: &str, stored: &str| {
|
||||
format!(
|
||||
r#"<w:r><w:fldChar w:fldCharType="begin"/></w:r><w:r><w:instrText xml:space="preserve"> {instr} </w:instrText></w:r><w:r><w:fldChar w:fldCharType="separate"/></w:r>{}<w:r><w:fldChar w:fldCharType="end"/></w:r>"#,
|
||||
run(stored)
|
||||
)
|
||||
};
|
||||
let seq = |label: &str| format!("{}{}", run(&format!("{label} Fig ")), field("SEQ Fig \\* ARABIC", "9"));
|
||||
let text_box = |inner: String| {
|
||||
format!(
|
||||
r##"<w:r><w:pict><v:shape type="#_x0000_t202" style="position:absolute;margin-left:300pt;width:150pt;height:30pt"><v:textbox><w:txbxContent><w:p>{inner}</w:p></w:txbxContent></v:textbox></v:shape></w:pict></w:r>"##
|
||||
)
|
||||
};
|
||||
let marked = format!(r#"<w:bookmarkStart w:id="1" w:name="boxmark"/>{}<w:bookmarkEnd w:id="1"/>"#, seq("B"));
|
||||
let paras = [
|
||||
seq("A"),
|
||||
format!("{}{}{}", run("anchor first: "), text_box(marked), seq("C")),
|
||||
seq("D"),
|
||||
format!("{}{}", seq("E"), text_box(seq("F"))),
|
||||
seq("G"),
|
||||
format!("{}{}", run("ref: "), field("REF boxmark \\h", "stored")),
|
||||
];
|
||||
let body: String = paras.iter().map(|p| format!("<w:p>{p}</w:p>")).collect();
|
||||
let document = format!("<w:document {ns}><w:body>{body}<w:sectPr/></w:body></w:document>");
|
||||
let root = r#"<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 mut zw = zip::ZipWriter::new(std::io::Cursor::new(Vec::new()));
|
||||
for (name, text) in [("_rels/.rels", root), ("word/document.xml", document.as_str())] {
|
||||
zw.start_file(name, zip::write::SimpleFileOptions::default()).unwrap();
|
||||
std::io::Write::write_all(&mut zw, text.as_bytes()).unwrap();
|
||||
}
|
||||
let s = Session::new(crate::io::open_bytes("boxes.docx", &zw.finish().unwrap().into_inner()).unwrap());
|
||||
let out = crate::cmd::references::output_fields(&s.doc).expect("fresh numbers");
|
||||
// The SEQ and REF results in reading order, text boxes where they are anchored.
|
||||
let mut results = Vec::new();
|
||||
out.objects_in_reading_order(&mut |o| {
|
||||
if let InlineObject::Field { instr, result, .. } = o {
|
||||
results.push((instr.split_whitespace().next().unwrap_or("").to_string(), result.clone()));
|
||||
}
|
||||
});
|
||||
let seqs: Vec<&str> = results.iter().filter(|(n, _)| n == "SEQ").map(|(_, r)| r.as_str()).collect();
|
||||
assert_eq!(seqs, ["1", "2", "3", "4", "5", "6", "7"], "{results:?}");
|
||||
let refs: Vec<&str> = results.iter().filter(|(n, _)| n == "REF").map(|(_, r)| r.as_str()).collect();
|
||||
assert_eq!(refs, ["B Fig 9"], "{results:?}");
|
||||
}
|
||||
|
||||
/// Printed or exported, a body REF to a bookmark in a header, footer, footnote, endnote or
|
||||
/// comment shows the bookmarked text, as Word's output does (measured in Word for Windows, each
|
||||
/// REF stored as "stale"), rather than Word's missing-bookmark error.
|
||||
#[test]
|
||||
fn output_shows_references_to_bookmarks_outside_the_body() {
|
||||
let ns = r#"xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships""#;
|
||||
let mark = |name: &str, id: u32| {
|
||||
format!(r#"<w:bookmarkStart w:id="{id}" w:name="{name}"/><w:r><w:t>Target {name}</w:t></w:r><w:bookmarkEnd w:id="{id}"/>"#)
|
||||
};
|
||||
let field = |name: &str| {
|
||||
format!(
|
||||
r#"<w:p><w:r><w:fldChar w:fldCharType="begin"/></w:r><w:r><w:instrText xml:space="preserve"> REF {name} \h </w:instrText></w:r><w:r><w:fldChar w:fldCharType="separate"/></w:r><w:r><w:t>stale</w:t></w:r><w:r><w:fldChar w:fldCharType="end"/></w:r></w:p>"#
|
||||
)
|
||||
};
|
||||
let names = ["HDR", "FTR", "FN", "EN", "CMT"];
|
||||
let refs: String = names.iter().map(|n| field(n)).collect();
|
||||
let document = format!(
|
||||
r#"<w:document {ns}><w:body>{refs}<w:p><w:r><w:t>Body </w:t></w:r><w:r><w:footnoteReference w:id="1"/></w:r><w:r><w:endnoteReference w:id="1"/></w:r><w:commentRangeStart w:id="0"/><w:r><w:t>commented</w:t></w:r><w:commentRangeEnd w:id="0"/><w:r><w:commentReference w:id="0"/></w:r></w:p><w:sectPr><w:headerReference w:type="default" r:id="rIdH"/><w:footerReference w:type="default" r:id="rIdF"/></w:sectPr></w:body></w:document>"#
|
||||
);
|
||||
let header = format!(r#"<w:hdr {ns}><w:p>{}</w:p></w:hdr>"#, mark("HDR", 1));
|
||||
let footer = format!(r#"<w:ftr {ns}><w:p>{}</w:p></w:ftr>"#, mark("FTR", 2));
|
||||
let footnotes =
|
||||
format!(r#"<w:footnotes {ns}><w:footnote w:id="1"><w:p><w:r><w:footnoteRef/></w:r>{}</w:p></w:footnote></w:footnotes>"#, mark("FN", 3));
|
||||
let endnotes = format!(r#"<w:endnotes {ns}><w:endnote w:id="1"><w:p><w:r><w:endnoteRef/></w:r>{}</w:p></w:endnote></w:endnotes>"#, mark("EN", 4));
|
||||
let comments = format!(r#"<w:comments {ns}><w:comment w:id="0" w:author="probe"><w:p>{}</w:p></w:comment></w:comments>"#, mark("CMT", 5));
|
||||
let rel = "http://schemas.openxmlformats.org/officeDocument/2006/relationships";
|
||||
let rels = format!(
|
||||
r#"<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="rIdH" Type="{rel}/header" Target="header1.xml"/><Relationship Id="rIdF" Type="{rel}/footer" Target="footer1.xml"/><Relationship Id="rIdN" Type="{rel}/footnotes" Target="footnotes.xml"/><Relationship Id="rIdE" Type="{rel}/endnotes" Target="endnotes.xml"/><Relationship Id="rIdC" Type="{rel}/comments" Target="comments.xml"/></Relationships>"#
|
||||
);
|
||||
let root = r#"<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 files = [
|
||||
("_rels/.rels", root.to_string()),
|
||||
("word/_rels/document.xml.rels", rels),
|
||||
("word/document.xml", document),
|
||||
("word/header1.xml", header),
|
||||
("word/footer1.xml", footer),
|
||||
("word/footnotes.xml", footnotes),
|
||||
("word/endnotes.xml", endnotes),
|
||||
("word/comments.xml", comments),
|
||||
];
|
||||
let mut zw = zip::ZipWriter::new(std::io::Cursor::new(Vec::new()));
|
||||
for (name, text) in &files {
|
||||
zw.start_file(*name, zip::write::SimpleFileOptions::default()).unwrap();
|
||||
std::io::Write::write_all(&mut zw, text.as_bytes()).unwrap();
|
||||
}
|
||||
let s = Session::new(crate::io::open_bytes("stories.docx", &zw.finish().unwrap().into_inner()).unwrap());
|
||||
let out = crate::cmd::references::output_fields(&s.doc).expect("fresh references");
|
||||
let mut results = Vec::new();
|
||||
for path in out.para_paths(StoryRef::Body) {
|
||||
for o in &out.para(StoryRef::Body, &path).unwrap().objects {
|
||||
if let InlineObject::Field { result, .. } = o {
|
||||
results.push(result.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
let expected: Vec<String> = names.iter().map(|n| format!("Target {n}")).collect();
|
||||
assert_eq!(results, expected);
|
||||
}
|
||||
|
||||
/// Printed or exported, a REF to a bookmark the document doesn't have shows Word's error
|
||||
/// (measured in Word for Windows: "Error! Reference source not found."); the editor and the
|
||||
/// document keep the stored result, and a REF showing "above" or "below" (`\p`) keeps it too.
|
||||
#[test]
|
||||
fn output_shows_words_error_for_a_reference_to_a_missing_bookmark() {
|
||||
let mut s = Session::new(wordcraft_doc::Document::from_text("See "));
|
||||
s.run("caret.docEnd", &json!({})).unwrap();
|
||||
for instr in ["REF NoSuchMark \\h", "REF NoSuchMark \\p"] {
|
||||
s.run("insert.field", &json!({"instr": instr, "result": "x"})).unwrap();
|
||||
s.run("text.insert", &json!({"text": " "})).unwrap();
|
||||
}
|
||||
let para = s.doc.para_mut(StoryRef::Body, &wordcraft_doc::Path::top(0)).unwrap();
|
||||
for o in &mut para.objects {
|
||||
if let InlineObject::Field { result, .. } = o {
|
||||
*result = "stale".into();
|
||||
}
|
||||
}
|
||||
para.touch();
|
||||
let shown = |l: &wordcraft_layout::DocLayout| -> Vec<String> {
|
||||
l.pages[0]
|
||||
.items
|
||||
.iter()
|
||||
.flat_map(|it| match it {
|
||||
wordcraft_layout::Placed::Lines { para, .. } => para.shown.iter().map(|s| s.1.clone()).collect(),
|
||||
_ => vec![],
|
||||
})
|
||||
.collect()
|
||||
};
|
||||
assert_eq!(shown(&s.export_layout()), [REFERENCE_NOT_FOUND, "stale"]);
|
||||
assert_eq!(shown(&s.layout()), ["stale", "stale"], "the editor shows the stored result");
|
||||
assert_eq!(fields(&s).iter().map(|f| f.1.as_str()).collect::<Vec<_>>(), ["stale", "stale"], "the document keeps it");
|
||||
}
|
||||
|
||||
/// A REF showing the bookmarked paragraph's number (`\r`, `\n`, `\w`), which output doesn't
|
||||
/// compute, keeps its stored number when printed or exported rather than show the paragraph's
|
||||
/// text; a plain REF to the same bookmark is filled in.
|
||||
#[test]
|
||||
fn output_keeps_paragraph_number_references_as_stored() {
|
||||
let mut s = Session::new(wordcraft_doc::Document::from_text("Target heading\nRefs "));
|
||||
s.run("select.text", &json!({"text": "Target heading"})).unwrap();
|
||||
s.run("insert.bookmark", &json!({"name": "Mark"})).unwrap();
|
||||
s.run("caret.docEnd", &json!({})).unwrap();
|
||||
let instrs = ["REF Mark \\r \\h", "REF Mark \\n", "REF Mark \\w \\h", "REF Mark \\h"];
|
||||
for instr in instrs {
|
||||
s.run("insert.field", &json!({"instr": instr, "result": "x"})).unwrap();
|
||||
s.run("text.insert", &json!({"text": " "})).unwrap();
|
||||
}
|
||||
let last = s.doc.para_paths(StoryRef::Body).pop().unwrap();
|
||||
let para = s.doc.para_mut(StoryRef::Body, &last).unwrap();
|
||||
for o in &mut para.objects {
|
||||
if let InlineObject::Field { result, .. } = o {
|
||||
*result = "2.1".into();
|
||||
}
|
||||
}
|
||||
para.touch();
|
||||
let out = output_fields(&s.doc).expect("the plain REF changes");
|
||||
let results: Vec<String> = out
|
||||
.para(StoryRef::Body, &last)
|
||||
.unwrap()
|
||||
.objects
|
||||
.iter()
|
||||
.filter_map(|o| match o {
|
||||
InlineObject::Field { result, .. } => Some(result.clone()),
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
assert_eq!(results, ["2.1", "2.1", "2.1", "Target heading"]);
|
||||
}
|
||||
|
||||
/// The results of the fields in the paragraph at `path` of `d`.
|
||||
fn results_in(d: &wordcraft_doc::Document, path: &wordcraft_doc::Path) -> Vec<String> {
|
||||
d.para(StoryRef::Body, path)
|
||||
.unwrap()
|
||||
.objects
|
||||
.iter()
|
||||
.filter_map(|o| match o {
|
||||
InlineObject::Field { result, .. } => Some(result.clone()),
|
||||
_ => None,
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Set every field result in the last body paragraph of `s` to `stale`; that paragraph's path.
|
||||
fn stale_last_paragraph(s: &mut Session, stale: &str) -> wordcraft_doc::Path {
|
||||
let last = s.doc.para_paths(StoryRef::Body).pop().unwrap();
|
||||
let para = s.doc.para_mut(StoryRef::Body, &last).unwrap();
|
||||
for o in &mut para.objects {
|
||||
if let InlineObject::Field { result, .. } = o {
|
||||
*result = stale.into();
|
||||
}
|
||||
}
|
||||
para.touch();
|
||||
last
|
||||
}
|
||||
|
||||
/// Word shows a REF to a bookmark that ends in a later paragraph as paragraphs of the result
|
||||
/// (measured in Word for Windows: "one" and "Beta" on two lines for a bookmark from "one" to
|
||||
/// "Beta"), which a field's one-paragraph result can't hold: output keeps the stored result
|
||||
/// rather than show the first paragraph's part alone.
|
||||
#[test]
|
||||
fn output_keeps_a_reference_to_a_bookmark_over_paragraphs_as_stored() {
|
||||
let mut s = Session::new(wordcraft_doc::Document::from_text("Alpha one\nBeta two\nShort mark\nRefs "));
|
||||
let at = |block: usize, off: usize| wordcraft_doc::Pos { story: StoryRef::Body, path: wordcraft_doc::Path::top(block), off };
|
||||
s.sel = crate::Selection { anchor: at(0, 6), focus: at(1, 4) };
|
||||
s.run("insert.bookmark", &json!({"name": "Span"})).unwrap();
|
||||
s.run("select.text", &json!({"text": "Short mark"})).unwrap();
|
||||
s.run("insert.bookmark", &json!({"name": "Short"})).unwrap();
|
||||
s.run("caret.docEnd", &json!({})).unwrap();
|
||||
for instr in ["REF Span \\h", "REF Short \\h"] {
|
||||
s.run("insert.field", &json!({"instr": instr, "result": "x"})).unwrap();
|
||||
s.run("text.insert", &json!({"text": " "})).unwrap();
|
||||
}
|
||||
let last = stale_last_paragraph(&mut s, "stored");
|
||||
let out = output_fields(&s.doc).expect("the short bookmark's REF changes");
|
||||
assert_eq!(results_in(&out, &last), ["stored", "Short mark"]);
|
||||
}
|
||||
|
||||
/// A STYLEREF to a heading holding a SEQ field shows the heading with the SEQ field's new number,
|
||||
/// in output and after F9: the numbers are filled in before the style references read them.
|
||||
#[test]
|
||||
fn a_style_reference_reads_the_headings_new_sequence_number() {
|
||||
let mut s = Session::new(wordcraft_doc::Document::from_text("Chapter \nBody\nRefs "));
|
||||
s.run("select.text", &json!({"text": "Chapter "})).unwrap();
|
||||
s.run("styles.apply", &json!({"style": "Heading 1"})).unwrap();
|
||||
s.sel = crate::Selection::caret(wordcraft_doc::Pos { story: StoryRef::Body, path: wordcraft_doc::Path::top(0), off: 8 });
|
||||
s.run("insert.field", &json!({"instr": "SEQ Chapter \\* ARABIC", "result": "x"})).unwrap();
|
||||
s.run("caret.docEnd", &json!({})).unwrap();
|
||||
s.run("insert.field", &json!({"instr": "STYLEREF \"Heading 1\"", "result": "x"})).unwrap();
|
||||
// Stale stored results: the heading's number 9, the reference "stale".
|
||||
let heading = wordcraft_doc::Path::top(0);
|
||||
let para = s.doc.para_mut(StoryRef::Body, &heading).unwrap();
|
||||
for o in &mut para.objects {
|
||||
if let InlineObject::Field { result, .. } = o {
|
||||
*result = "9".into();
|
||||
}
|
||||
}
|
||||
para.touch();
|
||||
let last = stale_last_paragraph(&mut s, "stale");
|
||||
let out = output_fields(&s.doc).expect("the fields change");
|
||||
assert_eq!(results_in(&out, &heading), ["1"]);
|
||||
assert_eq!(results_in(&out, &last), ["Chapter 1"]);
|
||||
update_seq(&mut s).unwrap();
|
||||
assert_eq!(results_in(&s.doc, &last), ["Chapter 1"], "F9's renumbering too");
|
||||
}
|
||||
|
||||
/// Output looks the bookmarks up once for all of a document's REF fields: several fields to one
|
||||
/// bookmark, fields to others, and one to a bookmark the document lacks each show what
|
||||
/// `bookmark_text` (F9's lookup) gives, or Word's error.
|
||||
#[test]
|
||||
fn output_gives_every_reference_its_bookmarks_text() {
|
||||
let mut s = Session::new(wordcraft_doc::Document::from_text("First target\nSecond target\nRefs "));
|
||||
for (text, name) in [("First target", "A"), ("Second target", "B")] {
|
||||
s.run("select.text", &json!({"text": text})).unwrap();
|
||||
s.run("insert.bookmark", &json!({"name": name})).unwrap();
|
||||
}
|
||||
s.run("caret.docEnd", &json!({})).unwrap();
|
||||
let targets = ["A", "B", "A", "Missing", "A"];
|
||||
for t in targets {
|
||||
s.run("insert.field", &json!({"instr": format!("REF {t} \\h"), "result": "x"})).unwrap();
|
||||
s.run("text.insert", &json!({"text": " "})).unwrap();
|
||||
}
|
||||
let last = s.doc.para_paths(StoryRef::Body).pop().unwrap();
|
||||
let para = s.doc.para_mut(StoryRef::Body, &last).unwrap();
|
||||
for o in &mut para.objects {
|
||||
if let InlineObject::Field { result, .. } = o {
|
||||
*result = "stale".into();
|
||||
}
|
||||
}
|
||||
para.touch();
|
||||
let out = output_fields(&s.doc).expect("the stale results change");
|
||||
let results: Vec<String> = out
|
||||
.para(StoryRef::Body, &last)
|
||||
.unwrap()
|
||||
.objects
|
||||
.iter()
|
||||
.filter_map(|o| match o {
|
||||
InlineObject::Field { result, .. } => Some(result.clone()),
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
let expected: Vec<String> = targets.iter().map(|t| super::bookmark_text(&s.doc, t).unwrap_or_else(|| REFERENCE_NOT_FOUND.to_string())).collect();
|
||||
assert_eq!(results, expected);
|
||||
assert_eq!(results, ["First target", "Second target", "First target", REFERENCE_NOT_FOUND, "First target"]);
|
||||
}
|
||||
@@ -31,6 +31,9 @@ pub fn save(ext: &str, doc: &Document, opts: &crate::io::ExportOptions) -> Optio
|
||||
"xml" => Some(wordcraft_docx::write_flat(doc).map_err(|e| e.to_string())),
|
||||
"png" => Some(render_png(doc, 0, 2.0, opts)),
|
||||
"pdf" => {
|
||||
// Output shows the fields Word fills in as it prints (see `output_fields`).
|
||||
let out = crate::cmd::references::output_fields(doc);
|
||||
let doc = out.as_ref().unwrap_or(doc);
|
||||
let pdf = wordcraft_pdf::PdfOptions {
|
||||
include_markup: opts.markup,
|
||||
comments: opts.markup && opts.comments,
|
||||
@@ -46,6 +49,8 @@ pub fn save(ext: &str, doc: &Document, opts: &crate::io::ExportOptions) -> Optio
|
||||
/// Render page `page` as PNG at `scale` px/pt. With Print Markup's comments the page has its
|
||||
/// markup area with the comments in balloons (page images always show tracked changes).
|
||||
pub fn render_png(doc: &Document, page: usize, scale: f32, opts: &crate::io::ExportOptions) -> Result<Vec<u8>, String> {
|
||||
let out = crate::cmd::references::output_fields(doc);
|
||||
let doc = out.as_ref().unwrap_or(doc);
|
||||
let lo = wordcraft_layout::LayoutOptions { comment_balloons: opts.markup && opts.comments, numeral: opts.numeral, ..Default::default() };
|
||||
let l = wordcraft_layout::layout(doc, &mut wordcraft_layout::LayoutCache::new(), &lo);
|
||||
let p = l.pages.get(page).ok_or_else(|| format!("no page {}", page + 1))?;
|
||||
|
||||
@@ -219,7 +219,9 @@ impl Session {
|
||||
}
|
||||
// Print Markup (File › Print) decides whether tracked changes and comments print.
|
||||
let markup = crate::cmd::file::export_options(self, v);
|
||||
let lay = self.export_layout_with(markup.markup);
|
||||
let out = crate::cmd::references::output_fields(&self.doc);
|
||||
let doc = out.as_ref().unwrap_or(&self.doc);
|
||||
let lay = self.export_layout_of(doc, markup.markup);
|
||||
let plan = plan(v, lay.pages.len(), self.current_pages(&lay))?;
|
||||
let mut single = v.clone();
|
||||
single["copies"] = json!(1);
|
||||
@@ -238,7 +240,7 @@ impl Session {
|
||||
links,
|
||||
..Default::default()
|
||||
};
|
||||
let bytes = wordcraft_pdf::export_layout(&self.doc, &lay, &opts).map_err(|e| CmdError::Failed(e.to_string()))?;
|
||||
let bytes = wordcraft_pdf::export_layout(doc, &lay, &opts).map_err(|e| CmdError::Failed(e.to_string()))?;
|
||||
Ok((bytes, plan))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -161,8 +161,15 @@ impl Session {
|
||||
}
|
||||
|
||||
/// [`Session::export_layout`]; with `markup` (Print Markup) each page also has the markup
|
||||
/// area with the comments in balloons.
|
||||
/// area with the comments in balloons. Fields Word fills in for output show their fresh
|
||||
/// results ([`crate::cmd::references::output_fields`]).
|
||||
pub fn export_layout_with(&self, markup: bool) -> Arc<DocLayout> {
|
||||
let out = crate::cmd::references::output_fields(&self.doc);
|
||||
self.export_layout_of(out.as_ref().unwrap_or(&self.doc), markup)
|
||||
}
|
||||
|
||||
/// [`Session::export_layout_with`] of `doc`, this document as it is output.
|
||||
pub(crate) fn export_layout_of(&self, doc: &Document, markup: bool) -> Arc<DocLayout> {
|
||||
let (fn_name, fn_path) = self.resolved_filenames();
|
||||
let opts = LayoutOptions {
|
||||
view: ViewMode::Print,
|
||||
@@ -172,7 +179,7 @@ impl Session {
|
||||
filepath: Some(Arc::from(fn_path.as_str())),
|
||||
..Default::default()
|
||||
};
|
||||
Arc::new(wordcraft_layout::layout(&self.doc, &mut LayoutCache::new(), &opts))
|
||||
Arc::new(wordcraft_layout::layout(doc, &mut LayoutCache::new(), &opts))
|
||||
}
|
||||
|
||||
/// Invalidate the cached layout (fonts changed etc.).
|
||||
|
||||
+100
-18
@@ -149,6 +149,10 @@ pub struct StyleIndex {
|
||||
by_path: HashMap<Path, usize>,
|
||||
/// Lower-cased style id or name → (id, character style).
|
||||
styles: HashMap<String, (String, bool)>,
|
||||
/// Paragraph style id → the entries of that style, in order.
|
||||
para_entries: HashMap<String, Vec<usize>>,
|
||||
/// Character style id → the entries with a run of that style, in order.
|
||||
char_entries: HashMap<String, Vec<usize>>,
|
||||
}
|
||||
|
||||
fn clean(s: &str) -> String {
|
||||
@@ -167,6 +171,8 @@ impl StyleIndex {
|
||||
let mut ordinals: HashMap<String, u32> = HashMap::new();
|
||||
let mut entries = Vec::new();
|
||||
let mut by_path = HashMap::new();
|
||||
let mut para_entries: HashMap<String, Vec<usize>> = HashMap::new();
|
||||
let mut char_entries: HashMap<String, Vec<usize>> = HashMap::new();
|
||||
for path in doc.para_paths(StoryRef::Body) {
|
||||
let Some(p) = doc.para(StoryRef::Body, &path) else { continue };
|
||||
let style = p.props.style.clone().unwrap_or_else(|| "Normal".to_string());
|
||||
@@ -206,10 +212,18 @@ impl StyleIndex {
|
||||
}
|
||||
off = end;
|
||||
}
|
||||
by_path.insert(path.clone(), entries.len());
|
||||
let i = entries.len();
|
||||
by_path.insert(path.clone(), i);
|
||||
para_entries.entry(style.clone()).or_default().push(i);
|
||||
for (cs, _) in &runs {
|
||||
let list = char_entries.entry(cs.clone()).or_default();
|
||||
if list.last() != Some(&i) {
|
||||
list.push(i);
|
||||
}
|
||||
}
|
||||
entries.push(Entry { path, style, text: clean(&p.plain_text()), runs, label, levels, ordinal });
|
||||
}
|
||||
StyleIndex { entries, by_path, styles }
|
||||
StyleIndex { entries, by_path, styles, para_entries, char_entries }
|
||||
}
|
||||
|
||||
/// The entry of the body paragraph at `path`.
|
||||
@@ -261,28 +275,50 @@ impl StyleIndex {
|
||||
}
|
||||
}
|
||||
|
||||
/// The entries `t` matches, in order.
|
||||
fn matches(&self, t: &Target) -> &[usize] {
|
||||
let list = match t {
|
||||
Target::Para(id) => self.para_entries.get(id),
|
||||
Target::Char(id) => self.char_entries.get(id),
|
||||
};
|
||||
list.map_or(&[], Vec::as_slice)
|
||||
}
|
||||
|
||||
/// The first entry of `found` (the entries a target matches, in order) in a STYLEREF's search
|
||||
/// order from `at`, with whether it counts as below the field. From a body paragraph: that
|
||||
/// paragraph and back to the start, then on from it. From a header or footer: the page's
|
||||
/// paragraphs (from the last when `last`), then back from the page, then on from it. A binary
|
||||
/// search over `found`, so a target few or no paragraphs have costs no walk over the body.
|
||||
fn first_in_order(found: &[usize], n: usize, at: At, last_first: bool) -> Option<(usize, bool)> {
|
||||
let at_or_before = |k: usize| found.partition_point(|&p| p <= k).checked_sub(1).and_then(|j| found.get(j)).copied();
|
||||
let from = |k: usize| found.get(found.partition_point(|&p| p < k)).copied().filter(|&p| p < n);
|
||||
match at {
|
||||
At::Body(i) => {
|
||||
let i = i.min(n.checked_sub(1)?);
|
||||
at_or_before(i).map(|p| (p, false)).or_else(|| from(i.saturating_add(1)).map(|p| (p, true)))
|
||||
}
|
||||
At::Page { first, last } => {
|
||||
if let Some(l) = last.filter(|&l| first <= l).map(|l| l.min(n.saturating_sub(1))) {
|
||||
let on = if last_first { at_or_before(l).filter(|&p| p >= first) } else { from(first).filter(|&p| p <= l) };
|
||||
if let Some(p) = on {
|
||||
return Some((p, true));
|
||||
}
|
||||
}
|
||||
let before = first.min(n).checked_sub(1).and_then(at_or_before);
|
||||
let after = last.map_or(first, |l| l.saturating_add(1));
|
||||
before.map(|p| (p, false)).or_else(|| from(after).map(|p| (p, true)))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The field's result.
|
||||
pub fn eval(&self, instr: &str, at: At) -> String {
|
||||
let code = parse(instr);
|
||||
let Some(target) = self.target(&code.style) else { return NO_TEXT.to_string() };
|
||||
let n = self.entries.len();
|
||||
// Candidates in search order, with whether each one counts as below the field (lazy:
|
||||
// headers evaluate this for every page on every pass).
|
||||
let order: Box<dyn Iterator<Item = (usize, bool)>> = match at {
|
||||
At::Page { first, last } => {
|
||||
let on = match last {
|
||||
Some(l) if first <= l => first..l.min(n.saturating_sub(1)).saturating_add(1),
|
||||
_ => 0..0,
|
||||
};
|
||||
let after = last.map_or(first, |l| l.saturating_add(1));
|
||||
let page: Box<dyn Iterator<Item = usize>> = if code.last { Box::new(on.rev()) } else { Box::new(on) };
|
||||
Box::new(page.map(|i| (i, true)).chain((0..first.min(n)).rev().map(|i| (i, false))).chain((after..n).map(|i| (i, true))))
|
||||
}
|
||||
At::Body(i) => Box::new((0..=i.min(n.saturating_sub(1))).rev().map(|k| (k, false)).chain((i.saturating_add(1)..n).map(|k| (k, true)))),
|
||||
};
|
||||
let Some((i, text, below)) = order.into_iter().find_map(|(i, below)| self.text_of(i, &target).map(|t| (i, t, below))) else {
|
||||
let Some((i, below)) = Self::first_in_order(self.matches(&target), self.entries.len(), at, code.last) else {
|
||||
return NO_TEXT.to_string();
|
||||
};
|
||||
let Some(text) = self.text_of(i, &target) else { return NO_TEXT.to_string() };
|
||||
let Some(e) = self.entries.get(i) else { return NO_TEXT.to_string() };
|
||||
let mut out = match code.number {
|
||||
Number::Text if code.position => String::new(),
|
||||
@@ -357,6 +393,52 @@ mod tests {
|
||||
assert_eq!(parse("STYLEREF").style, "");
|
||||
}
|
||||
|
||||
/// The search order the index's binary search follows, walked entry by entry as it was before
|
||||
/// the index: the body paragraph and back, then on; or the page (from its last paragraph when
|
||||
/// `last_first`), back from it, then on.
|
||||
fn first_by_walk(found: &[usize], n: usize, at: At, last_first: bool) -> Option<(usize, bool)> {
|
||||
let order: Vec<(usize, bool)> = match at {
|
||||
At::Page { first, last } => {
|
||||
let on: Vec<usize> = match last {
|
||||
Some(l) if first <= l => (first..l.min(n.saturating_sub(1)).saturating_add(1)).collect(),
|
||||
_ => Vec::new(),
|
||||
};
|
||||
let after = last.map_or(first, |l| l.saturating_add(1));
|
||||
let page: Vec<usize> = if last_first { on.into_iter().rev().collect() } else { on };
|
||||
page.into_iter().map(|i| (i, true)).chain((0..first.min(n)).rev().map(|i| (i, false))).chain((after..n).map(|i| (i, true))).collect()
|
||||
}
|
||||
At::Body(i) => (0..=i.min(n.saturating_sub(1))).rev().map(|k| (k, false)).chain((i.saturating_add(1)..n).map(|k| (k, true))).collect(),
|
||||
};
|
||||
order.into_iter().find(|(i, _)| *i < n && found.contains(i))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_index_finds_what_walking_the_body_finds() {
|
||||
// Every placement of matches among up to 7 paragraphs, from every body paragraph and every
|
||||
// page range (in and past the body), both ways round.
|
||||
for n in 0..=7usize {
|
||||
for mask in 0u32..(1 << n) {
|
||||
let found: Vec<usize> = (0..n).filter(|i| mask & (1 << i) != 0).collect();
|
||||
let mut ats: Vec<At> = (0..=n + 1).map(At::Body).collect();
|
||||
for first in 0..=n + 1 {
|
||||
ats.push(At::Page { first, last: None });
|
||||
for last in 0..=n + 1 {
|
||||
ats.push(At::Page { first, last: Some(last) });
|
||||
}
|
||||
}
|
||||
for at in ats {
|
||||
for last_first in [false, true] {
|
||||
assert_eq!(
|
||||
StyleIndex::first_in_order(&found, n, at, last_first),
|
||||
first_by_walk(&found, n, at, last_first),
|
||||
"n {n}, found {found:?}, {at:?}, last {last_first}"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn suppress_keeps_the_number() {
|
||||
assert_eq!(suppress("Chapter 2.", &[("2".into(), true)]), "2");
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Layout and pagination parity with Microsoft Word
|
||||
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-03 · **Change:** trivial (Print Markup balloon anchor discovery walks paragraph objects once (#3614)); previously trivial (行頭禁則 also covers the small kana and the prolonged sound mark at ordinary break opportunities, as JIS X 4051 §4 specifies, inferred from the standard and synthetic tests rather than checked in Word); previously trivial (a missing font is drawn in an installed metric twin before any other substitute: Arial for Helvetica on Windows, Tinos before macOS Times for Times New Roman); previously trivial (layout work bounds: header-row repeats budgeted per layout, cached picture outlines charged to the decode budget, linked-box chain widths exact up to a work cap, #3867); previously trivial (the line between columns runs as tall as the section's text, so columns for selected text have it, #5178); previously trivial (a table row that would leave a cell empty on a page that already holds something, its picture or first line not fitting there, moves on whole instead of leaving the other cells' text beside the empty cell, #4272); previously trivial (a middle or bottom cell of a row split across pages lines up on the first page); previously trivial (tab leader dots spaced and placed as in Word); previously trivial (a level above that hasn't counted yet takes its first number at the first deeper item); previously trivial (paragraph side borders lie beyond their distance from the text, #5036); previously trivial (with a header taller than the top margin, Margin-anchored drawings, header-cell pins and header-table obstacles use the rendered body top; TopMargin and InsideMargin keep their declared band, #2485; previously trivial: connector rerouting indexes laid-out object positions once per batch (#3631); previously trivial (field results take the `\* DollarText`, `\* Hex` and `\* ArabicDash` switches, #4226; previously trivial: a cell spanning columns shares its extra minimum width by the columns' preferred widths, equally in automatic tables, #1883; previously trivial: continuous sections one after another go onto new pages instead of running off the page; empty marks after another empty section mark take a line each until the page is full, then stay at its bottom drawing nothing, not even a list label; an empty mark that takes no room draws no list label either; a kept-together paragraph is measured against the page it would move to, so a tall different first-page header no longer splits it, #3907; previously trivial: a justified line ending in a manual line break is stretched to the margin unless `w:doNotExpandShiftReturn` is set, #3924; previously trivial: a no-break space is as wide as a space, #4192; previously trivial: a bookmark or other zero-width marker is no line-break opportunity, #4193; previously trivial: Arabic list labels: `arabicAlpha` and `arabicAbjad` draw the 28 Arabic letters (alphabetical and abjad order), doubling past the last letter; Hebrew, Hindi, Thai and Vietnamese still show decimal (`doc::section::tests::arabic_letters_count_in_both_orders`); previously trivial: Preserve grapheme edit/wrap boundaries across independently drawn font/style pieces; Exact-spacing table contact uses the specified line height rather than paragraph-mark metrics (#3977); previously trivial: cu20we: restrict pre-spacing float origins to paragraph anchors; document cap helpers and regressions; previously trivial: cu20we: outline caps; previously trivial: cu20we: box float paragraph origin; previously trivial: group connector rerouting preserves transformed free and unresolved endpoints under nonuniform scaling; readiness estimates unchanged; previously trivial: a word with no break opportunity in it no longer makes the layout quadratic, #3412; previously trivial: an empty paragraph whose mark is hidden shows nothing, not even its list number, which is still counted, #3982; previously trivial: a Thai run with Latin finds one installed font covering it all on Linux too, #1549; previously trivial: symbol fonts keep their line height without the font; previously trivial: an equation-array row without `&` is centred, #2617; previously trivial: a superscript or subscript sizes its line at the full font size, without its raise or drop, as Word does; previously trivial: an object dragged near the bottom of a page lands where it is dropped, #3169; previously trivial: centred list labels after leading page breaks, #2964; previously trivial: fixed font inputs in four layout controls; previously trivial: justified lines shrink their spaces by Word's measured limits; previously trivial: a right, centre or decimal tab whose text doesn't fit, or a left tab past the right indent, goes to the next line with its text, #1840; previously trivial: a click below a floating table goes to the text beside or after it, not to a cell of its last row, #2038; previously trivial: shapes drawn with their gradients and transparency, #1500; previously trivial: pLine truncated
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-03 · **Change:** trivial (SEQ, STYLEREF and REF fields show fresh results in output); previously trivial (Print Markup balloon anchor discovery walks paragraph objects once (#3614)); previously trivial (行頭禁則 also covers the small kana and the prolonged sound mark at ordinary break opportunities, as JIS X 4051 §4 specifies, inferred from the standard and synthetic tests rather than checked in Word); previously trivial (a missing font is drawn in an installed metric twin before any other substitute: Arial for Helvetica on Windows, Tinos before macOS Times for Times New Roman); previously trivial (layout work bounds: header-row repeats budgeted per layout, cached picture outlines charged to the decode budget, linked-box chain widths exact up to a work cap, #3867); previously trivial (the line between columns runs as tall as the section's text, so columns for selected text have it, #5178); previously trivial (a table row that would leave a cell empty on a page that already holds something, its picture or first line not fitting there, moves on whole instead of leaving the other cells' text beside the empty cell, #4272); previously trivial (a middle or bottom cell of a row split across pages lines up on the first page); previously trivial (tab leader dots spaced and placed as in Word); previously trivial (a level above that hasn't counted yet takes its first number at the first deeper item); previously trivial (paragraph side borders lie beyond their distance from the text, #5036); previously trivial (with a header taller than the top margin, Margin-anchored drawings, header-cell pins and header-table obstacles use the rendered body top; TopMargin and InsideMargin keep their declared band, #2485; previously trivial: connector rerouting indexes laid-out object positions once per batch (#3631); previously trivial (field results take the `\* DollarText`, `\* Hex` and `\* ArabicDash` switches, #4226; previously trivial: a cell spanning columns shares its extra minimum width by the columns' preferred widths, equally in automatic tables, #1883; previously trivial: continuous sections one after another go onto new pages instead of running off the page; empty marks after another empty section mark take a line each until the page is full, then stay at its bottom drawing nothing, not even a list label; an empty mark that takes no room draws no list label either; a kept-together paragraph is measured against the page it would move to, so a tall different first-page header no longer splits it, #3907; previously trivial: a justified line ending in a manual line break is stretched to the margin unless `w:doNotExpandShiftReturn` is set, #3924; previously trivial: a no-break space is as wide as a space, #4192; previously trivial: a bookmark or other zero-width marker is no line-break opportunity, #4193; previously trivial: Arabic list labels: `arabicAlpha` and `arabicAbjad` draw the 28 Arabic letters (alphabetical and abjad order), doubling past the last letter; Hebrew, Hindi, Thai and Vietnamese still show decimal (`doc::section::tests::arabic_letters_count_in_both_orders`); previously trivial: Preserve grapheme edit/wrap boundaries across independently drawn font/style pieces; Exact-spacing table contact uses the specified line height rather than paragraph-mark metrics (#3977); previously trivial: cu20we: restrict pre-spacing float origins to paragraph anchors; document cap helpers and regressions; previously trivial: cu20we: outline caps; previously trivial: cu20we: box float paragraph origin; previously trivial: group connector rerouting preserves transformed free and unresolved endpoints under nonuniform scaling; readiness estimates unchanged; previously trivial: a word with no break opportunity in it no longer makes the layout quadratic, #3412; previously trivial: an empty paragraph whose mark is hidden shows nothing, not even its list number, which is still counted, #3982; previously trivial: a Thai run with Latin finds one installed font covering it all on Linux too, #1549; previously trivial: symbol fonts keep their line height without the font; previously trivial: an equation-array row without `&` is centred, #2617; previously trivial: a superscript or subscript sizes its line at the full font size, without its raise or drop, as Word does; previously trivial: an object dragged near the bottom of a page lands where it is dropped, #3169; previously trivial: centred list labels after leading page breaks, #2964; previously trivial: fixed font inputs in four layout controls; previously trivial: justified lines shrink their spaces by Word's measured limits; previously trivial: a right, centre or decimal tab whose text doesn't fit, or a left tab past the right indent, goes to the next line with its text, #1840; previously trivial: a click below a floating table goes to the text beside or after it, not to a cell of its last row, #2038; previously trivial: Line truncated
|
||||
|
||||
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,
|
||||
@@ -86,6 +86,7 @@ Exact line spacing keeps the run font baseline around tall equations; automatic
|
||||
| Fields: PAGE, NUMPAGES, SECTIONPAGES, TOC page numbers | ✅ | #52; totals in the body need a second pass once the page count is known (#451); SECTIONPAGES includes shared continuous-section start pages | — |
|
||||
| Compatibility modes (Word 2003/2007/2010 layout for old files: `w:compat` options) | 🟡 | `allowSpaceOfSameStyleInTable` supported in modes 11/12/14; mode 15 retains contextual suppression. `doNotExpandShiftReturn` keeps justified lines that end in a manual line break short (#3924). Most other legacy compat options remain ignored | 10–20 |
|
||||
| Fields: STYLEREF in headers and footers (running chapter titles) | ✅ | Evaluated per page at layout time: the page's first (or with `\l` last) paragraph of the style, else the nearest before it; style name, id or heading level (a level finds the style named `heading N` whatever its id, so localized documents match, #1286); character styles; `\n \r \w \s \t \p` (`\r` treated as `\w`); body STYLEREF updated with the other fields (#775). Field results with right-to-left text (a Hebrew heading, "פרק א") are laid out with bidi reordering like ordinary text: the result is an isolate taking its first strong letter's direction (else the text's around the field), and each level run is shaped and mirrored in its own direction, so `{ PAGE }` in field-code view and `(Chapter 1)` keep their brackets in right-to-left paragraphs; this covers every field and note mark shown as text (#1670). Header paragraphs are cached on their per-page results, so typing in body text no STYLEREF shows doesn't lay the headers out again (#1286) | — |
|
||||
| Fields filled in for output: SEQ, STYLEREF, REF in the body | ✅ | PDF export, print jobs and page images lay out a copy whose unlocked SEQ, STYLEREF and REF fields show their current results, as Word does when printing or exporting with Update fields before printing off (measured in Word for Windows with stale stored results); NUMWORDS, FILENAME, the document properties and formulas keep their stored results, as in Word; REF fields with `\p`, `\r`, `\n` or `\w` (above or below, and paragraph numbers) keep theirs until those are computed, and so does a REF to a bookmark that ends in a later paragraph (Word shows its paragraphs as paragraphs of the result); SEQ numbers are filled in before the STYLEREF and REF fields that read them, so a STYLEREF to a heading holding a SEQ field shows its new number (after F9 too); SEQ fields in text boxes are numbered where their boxes are anchored, after F9 too, and a REF to a bookmark in a text box, header, footer, footnote, endnote or comment shows that text as stored, as in Word; a REF to a bookmark the document lacks prints "Error! Reference source not found."; the document is unchanged (`cmd::references::tests::output_shows_fresh_seq_styleref_and_ref_results`, `cmd::references::tests::output_numbers_text_box_captions_where_their_boxes_are_anchored`, `cmd::references::tests::output_shows_references_to_bookmarks_outside_the_body`, `cmd::references::tests::output_shows_words_error_for_a_reference_to_a_missing_bookmark`) | — |
|
||||
| Fields: PAGE, NUMPAGES, SECTIONPAGES, TOC page numbers, PAGEREF in headers and footers | ✅ | #52; totals in the body need a second pass once the page count is known (#451); SECTIONPAGES includes shared continuous-section start pages; a PAGE field in a paragraph that runs onto a later page (a page break inside it, or a natural split) shows that page's number, the rest of the paragraph broken again for the wider result (#1352); PAGEREF in headers and footers shows its bookmark's page, filled in after pagination like their totals (#1477); in the body it keeps its result until fields are updated | — |
|
||||
| Fields: `\* DollarText`, `\* Hex` and `\* ArabicDash` | 🟡 | `\* DollarText` writes lowercase cardinal text, "and", and the first two decimals over 100 (`{ = 9.20 + 5.35 \* DollarText }` shows "fourteen and 55/100"), `\* Hex` uppercase hexadecimal digits (458 shows "1CA"), and `\* ArabicDash` the number between hyphens (`{ PAGE \* ArabicDash }` shows "- 31 -"), in table formulas and in the page-number, SEQ, PAGEREF, NUMWORDS, NUMCHARS and REVNUM fields; a capitalization switch applies after them (#4226). A result past 999,999 in cardinal or ordinal text, or past 999,999.99 in dollar text, keeps its digits, and so does a negative result or a fractional Hex or ArabicDash result. `layout::fields::tests::dollar_text_hex_and_arabic_dash_switches`, `engine::cmd::table_formula::tests::dollar_text_and_hex_switches_write_the_result`, `engine::tests::table_formula_results_take_dollar_text_and_hex`; not compared with Word | — |
|
||||
| Page-by-page comparison harness against Word | ❌ | Run Word locally on our own synthetic documents, export PDF, compare line breaks; Word output stays under `plan/` (never committed) | 8–12 |
|
||||
@@ -278,6 +279,8 @@ layout suite are pending; this is not a new Word/native-IME acceptance claim.
|
||||
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-11-01 | trivial | SEQ fields in text boxes are numbered where their boxes are anchored, in output and after F9, and a REF finds a bookmark in a text box |
|
||||
| 2026-11-01 | trivial | SEQ, STYLEREF and REF fields show fresh results in output; readiness estimates unchanged |
|
||||
| 2026-10-29 | trivial | Print Markup balloon anchor discovery walks paragraph objects once (#3614); readiness estimates unchanged |
|
||||
| 2026-10-30 | trivial | Layout work bounds for hostile documents (#3867): header-row repeats budgeted per layout and counting canvas members; picture-outline decode budget charged for cached outlines too, so cold and warm layouts wrap alike; linked-box chains keep exact widths up to a work cap, not 16 widths; `gridAfter` bounded |
|
||||
| 2026-10-29 | trivial | A missing font is drawn in an installed metric twin before any other substitute (`fonts::word::METRIC_TWINS`, also what the missing-font warning calls metric-compatible): Arial for Helvetica on Windows, Arial for Liberation Sans on a Mac, Tinos before macOS Times for Times New Roman, Cousine before Menlo for Courier New. Documents in fonts with no installed twin lay out as before |
|
||||
|
||||
Reference in new issue
Block a user