Compare commits

...
Author SHA1 Message Date
Martin Ball fcdcef57d3 Merge remote-tracking branch 'origin/main' into HEAD
; Conflicts:
;	ROADMAP.md
2026-11-08 06:47:06 +00:00
Dylan M. TaylorandMartin Ball 4afff61f10 fix(geom): measure PNGs from their chunk headers, not the decoder's header read (#5459)
Co-authored-by: Martin Ball <martin.ball@ucscaregroup.co.uk>
2026-11-08 07:45:02 +01:00
Martin Ball a390c0541c Merge remote-tracking branch 'origin/main' into HEAD
; Conflicts:
;	ROADMAP.md
2026-11-08 06:34:29 +00:00
Dylan M. TaylorandMartin Ball e76293a564 fix(doc): refuse export links holding characters the writers drop (#5426)
Co-authored-by: Martin Ball <martin.ball@ucscaregroup.co.uk>
2026-11-08 07:30:15 +01:00
Martin Ball 6f487d67b8 Merge remote-tracking branch 'origin/main' into HEAD
; Conflicts:
;	ROADMAP.md
2026-11-08 06:19:30 +00:00
Dylan M. TaylorandMartin Ball e0d8312fbe fix(formats): count spreadsheet cell characters as they are added (#5421)
Co-authored-by: Martin Ball <martin.ball@ucscaregroup.co.uk>
2026-11-08 07:17:12 +01:00
Martin Ball df4d079583 Merge remote-tracking branch 'origin/main' into HEAD
; Conflicts:
;	ROADMAP.md
2026-11-08 06:04:08 +00:00
Dylan M. TaylorandMartin Ball 523e319424 fix(formats): parse at most MAX_COLS cells of a LaTeX tabular row (#5415)
Co-authored-by: Martin Ball <martin.ball@ucscaregroup.co.uk>
2026-11-08 07:01:59 +01:00
David PrietoandMartin Ball 735c2a5382 layout: keep post-break anchors with text across a wrapped row (#5937)
* layout: keep wrapped-row anchors after page breaks

* test: pin wrapped picture drops, history and DOCX

* docs: record wrapped-row anchor coverage

* docs: log wrapped-row picture anchoring fix

---------

Co-authored-by: Martin Ball <martin.ball@ucscaregroup.co.uk>
2026-11-08 06:59:44 +01:00
Martin Ball 5b09229b80 Merge remote-tracking branch 'origin/main' into HEAD
; Conflicts:
;	ROADMAP.md
2026-11-08 05:50:44 +00:00
Dylan M. TaylorandMartin Ball 7592d1440e fix(docx): refuse to carry parts named like WordCraft's bookkeeping (#5411)
Co-authored-by: Martin Ball <martin.ball@ucscaregroup.co.uk>
2026-11-08 06:48:33 +01:00
batistalucasvandMartin Ball fde39ab061 Desktop: constrain newly opened window dimensions to available screen width (#5871) (#5876)
Co-authored-by: Martin Ball <martin.ball@ucscaregroup.co.uk>
2026-11-08 06:44:01 +01:00
d34e341d75 Charts: an automatic series colour follows the series' c:idx (#5963) (#5975)
* Charts: an automatic series colour follows the series' c:idx (#5963)

A series without its own colour took the accent for its place in the
file. Word picks it by the series' c:idx (c:order doesn't matter), so a
chart whose series aren't numbered 0, 1, 2... in file order, such as a
lone series with idx 2 after a deletion, got different colours.

c:idx is read strictly as the unsignedInt the schema says; a missing or
invalid value falls back to the place in the file.

* Charts: the renumbered test helper fails if ser's layout changes (#5963)

---------

Co-authored-by: kiwi4boy <kiwi4boy@gmail.com>
Co-authored-by: Martin Ball <martin.ball@ucscaregroup.co.uk>
2026-11-08 06:41:38 +01:00
535b675e8a DOCX: paragraph indents and spacing beyond 16 bits wrap as in Word (#5655)
## What changed

Word stores paragraph indents (`w:ind` left/start, right/end, hanging, firstLine) in 16 signed bits and paragraph
spacing (`w:spacing` before/after) in 16 unsigned bits. A file that writes a larger plain integer is read by Word
modulo 2^16: `w:left="66256"` is a 720-twip (0.5") indent in Word. We read the full value, so such a paragraph was
indented by about 46 inches and its text was pushed off the page.

- `crates/docx/src/units.rs`: new `tw_i16` / `tw_u16` readers. A plain integer keeps its low 16 bits (two's
  complement for indents, so 66256 -> 720 and 65535 -> -1); values with a unit or a fraction read as before.
- `crates/docx/src/read/props.rs` and `read/styles.rs` use them for `w:ind` and `w:spacing` before/after, in direct
  and style formatting.
- In-range values read exactly as before.

## Testing

- `crates/docx/tests/it/twips_wrap.rs`: synthetic documents with out-of-range indents and spacing in paragraph and
  style properties check the wrapped values; in-range values and values with units are unchanged.
- Unit tests for the two readers; `cargo xtask ci` passes.

Found by a real-file corpus comparison against Word's own PDFs: a file whose indents exceed 16 bits shows its text on
the page in Word and off the page here.

Co-authored-by: tpawley2001 <268951182+tpawley2001@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Martin Ball <martin.ball@ucscaregroup.co.uk>
2026-11-08 06:39:22 +01:00
Martin Ball c78d41b295 Merge remote-tracking branch 'origin/main' into HEAD
; Conflicts:
;	ROADMAP.md
2026-11-08 05:36:49 +00:00
Dylan M. TaylorandMartin Ball c8618d3e05 fix(docx): look relationships up by id through an index (#5410)
Co-authored-by: Martin Ball <martin.ball@ucscaregroup.co.uk>
2026-11-08 06:34:27 +01:00
a891d861b7 Layout: contextual spacing drops the space after a cell's last Normal paragraph (#5450)
Word treats the end-of-cell mark as a paragraph in the default (Normal)
style, so a cell's last paragraph with contextual spacing in that style
(or in an undefined style, formatted as Normal) loses its space after, as
beside any same-style neighbour; one in another style keeps it.
`layout_box` kept the space after of every last cell paragraph except
automatic spacing, so the row grew by it and the text below the table sat
that much lower (docxide-pdf cell-end-text-context, cell-end-empty-context:
8 pt).

A middle or bottom cell of a row split across pages lines up with the
other cells' kept text, which `align_in_part` counted with its full space
after; it now stops at the cell's content end, so a dropped space after
(contextual or automatic) no longer pushes the aligned cell down.

Co-authored-by: Naxvog <4963183+naxvog@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Martin Ball <martin.ball@ucscaregroup.co.uk>
2026-11-08 06:27:07 +01:00
Kevin JosephandMartin Ball 85434d2c20 layout: line numbers take the "Line Number" style, not a fixed 10 pt grey (#5788)
* layout: line numbers take the "Line Number" style, not a fixed 10 pt grey

Line numbers were laid out in 10 pt grey in the default font, whatever
the document said. Word formats them with the "Line Number" character
style over the document's defaults: in a manuscript whose defaults are
Aptos 12 pt, Word's PDF shows them in Aptos 12 pt black (measured in
Word for Mac), WordCraft's in 10 pt grey.

Lay the number out with the document's "Line Number" style when it has
one, else its plain defaults, so size, font and colour follow the
document.

Test: line numbers take the document's default size and automatic
colour, and a "Line Number" style's size and colour when there is one.

* docs: progress log and parity entries for line-number-style

---------

Co-authored-by: Martin Ball <martin.ball@ucscaregroup.co.uk>
2026-11-08 06:26:44 +01:00
Martin Ball 6b6bf81132 Merge remote-tracking branch 'origin/main' into HEAD
; Conflicts:
;	ROADMAP.md
;	docs/gaps.md
2026-11-08 05:24:19 +00:00
Dylan M. Taylor c99e63a408 style: rustfmt 2026-11-03 16:09:55 +00:00
Dylan M. Taylor 6b3b3423b6 fix(docx): collect an embedded object's relationship ids in a set on save 2026-11-03 11:38:09 +00:00
34 changed files with 865 additions and 61 deletions
+16
View File
@@ -1,6 +1,7 @@
# WordCraft roadmap
> **CLI path update:** 2026-11-04 · Options before a non-Unicode input filename no longer make the CLI reject that filename; text option values still require Unicode; `run --file` and `run --script` retain native paths. No ranked gap or readiness estimate changes.
> **Floating anchor row segments:** 2026-11-07 · A floating anchor after a manual page/column break counts visible content on the far side of the same wrapped row. Marker-only cover anchors retain their previous-page rule. Synthetic picture spacing, placement and history controls; no new native Word measurement. Readiness estimates unchanged.
> **Highlight update:** 2026-10-25 · Highlight shortcut toggles the last chosen colour off when the whole selection has it; explicit colour commands remain setters (#4085). Readiness estimates unchanged.
@@ -150,6 +151,7 @@ Ranked, to beta (detail and remaining milestones in [docs/roadmap.md](docs/roadm
- 2026-11-08: Layout: a turned inline picture whose file keeps no room for the turn (no or a zero `wp:effectExtent`, as other tools write) keeps its line to its frame and overhangs it, cut at the line's top and bottom, as Word does: the frame as it is in Word 2007 layout, with width and height exchanged from 45° to 135° and 225° to 315° in Word 2013+ layout. Such a file saves the same way. Pictures with the room recorded (Word's own files) still take their rotated bounds. Readiness unchanged.
- 2026-11-08: XML text and CDATA read with their line ends normalised (XML 1.0 §2.11): CR LF and a lone CR are one line feed, `&#13;` stays a CR, so a line break inside `w:t` in a file saved with Windows line ends is one space, not two (#5897); readiness estimates unchanged.
- 2026-11-08: Layout: contextual spacing drops the space after a table cell's last paragraph when that paragraph is in the Normal style, the end-of-cell mark counting as a Normal paragraph next to it; a last paragraph in another style keeps its space after. The rule is inferred from docxide-pdf's notes and its `cell-end-*` fixtures (`cell-end-text-context`, `cell-end-empty-context`); a Word comparison that tells it apart from the neighbouring paragraph's style is still open. Readiness unchanged.
- 2026-11-06: Arabic interface: the Home tab's Styles gallery reads from the right, as the rest of the mirrored ribbon does (#5566). Normal is the rightmost tile, the next styles follow leftwards and the scroll arrows and More sit at the gallery's left end; left to right is unchanged. Readiness unchanged.
- 2026-11-05: ODT heading text of another size keeps it (gap 19): a smaller word in a heading came back at the heading's size in LibreOffice after a save.
- 2026-11-05: ODT table rows kept from breaking across pages stay so (gap 19): they came back from a save free to break.
@@ -167,6 +169,9 @@ Ranked, to beta (detail and remaining milestones in [docs/roadmap.md](docs/roadm
- 2026-11-07: DOCX: a VML text box keeps its text direction: `mso-layout-flow-alt:bottom-to-top` reads as Rotate all text 270° whatever `layout-flow` says, and otherwise `layout-flow:vertical` (or `vertical-ideographic`) as 90°, so the turned text box in a compatibility-mode document's header is drawn turned, as in Word. (#5193)
- 2026-11-06: Change Picture keeps the picture's width and takes the new picture's aspect ratio, dropping the old crop, instead of stretching the new picture into the old frame (a revised figure came out about 2% taller in a real manuscript) (#5761). Readiness estimates unchanged.
- 2026-11-06: Superscript and subscript text is set at 0.65 of its size rounded to the half point, as Word does (8 pt → 5, 11 → 7, 12 → 8, 24 → 15.5; measured in Word for Mac from digit advances with `cargo xtask word-compare`), not 0.65 exactly: lines with citation numbers and footnote references break where Word's do (the Arial superscript and author-line probes now match Word line for line). Readiness estimates unchanged.
- 2026-11-06: Line numbers take the document's "Line Number" style over its defaults, as in Word (Aptos 12 pt black in a manuscript whose defaults are Aptos 12 pt, measured in Word for Mac), instead of a fixed 10 pt grey. Readiness estimates unchanged.
- 2026-11-07: Newly opened secondary windows constrain their initial viewport dimensions to the monitor's usable area with margins for system panels, preventing new windows from exceeding screen width on high-DPI scaled displays (#5871).
- 2026-11-07: A floating anchor after a page break stays with text on the far side of the same wrapped row (Leaf #107); marker-only cover anchors retain their rule. Picture spacing, drop position, Undo/Redo and DOCX regressions.
- 2026-11-06: Windows print submissions run in separate helper processes (#5710). A helper acknowledges cancellation before it stops, so a driver stalled in a cancelled job cannot hold another job’s provider lock. A private temporary PDF and bounded settings messages cross the process boundary; normal shutdown reaps helpers and removes their inputs. The helper erases its plaintext inputs before driver work; a hard app crash during startup can still leave private temporary files. Jobs already sending still complete. Readiness estimates unchanged.
- 2026-11-06: File › Print: before sending starts, Cancel printing stops a waiting job, frees the Print page and keeps the settings; a successfully cancelled job is never sent. Cancellation remains available after the last document closes. Quitting cancels waiting jobs and joins jobs already sending (#5710). On Windows, winprint repeats the DEVMODE conversion after sending starts. A stall during that repeat prevents cancellation and can keep shutdown waiting, even before a native job exists. Readiness estimates unchanged.
- 2026-11-06: File › Print on Windows no longer waits on printer drivers to list printers (#5710). The list used to wait until each printer's driver said whether it prints on both sides, and a networked printer asleep in power save could keep it empty with Refresh unavailable; now discovery asks no driver (the Windows spooler still supplies the list), and printing still checks the job's options with the driver. The reporter's sleeping printer has not been retested yet. Readiness estimates unchanged.
@@ -248,6 +253,13 @@ Ranked, to beta (detail and remaining milestones in [docs/roadmap.md](docs/roadm
- 2026-11-03: Security: `.doc` import attaches each section to the paragraph that ends it through an index of paragraph ends, instead of scanning every paragraph for every section, so a file with a thousand sections that end nowhere can't multiply the work over its paragraphs. Readiness unchanged.
- 2026-11-03: Layout: a line can break after a hyphen that a slash and a letter follow ("Dataprotection-" / "/Secrecyviolation"), as Word does ("fitness-/1589", with a digit after the slash, stays whole), where UAX #14 forbids a break before the slash; an auto-fit table column holding such a compound wraps it instead of widening and taking the width from its neighbours. (#3408)
- 2026-11-03: Layout: a section's closing paragraph whose mark is hidden and that holds only an in-line picture (or a note reference) is no longer laid out as an empty hidden mark. The picture keeps its room, so the next continuous section's text starts below it instead of being drawn over it. A paragraph with only floating objects and a hidden mark still takes no room. Not observed in Word. (#5288)
- 2026-11-03: Security: DOCX import looks relationships up by id through an index built once per part, the first relationship with an id still winning, instead of scanning every relationship for each hyperlink, picture or embedded part that names one. Readiness unchanged.
- 2026-11-03: Security: DOCX import no longer carries a file's part named like WordCraft's own bookkeeping (`wordcraft:unmodelled.roots` and the like), which a save read back as that bookkeeping, adding relationships the file chose. Readiness unchanged.
- 2026-11-03: Security: LaTeX import parses at most 63 cells of a `tabular` row (what a table row keeps), so a very wide row can no longer widen the multirow tracking that every later row walks. Readiness unchanged.
- 2026-11-03: Security: XLSX and ODS import (mail merge recipient lists) keep each cell's character count as text is added, instead of counting the whole cell again for every piece of text, so a full cell followed by many small text events no longer costs its length each time. Readiness unchanged.
- 2026-11-03: Security: exported hyperlinks (ODT, LaTeX, HTML, Markdown, RTF) holding a control character or noncharacter that the writer drops are written as plain text, so `file\u{85}:///…` can't pass the link check and come out as a `file:` link. Readiness unchanged.
- 2026-11-03: Security: pictures are measured, and checked for transparency for tight text wrapping, from a PNG's chunk headers, instead of through the decoder, whose header read inflates an embedded ICC profile of up to 512 MiB for every picture it only measures. Readiness unchanged.
- 2026-11-03: Security: saving an embedded object (a native document's kept markup included) collects the relationship ids its markup uses in a set and checks them against the writable ones in one pass, instead of scanning every id collected so far for each attribute. Readiness unchanged.
- 2026-11-03: Language packs: language-packs-2 is published and WordCraft pins its index: 75 languages, adding Danish, German (Germany, Austria, Switzerland), Esperanto, Estonian, Persian, Croatian, Hungarian, Norwegian (Bokmål, Nynorsk), Dutch (#4145, #2505) and Swedish (Sweden, Finland), which need craft-proof's flag-based compounding (storytold/craft-libs#497, pinned). English (Australia) is checked against SCOWL's en-AU list as well as en-GB's (storytold/craft-libs#496). Mongolian and Thai stay hidden. Readiness unchanged.
- 2026-11-03: Language lists (CP4.3): a language installed from a Language pack (Bulgarian, say) can now be chosen. Set Proofing Language lists every built-in language and every installed or available pack's, each once and sorted by name; Review › Language and the status bar's menu add the installed packs' languages to the short list, with one way of working out the check mark, by tag. A pack's language is stored as a tag that finds the pack again (`sr-Latn-RS` is proofed by the Latin `sr-latn` pack, not the Cyrillic one; Valencian is Word's `ca-ES-valencia`). Every pack in language-packs-2 now has an English name (Belarusian used to show its own name, Беларуская). Readiness unchanged.
- 2026-11-03: Language packs (CP4.3): on a fresh install (or after an upgrade, with the list cached for an older pin) the missing-pack bar still offers a document language that has no dictionary, "Turkish proofing isn't installed. Install": the person's click turns on downloading, fetches the list and installs the pack if it lists one (if not, the bar goes). With downloading already on, a missing or stale list is fetched once in the background. Choosing a language with only the caret counts before anything is typed, and the spelling context menu's heading (Possible spelling mistake) is in the interface language, not the text's. Closing the bar is remembered per document, per pack and, before the list is known, per language tag. Readiness unchanged.
@@ -868,6 +880,7 @@ MATH glyphs that reference the same construction now share its immutable variant
| Date | Change | Summary |
|---|---|---|
| 2026-11-08 | trivial | Progress log: XML line ends in text normalised (#5897) |
| 2026-11-08 | trivial | Progress log: contextual spacing drops the space after a cell's last Normal paragraph (#5450) |
| 2026-11-06 | trivial | Progress log: the Arabic interface's Styles gallery reads from the right (#5566) |
| 2026-11-07 | trivial | Page Setup also bounds the unfocused margin display after a long numeric paste; the accepted value is retained (#5650). |
| 2026-11-05 | trivial | Page Setup's margin, paper-size and document-grid number boxes scroll long text inside the field instead of enlarging the dialog (#5650). Readiness estimates unchanged. |
@@ -879,6 +892,9 @@ MATH glyphs that reference the same construction now share its immutable variant
| 2026-11-02 | trivial | Progress log: alternative format chunks and MHT open |
| 2026-11-06 | trivial | Progress log: Change Picture keeps the width and takes the new picture's aspect ratio (#5761) |
| 2026-11-06 | trivial | Progress log: superscript and subscript text at 0.65 of its size rounded to the half point (Word for Mac) |
| 2026-11-06 | trivial | Progress log: line numbers take the document's "Line Number" style |
| 2026-11-07 | trivial | Charts: a series without its own colour takes the accent its `c:idx` picks, as Word does, not the one its place in the file picks (#5963) |
| 2026-11-07 | trivial | A floating anchor after a manual page/column break counts visible content on the far side of the same wrapped row. Marker-only cover anchors retain their previous-page rule. Synthetic picture spacing, placement and history controls; no new native Word measurement. Estimates unchanged. |
| 2026-11-06 | trivial | Print helpers remove loaded PDF/settings files before driver work, tolerate prior removal and report cleanup errors; a held-driver regression checks the files are already absent (#5710) |
| 2026-11-06 | trivial | Windows print submissions isolated per helper process; cancelled driver work cannot block a retry (#5710) |
| 2026-11-06 | trivial | Progress log: print cancellation before sending, including without a document, and the Windows repeated-conversion limitation (#5710) |
+2
View File
@@ -69,6 +69,8 @@ impl eframe::App for App {
if let Some((pos, size)) = self.1.and_then(|g| g.fit_on_monitor(&window)) {
ctx.send_viewport_cmd(egui::ViewportCommand::InnerSize(size));
ctx.send_viewport_cmd(egui::ViewportCommand::OuterPosition(pos));
let b = self.0.window_builder().clone().with_inner_size(size);
self.0.set_window_builder(b);
}
// The geometry saved for the next start is the active window's.
let prev = self.0.ui.window;
+10
View File
@@ -19,6 +19,12 @@ pub fn is_web_or_mail_link(url: &str) -> bool {
/// names another machine — following it on Windows leaks the user's NTLM credentials — so it is
/// refused too.
pub fn is_safe_href(url: &str) -> bool {
// Exporters drop other control characters and noncharacters when they write the link
// (`file\u{85}:` comes out `file:`), so a link holding one is refused, not judged as it
// stands and written as something else.
if url.chars().any(|c| (c.is_control() && !c.is_ascii_control()) || matches!(c, '\u{FFFE}' | '\u{FFFF}')) {
return false;
}
let cleaned: String = url.chars().filter(|c| !c.is_ascii_control() && *c != ' ').collect();
// `//host` (protocol-relative) and `\\host` (UNC), with `\` read as `/` the way Windows does.
if cleaned.replace('\\', "/").starts_with("//") {
@@ -200,6 +206,10 @@ mod tests {
"\\\\attacker.example\\share\\x",
"/\\attacker.example\\x",
" //attacker.example/x",
// Characters the exporters drop on writing don't hide a scheme.
"file\u{85}:///etc/passwd",
"java\u{9F}script:alert(1)",
"/\u{FFFE}/attacker.example/x",
"",
] {
assert!(!is_safe_href(bad), "{bad:?}");
+20 -1
View File
@@ -350,6 +350,7 @@ impl Package {
let path = resolve(dir, target);
self.canonical(&path).map_or(path, str::to_string)
};
rels.ids.entry(id.to_string()).or_insert(rels.list.len());
rels.list.push(Rel { id: id.to_string(), kind: r.attr("Type").unwrap_or("").to_string(), target: resolved, external });
}
rels
@@ -405,11 +406,13 @@ pub struct Rel {
#[derive(Clone, Debug, Default)]
pub struct Rels {
pub list: Vec<Rel>,
/// Each id's first relationship in `list`, so a lookup doesn't scan every relationship.
ids: HashMap<String, usize>,
}
impl Rels {
pub fn by_id(&self, id: &str) -> Option<&Rel> {
self.list.iter().find(|r| r.id == id)
self.ids.get(id).and_then(|&i| self.list.get(i))
}
pub fn by_type(&self, full: &str) -> Option<&Rel> {
self.list.iter().find(|r| rel_is(&r.kind, full))
@@ -547,6 +550,22 @@ mod tests {
assert_eq!(crate::package_title(std::io::Cursor::new(z), 1 << 20).as_deref(), Some("Plan"));
}
#[test]
fn relationships_are_looked_up_by_id_the_first_one_winning() {
let mut rels = String::from(r#"<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">"#);
for i in 0..20_000 {
rels.push_str(&format!(r#"<Relationship Id="rId{i}" Type="hyperlink" Target="https://example.com/{i}" TargetMode="External"/>"#));
}
rels.push_str(r#"<Relationship Id="rId7" Type="hyperlink" Target="https://example.com/dup" TargetMode="External"/></Relationships>"#);
let z = zip_of(&[("word/_rels/document.xml.rels", rels.into_bytes())]);
let r = Package::open(&z).unwrap().rels("word/document.xml");
assert_eq!(r.by_id("rId7").map(|r| r.target.as_str()), Some("https://example.com/7"), "the first rId7 wins");
assert_eq!(r.by_id("rId19999").map(|r| r.target.as_str()), Some("https://example.com/19999"));
assert!(r.by_id("rId20000").is_none());
// Hyperlink-style lookups, one per relationship, are each a map lookup.
assert_eq!((0..20_000).filter(|i| r.by_id(&format!("rId{i}")).is_some()).count(), 20_000);
}
#[test]
fn decompression_bomb_is_rejected() {
let z = zip_of(&[("word/document.xml", vec![b' '; 2_000_000])]);
+3
View File
@@ -66,6 +66,9 @@ pub(super) struct Axis {
pub(super) struct Series {
pub(super) name: String,
/// The series' `c:idx` (an `xsd:unsignedInt`), which picks its automatic colour; `None`
/// when missing or not a valid number.
pub(super) idx: Option<usize>,
pub(super) color: Option<Rgb>,
pub(super) line_w: f32,
/// Whether the series is drawn as a line (scatter series without one get markers).
+6 -2
View File
@@ -91,8 +91,11 @@ impl Chart {
self.accents.get(i % 6).copied().unwrap_or(Rgb::BLACK)
}
/// Colour of series `j`: its own, else the accent its `c:idx` picks (as Word does; `c:order`
/// doesn't matter), else the one its place in the file picks.
pub(super) fn series_colour(&self, j: usize) -> Rgb {
self.series.get(j).and_then(|s| s.color).unwrap_or_else(|| self.accent(j))
let s = self.series.get(j);
s.and_then(|s| s.color).unwrap_or_else(|| self.accent(s.and_then(|s| s.idx).unwrap_or(j)))
}
/// Colour of point `i` of series `j`: its `c:dPt` override, else the varied accent (pie) or
@@ -199,6 +202,7 @@ pub(super) fn text_size(space: &El) -> f32 {
impl Series {
fn parse(ser: &El, scatter: bool, theme: &[Rgb], cap: usize) -> Series {
let name = ser.child("c:tx").and_then(|t| t.find("c:v")).map(|v| label(v.text().trim())).unwrap_or_default();
let idx = ser.child("c:idx").and_then(|e| e.attr("val")).and_then(|v| v.trim().parse::<u32>().ok()).and_then(|v| usize::try_from(v).ok());
let sp = ser.child("c:spPr");
let ln = sp.and_then(|p| p.child("a:ln"));
let marker = ser.child("c:marker").and_then(|m| m.child("c:spPr")).and_then(|p| fill_colour(p, theme));
@@ -219,7 +223,7 @@ impl Series {
}
}
let labels = show_values(ser.child("c:dLbls"));
Series { name, color, line_w, line, values, xs, points, fmt, x_fmt, labels, raw: Vec::new() }
Series { name, idx, color, line_w, line, values, xs, points, fmt, x_fmt, labels, raw: Vec::new() }
}
}
+62
View File
@@ -528,3 +528,65 @@ fn negative_sections_and_scientific_numbers() {
assert_eq!(format_value(0.0, "0.00E+00", 1.0), "0.00E+00");
assert_eq!(format_value(-12345.0, "0.0E-00", 1.0), "-1.2E04");
}
// An automatic series colour follows the series' `c:idx`, not its place in the file (#5963), as
// measured in Word: `idx` 3 then 0 are accent4 then accent1, a lone `idx` 2 is accent3, and
// `c:order` changes nothing.
/// The fill colours of the bars of a column chart of `sers`, and of its legend swatches.
fn column_colours(sers: &[String]) -> (Vec<Rgb>, Vec<Rgb>) {
let plot = format!(
r#"<c:barChart><c:barDir val="col"/><c:grouping val="clustered"/><c:varyColors val="0"/>{}<c:axId val="1"/><c:axId val="2"/></c:barChart><c:catAx><c:axId val="1"/></c:catAx><c:valAx><c:axId val="2"/></c:valAx>"#,
sers.concat()
);
let items = with_legend(&plot, "r", 300.0, 200.0);
let bars = filled(&items).iter().map(|b| b.0).collect();
let keys = items
.iter()
.filter_map(|it| if let GraphicItem::Shape { rect, fill, .. } = it { (rect[2] == SWATCH).then_some(*fill).flatten() } else { None })
.collect();
(bars, keys)
}
/// `ser` with its `c:idx` / `c:order` replaced by `idx_order` (an empty string drops them).
fn renumbered(ser: &str, idx_order: &str) -> String {
let start = ser.find("<c:idx").expect("ser writes c:idx");
let end = ser.find("<c:tx>").expect("ser writes c:tx");
format!("{}{idx_order}{}", &ser[..start], &ser[end..])
}
#[test]
fn automatic_series_colours_follow_c_idx() {
let accent = |k: usize| theme()[4 + k];
let a = ser(0, "A", None, &["Q1"], &["1"]);
let b = ser(1, "B", None, &["Q1"], &["2"]);
// idx 3 first, idx 0 second: accent4, accent1, in the bars and the legend.
let sers = [renumbered(&a, r#"<c:idx val="3"/><c:order val="0"/>"#), renumbered(&b, r#"<c:idx val="0"/><c:order val="1"/>"#)];
assert_eq!(column_colours(&sers), (vec![accent(3), accent(0)], vec![accent(3), accent(0)]));
// A lone series with idx 2 is accent3.
let lone = [renumbered(&a, r#"<c:idx val="2"/><c:order val="0"/>"#)];
assert_eq!(column_colours(&lone).0, [accent(2)]);
// c:order doesn't matter: idx 0 and 1 stay accent1 and accent2 with their orders swapped.
let swapped = [renumbered(&a, r#"<c:idx val="0"/><c:order val="1"/>"#), renumbered(&b, r#"<c:idx val="1"/><c:order val="0"/>"#)];
assert_eq!(column_colours(&swapped).0, [accent(0), accent(1)]);
// A series' own colour still wins.
let own = [renumbered(&ser(0, "A", Some("FF0000"), &["Q1"], &["1"]), r#"<c:idx val="3"/><c:order val="0"/>"#)];
assert_eq!(column_colours(&own).0, [Rgb(0xFF, 0, 0)]);
}
#[test]
fn series_without_a_usable_c_idx_take_their_place() {
let accent = |k: usize| theme()[4 + k];
let a = ser(0, "A", None, &["Q1"], &["1"]);
let b = ser(1, "B", None, &["Q1"], &["2"]);
// No c:idx at all: the place in the file.
assert_eq!(column_colours(&[renumbered(&a, ""), renumbered(&b, "")]).0, [accent(0), accent(1)]);
// Junk, negative and out-of-range values: no panic, the place in the file.
for junk in ["x", "-1", "1e3", "99999999999999999999999"] {
let sers = [renumbered(&a, &format!(r#"<c:idx val="{junk}"/>"#)), renumbered(&b, &format!(r#"<c:idx val="{junk}"/>"#))];
assert_eq!(column_colours(&sers).0, [accent(0), accent(1)], "{junk:?}");
}
// The largest unsignedInt `c:idx` allows draws, without a panic.
let big = [renumbered(&a, &format!(r#"<c:idx val="{}"/>"#, u32::MAX))];
assert_eq!(column_colours(&big).0.len(), 1);
}
+24 -1
View File
@@ -239,7 +239,13 @@ impl Reader<'_> {
/// Keep one part's bytes and content type (not its relationships).
fn keep_part(&mut self, part: &str) -> bool {
let lower = part.to_ascii_lowercase();
if !manifest_field_ok(part) || lower.ends_with(".rels") || lower == "[content_types].xml" || part.eq_ignore_ascii_case(&self.main) {
// `wordcraft:` names are WordCraft's own bookkeeping in `passthrough`, never a file's part.
if !manifest_field_ok(part)
|| lower.ends_with(".rels")
|| lower == "[content_types].xml"
|| lower.starts_with("wordcraft:")
|| part.eq_ignore_ascii_case(&self.main)
{
log::warn!("docx: not carrying part {part:?} for an embedded object");
return false;
}
@@ -388,6 +394,23 @@ mod tests {
assert!(roots.contains("keep.bin"), "the part is dropped: {roots}");
}
/// A part named like WordCraft's own bookkeeping (`wordcraft:unmodelled.roots`) is not carried:
/// kept, it would be read back as that bookkeeping and add relationships the file chose.
#[test]
fn a_part_named_like_bookkeeping_is_not_carried() {
let forged = b"D\thttp://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument\tword/evil.xml\n";
let bytes = package(
r#"<w:p><w:r><w:t>text</w:t></w:r></w:p>"#,
&[("rIdDoc1", "/wordcraft:unmodelled.roots"), ("rIdDoc2", "/wordcraft:embedded.parts")],
&[("wordcraft:unmodelled.roots", forged), ("wordcraft:embedded.parts", b"P\tword/evil.xml\tapplication/xml\n")],
);
let doc = crate::read(&bytes).unwrap();
let roots = unmodelled_roots(&doc);
assert!(!roots.contains("wordcraft:") && !roots.contains("evil"), "{roots}");
let manifest = doc.passthrough.get(crate::package::EMBEDDED_PARTS).map(|v| String::from_utf8_lossy(v).to_string()).unwrap_or_default();
assert!(!manifest.contains("evil"), "{manifest}");
}
/// An unqualified attribute is a relationship only where the markup names a relationship
/// that way: a hyperlink's `id` names the part, a VML shape's `id` is its own.
#[test]
+7 -7
View File
@@ -15,7 +15,7 @@ use wordcraft_doc::section::{
use super::MAX_FONT_NAME;
use super::drawing_color::adjust_lum;
use crate::units::{int, line_multiple, measure, on_off, tw, u32_of};
use crate::units::{int, line_multiple, measure, on_off, tw, tw_i16, tw_u16, u32_of};
/// A text frame (`w:framePr` without a drop cap, ECMA-376 §17.3.1.11), sizes and offsets in
/// points (clamped), names kept as OOXML writes them.
@@ -407,10 +407,10 @@ impl PropCtx {
p.tabs = Some(tabs);
}
"w:spacing" => {
if let Some(v) = tw(k, "w:before") {
if let Some(v) = tw_u16(k, "w:before") {
p.space_before = Some(v.max(0.0));
}
if let Some(v) = tw(k, "w:after") {
if let Some(v) = tw_u16(k, "w:after") {
p.space_after = Some(v.max(0.0));
}
// HTML-style automatic spacing; "0" switches an inherited one off.
@@ -433,15 +433,15 @@ impl PropCtx {
}
}
"w:ind" => {
if let Some(v) = tw(k, "w:left").or_else(|| tw(k, "w:start")) {
if let Some(v) = tw_i16(k, "w:left").or_else(|| tw_i16(k, "w:start")) {
p.indent_left = Some(v);
}
if let Some(v) = tw(k, "w:right").or_else(|| tw(k, "w:end")) {
if let Some(v) = tw_i16(k, "w:right").or_else(|| tw_i16(k, "w:end")) {
p.indent_right = Some(v);
}
if let Some(v) = tw(k, "w:hanging") {
if let Some(v) = tw_i16(k, "w:hanging") {
p.indent_first = Some(-v);
} else if let Some(v) = tw(k, "w:firstLine") {
} else if let Some(v) = tw_i16(k, "w:firstLine") {
p.indent_first = Some(v);
}
// Character units (hundredths of a character), which win when non-zero.
+4 -3
View File
@@ -2,6 +2,7 @@ use super::{
AbstractNum, DocxError, El, Level, LevelSuffix, Num, NumFormat, PartFormat, Reader, Style, StyleKind, StyleSheet, TablePart, TableStyleParts,
VAlign, flag, int, props, tw, u32_of,
};
use crate::units::tw_i16;
use std::sync::Arc;
/// Built-in style names Word stores in lower case, and how the UI shows them.
@@ -317,12 +318,12 @@ impl Reader<'_> {
lv.align = props::align(j);
}
if let Some(ind) = l.child("w:pPr").and_then(|p| p.child("w:ind")) {
if let Some(v) = tw(ind, "w:left").or_else(|| tw(ind, "w:start")) {
if let Some(v) = tw_i16(ind, "w:left").or_else(|| tw_i16(ind, "w:start")) {
lv.indent = v;
}
if let Some(h) = tw(ind, "w:hanging") {
if let Some(h) = tw_i16(ind, "w:hanging") {
lv.hanging = h;
} else if let Some(f) = tw(ind, "w:firstLine") {
} else if let Some(f) = tw_i16(ind, "w:firstLine") {
lv.hanging = -f;
}
}
+39
View File
@@ -116,6 +116,27 @@ pub fn tw(e: &El, attr: &str) -> Option<f32> {
e.attr(attr).and_then(|v| measure(v, 20.0))
}
/// Signed twips Word keeps in 16 bits (paragraph and list indents, ECMA-376 Part 1 §17.3.1.12):
/// a plain integer outside `i16` keeps its low 16 bits, two's complement, as Word reads it
/// (66256 → 720). Values with a unit or a fraction read as [`tw`].
pub fn tw_i16(e: &El, attr: &str) -> Option<f32> {
let v = e.attr(attr)?;
match v.trim().parse::<i64>() {
Ok(i) => Some(f32::from(i as i16) / 20.0),
Err(_) => measure(v, 20.0),
}
}
/// Unsigned twips Word keeps in 16 bits (paragraph spacing, §17.3.1.33): a plain integer above
/// 65535 keeps its low 16 bits (65537 → 1). Other values read as [`tw`].
pub fn tw_u16(e: &El, attr: &str) -> Option<f32> {
let v = e.attr(attr)?;
match v.trim().parse::<i64>() {
Ok(i) if i > 0xFFFF => Some(f32::from((i & 0xFFFF) as u16) / 20.0),
_ => measure(v, 20.0),
}
}
/// ST_OnOff: absent `w:val` = on; `0`, `false`, `off` = off.
pub fn on_off(e: &El) -> bool {
match e.attr("w:val") {
@@ -148,6 +169,24 @@ pub fn emu(pt: f32) -> String {
mod tests {
use super::*;
#[test]
fn twips_beyond_16_bits_wrap() {
let el = |a: &str| El { attrs: vec![("w:v".into(), a.into())], ..El::default() };
let s = |a: &str| tw_i16(&el(a), "w:v");
let u = |a: &str| tw_u16(&el(a), "w:v");
assert_eq!(s("66256"), Some(36.0));
assert_eq!(s("-65516"), Some(1.0));
assert_eq!(s("98304"), Some(-1638.4));
assert_eq!(s("32767"), Some(1638.35));
assert_eq!(s("-32768"), Some(-1638.4));
assert_eq!(s("1in"), Some(72.0));
assert_eq!(s("x"), None);
assert_eq!(u("65537"), Some(0.05));
assert_eq!(u("65535"), Some(3276.75));
assert_eq!(u("12pt"), Some(12.0));
assert_eq!(u("-20"), Some(-1.0));
}
#[test]
fn measures() {
assert_eq!(measure("1440", 20.0), Some(72.0));
+25 -5
View File
@@ -353,11 +353,11 @@ impl super::Writer<'_> {
};
self.embeds.markup_bytes = left;
let mut root = xml::parse(src.xml.as_bytes()).ok()?;
let mut used = Vec::new();
let mut used = HashSet::new();
rel_attr_values(&root, &mut used, 0);
// Every relationship the markup uses must be writable, or the object would be broken.
let writable = |id: &String| src.refs.iter().any(|r| r.id == *id && (r.external || self.embeds.available(&r.target)));
if !used.iter().all(writable) {
let writable: HashSet<&str> = src.refs.iter().filter(|r| r.external || self.embeds.available(&r.target)).map(|r| r.id.as_str()).collect();
if !used.iter().all(|id| writable.contains(id.as_str())) {
log::warn!("docx: an embedded object's parts are missing; it is left out");
return None;
}
@@ -638,13 +638,15 @@ fn set_attr(e: &mut El, k: &str, v: &str) {
}
}
fn rel_attr_values(e: &El, out: &mut Vec<String>, depth: usize) {
/// The distinct relationship ids `e` uses: a set, so a large tree (a native document's markup
/// never went through the reader's id limit) costs no scan per attribute.
fn rel_attr_values(e: &El, out: &mut HashSet<String>, depth: usize) {
if depth > MAX_DEPTH {
return;
}
for (k, v) in &e.attrs {
if xml::is_rel_attr(&e.name, k) && !out.contains(v) {
out.push(v.clone());
out.insert(v.clone());
}
}
for c in e.els() {
@@ -852,6 +854,24 @@ fn valid_part_name(name: &str) -> String {
mod tests {
use super::*;
#[test]
fn relationship_ids_are_collected_once_each() {
// A native document's embedded markup skips the reader's id limit: 40,000 distinct ids
// (each named twice) were a scan of every id collected so far, per attribute.
let mut xml = String::from(
r#"<w:p xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships">"#,
);
for i in 0..40_000 {
xml.push_str(&format!(r#"<w:r r:id="rId{i}"/><w:r r:id="rId{i}"/>"#));
}
xml.push_str("</w:p>");
let root = xml::parse(xml.as_bytes()).unwrap();
let mut used = HashSet::new();
rel_attr_values(&root, &mut used, 0);
assert_eq!(used.len(), 40_000);
assert!(used.contains("rId39999"));
}
#[test]
fn charts_cut_to_the_limits_on_save_say_so_once() {
// A model deserialised as is can hold more than a chart part keeps; the save left the rest
+1
View File
@@ -40,6 +40,7 @@ mod strict_drawings;
mod text_alignment;
mod text_boxes;
mod toc_ranges;
mod twips_wrap;
mod undefined_style;
mod vml_shapes;
mod word_xml;
+55
View File
@@ -0,0 +1,55 @@
//! Paragraph indents and spacing written as plain twips beyond 16 bits: Word keeps only the low
//! 16 bits (ECMA-376 Part 1 §17.3.1.12 `w:ind`, §17.3.1.33 `w:spacing`). Measured in Word: a
//! `w:left` of 66256 starts the text 36 pt in, a `w:right` of 66976 leaves a 72 pt right indent.
//! Fixtures are hand-written.
use wordcraft_doc::Document;
use wordcraft_doc::props::ParaProps;
use crate::fixtures::zip;
/// A package whose body is `body`.
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>"#
);
zip(&[("_rels/.rels", root.as_bytes()), ("word/document.xml", doc.as_bytes())])
}
fn props(body: &str) -> Vec<ParaProps> {
let d: Document = wordcraft_docx::read(&docx(body)).unwrap();
d.body.iter().filter_map(|b| b.as_para()).map(|p| p.props.clone()).collect()
}
#[test]
fn indents_beyond_16_bits_wrap() {
let p = props(
r#"<w:p><w:pPr><w:ind w:left="66256"/></w:pPr><w:r><w:t>a</w:t></w:r></w:p>
<w:p><w:pPr><w:ind w:right="66976"/></w:pPr><w:r><w:t>b</w:t></w:r></w:p>
<w:p><w:pPr><w:ind w:start="65556" w:end="-65516"/></w:pPr><w:r><w:t>c</w:t></w:r></w:p>
<w:p><w:pPr><w:ind w:left="98304" w:hanging="65896"/></w:pPr><w:r><w:t>d</w:t></w:r></w:p>"#,
);
assert_eq!(p[0].indent_left, Some(36.0));
assert_eq!(p[1].indent_right, Some(72.0));
// 65556 -> 20 twips; -65516 -> 20 twips.
assert_eq!((p[2].indent_left, p[2].indent_right), (Some(1.0), Some(1.0)));
// 98304 = 0x18000 -> 0x8000 = -32768 as a signed 16-bit value; hanging 65896 -> 360.
assert_eq!((p[3].indent_left, p[3].indent_first), (Some(-1638.4), Some(-18.0)));
}
#[test]
fn spacing_beyond_16_bits_wraps() {
let p = props(r#"<w:p><w:pPr><w:spacing w:before="65537" w:after="65776"/></w:pPr><w:r><w:t>a</w:t></w:r></w:p>"#);
assert_eq!((p[0].space_before, p[0].space_after), (Some(0.05), Some(12.0)));
}
#[test]
fn in_range_and_unit_values_are_unchanged() {
let p = props(
r#"<w:p><w:pPr><w:ind w:left="32767" w:right="-32768"/><w:spacing w:before="65535"/></w:pPr><w:r><w:t>a</w:t></w:r></w:p>
<w:p><w:pPr><w:ind w:left="1in" w:right="720"/><w:spacing w:before="12pt"/></w:pPr><w:r><w:t>b</w:t></w:r></w:p>"#,
);
assert_eq!((p[0].indent_left, p[0].indent_right, p[0].space_before), (Some(1638.35), Some(-1638.4), Some(3276.75)));
assert_eq!((p[1].indent_left, p[1].indent_right, p[1].space_before), (Some(72.0), Some(36.0), Some(12.0)));
}
+37
View File
@@ -77,6 +77,43 @@ fn a_shape_dropped_just_after_a_page_break_in_a_paragraph_stays_there() {
assert!((o.rect.x - 100.0).abs() < 0.5 && (o.rect.y - 80.0).abs() < 0.5, "where it was dropped: {:?}", o.rect);
}
/// Leaf #107: wrapping can leave the anchor alone to the left and put the text on the right
/// of the same physical row. A drop after the break must still stay on the destination page.
#[test]
fn a_picture_with_wrap_spacing_dropped_after_a_leading_break_stays_there() {
for gap in [0.0, 6.0, 12.0] {
let mut s = Session::new(wordcraft_doc::Document::from_text("Source\nDestination"));
s.run("caret.set", &json!({"pos": Pos::body(1, 0)})).unwrap();
s.run("layout.break", &json!({"kind": "page"})).unwrap();
s.run("caret.set", &json!({"pos": Pos::body(0, 0)})).unwrap();
let data = super::super::insert::base64_encode(&png(20, 10));
s.run("insert.picture", &json!({"data": data, "width": 120})).unwrap();
s.run("arrange.wrap", &json!({"wrap": "square"})).unwrap();
super::select::with_float(&mut s, |f| {
f.dist = gap;
f.dist_top = gap;
f.dist_bottom = gap;
})
.unwrap();
let before = serde_json::to_value(&s.doc.body).unwrap();
let depth = s.undo_depth();
s.run("arrange.bounds", &json!({"page": 1, "x": 100, "y": 110})).unwrap();
let hit = object_area(&mut s);
assert_eq!(hit.page, 1, "gap={gap}: {hit:?}");
assert!((hit.rect.x - 100.0).abs() < 0.5 && (hit.rect.y - 110.0).abs() < 0.5, "gap={gap}: {:?}", hit.rect);
assert_eq!(s.undo_depth(), depth + 1);
let after = serde_json::to_value(&s.doc.body).unwrap();
s.run("edit.undo", &json!({})).unwrap();
assert_eq!(serde_json::to_value(&s.doc.body).unwrap(), before);
s.run("edit.redo", &json!({})).unwrap();
assert_eq!(serde_json::to_value(&s.doc.body).unwrap(), after);
let mut copy = Session::new(wordcraft_docx::read(&wordcraft_docx::write(&s.doc).unwrap()).unwrap());
let saved = object_area(&mut copy);
assert_eq!(saved.page, 1, "DOCX gap={gap}: {saved:?}");
assert!((saved.rect.x - hit.rect.x).abs() < 0.5 && (saved.rect.y - hit.rect.y).abs() < 0.5, "saved at the drop position");
}
}
/// What a click and a handle or body drag do in the editor (#142, #82): hit-test the object,
/// select it, then resize and move it with `arrange.bounds`.
fn click_resize_and_drag(insert: &str, params: Value) {
+3 -1
View File
@@ -104,7 +104,9 @@ pub(super) fn table(toks: &[Tok], spec: &str, depth: usize) -> FTable {
for r in rows {
let mut cells = Vec::new();
let mut g = 0usize;
for cell_toks in r {
// A document row keeps MAX_COLS cells; the rest would only widen `covering`, which is
// walked once per row.
for cell_toks in r.into_iter().take(wordcraft_doc::table::MAX_COLS) {
let mut cell = Cell::default();
let mut content = trim_cell(cell_toks);
// `\multicolumn{n}{spec}{content}`, `\multirow{n}{width}{content}`. Each wrapper
+11
View File
@@ -460,3 +460,14 @@ fn ligature_characters_split_across_runs_stay_apart() {
let back = import(tex.as_bytes());
assert_eq!(back.plain_text(Default::default()).trim(), "a--b ''x", "{tex}");
}
#[test]
fn a_tabular_row_keeps_the_cells_a_table_row_holds() {
// A 5,000-cell row with spans widened the multirow tracking every later row walks; the
// document keeps MAX_COLS cells a row, so the rest are left out while parsing.
let wide = vec!["\\multicolumn{63}{c}{x}"; 5000].join("&");
let toks = tokenize(&format!("{wide}\\\\ a & b \\\\"));
let t = table::table(&toks, "", 0);
assert_eq!(t.rows.first().map(Vec::len), Some(wordcraft_doc::table::MAX_COLS));
assert_eq!(t.rows.get(1).map(Vec::len), Some(2));
}
+23 -5
View File
@@ -323,11 +323,13 @@ fn attr(e: &BytesStart<'_>, name: &[u8]) -> Option<String> {
})
}
/// `s` + `t`, keeping `s` at most [`MAX_CELL_CHARS`] characters.
fn push_capped(s: &mut String, t: &str) {
let have = s.chars().count();
if have < MAX_CELL_CHARS {
s.extend(t.chars().take(MAX_CELL_CHARS - have));
/// `s` + `t`, keeping `s` at most [`MAX_CELL_CHARS`] characters. `have` is `s`'s character
/// count, kept up to date here: counting `s` again for every piece of text made a full cell
/// cost its length per further text event.
fn push_capped(s: &mut String, have: &mut usize, t: &str) {
for c in t.chars().take(MAX_CELL_CHARS.saturating_sub(*have)) {
s.push(c);
*have += 1;
}
}
@@ -465,6 +467,22 @@ mod tests {
])
}
#[test]
fn a_full_cell_takes_further_text_without_recounting() {
let (mut s, mut n) = (String::new(), 0usize);
push_capped(&mut s, &mut n, "é");
push_capped(&mut s, &mut n, &"x".repeat(MAX_CELL_CHARS));
assert_eq!((s.chars().count(), n), (MAX_CELL_CHARS, MAX_CELL_CHARS));
// A million more text events on the full cell each cost nothing (they used to count it).
let t = std::time::Instant::now();
for _ in 0..1_000_000 {
push_capped(&mut s, &mut n, "y");
}
assert!(t.elapsed() < std::time::Duration::from_secs(10), "{:?}", t.elapsed());
assert_eq!(s.chars().count(), MAX_CELL_CHARS);
assert!(!s.contains('y'));
}
/// #1287: one long shared string in thousands of cells stays under the text budget.
#[test]
fn xlsx_shared_strings_cannot_blow_up_memory() {
+8 -6
View File
@@ -11,6 +11,8 @@ struct OCell {
kind: String,
value: Option<String>,
text: String,
/// Characters in `text` (see [`push_capped`]).
text_chars: usize,
paras: usize,
}
@@ -77,7 +79,7 @@ pub(super) fn ods_read(xml: &[u8], want: Option<usize>) -> Result<(Vec<String>,
&& skip == 0
{
if c.paras > 0 {
push_capped(&mut c.text, "\n");
push_capped(&mut c.text, &mut c.text_chars, "\n");
}
c.paras += 1;
}
@@ -86,17 +88,17 @@ pub(super) fn ods_read(xml: &[u8], want: Option<usize>) -> Result<(Vec<String>,
b"s" if in_p > 0 && skip == 0 => {
let k = attr(e, b"c").and_then(|v| v.trim().parse::<usize>().ok()).unwrap_or(1).clamp(1, 1_000);
if let Some(c) = cell.as_mut() {
push_capped(&mut c.text, &" ".repeat(k));
push_capped(&mut c.text, &mut c.text_chars, &" ".repeat(k));
}
}
b"tab" if in_p > 0 && skip == 0 => {
if let Some(c) = cell.as_mut() {
push_capped(&mut c.text, "\t");
push_capped(&mut c.text, &mut c.text_chars, "\t");
}
}
b"line-break" if in_p > 0 && skip == 0 => {
if let Some(c) = cell.as_mut() {
push_capped(&mut c.text, "\n");
push_capped(&mut c.text, &mut c.text_chars, "\n");
}
}
_ => {}
@@ -107,7 +109,7 @@ pub(super) fn ods_read(xml: &[u8], want: Option<usize>) -> Result<(Vec<String>,
&& skip == 0
&& let Some(c) = cell.as_mut()
{
push_capped(&mut c.text, &t);
push_capped(&mut c.text, &mut c.text_chars, &t);
}
}
Ev::End(n) => {
@@ -181,7 +183,7 @@ fn ods_value(c: &OCell) -> String {
("string", Some(v)) if c.text.trim().is_empty() => {
// Attribute text obeys the same per-cell cap as `<text:p>` text.
let mut s = String::new();
push_capped(&mut s, v);
push_capped(&mut s, &mut 0, v);
s
}
_ => c.text.clone(),
+10 -7
View File
@@ -102,18 +102,18 @@ pub(super) fn xlsx_sheets(pkg: &mut Package<'_>) -> Result<Vec<SheetRef>, String
/// (`rPh`) left out.
fn shared_strings(xml: &[u8]) -> Vec<String> {
let mut out = Vec::new();
let mut cur: Option<String> = None;
let mut cur: Option<(String, usize)> = None;
let (mut in_t, mut phonetic) = (false, 0usize);
walk(xml, |ev| match ev {
Ev::Start(e) => match e.local_name().as_ref() {
b"si" => cur = Some(String::new()),
b"si" => cur = Some((String::new(), 0)),
b"t" => in_t = true,
b"rPh" => phonetic += 1,
_ => {}
},
Ev::End(n) => match n.as_slice() {
b"si" => {
if let Some(s) = cur.take()
if let Some((s, _)) = cur.take()
&& out.len() < MAX_SHARED
{
out.push(s);
@@ -126,9 +126,9 @@ fn shared_strings(xml: &[u8]) -> Vec<String> {
Ev::Text(t) => {
if in_t
&& phonetic == 0
&& let Some(s) = cur.as_mut()
&& let Some((s, n)) = cur.as_mut()
{
push_capped(s, &t);
push_capped(s, n, &t);
}
}
});
@@ -182,6 +182,9 @@ struct XCell {
style: usize,
value: String,
inline: String,
/// Characters in `value` and `inline` (see [`push_capped`]).
value_chars: usize,
inline_chars: usize,
}
pub(super) fn xlsx_rows(pkg: &mut Package<'_>, path: &str) -> Result<Vec<Vec<String>>, String> {
@@ -227,9 +230,9 @@ pub(super) fn xlsx_rows(pkg: &mut Package<'_>, path: &str) -> Result<Vec<Vec<Str
Ev::Text(t) => {
if let Some(c) = cell.as_mut() {
if in_v {
push_capped(&mut c.value, &t);
push_capped(&mut c.value, &mut c.value_chars, &t);
} else if in_is_t && phonetic == 0 {
push_capped(&mut c.inline, &t);
push_capped(&mut c.inline, &mut c.inline_chars, &t);
}
}
}
+93 -3
View File
@@ -51,6 +51,8 @@ pub struct Probe<'a> {
pub width: u32,
pub height: u32,
peak: u64,
/// Whether a PNG has transparency, read from its chunk headers; `None` for other formats.
png_alpha: Option<bool>,
}
/// TIFF compressions decoded within the caller's limits: none, CCITT Group 4, LZW, Deflate (both
@@ -72,9 +74,48 @@ pub fn probe(bytes: &[u8]) -> Result<Probe<'_>, Refusal> {
}
_ => return Err(Refusal::NotAPicture),
};
if format == ImageFormat::Png {
let (width, height, native, alpha) = png_chunks(bytes).ok_or_else(|| Refusal::Broken("its PNG header can't be read".into()))?;
let (width, height, peak) = sized(width, height, native, 0)?;
return Ok(Probe { bytes: Cow::Borrowed(bytes), dib: false, format, width, height, peak, png_alpha: Some(alpha) });
}
let decoder = image::ImageReader::with_format(Cursor::new(bytes), format).into_decoder().map_err(broken)?;
let (width, height, peak) = measure(&decoder, held)?;
Ok(Probe { bytes: Cow::Borrowed(bytes), dib: false, format, width, height, peak })
Ok(Probe { bytes: Cow::Borrowed(bytes), dib: false, format, width, height, peak, png_alpha: None })
}
/// A PNG's width, height, the bytes a pixel its decoder gives (samples under 8 bits widened to 8,
/// a palette to RGB, `tRNS` to an alpha channel) and whether it has transparency, from `IHDR` and
/// the chunk headers before the image data. The decoder's own header read inflates an ICC profile
/// (`iCCP`) up to its 512 MiB default limit, for a picture that may only be measured.
fn png_chunks(bytes: &[u8]) -> Option<(u32, u32, u64, bool)> {
let be = |at: usize| bytes.get(at..at.checked_add(4)?).and_then(|b| <[u8; 4]>::try_from(b).ok()).map(u32::from_be_bytes);
// The 8-byte signature, then IHDR: length, type, width, height, bit depth, colour type.
if bytes.get(12..16) != Some(b"IHDR".as_slice()) {
return None;
}
let (width, height) = (be(16)?, be(20)?);
let (depth, color) = (*bytes.get(24)?, *bytes.get(25)?);
let mut trns = false;
let mut at = 8usize;
loop {
let len = usize::try_from(be(at)?).ok()?;
match bytes.get(at.checked_add(4)?..at.checked_add(8)?)? {
b"IDAT" => break,
b"IEND" => return None,
b"tRNS" => trns = true,
_ => {}
}
at = at.checked_add(12)?.checked_add(len)?;
}
let (channels, alpha) = match color {
0 => (1 + u64::from(trns), trns),
2 | 3 => (3 + u64::from(trns), trns),
4 => (2, true),
6 => (4, true),
_ => return None,
};
Some((width, height, channels * if depth == 16 { 2 } else { 1 }, alpha))
}
/// Read a DIB's header: a BMP without its 14-byte file header, as the Windows clipboard holds
@@ -86,16 +127,20 @@ pub fn probe_dib(dib: Vec<u8>) -> Result<Probe<'static>, Refusal> {
let decoder = image::codecs::bmp::BmpDecoder::new_without_file_header(Cursor::new(dib.as_slice())).map_err(broken)?;
measure(&decoder, 0)?
};
Ok(Probe { bytes: Cow::Owned(dib), dib: true, format: ImageFormat::Bmp, width, height, peak })
Ok(Probe { bytes: Cow::Owned(dib), dib: true, format: ImageFormat::Bmp, width, height, peak, png_alpha: None })
}
/// A decoder's size and the peak bytes of decoding it, `held` bytes kept beside its output.
fn measure(decoder: &impl ImageDecoder, held: u64) -> Result<(u32, u32, u64), Refusal> {
let (width, height) = decoder.dimensions();
sized(width, height, u64::from(decoder.color_type().bytes_per_pixel()), held)
}
/// A `width` × `height` picture of `native` bytes a pixel, with its peak (see [`peak_bytes`]).
fn sized(width: u32, height: u32, native: u64, held: u64) -> Result<(u32, u32, u64), Refusal> {
if width == 0 || height == 0 {
return Err(Refusal::Broken("it has no pixels".into()));
}
let native = u64::from(decoder.color_type().bytes_per_pixel());
let peak = peak_bytes(width, height, native, held).ok_or(Refusal::TooLarge { width, height })?;
Ok((width, height, peak))
}
@@ -106,6 +151,18 @@ impl Probe<'_> {
self.peak
}
/// Whether the picture has transparency, from its header: a PNG's from its chunk headers
/// (an ICC profile isn't inflated to say), another format's from its decoder.
pub fn has_alpha(&self) -> bool {
if let Some(alpha) = self.png_alpha {
return alpha;
}
if self.dib {
return image::codecs::bmp::BmpDecoder::new_without_file_header(Cursor::new(&*self.bytes)).is_ok_and(|d| d.color_type().has_alpha());
}
image::ImageReader::with_format(Cursor::new(&*self.bytes), self.format).into_decoder().is_ok_and(|d| d.color_type().has_alpha())
}
/// Whether decoding fits `budget` bytes.
pub fn fits(&self, budget: u64) -> bool {
self.peak <= budget
@@ -305,6 +362,39 @@ mod tests {
!c
}
/// `png` with a chunk of `kind` holding `data` placed after IHDR.
fn with_chunk(png: &[u8], kind: &[u8; 4], data: &[u8]) -> Vec<u8> {
let mut chunk = (data.len() as u32).to_be_bytes().to_vec();
chunk.extend_from_slice(kind);
chunk.extend_from_slice(data);
let crc = crc32(&chunk[4..]);
chunk.extend_from_slice(&crc.to_be_bytes());
let mut out = png.get(..33).unwrap().to_vec();
out.extend(chunk);
out.extend_from_slice(&png[33..]);
out
}
#[test]
fn a_png_is_measured_from_its_chunk_headers() {
// An ICC profile isn't inflated to measure a picture: one whose deflate stream is junk
// (the decoder refused it while reading the header) still measures, and isn't decoded.
let rgb = encode(image::RgbImage::new(4, 2), ImageFormat::Png);
let icc = with_chunk(&rgb, b"iCCP", b"p\0\0\xFF\xFF\xFF\xFF");
let p = probe(&icc).unwrap();
assert_eq!((p.width, p.height, p.peak_bytes()), (4, 2, 4 * 2 * 8));
assert!(!p.has_alpha());
assert_eq!(probe(&rgb).unwrap().peak_bytes(), p.peak_bytes(), "as the decoder measures it");
// Transparency: an alpha channel, or tRNS before the image data.
assert!(probe(&encode(image::GrayAlphaImage::new(2, 2), ImageFormat::Png)).unwrap().has_alpha());
let trns = with_chunk(&rgb, b"tRNS", &[0, 0, 0, 0, 0, 0]);
let p = probe(&trns).unwrap();
assert!(p.has_alpha());
assert_eq!(p.decode(1 << 20).map(|i| i.color().has_alpha()), Ok(true), "the decoder agrees");
// No image data: broken.
assert!(probe(&rgb[..33]).is_err());
}
#[test]
fn small_pictures_decode_and_peaks_count_every_buffer() {
let png = encode(image::RgbaImage::from_pixel(16, 8, image::Rgba([1, 2, 3, 4])), ImageFormat::Png);
+6 -2
View File
@@ -366,8 +366,12 @@ pub(crate) fn layout_box(ctx: &mut Ctx, input: BoxInput<'_>) -> BoxLayout {
items.splice(at..at, back);
items.extend(front);
}
// Automatic space after: none after the last paragraph of a cell or box.
let after = if pl.rp.after_auto && i + 1 == blocks.len() { 0.0 } else { pl.rp.space_after };
// Automatic space after: none after the last paragraph of a cell or box. Nor
// contextual spacing after a cell's last paragraph in the default style (or an
// undefined one, formatted as Normal): the end-of-cell mark is a Normal paragraph.
let last = i + 1 == blocks.len();
let normal = || pl.rp.style == "Normal" || ctx.doc.styles.get(&pl.rp.style).is_none();
let after = if last && (pl.rp.after_auto || (in_cell && ctxl && normal())) { 0.0 } else { pl.rp.space_after };
y += edges.top + pl.height + edges.bottom + after;
prev_after = after;
prev_after_auto = pl.rp.after_auto;
+1 -4
View File
@@ -286,16 +286,13 @@ impl Alphas {
/// without transparency or that don't decode; [`OverBudget`] for one that would take `decoded`
/// past [`MAX_DECODED_PIXELS`]. Pictures whose header says they're opaque cost nothing.
fn decode_alpha(bytes: &[u8], decoded: &mut u64) -> Result<Option<Alpha>, OverBudget> {
use image::ImageDecoder;
let Ok(pic) = wordcraft_geom::picture::probe(bytes) else { return Ok(None) };
// Formats that can't be transparent aren't decoded.
if pic.format == image::ImageFormat::Jpeg || pic.width > MAX_PICTURE_SIDE || pic.height > MAX_PICTURE_SIDE {
return Ok(None);
}
// Only the header is read here (a PNG's `tRNS` counts as transparency).
let has_alpha =
image::ImageReader::with_format(std::io::Cursor::new(bytes), pic.format).into_decoder().ok().is_some_and(|d| d.color_type().has_alpha());
if !has_alpha {
if !pic.has_alpha() {
return Ok(None);
}
let pixels = u64::from(pic.width) * u64::from(pic.height);
+9 -5
View File
@@ -4,9 +4,12 @@ use super::headers::HfLinks;
use super::placed::Placed;
use super::{
Anchor, Arc, Block, CharProps, Counters, DocLayout, Document, FieldCtx, Float, HashMap, HashSet, HideDeleted, InlineObject, LayoutOptions, Level,
ParaLayout, Paragraph, Rect, Rgb, SectionProps, StoryRef, ViewMode, Wrap, contour, math, para, styleref, textbox,
ParaLayout, Paragraph, Rect, SectionProps, StoryRef, ViewMode, Wrap, contour, math, para, styleref, textbox,
};
/// The character style Word formats line numbers with (`w:styleId="LineNumber"`).
const LINE_NUMBER_STYLE: &str = "LineNumber";
/// A layout pass: finished, or to be done again with the page totals it found, or with the
/// floats placed above their anchors it found ([`EarlyFloats`]).
pub(crate) enum Pass {
@@ -193,10 +196,11 @@ impl Ctx<'_> {
if let Some(p) = self.numbers.get(&n) {
return p.clone();
}
let mut p = Paragraph::with_text(
&n.to_string(),
CharProps { size: Some(10.0), color: Some(wordcraft_doc::TextColor::Rgb(Rgb(0x60, 0x60, 0x60))), ..Default::default() },
);
// Word formats line numbers with the "Line Number" character style over the document's
// defaults (measured in Word for Mac: Aptos 12 pt black in a document whose defaults are
// Aptos 12 pt), not in a fixed small grey.
let style = self.doc.styles.get(LINE_NUMBER_STYLE).map(|s| s.id.clone());
let mut p = Paragraph::with_text(&n.to_string(), CharProps { style, ..Default::default() });
p.props.space_after = Some(0.0);
p.props.space_before = Some(0.0);
let env = para::ParaEnv {
+13 -3
View File
@@ -438,13 +438,23 @@ pub(crate) fn place_late(ctx: &mut Ctx, pb: &mut PageBuilder, p: &Paragraph, oi:
/// the one holding its anchor, or for anchors on a line after a page or column break that holds
/// nothing taking room ([`para::takes_room`]: only anchors and markers such as bookmarks), the
/// break's: Word keeps them on the page before (a cover page's title box, #1632). With text after
/// them they are on the new page with it (#4262).
/// them they are on the new page with it (#4262). The text may be in the same row's
/// continuation on the far side of a wrapping object, not in the anchor's segment (Leaf #107).
pub(crate) fn anchor_lines(p: &Paragraph, pl: &ParaLayout) -> Vec<Option<usize>> {
let mut out = vec![None; p.objects.len()];
for (li, l) in pl.lines.iter().enumerate() {
let clusters = pl.clusters.get(l.c0..l.c1).unwrap_or_default();
let lead = li.checked_sub(1).and_then(|k| pl.lines.get(k)).is_some_and(|l| matches!(l.end, LineEnd::PageBreak | LineEnd::ColumnBreak))
&& !clusters.iter().any(para::takes_room);
let after_break = li.checked_sub(1).and_then(|k| pl.lines.get(k)).is_some_and(|l| matches!(l.end, LineEnd::PageBreak | LineEnd::ColumnBreak));
// A wrap can leave only the zero-width anchor in the row's left segment, with its
// text on the right. That is still visible content after the break, not a cover box.
let lead = after_break
&& !clusters.iter().any(para::takes_room)
&& !pl
.lines
.iter()
.skip(li + 1)
.take_while(|next| next.beside)
.any(|next| pl.clusters.get(next.c0..next.c1).unwrap_or_default().iter().any(para::takes_room));
for c in clusters {
let para::ClKind::Object(oi) = c.kind else { continue };
if let Some(slot) = out.get_mut(oi).filter(|s| s.is_none()) {
+4 -2
View File
@@ -1201,14 +1201,16 @@ fn align_in_part(
}
let limit = cut - row.bottom.clamp(0.0, cut);
let mut kept = f32::MIN;
for ((items, splits), _) in cells.iter().zip(splits).zip(realign).filter(|(_, al)| al.is_none()) {
for (((items, splits), _), end) in cells.iter().zip(splits).zip(realign).zip(ends.iter()).filter(|((_, al), _)| al.is_none()) {
// No further than the cell's content: its last paragraph may have dropped its space after.
let end = end.unwrap_or(f32::MAX);
for (it, &split) in items.iter().zip(splits) {
let bottom = match (it.turned_bounds(), it) {
(Some(r), _) => Some(r.bottom()),
// A paragraph kept whole ends with its space after.
(None, Placed::Lines { para, l0, y, .. }) if split > *l0 => crate::item_bottom(*y, para, *l0, split)
.filter(|&b| b <= limit + 0.01)
.map(|b| if split == para.lines.len() { (b + para.rp.space_after).min(limit) } else { b }),
.map(|b| if split == para.lines.len() { (b + para.rp.space_after).min(end.max(b)).min(limit) } else { b }),
(None, Placed::Image { rect, .. } | Placed::Shape { rect, .. } | Placed::Graphic { rect, .. } | Placed::Clip { rect, .. }) => {
Some(rect.bottom())
}
+192
View File
@@ -298,6 +298,82 @@ fn legacy_table_contextual_spacing_flag_is_scoped_to_cells_and_legacy_mode() {
}
}
/// Baseline of the paragraph after a one-row table, less that of the first cell's last
/// paragraph, which has 12 pt after in `style`, contextual or not. `next_cell` adds a second cell
/// whose paragraph has that style; `after_table` styles the paragraph after the table; `text` is
/// the last paragraph's text.
fn cell_end_gap(style: &str, contextual: bool) -> f32 {
cell_end_gap_in(Document::new(), style, contextual, None, None, "Beta probe")
}
fn cell_end_gap_in(mut d: Document, style: &str, contextual: bool, next_cell: Option<&str>, after_table: Option<&str>, text: &str) -> f32 {
use wordcraft_doc::props::LineSpacing;
Arc::make_mut(&mut d.styles).upsert(wordcraft_doc::Style {
id: "Other".into(),
name: "Other".into(),
based_on: Some("Normal".into()),
..Default::default()
});
let chr = wordcraft_doc::CharProps { font: Some("Source Sans 3".into()), size: Some(12.0), ..Default::default() };
let props = ParaProps { space_before: Some(0.0), space_after: Some(0.0), line_spacing: Some(LineSpacing::Exactly(24.0)), ..Default::default() };
let para = |text: &str, props: ParaProps| {
let mut p = wordcraft_doc::Paragraph::with_text(text, chr.clone());
p.props = props;
wordcraft_doc::para_block(p)
};
let styled = |s: Option<&str>| ParaProps { style: s.map(Into::into), ..props.clone() };
let mut table = Table::new(1, if next_cell.is_some() { 2 } else { 1 }, 468.0);
table.props.style = None;
table.props.cell_margins = Some([0.0; 4]);
let last = ParaProps { style: Some(style.into()), space_after: Some(12.0), contextual_spacing: Some(contextual), ..props.clone() };
table.rows[0].cells[0].blocks = vec![para("Alpha probe", props.clone()), para(text, last)];
if let Some(cell) = table.rows[0].cells.get_mut(1) {
cell.blocks = vec![para("Delta probe", styled(next_cell))];
}
d.body = vec![std::sync::Arc::new(wordcraft_doc::Block::Table(table)), para("Gamma probe", styled(after_table))];
let l = lay(&d);
let baseline = |at: &[u32]| {
l.pages[0].items.iter().find_map(|i| match i {
Placed::Lines { para, y, path, .. } if path.0 == at => Some(y + para.lines[0].baseline),
_ => None,
})
};
let (Some(cell), Some(after)) = (baseline(&[0, 0, 0, 1]), baseline(&[1])) else { panic!("probe lines missing") };
after - cell
}
#[test]
fn contextual_spacing_drops_the_space_after_a_cells_last_normal_paragraph() {
// The end-of-cell mark is a Normal paragraph: contextual spacing drops the 12 pt after a
// Normal last paragraph (docxide-pdf `cell-end-text-context`, `cell-end-empty-context`), not
// after one in another style (`cell-end-other-style`) or without contextual spacing.
let kept = cell_end_gap("Normal", false);
assert!((cell_end_gap("Other", true) - kept).abs() < 0.01);
let dropped = cell_end_gap("Normal", true);
assert!((kept - dropped - 12.0).abs() < 0.01, "kept {kept}, dropped {dropped}");
// An undefined style is formatted as Normal, and drops it too.
assert!((cell_end_gap("Missing", true) - dropped).abs() < 0.01);
// The mark decides, not a neighbour: another style in the next cell or after the table
// leaves the drop as it is.
for (next_cell, after_table) in [(Some("Other"), None), (None, Some("Other"))] {
let gap = cell_end_gap_in(Document::new(), "Normal", true, next_cell, after_table, "Beta probe");
assert!((gap - dropped).abs() < 0.01, "{next_cell:?} {after_table:?}: {gap}, dropped {dropped}");
}
// And the reverse: another style keeps its space beside Normal neighbours.
let gap = cell_end_gap_in(Document::new(), "Other", true, Some("Normal"), Some("Normal"), "Beta probe");
assert!((gap - kept).abs() < 0.01, "other beside Normal: {gap}, kept {kept}");
// An empty last paragraph drops it too (`cell-end-empty-context`).
let gap = cell_end_gap_in(Document::new(), "Normal", true, None, None, "");
assert!((gap - dropped).abs() < 0.01, "empty: {gap}, dropped {dropped}");
// Where `allowSpaceOfSameStyleInTable` keeps same-style space in cells (modes 11/12/14), the
// 12 pt stays.
let mut legacy = Document::new();
legacy.settings.compat_mode = 14;
legacy.settings.allow_space_of_same_style_in_table = true;
let gap = cell_end_gap_in(legacy, "Normal", true, None, None, "Beta probe");
assert!((gap - kept).abs() < 0.01, "legacy {gap}, kept {kept}");
}
fn contextual_gap(after: f32, before: f32, first: bool, second: bool, different_style: bool, header: bool) -> f32 {
use wordcraft_doc::props::LineSpacing;
let mut d = Document::new();
@@ -3851,6 +3927,40 @@ fn extreme_page_and_line_number_starts_do_not_overflow() {
assert!(l.pages.len() >= 3, "{} pages", l.pages.len());
}
/// Line numbers take the document's character formatting through the "Line Number" style, as in
/// Word (Aptos 12 pt black in a document whose defaults are Aptos 12 pt), not a fixed small grey.
#[test]
fn line_numbers_follow_the_line_number_style() {
let number_runs = |d: &Document| {
let l = lay(d);
l.pages[0]
.items
.iter()
.filter_map(|it| match it {
Placed::Lines { story, para, .. } if *story == DECOR_STORY => para.styles.first().map(|s| (s.size, s.rc.color)),
_ => None,
})
.collect::<Vec<_>>()
};
let mut d = Document::from_text("one\ntwo\nthree");
d.last_section.line_numbers = Some(Default::default());
Arc::make_mut(&mut d.styles).default_chr.size = Some(12.0);
let plain = number_runs(&d);
assert_eq!(plain.len(), 3);
assert!(plain.iter().all(|(size, color)| *size == 12.0 && *color == wordcraft_doc::TextColor::Auto), "{plain:?}");
let red = wordcraft_doc::TextColor::Rgb(wordcraft_doc::Rgb(0xC0, 0, 0));
Arc::make_mut(&mut d.styles).upsert(wordcraft_doc::Style {
id: "LineNumber".into(),
name: "line number".into(),
kind: wordcraft_doc::StyleKind::Character,
chr: CharProps { size: Some(8.0), color: Some(red), ..Default::default() },
..Default::default()
});
let styled = number_runs(&d);
assert!(styled.iter().all(|(size, color)| *size == 8.0 && *color == red), "{styled:?}");
}
#[test]
fn line_numbers_borders_text_boxes() {
let mut d = Document::from_text("one\ntwo\nthree");
@@ -12152,6 +12262,88 @@ fn a_middle_or_bottom_cell_of_a_split_row_lines_up_on_the_first_page() {
assert_eq!(pages[1][0].len(), 25 - labels.len(), "{pages:?}");
}
/// The line tops on the first page of a row split across pages, by cell: a label aligned with
/// `valign`, 30 lines that split, and a 160 pt spacer above `note`.
fn split_row_beside_a_note(valign: wordcraft_doc::props::VAlign, note: &ParaProps) -> [Vec<f32>; 3] {
use wordcraft_doc::props::LineSpacing;
let plain = split_note_plain();
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, d.last_section.page_h) = (400.0, 360.0);
d.last_section.margin_top = 36.0;
d.last_section.margin_bottom = 36.0;
let mut t = Table::new(1, 3, 328.0);
t.props.style = None;
t.props.cell_margins = Some([0.0; 4]);
t.rows[0].cells[0].props.valign = Some(valign);
t.rows[0].cells[0].blocks = vec![para("Label", plain.clone())];
t.rows[0].cells[1].blocks = (0..30).map(|i| para(&format!("Line {i}"), plain.clone())).collect();
let spacer = ParaProps { line_spacing: Some(LineSpacing::Exactly(160.0)), ..plain.clone() };
t.rows[0].cells[2].blocks = vec![para("Spacer", spacer), para("Note", note.clone())];
d.body = vec![para("Filler", ParaProps { line_spacing: Some(LineSpacing::Exactly(100.0)), ..plain }), Arc::new(Block::Table(t))];
let l = lay(&d);
let tops = |cell: u32| -> Vec<f32> {
l.pages[0]
.items
.iter()
.filter_map(|item| match item {
Placed::Lines { path, y, .. } if path.0.len() == 4 && path.0[0] == 1 && path.0[2] == cell => Some(*y),
_ => None,
})
.collect()
};
let cells = [tops(0), tops(1), tops(2)];
assert!(cells[1].len() > 5 && cells[1].len() < 30, "the row splits: {cells:?}");
assert_eq!(cells[2].len(), 2, "the note stays on the page: {cells:?}");
cells
}
/// Single 12 pt lines with no space around them.
fn split_note_plain() -> ParaProps {
ParaProps {
space_before: Some(0.0),
space_after: Some(0.0),
line_spacing: Some(wordcraft_doc::props::LineSpacing::Exactly(12.0)),
widow_control: Some(false),
..Default::default()
}
}
#[test]
fn a_bottom_cell_of_a_split_row_ignores_a_dropped_space_after_beside_it() {
use wordcraft_doc::props::VAlign;
// The note's space after (contextual 300 pt, or automatic) drops at the cell's end and would
// otherwise reach below the split lines: a bottom label lines up with those, a middle one is
// centred between their top and bottom.
let plain = split_note_plain();
let contextual = ParaProps { space_after: Some(300.0), contextual_spacing: Some(true), ..plain.clone() };
let automatic = ParaProps { space_after_auto: Some(true), ..plain };
for note in [contextual, automatic] {
let [labels, lines, _] = split_row_beside_a_note(VAlign::Bottom, &note);
assert_eq!(labels.last(), lines.last(), "{note:?}: labels {labels:?}, lines {lines:?}");
let [labels, lines, _] = split_row_beside_a_note(VAlign::Center, &note);
let (Some(&first), Some(&last), Some(&label)) = (lines.first(), lines.last(), labels.first()) else { panic!("{labels:?} {lines:?}") };
let want = first + (last - first) / 2.0;
assert!((label - want).abs() < 0.01, "{note:?}: centred label at {label}, want {want}");
}
}
#[test]
fn a_bottom_cell_of_a_split_row_lines_up_with_a_kept_space_after_beside_it() {
use wordcraft_doc::props::VAlign;
// A note that keeps its 14 pt after (no contextual spacing) still ends there: the bottom
// label's line ends 14 pt below the note's.
let note = ParaProps { space_after: Some(14.0), ..split_note_plain() };
let [labels, lines, notes] = split_row_beside_a_note(VAlign::Bottom, &note);
let (Some(&label), Some(&note_top), Some(&line)) = (labels.last(), notes.last(), lines.last()) else { panic!("{labels:?} {notes:?}") };
assert!(note_top + 14.0 > line, "the note's space after reaches below the split lines: {notes:?} {lines:?}");
assert!((label - (note_top + 14.0)).abs() < 0.01, "label at {label}, note at {note_top}");
}
fn row_keep_pages(d: &Document) -> Vec<usize> {
let l = lay(d);
(0..d.table(StoryRef::Body, &Path::top(1)).unwrap().rows.len())
+59 -1
View File
@@ -70,7 +70,7 @@ const MAX_SIZE: egui::Vec2 = egui::vec2(16384.0, 16384.0);
const MAX_COORD: f32 = 32768.0;
/// Room a window leaves on each side of its monitor for the taskbar, the Dock or a desktop panel,
/// which the monitor size (all egui reports) includes.
const SYSTEM_BAR: egui::Vec2 = egui::vec2(48.0, 48.0);
pub const SYSTEM_BAR: egui::Vec2 = egui::vec2(48.0, 48.0);
/// How much of a window's top edge must be on one monitor for the pointer to drag it back.
const GRIP: egui::Vec2 = egui::vec2(96.0, 16.0);
@@ -234,6 +234,30 @@ pub fn requested_size(size: egui::Vec2, max_side: f32) -> Option<egui::Vec2> {
Some(size.max(MIN_SIZE).min(max))
}
/// Clamps an inner window size so that the window (including decoration `frame`) fits on `monitor_size`
/// leaving room on each side for system bars ([`SYSTEM_BAR`]), bounded below by [`MIN_SIZE`] (#5871).
pub fn fit_inner_size(inner: egui::Vec2, monitor_size: egui::Vec2, frame: egui::Vec2) -> egui::Vec2 {
if !inner.is_finite() || inner.x <= 0.0 || inner.y <= 0.0 {
return MIN_SIZE;
}
if !monitor_size.is_finite() || monitor_size.x <= 0.0 || monitor_size.y <= 0.0 {
return inner.max(MIN_SIZE);
}
let frame = if frame.is_finite() { frame.max(egui::Vec2::ZERO) } else { egui::Vec2::ZERO };
let room = (monitor_size - 2.0 * SYSTEM_BAR).max(MIN_SIZE);
let max = (room - frame).max(MIN_SIZE);
inner.min(max).max(MIN_SIZE)
}
/// Returns a copy of `builder` whose requested inner size is clamped to fit within `monitor_size`
/// (including decoration `frame`) with room for system bars ([`SYSTEM_BAR`]) (#5871).
pub fn fit_builder_to_monitor(builder: egui::ViewportBuilder, monitor_size: egui::Vec2, frame: egui::Vec2) -> egui::ViewportBuilder {
match builder.inner_size {
Some(inner) => builder.with_inner_size(fit_inner_size(inner, monitor_size, frame)),
None => builder,
}
}
#[cfg(test)]
mod tests {
use super::*;
@@ -567,4 +591,38 @@ mod tests {
// Wayland reports no rects.
assert_eq!(system.fit_on_monitor(&RawWindowState { monitor_size: Some(laptop), ..Default::default() }), None);
}
/// On displays with smaller dimensions or high DPI scaling (e.g. 2560x1440 at 200% scale,
/// yielding a 1280x720 pt screen), the requested inner size shrinks to fit the screen
/// rather than extending past the boundaries (#5871).
#[test]
fn fit_inner_size_constrains_window_to_available_screen_dimensions() {
let default_inner = egui::vec2(1440.0, 920.0);
let frame = egui::vec2(16.0, 39.0);
// 2560x1440 display with 200% scaling: 1280x720 pt.
let scaled_1440p = egui::vec2(1280.0, 720.0);
let fitted = fit_inner_size(default_inner, scaled_1440p, frame);
assert_eq!(fitted, egui::vec2(1168.0, 585.0));
assert!(fitted.x + frame.x <= scaled_1440p.x);
assert!(fitted.y + frame.y <= scaled_1440p.y);
// Standard 2560x1440 at 100% scaling: ample room, size remains untouched.
let large_monitor = egui::vec2(2560.0, 1440.0);
assert_eq!(fit_inner_size(default_inner, large_monitor, frame), default_inner);
// Tiny display: clamps to minimum inner size.
let tiny = egui::vec2(600.0, 400.0);
assert_eq!(fit_inner_size(default_inner, tiny, frame), MIN_SIZE);
// Hostile / non-finite inputs never panic and fall back safely.
assert_eq!(fit_inner_size(egui::vec2(f32::NAN, 100.0), scaled_1440p, frame), MIN_SIZE);
assert_eq!(fit_inner_size(default_inner, egui::vec2(f32::INFINITY, f32::NAN), frame), default_inner.max(MIN_SIZE));
// Viewport builder adaptation preserves other builder properties while clamping inner size.
let builder = egui::ViewportBuilder::default().with_inner_size([1440.0, 920.0]).with_title("Test");
let adapted = fit_builder_to_monitor(builder, scaled_1440p, frame);
assert_eq!(adapted.inner_size, Some(egui::vec2(1168.0, 585.0)));
assert_eq!(adapted.title, Some("Test".into()));
}
}
+37 -1
View File
@@ -307,6 +307,11 @@ impl WordApp {
self.windows.builder = builder;
}
/// The viewport builder that new windows start from.
pub fn window_builder(&self) -> &egui::ViewportBuilder {
&self.windows.builder
}
/// Whether a close request for the root viewport closes only the window drawn there: true
/// while other windows are open. [`WordApp::ui`] closes it (a [`WindowEvent::CloseRequested`]),
/// and the host keeps the app running.
@@ -597,7 +602,38 @@ impl WordApp {
let mut list = std::mem::take(&mut self.windows.list);
self.windows.recent = list.recent().filter_map(|id| list.get(id).copied()).collect();
let titles: HashMap<u64, String> = self.windows.open.iter().map(|w| (w.key, w.title.clone())).collect();
let base = self.windows.builder.clone();
let target_monitor = list
.recent()
.find_map(|id| {
ctx.input_for(viewports.viewport_id(id), |i| {
let m = i.viewport().monitor_size?;
(m.is_finite() && m.x > 0.0 && m.y > 0.0).then_some(m)
})
})
.or_else(|| {
ctx.input(|i| {
let m = i.viewport().monitor_size?;
(m.is_finite() && m.x > 0.0 && m.y > 0.0).then_some(m)
})
});
let frame = list
.recent()
.find_map(|id| {
ctx.input_for(viewports.viewport_id(id), |i| {
let v = i.viewport();
let (outer, inner) = (v.outer_rect?, v.inner_rect?);
if !outer.is_finite() || !inner.is_finite() {
return None;
}
Some((outer.size() - inner.size()).max(egui::Vec2::ZERO))
})
})
.filter(|f| f.is_finite())
.unwrap_or_else(|| egui::vec2(16.0, 39.0));
let base = match target_monitor {
Some(mon) => crate::window_geometry::fit_builder_to_monitor(self.windows.builder.clone(), mon, frame),
None => self.windows.builder.clone(),
};
self.windows.showing = true;
let events = viewports.show(
ui,
+68
View File
@@ -21,6 +21,8 @@ thread_local! {
static UPLOADS: RefCell<usize> = const { RefCell::new(0) };
/// Each viewport's last input-method output.
static IME: RefCell<HashMap<ViewportId, Option<egui::output::IMEOutput>>> = RefCell::new(HashMap::new());
/// Viewport builders passed to immediate viewports, by viewport id.
static BUILDERS: RefCell<HashMap<ViewportId, egui::ViewportBuilder>> = RefCell::new(HashMap::new());
}
fn input(id: ViewportId) -> RawInput {
@@ -49,10 +51,12 @@ fn backend() -> egui::Context {
EVENTS.with(|e| e.borrow_mut().clear());
COMMANDS.with(|c| c.borrow_mut().clear());
UPLOADS.with(|u| *u.borrow_mut() = 0);
BUILDERS.with(|b| b.borrow_mut().clear());
let ctx = egui::Context::default();
ctx.set_embed_viewports(false);
egui::Context::set_immediate_viewport_renderer(|ctx, viewport| {
let id = viewport.ids.this;
BUILDERS.with(|b| b.borrow_mut().insert(id, viewport.builder.clone()));
let mut raw = input(id);
raw.viewports.entry(id).or_default().parent = Some(viewport.ids.parent);
let mut cb = viewport.viewport_ui_cb;
@@ -630,3 +634,67 @@ fn every_open_replaces_an_untouched_blank_document_in_its_window() {
}
std::fs::remove_dir_all(second.parent().unwrap()).ok();
}
/// Opening a second document when another is already open constrains the new window's requested
/// dimensions so that it fits within the active monitor's bounds, even on displays with high
/// DPI scaling (such as 2560x1440 at 200% scale = 1280x720 pt) where the unconstrained default
/// size (1440x920 pt) would extend past the right edge (#5871).
#[test]
fn newly_opened_window_does_not_exceed_screen_width() {
let doc1 = docx_on_disk("doc1", "Doc One");
let doc2 = docx_on_disk("doc2", "Doc Two");
let ctx = backend();
// Simulate 2560x1440 at 200% scale (1280x720 pt).
let screen = egui::vec2(1280.0, 720.0);
let frame_decorations = egui::vec2(16.0, 39.0);
INFOS.with(|i| {
let mut infos = i.borrow_mut();
let root = infos.entry(ViewportId::ROOT).or_default();
root.monitor_size = Some(screen);
root.inner_rect = Some(egui::Rect::from_min_size(egui::pos2(48.0, 48.0), egui::vec2(1168.0, 585.0)));
root.outer_rect = Some(egui::Rect::from_min_size(egui::pos2(40.0, 17.0), egui::vec2(1184.0, 624.0)));
});
let mut a = WordApp::new(Session::new(wordcraft_doc::Document::new()), Services::default());
frames(&ctx, &mut a, 2);
// Open first document in the primary window.
a.run("file.open", json!({"path": doc1.to_string_lossy()})).unwrap();
frames(&ctx, &mut a, 2);
assert_eq!(a.window_count(), 1);
// Open second document, creating a second window while the first is active.
a.run("file.open", json!({"path": doc2.to_string_lossy()})).unwrap();
frames(&ctx, &mut a, 3);
assert_eq!(a.window_count(), 2);
// Find the immediate viewport created for the new window.
let child_builder = BUILDERS.with(|b| {
b.borrow()
.iter()
.find(|(id, _)| **id != ViewportId::ROOT)
.map(|(_, builder)| builder.clone())
.expect("child window viewport builder was recorded")
});
let inner_size = child_builder.inner_size.expect("child window has an inner size requested");
assert!(inner_size.x <= 1168.0, "new window width ({}) must not exceed available width on 1280pt monitor (1168pt)", inner_size.x);
assert!(
inner_size.x + frame_decorations.x <= screen.x,
"new window outer width ({} + {}) must fit within screen width ({})",
inner_size.x,
frame_decorations.x,
screen.x
);
assert!(
inner_size.y + frame_decorations.y <= screen.y,
"new window outer height ({} + {}) must fit within screen height ({})",
inner_size.y,
frame_decorations.y,
screen.y
);
std::fs::remove_dir_all(doc1.parent().unwrap()).ok();
std::fs::remove_dir_all(doc2.parent().unwrap()).ok();
}
+3 -1
View File
@@ -133,7 +133,7 @@ Measured by grepping `crates/docx/src/read` and `src/write` for the OOXML elemen
| Group shapes (`wpg:`), drawing canvas (`wpc:`) | ✅ | ✅ | Groups (#267) and canvases (#344) with pictures, shapes, freeforms (custom geometry, #743; a path drawn back to its start without `a:close` is filled, as in Word) and text boxes; nested groups flattened; members' `a:grpFill` takes their group's fill, and a canvas background (`wpc:bg`) may be a gradient or transparent (#1500). The canvas schema has no transform, so a turned or flipped canvas keeps its turn in our own `wpc:extLst` extension (`urn:wordcraft:canvas-xfrm`; Word shows it unturned). Legacy VML canvases (`v:group editas="canvas"`) aren't read as canvases |
| VML (`w:pict`, `v:shape`, `v:textbox`) | 🟡 | 🟡 | Pictures, text boxes, lines (with arrowheads, their kind, width and length kept), rectangles and ovals, best effort (#242, #611); `v:fill` opacity and gradients (`type="gradient"`/`gradientRadial`, `color2`, `colors`, `angle`, `focus`, `focusposition`, `o:opacity2`), saved as DrawingML (#1500). Preset shapes (`v:shape` whose `o:spt`, own or from its `v:shapetype`, or Word's shapetype id `_x0000_tNN`, names a preset; [MS-ODRAW] §2.4.24 numbers) are drawn and saved like the DrawingML preset of the same shape: exactly for rectangle, rounded rectangle, ellipse, diamond, isosceles triangle, five-point star, right arrow, line and straight connector, heart, text box, and the flowchart process, decision and connector; the others as their nearest shape, as for DrawingML (left/up/down arrows as the arrow, right triangle as the triangle, callouts as their box). Picture frames without a picture aren't drawn. Fill, outline and size come from the shape, else its shapetype; text inside keeps the shape around it (#1377). Adjust values (`adj`) aren't read: presets use WordCraft's proportions. Text boxes keep their internal margins (`inset`, unless `o:insetmode="auto"`), Resize shape to fit text (`mso-fit-shape-to-text`) and vertical alignment (`v-text-anchor`). Hidden shapes (`visibility:hidden`, like DrawingML's `wp:docPr/@hidden`) are kept and saved but neither drawn nor laid out, as in Word for Windows (Word for Mac draws them). Picture `gain` and `blacklevel` on `v:imagedata` (Word's picture watermark "Washout") are read as brightness and contrast (#1711). Saving as DOCX writes them as DrawingML `a:lum`; saving as Word 2003 XML writes them back as VML `gain`/`blacklevel` (transparency has no place on `v:imagedata` and is dropped there). Text-box text direction is read as Word reads it: `mso-layout-flow-alt:bottom-to-top` as `vert270` whatever `layout-flow` says, else `layout-flow:vertical` or `vertical-ideographic` as `vert`, else horizontal (#5193). Word's text watermark (a `v:textpath` shape in a header or footer, recognised by Word's `PowerPlusWaterMarkObject` id or by being centred on the page both ways) becomes the document's watermark: text, font, fill colour, semitransparent (`v:fill` `opacity`) and diagonal (any turn); drawn with WordCraft's own size and angle, on every page even when only some sections' headers (or only the first-page header) have it (#1290). Saving writes the watermark back the same way: a `v:textpath` shape centred on the margins, behind the text, at the start of every header's first paragraph; a section whose pages would show no header gets a header holding only the watermark (default, and first-page or even-page ones when those are on), while sections linked to an earlier header stay linked. Freeforms (`v:shape` with its own `path`: moves, lines, Béziers and closes in its `coordsize`/`coordorigin` space, `nf`/`ns`, `v:stroke` opacity) are read as freeforms and saved as DrawingML custom geometry; fill opacity kept (no fill at 0); arcs are drawn straight; shapes whose path uses formulas (`@n`) and paths inherited from a `v:shapetype` aren't read. Word signature lines (Insert › Signature Line: a VML picture with an `o:signatureline`) are shown as their picture and saved back whole with it, at the size set in WordCraft, so they stay signable in Word instead of becoming plain pictures. Other VML is written only for OLE object pictures and signature lines. Other VML WordArt (Word 2007's: `v:textpath` in the body, or a header logo) becomes a text box without fill or outline holding its `string`, in the textpath's font, the shape's fill colour and a size from the shape's height; its WordArt shape (`o:spt` 136–175) becomes the DrawingML text warp of the same shape (#2875). Its outline, shadow and 3-D effects aren't read |
| Alt text of drawings (`wp:docPr`/`cNvPr` `descr` and `title`, decorative mark `adec:decorative` from [MS-ODRAWXML]) and tables (`w:tblCaption`, `w:tblDescription`) | ✅ | ✅ | Pictures, shapes, text boxes, groups and their members, charts and SmartArt; OLE objects get theirs written into the kept XML (#386) |
| Charts (`c:chart`), SmartArt (`dgm`) | ✅ | ✅ | Drawn from the chart's cached data and SmartArt's stored drawing (#292), with data labels (`c:showVal`); their text (title, axis labels, legend) is drawn in the document's body font, and characters it lacks (Chinese in Calibri's substitute) in a fallback face that covers them, one face per run as in body text (#4720); a chart of only types not drawn yet (radar, stock, bubble, surface, 3-D, pie of pie) shows a placeholder in its frame with its title and type (#998). Charts made in WordCraft (Insert › Chart, #409) are written as a `c:chartSpace` part with their data as literals (`c:strLit`/`c:numLit`, no embedded workbook, so Word shows them and offers to create a workbook on Edit Data) and read back as editable; a chart part holding anything more (any chart from Word, or one re-saved by Word) stays read-only. Saved back as read, with every part they reach (chart, embedded workbook, colours and style; diagram data, layout, quick style, colours and drawing) and their content types; a moved or resized one keeps its new frame, and a pasted copy gets its own parts (#319). SmartArt made in WordCraft (#497) is written from its model: data (`dgm:dataModel` points and connections), our own layout, quick-style and colour definitions under the built-in layout identifiers, and a `dsp:drawing` with every shape placed; it reads back editable only when its data part is exactly what WordCraft writes |
| Charts (`c:chart`), SmartArt (`dgm`) | ✅ | ✅ | Drawn from the chart's cached data and SmartArt's stored drawing (#292), with data labels (`c:showVal`); a series without its own colour takes the accent its `c:idx` picks, as Word does (#5963); their text (title, axis labels, legend) is drawn in the document's body font, and characters it lacks (Chinese in Calibri's substitute) in a fallback face that covers them, one face per run as in body text (#4720); a chart of only types not drawn yet (radar, stock, bubble, surface, 3-D, pie of pie) shows a placeholder in its frame with its title and type (#998). Charts made in WordCraft (Insert › Chart, #409) are written as a `c:chartSpace` part with their data as literals (`c:strLit`/`c:numLit`, no embedded workbook, so Word shows them and offers to create a workbook on Edit Data) and read back as editable; a chart part holding anything more (any chart from Word, or one re-saved by Word) stays read-only. Saved back as read, with every part they reach (chart, embedded workbook, colours and style; diagram data, layout, quick style, colours and drawing) and their content types; a moved or resized one keeps its new frame, and a pasted copy gets its own parts (#319). SmartArt made in WordCraft (#497) is written from its model: data (`dgm:dataModel` points and connections), our own layout, quick-style and colour definitions under the built-in layout identifiers, and a `dsp:drawing` with every shape placed; it reads back editable only when its data part is exactly what WordCraft writes |
| OLE objects (`w:object`: embedded or linked files, ActiveX) | 🟡 | ✅ | Shown as their picture (VML or DrawingML); saved back whole with the embedding and picture parts, at the size, rotation and flips set in WordCraft (#319, #332). Can't be opened or edited |
| Native Word ink (`w14:contentPart`, InkML) | 🟡 | 🟡 | Drawing-level ink kept opaque with its InkML part, relationship and content type; direct run-level `w:contentPart` and run-level `mc:AlternateContent` keep the native branch and VML picture fallback. Direct content parts remain direct through repeated save/reopen cycles, without a native rendering. Drawing-level ink shows the preview or a missing-picture box. Ink members of groups (`wpg:wgp`, nested groups too) and drawing canvases (`wpc:wpc`), bare or in `mc:AlternateContent` with a `pic:pic` fallback, keep their markup, part, relationship and place among the members; they are written at the member's current position in the group and drawn from their fallback picture. Such a group can't be ungrouped yet (Ungroup is unavailable, so the ink is never left in a form it can't be saved in). Their part is dropped with the group (no orphan part); ink whose part is missing or isn't InkML gives way to its fallback, else is dropped. No native stroke rendering, eraser, conversion or replay |
| 3D models | ❌ | ❌ | Dropped on read, not preserved on save |
@@ -335,6 +335,7 @@ For field results, the engine parser treats a backslash in quoted arguments as e
| Date | Change | Summary |
|---|---|---|
| 2026-11-08 | trivial | XML text and CDATA read with their line ends normalised (XML 1.0 §2.11): CR LF and a lone CR are one line feed, `&#13;` stays a CR, so a line break inside `w:t` in a file saved with Windows line ends is one space, not two (#5897) |
| 2026-11-07 | trivial | Charts: a series without its own colour takes the accent its `c:idx` picks, as Word does, not the one its place in the file picks (#5963) |
| 2026-11-05 | trivial | ODT: text of another size in a heading, title or code paragraph keeps it. Every run's size went with the style, on screen and in LibreOffice after a save Text at the heading's size goes with the style, stated or not, and a percentage is of the heading's size. Text in a heading style that states no size keeps a size it states. |
| 2026-11-05 | trivial | ODT: a table row kept from breaking across pages (`fo:keep-together` on the row) is read and written; it came back from a save free to break A row whose style doesn't say follows the default style. A row free to break says so where the file's default row style, which stays, keeps rows together. |
| 2026-11-05 | trivial | ODT: a watermark added, changed or removed, and Different Odd & Even Pages turned on or off, in a file read from ODT are saved. The file's own page definitions were written back whatever happened to these, so the change was gone from the saved file (`odt::tests_more::headers`). Such a change writes the page definitions from the model, as other changes to what a page is made of do, so what WordCraft doesn't model of the page (a header's background) doesn't survive that save. Over 79 LibreOffice-authored files with a watermark added: all 79 have it, with the same page count in LibreOffice 26.8. |
@@ -683,3 +684,4 @@ For field results, the engine parser treats a backslash in quoted arguments as e
| 2026-10-27 | trivial | 2026-10-27 · #4533 review: numeric REF updates lazily index body bookmarks once per update, retaining the first body bookmark for duplicate names. Unsupported switches and ordinary REF/PAGEREF behavior are unchanged; a synthetic 64-reference regression covers repeated updates and duplicate names. Estimates unchanged. |
| 2026-10-27 | trivial | 2026-10-27 · #4533: numeric REF fields use paragraph list numbers for no/full/relative context, reusing bounded numbering. Shared context already embedded in a level label is removed; merged-away cells do not count. Unsupported separator/text/position switches and ambiguous contexts or delimiter-free joins retain cached results. Synthetic OOXML regressions; no native Word or real-document validation, estimates unchanged. |
| 2026-11-05 | trivial | DOCX: a block-level content control (including `w15:repeatingSection` / `w15:repeatingSectionItem` nested around one paragraph) followed by body-level bookmark, comment or permission markup (Word's `_GoBack` bookmark) is written back around its paragraph instead of inside it, so Word opens the saved file; the markers are kept at the end of that paragraph (P0069). |
| 2026-11-04 | trivial | DOCX: paragraph and list-level indents (`w:ind`) and paragraph spacing (`w:spacing` before/after) written as plain twips beyond 16 bits keep their low 16 bits, as Word reads them (`w:left="66256"` is a 36 pt indent), instead of pushing the text off the page (`twips_wrap.rs`, P0059) |
+6
View File
File diff suppressed because one or more lines are too long
+2 -1
View File
@@ -1,6 +1,6 @@
# Hardware parity with Microsoft Word
> **Last reviewed:** 2026-10-12 · **Last updated:** 2026-11-06 · **Change:** trivial (print helpers erase loaded plaintext inputs before driver work, #5710); previously trivial (Windows print submissions use isolated helpers so a cancelled driver cannot block a retry, #5710); previously trivial (print cancellation remains available without a document; sending-boundary limitation clarified, #5710); previously trivial (a print job waiting on its driver can be cancelled, and quitting doesn't wait for it, #5710); previously trivial (the printer list no longer waits on printer drivers, #5710); previously trivial (Linux Read Aloud: a sentence a screen reader cuts off is said again); previously trivial (ink page lookup work, #3614); previously trivial (only the PDF sent to the printer has no link annotations; Save these pages as PDF keeps them, #4734); previously trivial (a document with links prints: the print job's PDF has no link annotations, #4734; previously trivial: Pen / stylus row: two-stroke and closed-head arrows; previously trivial: Pen / stylus row: ink, eraser and Ink to Shape incl. arrows landed; previously trivial: a window too big for its screen shrinks to fit when it opens, #1215; previously trivial: 32-bit Windows starts on OpenGL; previously major: first version) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4 and Word for Windows
> **Last reviewed:** 2026-10-12 · **Last updated:** 2026-11-07 · **Change:** trivial (newly opened window dimensions are constrained to fit available screen dimensions, #5871); previously trivial (print helpers erase loaded plaintext inputs before driver work, #5710); previously trivial (Windows print submissions use isolated helpers so a cancelled driver cannot block a retry, #5710); previously trivial (print cancellation remains available without a document; sending-boundary limitation clarified, #5710); previously trivial (a print job waiting on its driver can be cancelled, and quitting doesn't wait for it, #5710); previously trivial (the printer list no longer waits on printer drivers, #5710); previously trivial (Linux Read Aloud: a sentence a screen reader cuts off is said again); previously trivial (ink page lookup work, #3614); previously trivial (only the PDF sent to the printer has no link annotations; Save these pages as PDF keeps them, #4734); previously trivial (a document with links prints: the print job's PDF has no link annotations, #4734; previously trivial: Pen / stylus row: two-stroke and closed-head arrows; previously trivial: Pen / stylus row: ink, eraser and Ink to Shape incl. arrows landed; previously trivial: a window too big for its screen shrinks to fit when it opens, #1215; previously trivial: 32-bit Windows starts on OpenGL; previously major: first version) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4 and Word for Windows
> **Printing update:** 2026-10-12 · direct desktop printing through craft-print; details below.
@@ -28,6 +28,7 @@ File > Print offers 2, 4, 6, 9 or 16 logical pages per physical sheet side and l
| Date | Change | Summary |
|---|---|---|
| 2026-11-07 | trivial | Newly opened secondary windows constrain their initial viewport dimensions to the monitor's usable area, preventing windows from exceeding screen width on scaled displays (#5871) |
| 2026-11-06 | trivial | Print helpers remove loaded PDF/settings files before driver work, tolerate prior removal and report cleanup errors; a held-driver regression checks the files are already absent (#5710) |
| 2026-11-06 | trivial | Windows print submissions use a separate helper per job, preserving provider serialization inside the helper. An acknowledged cancellation stops that helper, so a stalled cancelled driver cannot block a later job; normal shutdown reaps helpers and removes temporary PDFs. The helper erases its plaintext inputs before driver work; a hard app crash during startup can still leave private temporary files. Startup requires an explicit start grant; after sending begins, jobs complete (#5710) |
| 2026-11-06 | trivial | File › Print: before sending starts, Cancel printing (`ui.printCancel`) stops a waiting job and frees the Print page while keeping its settings, including after the last document closes; a successfully cancelled job is never sent. Quitting cancels waiting jobs and joins jobs already sending (#5710). On Windows, winprint repeats the DEVMODE conversion after sending starts. A stall during that repeat prevents cancellation and can keep shutdown waiting, even before a native job exists. |
File diff suppressed because one or more lines are too long