mirror of
https://github.com/storytold/wordcraft.git
synced 2026-11-10 20:51:50 +00:00
Merge upstream/main into control-mcp-delivery-1326-1331
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
commit
833c0154aa
40 files changed
+1505
-61
No files matched your search
Generated
+13
-1
@@ -1158,10 +1158,21 @@ dependencies = [
|
||||
"zip 9.1.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "craft-web-api"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=fd5e2feff6ea25bed371a99cc732b3968d621505#fd5e2feff6ea25bed371a99cc732b3968d621505"
|
||||
dependencies = [
|
||||
"js-sys",
|
||||
"serde_json",
|
||||
"wasm-bindgen",
|
||||
"wasm-bindgen-futures",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "craft-web-host"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=2e2312ddaf21bc3d85cd3395d0955c7d239306f6#2e2312ddaf21bc3d85cd3395d0955c7d239306f6"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=fd5e2feff6ea25bed371a99cc732b3968d621505#fd5e2feff6ea25bed371a99cc732b3968d621505"
|
||||
dependencies = [
|
||||
"wasm-bindgen",
|
||||
"web-sys",
|
||||
@@ -7098,6 +7109,7 @@ dependencies = [
|
||||
name = "wordcraft-web"
|
||||
version = "0.15.1"
|
||||
dependencies = [
|
||||
"craft-web-api",
|
||||
"craft-web-host",
|
||||
"eframe",
|
||||
"egui",
|
||||
|
||||
+15
@@ -149,6 +149,7 @@ Ranked, to beta (detail and remaining milestones in [docs/roadmap.md](docs/roadm
|
||||
## Progress log
|
||||
|
||||
- 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, ` ` 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-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.
|
||||
@@ -164,6 +165,8 @@ Ranked, to beta (detail and remaining milestones in [docs/roadmap.md](docs/roadm
|
||||
|
||||
- 2026-11-01: Shapes: a line's arrowheads are as long and as wide as Word draws them: 2, 3 or 5 times the line's width for small, medium and large (a line under 2 points as a 2-point one). Their kind, width and length are read from DOCX (DrawingML and VML) and saved back; before, every arrowhead was one size and saved as a medium triangle.
|
||||
- 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: 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.
|
||||
@@ -237,6 +240,9 @@ Ranked, to beta (detail and remaining milestones in [docs/roadmap.md](docs/roadm
|
||||
- 2026-11-03: Find and Replace: a keyboard apostrophe also finds typographic single quotes, so smart-quoted typing stays searchable (#5388). Regex, wildcards, literal searches and numeric character codes remain exact. Original UTF-8 match ranges and replacement spelling are preserved; readiness unchanged.
|
||||
- 2026-11-03: Security: XLSX import finds each sheet's worksheet part through an index of the workbook's relationships, built once, instead of searching every relationship for every sheet. Readiness unchanged.
|
||||
- 2026-11-03: Layout: a row of a nested table marked Can't Split isn't split with its outer row any more. The outer row moves to the next page whole when that row comes first in it, as Word does (docxide-pdf `nested-first-row-*-cant1`), and otherwise splits above it (inferred, not yet checked in Word); on a fresh page a nested row taller than the room still splits rather than running into the margin. Readiness unchanged.
|
||||
- 2026-11-03: Layout: no line break between a full stop and an opening parenthesis. UAX #14 allows one there, Word doesn't: `29.05.2023г.(оформление ЗУ)` and `2023.(Example detail)` now wrap whole at the space before, as in the docxide-pdf `dot-open-token*` Word references. Readiness unchanged.
|
||||
- 2026-11-03: Layout: before Word 2013 layout a floating table that may not overlap another and is moved below it keeps its own left edge, as Word places it, also when its first cell has no left margin; the layout took a zero margin for Word 2013 layout and stepped the table in by its distance from the text. Readiness unchanged.
|
||||
- 2026-11-03: Layout: an empty paragraph after a document's leading floating table takes no space before, as in Word's PDFs of docxide-pdf's `leading-empty-before-float` cases (beside the table); below a full-width one it loses it too, which is untested. Readiness unchanged.
|
||||
- 2026-11-03: Security: pasting text boxes charges each copied story for its formatting too (run, mark, paragraph and section properties), not only its text and objects, so a story with huge style or font names can't copy past the paste's 32 MiB text box budget. Readiness unchanged.
|
||||
- 2026-11-03: Security: `.doc` import keeps one width, shading, alignment and merge override per table cell, the last one winning as before, instead of a copy every time a paragraph's properties repeat the sprm, so a shared PAPX can't pile up copies on every paragraph mark. Readiness unchanged.
|
||||
- 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.
|
||||
@@ -353,6 +359,7 @@ Imported Word 97–2003 `.doc`/`.dot` files now ask for a supported DOCX target
|
||||
| Date | What landed |
|
||||
| 2026-11-04 | The CLI opens a non-Unicode input filename even after options such as `--page 2` or `--format flatopc`; text option values still require Unicode; `run --file` and `run --script` retain native paths. |
|
||||
| 2026-11-06 | Accept All and Reject All no longer restyle the paragraph after an empty one carrying a section break (#5667): a Compare that added a section, then Reject All, left `Body` in the removed paragraph's Normal style and indents. The empty paragraph now goes whole and its section joins the following one, as deleting the break does. |
|
||||
| 2026-11-07 | Deleting a preceding empty paragraph keeps the following paragraph's style, outline and mark formatting rather than promoting body text to a heading (Leaf #152). Non-empty merges and section absorption retain their existing rules; synthetic model, command, DOCX and Undo/Redo coverage. |
|
||||
| 2026-11-04 | Pictures keep the brightness, contrast and transparency their file gives them (#1711, #1766): a "Washout" picture watermark (VML `gain`/`blacklevel`) and a washed-out letterhead picture behind the text (`a:lum`) are drawn faded on screen and in PDF instead of at full colour, with the pixel curve measured on Word's own PDF export; `a:alphaModFix` transparency too. Saved back as DrawingML (and as VML `gain`/`blacklevel` in Word 2003 XML, without the transparency). Picture Format › Corrections, Transparency and Color › Washout replace the file's values (other Color modes leave them alone), and Reset Picture discards them all, as in Word; the PDF embeds the corrected pixels once per picture |
|
||||
|---|---|
|
||||
| 2026-10-17 | Copies keep their formatting in programs that read RTF rather than HTML: Copy and Cut put RTF beside the text and HTML on the system clipboard on Windows, macOS and Wayland (an X11 copy stays text and HTML, since arboard owns the selection there; a copy of only pictures or shapes carries no HTML or RTF, so pasting it back keeps every detail), and Paste reads another program's RTF on macOS and X11 too, not only on Windows and Wayland |
|
||||
@@ -381,6 +388,7 @@ Imported Word 97–2003 `.doc`/`.dot` files now ask for a supported DOCX target
|
||||
| 2026-10-31 | A picture or shape anchored in a line of a long paragraph on a later page is drawn on that page, as in Word, instead of the page the paragraph starts on; one dropped on such a page stays where it was dropped (#4250) |
|
||||
| 2026-10-31 | A picture or shape anchored in a line of a long paragraph on a later page is drawn on that page, as in Word, instead of the page the paragraph starts on; one dropped on such a page stays where it was dropped (#4250) |
|
||||
| 2026-10-31 | A picture or shape dropped beside the first line after a page break inside a paragraph stays on the new page, as in Word, instead of going back to the page before the break (#4262) |
|
||||
| 2026-11-07 | A brace or bracket shape in a `.doc` file is drawn without a fill unless the file gives it one, as in Word, instead of as a white box that hides the text behind it (#5943) |
|
||||
| 2026-10-27 | Connector rerouting indexes laid-out object positions once per batch, preserving page hints, first-match order and same-page glue; synthetic equivalence, geometry and scaling regressions (#3631); routing policy and readiness estimates unchanged |
|
||||
| 2026-10-28 | Markdown import bounds nested undefined reference-label work within the existing link allowance; literal source and normal reference matching are preserved (#4658). |
|
||||
| 2026-10-23 | A command that changes nothing is not an edit: it adds no undo step, marks no unsaved changes and forces no relayout, so the redo stack survives it and five hundred of them can no longer push the user's own edits out of the undo history. |
|
||||
@@ -849,6 +857,7 @@ MATH glyphs that reference the same construction now share its immutable variant
|
||||
> 2026-11-07 · Progress log: VML text boxes keep their text direction (#5193).
|
||||
>
|
||||
> 2026-11-03 · Arabic dialogs read right to left (rtl::Table); recorded in the progress log, readiness unchanged.
|
||||
> 2026-11-03 · Before Word 2013 layout a floating table moved below another keeps its own left edge, also when its first cell has no left margin; readiness unchanged.
|
||||
>
|
||||
> 2026-10-30 · Technical synchronization: retain the Proofing pane and command/selection/undo contracts on main `3b7f7ed`; new-SHA validation pending, readiness unchanged.
|
||||
>
|
||||
@@ -861,6 +870,7 @@ MATH glyphs that reference the same construction now share its immutable variant
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-10-31 | trivial | Control/MCP #2596 review fixes; readiness estimates unchanged |
|
||||
| 2026-11-08 | trivial | Progress log: XML line ends in text normalised (#5897) |
|
||||
| 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. |
|
||||
@@ -870,6 +880,8 @@ MATH glyphs that reference the same construction now share its immutable variant
|
||||
| 2026-10-31 | trivial | Progress log: font menu entries are drawn from shared textures (#5105) |
|
||||
| 2026-11-02 | trivial | Progress log: a linked picture shows and follows its link; DOCX `a:hlinkClick` (#5267) |
|
||||
| 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 | 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) |
|
||||
@@ -902,6 +914,7 @@ MATH glyphs that reference the same construction now share its immutable variant
|
||||
| 2026-11-04 | trivial | Progress log: ODT element audit (gap 19) |
|
||||
| 2026-11-04 | trivial | Progress log: ODT checked against 220 files from other applications (gap 19) |
|
||||
| 2026-11-04 | trivial | Progress log: OpenDocument files shown in Microsoft Word style or LibreOffice style (gap 19) |
|
||||
| 2026-11-07 | trivial | Progress log: `.doc` brace and bracket shapes unfilled by default (#5943) |
|
||||
| 2026-11-04 | trivial | PR #4609: relative REF keeps context already displayed by the own or a needed ancestor label; six synthetic legal-template cases and separate-list context follow the Word for Windows measurements reported by @pakiat in PR discussion. Existing unknown-switch/cache boundaries and readiness estimates unchanged; no new native Word run. |
|
||||
| 2026-11-01 | trivial | Progress log: SEQ, STYLEREF and REF fields show fresh results in output |
|
||||
| 2026-10-29 | trivial | Progress log: Print Markup balloon anchors use one object walk per paragraph (#3614); readiness estimates unchanged |
|
||||
@@ -917,6 +930,8 @@ MATH glyphs that reference the same construction now share its immutable variant
|
||||
| 2026-11-03 | trivial | Find smart-quoted text with a keyboard apostrophe (#5388); exact search modes and replacement spelling preserved. |
|
||||
| 2026-11-03 | trivial | Progress log: a picture in a paragraph whose hidden mark closes a section keeps its room (#5288) |
|
||||
| 2026-11-03 | trivial | Progress log: a nested table's Can't Split row moves on with its outer row |
|
||||
| 2026-11-03 | trivial | Progress log: no line break between a full stop and an opening parenthesis |
|
||||
| 2026-11-03 | trivial | Progress log: an empty paragraph after a document's leading floating table takes no space before |
|
||||
| 2026-11-03 | trivial | Progress log: Replace and Remove Table of Contents with Word's title above a section break (#4041) |
|
||||
| 2026-11-03 | trivial | Progress log: language-packs-2 published and pinned, 75 Language packs |
|
||||
| 2026-11-02 | trivial | Progress log: language-packs-1 published and pinned, 61 Language packs (CP4.4) |
|
||||
|
||||
@@ -17,7 +17,9 @@ path = "src/main.rs"
|
||||
wordcraft-engine = { workspace = true }
|
||||
wordcraft-ui-egui = { workspace = true }
|
||||
# The host messages (storytold/craft-libs): one contract and one bridge for the web builds.
|
||||
craft-web-host = { git = "https://github.com/storytold/craft-libs", rev = "2e2312ddaf21bc3d85cd3395d0955c7d239306f6", version = "0.1.0" }
|
||||
# The page command API (storytold/craft-libs#413): window.wordcraft over the control channel.
|
||||
craft-web-api = { git = "https://github.com/storytold/craft-libs", rev = "fd5e2feff6ea25bed371a99cc732b3968d621505", version = "0.1.0" }
|
||||
craft-web-host = { git = "https://github.com/storytold/craft-libs", rev = "fd5e2feff6ea25bed371a99cc732b3968d621505", version = "0.1.0" }
|
||||
egui = { workspace = true }
|
||||
log = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
//! `window.wordcraft`: the page command API (`craft-web-api`, storytold/craft-libs#413) over the
|
||||
//! app's own control channel, the one the desktop control server drives (`docs/control-protocol.md`).
|
||||
//!
|
||||
//! The crate owns the JavaScript side (`request`, `execute`, `commands`, `docInspect`, `uiInspect`,
|
||||
//! `screenshot`, as promises with a timeout). This module is WordCraft's part: a [`Request`] that
|
||||
//! queues a [`ControlRequest`] for the app, which answers it on its next frame, and waits for the
|
||||
//! reply. WordCraft's control request has no abandon state, so a call the page gave up on still runs
|
||||
//! when the app reaches it, as it does for a desktop client that timed out.
|
||||
|
||||
use std::rc::Rc;
|
||||
use std::sync::mpsc::{Receiver, TryRecvError, channel};
|
||||
|
||||
use craft_web_api::methods::Methods;
|
||||
use craft_web_api::page::{PageApi, Request};
|
||||
use serde_json::{Value, json};
|
||||
use wasm_bindgen_futures::JsFuture;
|
||||
use wordcraft_ui_egui::ControlRequest;
|
||||
|
||||
/// The app id in `window.<app>`.
|
||||
const APP: &str = "wordcraft";
|
||||
|
||||
/// How often a waiting call looks for the app's reply, in milliseconds (about a frame).
|
||||
const POLL_MS: i32 = 16;
|
||||
|
||||
/// Put `window.wordcraft` on the page and return the queue the app drains each frame (hand it to
|
||||
/// `WordApp::with_control`). An existing `window.wordcraft` keeps the functions it already has.
|
||||
pub fn install(ctx: egui::Context) -> Receiver<ControlRequest> {
|
||||
let (queue, requests) = channel::<ControlRequest>();
|
||||
let request: Request = Rc::new(move |method, params| {
|
||||
let (control, reply) = ControlRequest::new(method, params);
|
||||
if queue.send(control).is_err() {
|
||||
return Box::pin(async { json!({"ok": false, "error": "the app is not running"}) });
|
||||
}
|
||||
ctx.request_repaint();
|
||||
Box::pin(wait(reply))
|
||||
});
|
||||
if let Err(e) = Rc::new(PageApi::new(APP, Methods::default(), request)).install() {
|
||||
log::warn!("couldn't install window.{APP}: {e:?}");
|
||||
}
|
||||
requests
|
||||
}
|
||||
|
||||
/// The app's reply, polled about once a frame.
|
||||
async fn wait(reply: Receiver<Value>) -> Value {
|
||||
loop {
|
||||
match reply.try_recv() {
|
||||
Ok(reply) => return reply,
|
||||
Err(TryRecvError::Empty) => sleep(POLL_MS).await,
|
||||
Err(TryRecvError::Disconnected) => return json!({"ok": false, "error": "the app dropped the request"}),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn sleep(ms: i32) {
|
||||
let promise = js_sys::Promise::new(&mut |resolve, _| {
|
||||
if let Some(window) = web_sys::window() {
|
||||
let _ = window.set_timeout_with_callback_and_timeout_and_arguments_0(&resolve, ms);
|
||||
}
|
||||
});
|
||||
let _ = JsFuture::from(promise).await;
|
||||
}
|
||||
@@ -20,6 +20,8 @@ mod prefs;
|
||||
#[cfg(any(target_arch = "wasm32", test))]
|
||||
mod save_fallback;
|
||||
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
mod api;
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
mod host;
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
|
||||
@@ -76,7 +76,8 @@ pub fn start() {
|
||||
if let Some(bridge) = &app_bridge {
|
||||
bridge.connect(&mut services, inbox.clone(), cc.egui_ctx.clone());
|
||||
}
|
||||
let mut app = WordApp::new(session, services);
|
||||
let control = crate::api::install(cc.egui_ctx.clone());
|
||||
let mut app = WordApp::new(session, services).with_control(control);
|
||||
// Pasted pictures wait apart from dropped files: the app inserts one only when the page is the edit target (#1349).
|
||||
app.services.pasted = Some(pasted);
|
||||
app.autosave = false;
|
||||
|
||||
@@ -772,6 +772,10 @@ impl Document {
|
||||
if a.path.parent() == b.path.parent() {
|
||||
// Same container: trim the ends, drop the blocks between, join.
|
||||
let (ia, ib) = (a.path.last(), b.path.last());
|
||||
// Removing an already-empty paragraph must not impose its style or mark formatting
|
||||
// on the following content. Test before trimming: deleting actual heading text is
|
||||
// still an ordinary merge that keeps the first paragraph's properties.
|
||||
let empty_start = self.para(a.story, &a.path).is_some_and(Paragraph::is_empty);
|
||||
// Kept markers of the paragraphs in between move to the join.
|
||||
let mut moved: Vec<(InlineObject, CharProps)> = Vec::new();
|
||||
// `kept` is sorted: one walk of each paragraph's objects and runs (#3614).
|
||||
@@ -819,15 +823,18 @@ impl Document {
|
||||
if ib < bl.len() && ia < ib {
|
||||
bl.drain(ia + 1..=ib);
|
||||
}
|
||||
tail.props = ParaProps::default();
|
||||
// The deleted marks include `a`'s, so a section break there goes too: the joined
|
||||
// paragraph ends where `b`'s mark did, and its text takes the following section's
|
||||
// setup, as in Word (#2127).
|
||||
let sect = tail.section.take();
|
||||
let pa = self.para_mut(a.story, &a.path)?;
|
||||
let keep_props = pa.props.clone();
|
||||
let keep_props = if empty_start { tail.props.clone() } else { pa.props.clone() };
|
||||
let keep_mark = empty_start.then(|| tail.mark.clone());
|
||||
pa.append(tail);
|
||||
pa.props = keep_props;
|
||||
if let Some(mark) = keep_mark {
|
||||
pa.mark = mark;
|
||||
}
|
||||
let before = std::mem::replace(&mut pa.section, sect);
|
||||
if a.story == crate::StoryRef::Body && a.path.0.len() == 1 {
|
||||
// Nearest first, as "link to previous" resolved them before the delete.
|
||||
|
||||
@@ -137,9 +137,12 @@ impl ResolvedChar {
|
||||
pub fn uses_complex(&self, c: char) -> bool {
|
||||
self.rtl || self.cs || crate::bidi::is_complex_script(c)
|
||||
}
|
||||
/// The size glyphs are drawn at (super/subscript shrink to ~2/3).
|
||||
/// The size glyphs are drawn and measured at. Word sets superscript and subscript text at
|
||||
/// 0.65 of the run's size rounded to the half point, a font size the format can store
|
||||
/// (measured in Word for Mac from digit advances: 8 pt → 5, 9 → 6, 11 → 7, 12 → 8,
|
||||
/// 16 → 10.5, 24 → 15.5, 36 → 23.5).
|
||||
pub fn draw_size(&self) -> f32 {
|
||||
if self.vert_align == VertAlign::Baseline { self.size } else { self.size * 0.65 }
|
||||
if self.vert_align == VertAlign::Baseline { self.size } else { (self.size * 0.65 * 2.0).round() / 2.0 }
|
||||
}
|
||||
/// Baseline shift, points (positive = up).
|
||||
pub fn baseline_shift(&self) -> f32 {
|
||||
@@ -453,6 +456,32 @@ fn diff_patch<T: Serialize + serde::de::DeserializeOwned + Default>(with: &T, wi
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// Superscript and subscript sizes as Word for Mac sets them (measured from digit advances in
|
||||
/// its PDF export): 0.65 of the size, rounded to the half point.
|
||||
#[test]
|
||||
fn raised_and_lowered_text_is_sized_to_the_half_point() {
|
||||
for (size, want) in [
|
||||
(8.0, 5.0),
|
||||
(9.0, 6.0),
|
||||
(10.0, 6.5),
|
||||
(11.0, 7.0),
|
||||
(12.0, 8.0),
|
||||
(13.0, 8.5),
|
||||
(14.0, 9.0),
|
||||
(16.0, 10.5),
|
||||
(20.0, 13.0),
|
||||
(24.0, 15.5),
|
||||
(36.0, 23.5),
|
||||
] {
|
||||
for vert_align in [VertAlign::Superscript, VertAlign::Subscript] {
|
||||
let rc = ResolvedChar { size, vert_align, ..StyleSheet::builtin().resolve_char(None, &CharProps::default()) };
|
||||
assert_eq!(rc.draw_size(), want, "{size} pt {vert_align:?}");
|
||||
}
|
||||
}
|
||||
let rc = StyleSheet::builtin().resolve_char(None, &CharProps { size: Some(11.0), ..Default::default() });
|
||||
assert_eq!(rc.draw_size(), 11.0, "baseline text keeps its size");
|
||||
}
|
||||
|
||||
/// #2036: only a character itself (direct formatting) has a symbol font of its own; a
|
||||
/// style never gives one to its text.
|
||||
#[test]
|
||||
|
||||
@@ -19,8 +19,8 @@
|
||||
//!
|
||||
//! Tables are indexed by code − 0x20; 0 = no Unicode equivalent.
|
||||
|
||||
/// The symbol fonts this module knows, by lowercase family name.
|
||||
fn table(family: &str) -> Option<&'static [u32; 224]> {
|
||||
/// The symbol table for a single family name, by lowercase family name.
|
||||
fn single_table(family: &str) -> Option<&'static [u32; 224]> {
|
||||
let f = family.trim();
|
||||
if f.eq_ignore_ascii_case("symbol") || f.eq_ignore_ascii_case("symbol (pcl6)") {
|
||||
Some(&SYMBOL)
|
||||
@@ -37,6 +37,11 @@ fn table(family: &str) -> Option<&'static [u32; 224]> {
|
||||
}
|
||||
}
|
||||
|
||||
/// The symbol fonts this module knows, by lowercase family name.
|
||||
fn table(family: &str) -> Option<&'static [u32; 224]> {
|
||||
if family.contains(';') { family.split(';').map(str::trim).filter(|s| !s.is_empty()).find_map(single_table) } else { single_table(family) }
|
||||
}
|
||||
|
||||
/// Whether `family` is one of Word's symbol fonts (Symbol, Wingdings, Wingdings 2, Wingdings 3,
|
||||
/// Webdings), whose characters [`symbol_char`] maps.
|
||||
pub fn is_symbol_font(family: &str) -> bool {
|
||||
|
||||
@@ -0,0 +1,158 @@
|
||||
use std::sync::Arc;
|
||||
use wordcraft_doc::props::{Align, CharProps, ParaProps};
|
||||
use wordcraft_doc::{Block, Document, Paragraph, Path, Pos, StoryRef, Table, para_block};
|
||||
|
||||
/// Build an empty heading followed by explicitly formatted, mixed-run body text.
|
||||
fn fixture(first: &str, style: Option<&str>) -> Document {
|
||||
let mut d = Document::from_text(&format!("{first}\nBody paragraph."));
|
||||
let p = d.para_mut(StoryRef::Body, &Path::top(0)).unwrap();
|
||||
p.props.style = Some("Heading1".into());
|
||||
p.props.outline_level = Some(0);
|
||||
p.mark = CharProps { size: Some(40.0), bold: Some(true), ..Default::default() };
|
||||
let p = d.para_mut(StoryRef::Body, &Path::top(1)).unwrap();
|
||||
p.props = ParaProps {
|
||||
style: style.map(str::to_string),
|
||||
align: Some(Align::Right),
|
||||
indent_left: Some(18.0),
|
||||
space_after: Some(7.0),
|
||||
keep_next: Some(false),
|
||||
..Default::default()
|
||||
};
|
||||
p.mark = CharProps { size: Some(11.0), bold: Some(false), lang: Some("en-GB".into()), ..Default::default() };
|
||||
p.format(0, 4, &|c| c.italic = Some(true)).unwrap();
|
||||
d
|
||||
}
|
||||
|
||||
/// Compare paragraph, mark and inline-run formatting independently of text.
|
||||
fn assert_format(p: &Paragraph, expected: &Paragraph) {
|
||||
assert_eq!(p.props, expected.props, "the following paragraph keeps all its properties");
|
||||
assert_eq!(p.mark, expected.mark, "the empty heading's mark must not replace the following mark");
|
||||
assert_eq!(serde_json::to_value(&p.runs).unwrap(), serde_json::to_value(&expected.runs).unwrap());
|
||||
}
|
||||
|
||||
/// Deleting an empty paragraph preserves the following style, including implicit Normal.
|
||||
#[test]
|
||||
fn deleting_an_empty_paragraph_keeps_the_following_format() {
|
||||
for first_style in ["Heading1", "Heading3", "Normal"] {
|
||||
for style in [None, Some("Normal"), Some("Heading2")] {
|
||||
let mut d = fixture("", style);
|
||||
d.para_mut(StoryRef::Body, &Path::top(0)).unwrap().props.style = Some(first_style.into());
|
||||
let expected = d.para_at(&Pos::body(1, 0)).unwrap().clone();
|
||||
assert_eq!(d.delete_range(&Pos::body(0, 0), &Pos::body(1, 0)).unwrap(), Pos::body(0, 0));
|
||||
let p = d.para_at(&Pos::body(0, 0)).unwrap();
|
||||
assert_eq!(p.text, expected.text);
|
||||
assert_format(p, &expected);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A selection spanning several paragraphs keeps the surviving tail format in both directions.
|
||||
#[test]
|
||||
fn an_empty_start_preserves_the_last_paragraph_over_a_multi_paragraph_selection() {
|
||||
for backwards in [false, true] {
|
||||
let mut d = fixture("", None);
|
||||
d.body.insert(1, para_block(Paragraph::with_text("Removed middle paragraph", Default::default()).styled("Heading2")));
|
||||
let mut expected = d.para_at(&Pos::body(2, 0)).unwrap().clone();
|
||||
expected.delete(0, 5).unwrap();
|
||||
let (a, b) = (Pos::body(0, 0), Pos::body(2, 5));
|
||||
d.delete_range(if backwards { &b } else { &a }, if backwards { &a } else { &b }).unwrap();
|
||||
assert_eq!(d.body.len(), 1);
|
||||
let p = d.para_at(&Pos::body(0, 0)).unwrap();
|
||||
assert_eq!(p.text, "paragraph.");
|
||||
assert_format(p, &expected);
|
||||
}
|
||||
}
|
||||
|
||||
/// A control whose closing marker survives must move across the join without replacing tail formatting.
|
||||
#[test]
|
||||
fn an_empty_start_keeps_a_control_from_a_deleted_middle_paragraph() {
|
||||
use wordcraft_doc::control::ContentControl;
|
||||
use wordcraft_doc::{InlineObject, Revision, RevisionKind};
|
||||
|
||||
let mut d = fixture("", Some("Normal"));
|
||||
d.body.insert(1, para_block(Paragraph::with_text("Removed", Default::default())));
|
||||
d.body.push(para_block(Paragraph::with_text("After", Default::default())));
|
||||
d.revisions.push(Revision { kind: RevisionKind::Insert, ..Default::default() });
|
||||
d.revisions.push(Revision { kind: RevisionKind::Delete, ..Default::default() });
|
||||
let p = d.para_mut(StoryRef::Body, &Path::top(2)).unwrap();
|
||||
p.mark.ins = Some(0);
|
||||
p.mark.del = Some(1);
|
||||
let expected = p.clone();
|
||||
let marker = CharProps { bold: Some(true), ins: Some(0), ..Default::default() };
|
||||
d.insert_object(&Pos::body(1, 0), InlineObject::ControlStart { control: Box::new(ContentControl::default()) }, &marker).unwrap();
|
||||
d.insert_object(&Pos::body(3, 5), InlineObject::ControlEnd, &Default::default()).unwrap();
|
||||
assert_eq!(d.control_ranges(StoryRef::Body).len(), 1);
|
||||
|
||||
d.delete_range(&Pos::body(0, 0), &Pos::body(2, 0)).unwrap();
|
||||
let p = d.para_at(&Pos::body(0, 0)).unwrap();
|
||||
assert_eq!(p.props, expected.props);
|
||||
assert_eq!(p.mark, expected.mark, "tail revision IDs and formatting survive the moved marker");
|
||||
assert_eq!(p.props_of_char(0), &marker);
|
||||
assert!(matches!(p.objects.as_slice(), [InlineObject::ControlStart { .. }]));
|
||||
assert_eq!(p.text, format!("\u{fffc}{}", expected.text));
|
||||
let ranges = d.control_ranges(StoryRef::Body);
|
||||
assert_eq!(ranges.len(), 1);
|
||||
assert_eq!(ranges[0].start, Pos::body(0, 0));
|
||||
assert_eq!(ranges[0].end, Pos::body(1, 5));
|
||||
assert!(!d.has_unbalanced_controls());
|
||||
}
|
||||
|
||||
/// The empty-paragraph rule applies in header stories and table-cell containers.
|
||||
#[test]
|
||||
fn empty_paragraph_merges_work_in_a_header_and_a_table_cell() {
|
||||
for cell in [false, true] {
|
||||
let mut d = fixture("", Some("Normal"));
|
||||
let expected = d.para_at(&Pos::body(1, 0)).unwrap().clone();
|
||||
let (story, a, b) = if cell {
|
||||
let mut table = Table::new(1, 1, 300.0);
|
||||
table.rows[0].cells[0].blocks = d.body.clone();
|
||||
d.body = vec![Arc::new(Block::Table(table)), para_block(Paragraph::new())];
|
||||
(StoryRef::Body, Path(vec![0, 0, 0, 0]), Path(vec![0, 0, 0, 1]))
|
||||
} else {
|
||||
let id = d.add_part(wordcraft_doc::PartKind::Header, d.body.clone());
|
||||
(StoryRef::Part(id), Path::top(0), Path::top(1))
|
||||
};
|
||||
d.delete_range(&Pos { story, path: a.clone(), off: 0 }, &Pos { story, path: b, off: 0 }).unwrap();
|
||||
assert_format(d.para(story, &a).unwrap(), &expected);
|
||||
}
|
||||
}
|
||||
|
||||
/// Absorbing an empty section keeps the following format and inherited header.
|
||||
#[test]
|
||||
fn deleting_an_empty_section_paragraph_keeps_format_and_linked_headers() {
|
||||
let mut d = fixture("", None);
|
||||
let expected = d.para_at(&Pos::body(1, 0)).unwrap().clone();
|
||||
let header = d.add_part(wordcraft_doc::PartKind::Header, vec![para_block(Paragraph::with_text("Header", Default::default()))]);
|
||||
let mut section = d.last_section.clone();
|
||||
section.headers.default = Some(header);
|
||||
d.para_mut(StoryRef::Body, &Path::top(0)).unwrap().section = Some(Box::new(section));
|
||||
d.last_section.margin_left = 96.0;
|
||||
d.delete_range(&Pos::body(0, 0), &Pos::body(1, 0)).unwrap();
|
||||
assert_format(d.para_at(&Pos::body(0, 0)).unwrap(), &expected);
|
||||
assert!(d.para_at(&Pos::body(0, 0)).unwrap().section.is_none());
|
||||
assert_eq!(d.last_section.headers.default, Some(header));
|
||||
assert_eq!(d.last_section.margin_left, 96.0);
|
||||
}
|
||||
|
||||
/// Deleting actual text continues to use the first paragraph merge rule.
|
||||
#[test]
|
||||
fn joining_nonempty_paragraphs_keeps_the_existing_first_paragraph_rule() {
|
||||
for off in [0, 7] {
|
||||
let mut d = fixture("Heading", None);
|
||||
let expected = d.para_at(&Pos::body(0, 0)).unwrap().props.clone();
|
||||
d.delete_range(&Pos::body(0, off), &Pos::body(1, 0)).unwrap();
|
||||
assert_eq!(d.para_at(&Pos::body(0, 0)).unwrap().props, expected);
|
||||
}
|
||||
}
|
||||
|
||||
/// Removing a soft line break must not change paragraph formatting.
|
||||
#[test]
|
||||
fn a_soft_break_is_not_an_empty_paragraph_merge() {
|
||||
let mut d = fixture("", None);
|
||||
let p = d.para_mut(StoryRef::Body, &Path::top(0)).unwrap();
|
||||
p.insert_text(0, "\nHeading", &Default::default()).unwrap();
|
||||
let expected = p.props.clone();
|
||||
d.delete_range(&Pos::body(0, 0), &Pos::body(0, 1)).unwrap();
|
||||
assert_eq!(d.body.len(), 2);
|
||||
assert_eq!(d.para_at(&Pos::body(0, 0)).unwrap().props, expected);
|
||||
}
|
||||
@@ -211,7 +211,11 @@ fn leaf(sp: &Sp, w: f32, h: f32, float: Float, b: &Build, pics: &mut Pictures) -
|
||||
kind = ShapeKind::TextBox;
|
||||
}
|
||||
let line = kind == ShapeKind::Line;
|
||||
let fill = if line || p.filled == Some(false) { None } else { Some(Fill::solid(p.fill_color.unwrap_or(Rgb::WHITE))) };
|
||||
// Brackets and braces (85 to 88, bracket and brace pairs 185, 186) are unfilled unless
|
||||
// `fFilled` says otherwise, as Word's VML shapetypes for them say (`filled="f"`). They are
|
||||
// drawn as their box here, which a default fill would make an opaque block over the text.
|
||||
let unfilled = p.filled == Some(false) || (matches!(sp.spt, 85..=88 | 185 | 186) && p.filled != Some(true));
|
||||
let fill = if line || unfilled { None } else { Some(Fill::solid(p.fill_color.unwrap_or(Rgb::WHITE))) };
|
||||
let stroke = if p.line == Some(false) { None } else { Some(p.line_color.unwrap_or(Rgb::BLACK)) };
|
||||
// Line widths are EMUs; Word's default is 0.75 pt.
|
||||
let stroke_width = p.line_width.map_or(0.75, |e| (e as f32 / 12_700.0).clamp(0.0, 100.0));
|
||||
|
||||
@@ -183,6 +183,41 @@ fn shapes_keep_kind_colours_wrap_and_text_box_text() {
|
||||
assert_eq!(part.blocks.first().and_then(|b| b.as_para()).map(|p| p.text.as_str()), Some("Inside"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn braces_and_brackets_are_unfilled_unless_filled_is_set() {
|
||||
// A right brace (88) in front of the text with no fill properties, and a left bracket (85)
|
||||
// whose fFilled is set: only the bracket is filled. A plain rectangle (1) keeps its default
|
||||
// white fill.
|
||||
let brace = sp(&[fsp(88, 1026, 0xA00)]);
|
||||
let bracket = sp(&[fsp(85, 1027, 0xA00), fopt(&[(0x01BF, (1 << 20) | (1 << 4))], &[])]);
|
||||
let rect = sp(&[fsp(1, 1028, 0xA00)]);
|
||||
let art = art_content(&[], &[brace, bracket, rect]);
|
||||
let flags = spa_flags(0, 0, 3, false);
|
||||
let spas =
|
||||
plc(&[1, 2, 3, 5], &[spa(1026, [0, 0, 240, 1140], flags), spa(1027, [0, 0, 240, 1140], flags), spa(1028, [0, 0, 240, 1140], flags)].concat());
|
||||
let text = "A\u{8}\u{8}\u{8}B\r";
|
||||
let ccp = text.chars().count() as u32;
|
||||
let spec = StructSpec { text, extra_pairs: vec![(40, spas), (50, art)], ..structured_spec(text, ccp) };
|
||||
let doc = read(&struct_doc(&spec)).expect("opens");
|
||||
let p0 = doc.body.first().and_then(|b| b.as_para()).expect("para");
|
||||
let fills: Vec<_> = p0
|
||||
.objects
|
||||
.iter()
|
||||
.map(|o| match o {
|
||||
InlineObject::Shape { fill, stroke, w, h, .. } => (fill.clone(), *stroke, *w, *h),
|
||||
other => panic!("{other:?}"),
|
||||
})
|
||||
.collect();
|
||||
assert_eq!(
|
||||
fills,
|
||||
[
|
||||
(None, Some(Rgb::BLACK), 12.0, 57.0),
|
||||
(Some(Fill::solid(Rgb::WHITE)), Some(Rgb::BLACK), 12.0, 57.0),
|
||||
(Some(Fill::solid(Rgb::WHITE)), Some(Rgb::BLACK), 12.0, 57.0),
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn text_boxes_pointing_at_the_same_story_text_read_it_once() {
|
||||
// Two text boxes whose PlcftxbxTxt stories are both [0, 7) (the table steps back to CP 0
|
||||
|
||||
@@ -55,6 +55,8 @@ impl Reader<'_> {
|
||||
.children("w:font")
|
||||
.filter(|f| f.child("w:family").and_then(|v| v.attr("w:val")) == Some("roman"))
|
||||
.filter_map(|f| f.attr("w:name"))
|
||||
.flat_map(|n| n.split(';'))
|
||||
.map(str::trim)
|
||||
.filter(|n| !n.is_empty() && n.len() <= MAX_FONT_NAME)
|
||||
.take(MAX_TABLE_FONTS)
|
||||
.map(str::to_string)
|
||||
|
||||
@@ -13,6 +13,7 @@ use wordcraft_doc::section::{
|
||||
PageVAlign, SectionProps, SectionStart,
|
||||
};
|
||||
|
||||
use super::MAX_FONT_NAME;
|
||||
use super::drawing_color::adjust_lum;
|
||||
use crate::units::{int, line_multiple, measure, on_off, tw, u32_of};
|
||||
|
||||
@@ -145,6 +146,29 @@ pub(crate) fn resolve_level_only(sheet: &wordcraft_doc::styles::StyleSheet, p: &
|
||||
}
|
||||
}
|
||||
|
||||
/// A `w:rFonts` value as the document keeps it: `None` when it names no family, else its leading
|
||||
/// families up to [`MAX_FONT_NAME`] bytes, cut at a `;` so no name is split. A LibreOffice file
|
||||
/// names its Latin font as a semicolon-separated fallback list, and a real list names a few
|
||||
/// families; a longer value is corrupt or hostile, and every piece of text drawn in it would pay
|
||||
/// for its whole length as the list is classified and resolved. Keeping the leading families keeps
|
||||
/// the family such a list picks, and the rest of the value is dropped like `w:cs`/`w:eastAsia`
|
||||
/// drop an over-long name.
|
||||
fn bounded_font_list(v: &str) -> Option<String> {
|
||||
if v.len() <= MAX_FONT_NAME {
|
||||
return (!v.is_empty()).then(|| v.to_string());
|
||||
}
|
||||
let mut end = 0usize;
|
||||
for part in v.split_inclusive(';') {
|
||||
let next = end + part.len();
|
||||
if next > MAX_FONT_NAME {
|
||||
break;
|
||||
}
|
||||
end = next;
|
||||
}
|
||||
let kept = v.get(..end)?.trim_end_matches(';');
|
||||
(!kept.is_empty()).then(|| kept.to_string())
|
||||
}
|
||||
|
||||
impl PropCtx {
|
||||
pub fn style_id(&self, id: &str) -> String {
|
||||
self.alias.get(id).cloned().unwrap_or_else(|| id.to_string())
|
||||
@@ -226,6 +250,8 @@ impl PropCtx {
|
||||
shd_fill(e)
|
||||
}
|
||||
|
||||
/// The character properties `e` (a `w:rPr`) sets: direct formatting, plus the fonts, size,
|
||||
/// weight and posture of the complex-script and East Asian text beside them.
|
||||
pub fn rpr(&self, e: &El) -> CharProps {
|
||||
let mut c = CharProps::default();
|
||||
for k in e.els() {
|
||||
@@ -234,7 +260,11 @@ impl PropCtx {
|
||||
"w:rFonts" => {
|
||||
// A theme font supersedes the fixed font beside it (`w:asciiTheme` over
|
||||
// `w:ascii`, `w:hAnsiTheme` over `w:hAnsi`).
|
||||
let named = |a: &str| k.attr(a).filter(|f| !f.is_empty()).map(str::to_string);
|
||||
// A LibreOffice file names the Latin font as a semicolon-separated fallback
|
||||
// list. Names are bounded like `w:cs`/`w:eastAsia` below: a longer one is
|
||||
// corrupt or hostile, and every piece of text drawn in it would pay for its
|
||||
// whole length as the list is classified (see `bounded_font_list`).
|
||||
let named = |a: &str| k.attr(a).and_then(bounded_font_list);
|
||||
let themed = |a: &str| k.attr(a).and_then(|t| self.theme_font(t));
|
||||
// `w:eastAsia` and `w:cs` name fonts for other scripts and stay in `font_ea` /
|
||||
// `font_cs`: `w:hint` only picks between them for ambiguous characters, never for
|
||||
|
||||
@@ -83,13 +83,14 @@ pub(super) fn toc_control_to_toc(body: &mut Blocks) {
|
||||
}
|
||||
// An empty paragraph opening the control takes the field. It may end a section, as above
|
||||
// entries in columns (#3889).
|
||||
if engine.get(s).and_then(|b| b.as_para()).is_some_and(|p| p.is_empty()) {
|
||||
let empty_heading = engine.get(s).and_then(|b| b.as_para()).is_some_and(|p| p.is_empty());
|
||||
if empty_heading {
|
||||
field_to_heading(&mut engine, s + 1);
|
||||
} else {
|
||||
toc_field_to_heading(&mut engine);
|
||||
}
|
||||
if crate::write::written_toc_span(&engine) == Some((s, last)) {
|
||||
*body = toc;
|
||||
*body = if empty_heading { toc } else { engine };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -97,7 +98,7 @@ pub(super) fn toc_control_to_toc(body: &mut Blocks) {
|
||||
/// reproduce exactly. Other imported TOCs keep their explicit boundaries, independent of styles.
|
||||
pub(super) fn toc_field_to_heading(body: &mut Blocks) {
|
||||
for i in 1..body.len() {
|
||||
if body[i - 1].as_para().is_some_and(|p| p.props.style.as_deref() == Some("TOCHeading")) {
|
||||
if body.get(i - 1).and_then(|b| b.as_para()).is_some_and(|p| p.props.style.as_deref() == Some("TOCHeading")) {
|
||||
field_to_heading(body, i);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -86,11 +86,28 @@ pub fn plan(bl: &Blocks) -> Plan<'_> {
|
||||
&& (0..off).step_by(OBJ.len_utf8()).all(|o| matches!(p.object_at(o), Some(wordcraft_doc::InlineObject::ControlStart { .. })))
|
||||
})
|
||||
};
|
||||
// Bookmark, comment and permission markers after the end may stay there: a body-level
|
||||
// bookmark after a block control (Word's `_GoBack`) is read onto the end of its paragraph,
|
||||
// and the control still covers the whole paragraph.
|
||||
let at_end = |b: usize, off: usize| {
|
||||
use wordcraft_doc::InlineObject as O;
|
||||
para(b).is_some_and(|p| {
|
||||
let from = off + OBJ.len_utf8();
|
||||
p.text.get(from..).is_some_and(|t| t.chars().all(|c| c == OBJ))
|
||||
&& (from..p.len()).step_by(OBJ.len_utf8()).all(|o| matches!(p.object_at(o), Some(wordcraft_doc::InlineObject::ControlEnd)))
|
||||
&& (from..p.len()).step_by(OBJ.len_utf8()).all(|o| {
|
||||
matches!(
|
||||
p.object_at(o),
|
||||
Some(
|
||||
O::ControlEnd
|
||||
| O::BookmarkStart { .. }
|
||||
| O::BookmarkEnd { .. }
|
||||
| O::CommentStart { .. }
|
||||
| O::CommentEnd { .. }
|
||||
| O::PermStart { .. }
|
||||
| O::PermEnd { .. }
|
||||
)
|
||||
)
|
||||
})
|
||||
})
|
||||
};
|
||||
let mut mode = vec![Mode::Inline; ranges.len()];
|
||||
|
||||
+31
-2
@@ -462,15 +462,22 @@ fn parse_with(bytes: &[u8], extra: &[(&'static str, &str)], strict: bool) -> Res
|
||||
attach(&mut stack, &mut root, el);
|
||||
}
|
||||
}
|
||||
// Line ends are normalised as XML 1.0 says (§2.11): CR LF and a lone CR are read as
|
||||
// LF, so a file saved with Windows line ends has no CR in its text. A CR written as
|
||||
// ` ` is a character reference and stays.
|
||||
Event::Text(t) => {
|
||||
if let Some(top) = stack.last_mut() {
|
||||
let s = t.decode().map_err(|e| DocxError::Xml(e.to_string()))?;
|
||||
let s = t.xml_content(XmlVersion::Explicit1_0).map_err(|e| DocxError::Xml(e.to_string()))?;
|
||||
push_text(top, &s);
|
||||
}
|
||||
}
|
||||
Event::CData(t) => {
|
||||
if let Some(top) = stack.last_mut() {
|
||||
let s = String::from_utf8_lossy(&t).into_owned();
|
||||
// CDATA that isn't UTF-8 is decoded lossily, with its line ends normalised the same way.
|
||||
let s = t
|
||||
.xml_content(XmlVersion::Explicit1_0)
|
||||
.map(|s| s.into_owned())
|
||||
.unwrap_or_else(|_| String::from_utf8_lossy(&t).replace("\r\n", "\n").replace('\r', "\n"));
|
||||
push_text(top, &s);
|
||||
}
|
||||
}
|
||||
@@ -724,6 +731,28 @@ mod tests {
|
||||
assert_eq!(t.attr("xml:space"), Some("preserve"));
|
||||
}
|
||||
|
||||
/// CDATA that isn't UTF-8 is decoded lossily, and its line ends are still normalised.
|
||||
#[test]
|
||||
fn line_ends_in_cdata_that_isnt_utf8_are_normalised() {
|
||||
let x =
|
||||
b"<x:document xmlns:x=\"http://schemas.openxmlformats.org/wordprocessingml/2006/main\"><x:t><![CDATA[a\xff\r\nb\rc]]></x:t></x:document>";
|
||||
assert_eq!(parse(x).unwrap().child("w:t").unwrap().text(), "a\u{FFFD}\nb\nc");
|
||||
}
|
||||
|
||||
/// XML 1.0 §2.11: CR LF and a lone CR in text and CDATA are read as LF; ` ` stays a CR.
|
||||
#[test]
|
||||
fn line_ends_in_text_are_normalised_but_a_cr_reference_stays() {
|
||||
let x = b"<x:document xmlns:x=\"http://schemas.openxmlformats.org/wordprocessingml/2006/main\"><x:t>a\r\nb\rc d<![CDATA[e\r\nf]]></x:t></x:document>";
|
||||
let e = parse(x).unwrap();
|
||||
assert_eq!(e.child("w:t").unwrap().text(), "a\nb\nc\rde\nf");
|
||||
// The writer escapes a CR, so a CR in the text survives a save.
|
||||
let x = format!(
|
||||
"<x:document xmlns:x=\"http://schemas.openxmlformats.org/wordprocessingml/2006/main\"><x:t>{}</x:t></x:document>",
|
||||
esc("a\r\nb\rc")
|
||||
);
|
||||
assert_eq!(parse(x.as_bytes()).unwrap().child("w:t").unwrap().text(), "a\r\nb\rc");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn choice_requirements_resolve_namespace_aliases_and_rebindings() {
|
||||
let x = br#"<mc:AlternateContent xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" xmlns:pen="http://schemas.microsoft.com/office/word/2010/wordprocessingInk"><mc:Choice Requires="pen"/><mc:Choice xmlns:pen="urn:future" Requires="pen"/><mc:Choice Requires="undeclared"/></mc:AlternateContent>"#;
|
||||
|
||||
@@ -342,3 +342,57 @@ fn toc_gallery_around_a_toc_whose_heading_is_in_a_block_control_round_trips() {
|
||||
assert!(xml.rfind("</w:sdt>").unwrap() < xml.rfind(">Chapter One<").unwrap(), "pass {pass}: {xml}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn word_toc_gallery_with_field_end_after_entries_round_trips_without_update() {
|
||||
let style = |s: &str, inner: &str| format!(r#"<w:p><w:pPr><w:pStyle w:val="{s}"/></w:pPr>{inner}</w:p>"#);
|
||||
let begin = r#"<w:r><w:fldChar w:fldCharType="begin"/></w:r><w:r><w:instrText xml:space="preserve"> TOC \o "1-3" \h \z \u </w:instrText></w:r><w:r><w:fldChar w:fldCharType="separate"/></w:r>"#;
|
||||
let content = format!(
|
||||
"{}{}{}<w:p><w:r><w:fldChar w:fldCharType=\"end\"/></w:r></w:p>",
|
||||
style("TOCHeading", &t("Contents")),
|
||||
style("TOC1", &format!("{begin}{}", t("Chapter One\t1"))),
|
||||
style("TOC2", &t("Section\t2"))
|
||||
);
|
||||
let pr = r#"<w:id w:val="-1234567"/><w:docPartObj><w:docPartGallery w:val="Table of Contents"/><w:docPartUnique/></w:docPartObj>"#;
|
||||
let mut bytes = package(&format!("{}{}", sdt(pr, &content), p(&t("Chapter One"))));
|
||||
for pass in 0..2 {
|
||||
let d = read(&bytes).unwrap();
|
||||
bytes = write(&d).unwrap();
|
||||
let xml = document_xml(&bytes);
|
||||
assert_eq!(xml.matches("<w:sdt>").count(), 1, "pass {pass}: {xml}");
|
||||
assert!(xml.contains(r#"<w:docPartGallery w:val="Table of Contents"/>"#), "pass {pass}: {xml}");
|
||||
assert!(xml.find("</w:sdt>").unwrap() < xml.rfind(">Chapter One<").unwrap(), "pass {pass}: {xml}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn range_toc_without_control_stays_unwrapped_and_section_break_toc_control_hands_over() {
|
||||
let style = |s: &str, inner: &str| format!(r#"<w:p><w:pPr><w:pStyle w:val="{s}"/></w:pPr>{inner}</w:p>"#);
|
||||
let begin = r#"<w:r><w:fldChar w:fldCharType="begin"/></w:r><w:r><w:instrText xml:space="preserve"> TOC \o "1-3" \h \z \u </w:instrText></w:r><w:r><w:fldChar w:fldCharType="separate"/></w:r>"#;
|
||||
// A range TOC that was never in a content control (with FieldEnd in its own paragraph) must
|
||||
// not gain a Table of Contents w:sdt on save.
|
||||
let bare = format!(
|
||||
"{}{}{}<w:p><w:r><w:fldChar w:fldCharType=\"end\"/></w:r></w:p>{}",
|
||||
style("TOCHeading", &t("Contents")),
|
||||
style("TOC1", &format!("{begin}{}", t("Chapter One\t1"))),
|
||||
style("TOC2", &t("Section\t2")),
|
||||
p(&t("Chapter One")),
|
||||
);
|
||||
let d_bare = read(&package(&bare)).unwrap();
|
||||
let xml_bare = document_xml(&write(&d_bare).unwrap());
|
||||
assert_eq!(xml_bare.matches("<w:sdt>").count(), 0, "{xml_bare}");
|
||||
|
||||
// #3889: a Table of Contents control opening on an empty paragraph that ends a section above
|
||||
// the entries hands its control over on read so Update Table reuses the section-break paragraph.
|
||||
let sect_toc = format!(
|
||||
r#"<w:p><w:pPr><w:sectPr><w:pgSz w:w="11906" w:h="16838"/></w:sectPr></w:pPr></w:p>{}{}<w:p><w:r><w:fldChar w:fldCharType="end"/></w:r></w:p>"#,
|
||||
style("TOC1", &format!("{begin}{}", t("Chapter One\t1"))),
|
||||
style("TOC2", &t("Section\t2")),
|
||||
);
|
||||
let pr = r#"<w:id w:val="-1234567"/><w:docPartObj><w:docPartGallery w:val="Table of Contents"/><w:docPartUnique/></w:docPartObj>"#;
|
||||
let d_sect = read(&package(&format!("{}{}", sdt(pr, §_toc), p(&t("Chapter One"))))).unwrap();
|
||||
assert!(controls(&d_sect).is_empty());
|
||||
assert!(d_sect.body[0].as_para().unwrap().section.is_some());
|
||||
let xml_sect = document_xml(&write(&d_sect).unwrap());
|
||||
assert!(xml_sect.contains("Chapter One") && xml_sect.contains("Section"), "{xml_sect}");
|
||||
}
|
||||
@@ -3947,6 +3947,33 @@ fn bidi_theme_slots_follow_the_theme_font_language() {
|
||||
}
|
||||
}
|
||||
|
||||
/// A `w:rFonts` fallback list longer than the reader keeps is cut at the last complete family
|
||||
/// within the bound, so the family a real list would pick survives while a hostile list cannot
|
||||
/// cost its whole length in every piece of text drawn in it.
|
||||
#[test]
|
||||
fn a_hostile_fallback_list_keeps_its_leading_families() {
|
||||
let candidates: Vec<String> = (0..500).map(|i| format!("MissingCandidateFont{i}")).collect();
|
||||
let list = format!("{};Source Serif 4", candidates.join(";"));
|
||||
let body = format!(r#"<w:p><w:r><w:rPr><w:rFonts w:ascii="{list}" w:hAnsi="{list}"/></w:rPr><w:t>x</w:t></w:r></w:p>"#);
|
||||
let d = read_body(&body);
|
||||
let c = &d.body[0].as_para().unwrap().runs[0].props;
|
||||
let kept = c.font.as_deref().expect("the leading families are kept");
|
||||
assert!(kept.len() <= 256, "{} bytes kept", kept.len());
|
||||
assert!(kept.starts_with("MissingCandidateFont0"), "{kept}");
|
||||
// Every kept entry is a whole family name: the cut lands on a `;`, never inside a name.
|
||||
assert!(kept.split(';').all(|f| f.starts_with("MissingCandidateFont") || f.contains("Source Serif")), "{kept}");
|
||||
assert!(!kept.ends_with(';'), "no dangling separator: {kept}");
|
||||
}
|
||||
|
||||
/// A real LibreOffice fallback list is read whole: a few families, well under any bound.
|
||||
#[test]
|
||||
fn a_real_fallback_list_is_read_whole() {
|
||||
let body = r#"<w:p><w:r><w:rPr><w:rFonts w:ascii="Liberation Sans;Arial" w:hAnsi="Liberation Sans;Arial"/></w:rPr><w:t>x</w:t></w:r></w:p>"#;
|
||||
let d = read_body(body);
|
||||
let c = &d.body[0].as_para().unwrap().runs[0].props;
|
||||
assert_eq!(c.font.as_deref(), Some("Liberation Sans;Arial"));
|
||||
}
|
||||
|
||||
/// The font table's family classes: Word draws a missing roman font in a serif face.
|
||||
#[test]
|
||||
fn roman_fonts_are_read_from_the_font_table() {
|
||||
@@ -3956,6 +3983,15 @@ fn roman_fonts_are_read_from_the_font_table() {
|
||||
assert_eq!(d.settings.roman_fonts, ["Brand Serif"]);
|
||||
}
|
||||
|
||||
/// Semicolon-separated roman font fallback lists in `fontTable.xml` register each candidate family.
|
||||
#[test]
|
||||
fn roman_fonts_split_fallback_lists() {
|
||||
let fonts = r#"<?xml version="1.0" encoding="UTF-8" standalone="yes"?><w:fonts xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"><w:font w:name="Liberation Serif;Times New Roman"><w:family w:val="roman"/></w:font></w:fonts>"#;
|
||||
let rels = [("rId9", "http://schemas.openxmlformats.org/officeDocument/2006/relationships/fontTable", "fontTable.xml")];
|
||||
let d = wordcraft_docx::read(&docx("<w:p/>", &rels, &[("word/fontTable.xml", fonts)])).unwrap();
|
||||
assert_eq!(d.settings.roman_fonts, ["Liberation Serif", "Times New Roman"]);
|
||||
}
|
||||
|
||||
/// `w:doNotUseHTMLParagraphAutoSpacing` in the settings' `w:compat`: paragraph spacing adds up.
|
||||
#[test]
|
||||
fn html_auto_spacing_off_is_read_and_kept() {
|
||||
|
||||
@@ -21,6 +21,7 @@ mod heading_levels;
|
||||
mod legacy_forms;
|
||||
mod ligatures;
|
||||
mod malformed;
|
||||
mod nested_block_controls;
|
||||
mod note_comments;
|
||||
mod passthrough;
|
||||
mod picture_links;
|
||||
|
||||
@@ -0,0 +1,180 @@
|
||||
//! A body-level content control nested directly in another body-level one stays body-level when
|
||||
//! saved, even when it holds a single paragraph (ECMA-376 §17.5.2.29-30; [MS-DOCX] `w15`
|
||||
//! repeating sections, which must wrap whole paragraphs or rows). All markup here is synthetic.
|
||||
|
||||
use std::io::Write;
|
||||
|
||||
use wordcraft_doc::{ContentControl, Document, StoryRef};
|
||||
use wordcraft_docx::{read, write};
|
||||
|
||||
const NS: &str =
|
||||
r#"xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main" xmlns:w15="http://schemas.microsoft.com/office/word/2012/wordml""#;
|
||||
const ROOT_RELS: &str = r#"<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument" Target="word/document.xml"/></Relationships>"#;
|
||||
|
||||
fn package(body: &str) -> Vec<u8> {
|
||||
let doc = format!(r#"<w:document {NS}><w:body>{body}</w:body></w:document>"#);
|
||||
let mut zw = zip::ZipWriter::new(std::io::Cursor::new(Vec::new()));
|
||||
for (name, bytes) in [("_rels/.rels", ROOT_RELS.as_bytes()), ("word/document.xml", doc.as_bytes())] {
|
||||
zw.start_file(name, zip::write::SimpleFileOptions::default()).unwrap();
|
||||
zw.write_all(bytes).unwrap();
|
||||
}
|
||||
zw.finish().unwrap().into_inner()
|
||||
}
|
||||
|
||||
fn document_xml(docx: &[u8]) -> String {
|
||||
let mut z = zip::ZipArchive::new(std::io::Cursor::new(docx)).unwrap();
|
||||
let mut s = String::new();
|
||||
std::io::Read::read_to_string(&mut z.by_name("word/document.xml").unwrap(), &mut s).unwrap();
|
||||
s
|
||||
}
|
||||
|
||||
fn t(s: &str) -> String {
|
||||
format!(r#"<w:r><w:t xml:space="preserve">{s}</w:t></w:r>"#)
|
||||
}
|
||||
fn p(inner: &str) -> String {
|
||||
format!("<w:p>{inner}</w:p>")
|
||||
}
|
||||
fn sdt(pr: &str, content: &str) -> String {
|
||||
format!("<w:sdt><w:sdtPr>{pr}</w:sdtPr><w:sdtContent>{content}</w:sdtContent></w:sdt>")
|
||||
}
|
||||
|
||||
fn controls(d: &Document) -> Vec<ContentControl> {
|
||||
let mut out = Vec::new();
|
||||
wordcraft_doc::control::walk_controls(&d.body, &mut |c| out.push(c.clone()));
|
||||
out
|
||||
}
|
||||
|
||||
/// How many `w:p` elements are open at byte `at` of `xml`.
|
||||
fn open_paragraphs(xml: &str, at: usize) -> usize {
|
||||
let head = &xml[..at];
|
||||
let opened = head.matches("<w:p>").count() + head.matches("<w:p ").count();
|
||||
opened - head.matches("</w:p>").count()
|
||||
}
|
||||
|
||||
/// Paragraph depth at the start of the `w:sdt` whose `w:sdtPr` holds `marker`.
|
||||
fn depth_of_control(xml: &str, marker: &str) -> usize {
|
||||
let at = xml.find(marker).unwrap_or_else(|| panic!("{marker} not written:\n{xml}"));
|
||||
let start = xml[..at].rfind("<w:sdt>").expect("control start");
|
||||
open_paragraphs(xml, start)
|
||||
}
|
||||
|
||||
fn round_trip(body: &str) -> (Document, Document, String) {
|
||||
let d = read(&package(body)).unwrap();
|
||||
let bytes = write(&d).unwrap();
|
||||
let back = read(&bytes).unwrap();
|
||||
(d, back, document_xml(&bytes))
|
||||
}
|
||||
|
||||
fn repeating_section() -> String {
|
||||
let field = sdt(r#"<w:alias w:val="note"/><w:id w:val="3"/><w:text/>"#, &t("note 1"));
|
||||
let item = sdt(r#"<w:alias w:val="item"/><w:id w:val="2"/><w15:repeatingSectionItem/>"#, &p(&format!("{field}{}", t(" "))));
|
||||
let section = sdt(r#"<w:alias w:val="notes"/><w:id w:val="1"/><w15:repeatingSection/>"#, &item);
|
||||
format!("{}{section}{}", p(&t("Notes")), p(&t("End")))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_repeating_section_around_one_paragraph_stays_around_the_paragraph() {
|
||||
let (_, _, xml) = round_trip(&repeating_section());
|
||||
assert_eq!(depth_of_control(&xml, r#"<w:alias w:val="notes"/>"#), 0, "section written inside a paragraph:\n{xml}");
|
||||
assert_eq!(depth_of_control(&xml, r#"<w:alias w:val="item"/>"#), 0, "item written inside a paragraph:\n{xml}");
|
||||
// The text control stays inside the paragraph, and the item holds that paragraph.
|
||||
assert_eq!(depth_of_control(&xml, r#"<w:alias w:val="note"/>"#), 1);
|
||||
assert!(xml.contains("<w15:repeatingSectionItem/></w:sdtPr><w:sdtContent><w:p"), "{xml}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_repeating_section_round_trips_its_controls_and_text() {
|
||||
let (d, back, _) = round_trip(&repeating_section());
|
||||
assert_eq!(controls(&back), controls(&d));
|
||||
assert_eq!(back.plain_text(StoryRef::Body), d.plain_text(StoryRef::Body));
|
||||
assert_eq!(back.body.len(), d.body.len());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn plain_nested_block_controls_around_one_paragraph_stay_block_level() {
|
||||
let inner = sdt(r#"<w:alias w:val="inner"/><w:id w:val="11"/>"#, &p(&t("Only paragraph")));
|
||||
let outer = sdt(r#"<w:alias w:val="outer"/><w:id w:val="10"/>"#, &inner);
|
||||
let (d, back, xml) = round_trip(&format!("{outer}{}", p(&t("After"))));
|
||||
assert_eq!(depth_of_control(&xml, r#"<w:alias w:val="outer"/>"#), 0, "{xml}");
|
||||
assert_eq!(depth_of_control(&xml, r#"<w:alias w:val="inner"/>"#), 0, "{xml}");
|
||||
assert_eq!(controls(&back), controls(&d));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nested_block_controls_in_a_table_cell_stay_block_level() {
|
||||
let inner = sdt(r#"<w:alias w:val="cellInner"/><w:id w:val="21"/>"#, &p(&t("In cell")));
|
||||
let outer = sdt(r#"<w:alias w:val="cellOuter"/><w:id w:val="20"/>"#, &inner);
|
||||
let table = format!(r#"<w:tbl><w:tblGrid><w:gridCol w:w="4000"/></w:tblGrid><w:tr><w:tc>{outer}</w:tc></w:tr></w:tbl>"#);
|
||||
let (_, _, xml) = round_trip(&format!("{table}{}", p(&t("After"))));
|
||||
assert_eq!(depth_of_control(&xml, r#"<w:alias w:val="cellOuter"/>"#), 0, "{xml}");
|
||||
assert_eq!(depth_of_control(&xml, r#"<w:alias w:val="cellInner"/>"#), 0, "{xml}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_run_level_control_stays_inside_its_paragraph() {
|
||||
let field = sdt(r#"<w:alias w:val="inline"/><w:id w:val="30"/>"#, &t("value"));
|
||||
let (_, _, xml) = round_trip(&p(&format!("{}{field}", t("Name: "))));
|
||||
assert_eq!(depth_of_control(&xml, r#"<w:alias w:val="inline"/>"#), 1, "{xml}");
|
||||
}
|
||||
|
||||
/// A bound repeating section as Word writes one at the end of a form, optionally followed by
|
||||
/// the body-level `_GoBack` bookmark Word leaves after the last control.
|
||||
fn bound_section_body(bound: bool, bookmark: bool) -> String {
|
||||
let section_binding = if bound {
|
||||
r#"<w15:dataBinding w:prefixMappings="" w:xpath="/a[1]/n[1]" w:storeItemID="{00000000-0000-0000-0000-0000000000AA}"/>"#
|
||||
} else {
|
||||
""
|
||||
};
|
||||
let trailing = if bookmark { r#"<w:bookmarkStart w:id="0" w:name="_GoBack"/><w:bookmarkEnd w:id="0"/>"# } else { "" };
|
||||
let bind = |x: &str| format!(r#"<w:dataBinding w:prefixMappings="" w:xpath="{x}" w:storeItemID="{{00000000-0000-0000-0000-0000000000AA}}"/>"#);
|
||||
let field = format!(
|
||||
r#"<w:sdt><w:sdtPr><w:alias w:val="note"/><w:placeholder><w:docPart w:val="DefaultPlaceholder_1"/></w:placeholder>{}<w:text/></w:sdtPr><w:sdtEndPr/><w:sdtContent>{}</w:sdtContent></w:sdt>"#,
|
||||
if bound { bind("/a[1]/n[1]") } else { String::new() },
|
||||
t("note 1")
|
||||
);
|
||||
let item = format!(
|
||||
r#"<w:sdt><w:sdtPr><w:alias w:val="item"/><w:placeholder><w:docPart w:val="DefaultPlaceholder_2"/></w:placeholder><w15:repeatingSectionItem/></w:sdtPr><w:sdtEndPr/><w:sdtContent>{}</w:sdtContent></w:sdt>"#,
|
||||
p(&format!("{field}{}", t(" ")))
|
||||
);
|
||||
let section = format!(
|
||||
r#"<w:sdt><w:sdtPr><w:alias w:val="notes"/>{section_binding}<w15:repeatingSection/></w:sdtPr><w:sdtContent>{item}</w:sdtContent></w:sdt>"#
|
||||
);
|
||||
format!(r#"{}{section}{trailing}<w:sectPr><w:pgSz w:w="12240" w:h="15840"/></w:sectPr>"#, p(&t("Notes")))
|
||||
}
|
||||
|
||||
fn assert_section_block_level(body: &str) {
|
||||
let (d, back, xml) = round_trip(body);
|
||||
assert_eq!(depth_of_control(&xml, r#"<w:alias w:val="notes"/>"#), 0, "section written inside a paragraph:\n{xml}");
|
||||
assert_eq!(depth_of_control(&xml, r#"<w:alias w:val="item"/>"#), 0, "item written inside a paragraph:\n{xml}");
|
||||
assert_eq!(depth_of_control(&xml, r#"<w:alias w:val="note"/>"#), 1, "{xml}");
|
||||
assert_eq!(controls(&back), controls(&d));
|
||||
assert_eq!(back.plain_text(StoryRef::Body), d.plain_text(StoryRef::Body));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_bound_repeating_section_followed_by_a_body_bookmark_stays_block_level() {
|
||||
assert_section_block_level(&bound_section_body(true, true));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_bound_repeating_section_at_the_end_of_the_body_stays_block_level() {
|
||||
assert_section_block_level(&bound_section_body(true, false));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_repeating_section_followed_by_a_body_bookmark_stays_block_level() {
|
||||
assert_section_block_level(&bound_section_body(false, true));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_block_control_followed_by_a_body_bookmark_stays_around_its_paragraph() {
|
||||
let inner = sdt(r#"<w:alias w:val="inner"/><w:id w:val="41"/>"#, &p(&t("Last paragraph")));
|
||||
let outer = sdt(r#"<w:alias w:val="outer"/><w:id w:val="40"/>"#, &inner);
|
||||
let body = format!(r#"{}{outer}<w:bookmarkStart w:id="0" w:name="_GoBack"/><w:bookmarkEnd w:id="0"/>"#, p(&t("First")));
|
||||
let (d, back, xml) = round_trip(&body);
|
||||
assert_eq!(depth_of_control(&xml, r#"<w:alias w:val="outer"/>"#), 0, "{xml}");
|
||||
assert_eq!(depth_of_control(&xml, r#"<w:alias w:val="inner"/>"#), 0, "{xml}");
|
||||
assert_eq!(controls(&back), controls(&d));
|
||||
// The bookmark is kept.
|
||||
assert!(xml.contains(r#"w:name="_GoBack""#), "{xml}");
|
||||
}
|
||||
@@ -270,3 +270,12 @@ fn flat_opc_limits_count_what_is_not_kept() {
|
||||
// A part nothing reads, one element over what the XML tree reader takes in a part (8 000 000).
|
||||
assert!(limited(&format!(r#"<pkg:part pkg:name="/word/unused.xml"><pkg:xmlData><r>{}</r></pkg:xmlData></pkg:part>"#, "<y/>".repeat(8_000_000))));
|
||||
}
|
||||
|
||||
/// A Word 2003 XML Document saved with Windows line ends: XML reads CR LF as one line feed (XML
|
||||
/// 1.0 §2.11), so a line break inside `w:t` is one space, not two.
|
||||
#[test]
|
||||
fn word_2003_xml_with_windows_line_ends_reads_one_space_per_line_break() {
|
||||
let xml = "<?xml version=\"1.0\" encoding=\"UTF-8\" standalone=\"yes\"?>\r\n<w:wordDocument xmlns:w=\"http://schemas.microsoft.com/office/word/2003/wordml\">\r\n<w:body><w:p><w:r><w:t>a\r\nb</w:t></w:r></w:p></w:body></w:wordDocument>\r\n";
|
||||
let d = read_xml(xml.as_bytes()).unwrap();
|
||||
assert_eq!(paras(&d)[0].plain_text(), "a b");
|
||||
}
|
||||
@@ -132,14 +132,26 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
} else {
|
||||
return Err(CmdError::Params("`path` or `data` required".into()));
|
||||
};
|
||||
wordcraft_geom::picture::probe(&bytes).map_err(|e| match e {
|
||||
wordcraft_geom::picture::Refusal::Unsupported(why) => CmdError::Failed(why.into()),
|
||||
_ => CmdError::Failed("not a supported image".into()),
|
||||
})?;
|
||||
let (px_w, px_h) = wordcraft_geom::picture::probe(&bytes)
|
||||
.map(|p| (p.width, p.height))
|
||||
.map_err(|e| match e {
|
||||
wordcraft_geom::picture::Refusal::Unsupported(why) => CmdError::Failed(why.into()),
|
||||
_ => CmdError::Failed("not a supported image".into()),
|
||||
})?;
|
||||
let key = s.doc.add_media(bytes.clone(), crate::cmd::insert::image_ext(&bytes));
|
||||
with_obj(s, |o| {
|
||||
if let InlineObject::Image { media, .. } = o {
|
||||
if let InlineObject::Image { media, w, h, crop, .. } = o {
|
||||
*media = key.clone();
|
||||
// The replacement keeps the picture's width and takes its own aspect ratio, so a
|
||||
// picture of another shape isn't stretched into the old frame. The old crop
|
||||
// described the old picture, so it is dropped.
|
||||
if px_w > 0 && px_h > 0 && w.is_finite() && *w > 0.0 {
|
||||
let nh = *w * px_h as f32 / px_w as f32;
|
||||
if nh.is_finite() && nh > 0.0 {
|
||||
*h = nh;
|
||||
}
|
||||
}
|
||||
*crop = [0.0; 4];
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
@@ -10817,6 +10817,44 @@ fn picture_change_keeps_the_replacement_format() {
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
/// Change Picture keeps the width and takes the replacement's aspect ratio: swapping a figure for
|
||||
/// a revised one of a different shape must not stretch it into the old frame.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
#[test]
|
||||
fn picture_change_keeps_width_and_takes_new_aspect() {
|
||||
let dir = scratch_dir("picture-change-aspect");
|
||||
let mut wide = Vec::new();
|
||||
image::RgbImage::from_pixel(40, 20, image::Rgb([200, 30, 30])).write_to(&mut std::io::Cursor::new(&mut wide), image::ImageFormat::Png).unwrap();
|
||||
let mut tall = Vec::new();
|
||||
image::RgbImage::from_pixel(30, 60, image::Rgb([30, 30, 200])).write_to(&mut std::io::Cursor::new(&mut tall), image::ImageFormat::Png).unwrap();
|
||||
let (first, second) = (dir.join("wide.png"), dir.join("tall.png"));
|
||||
std::fs::write(&first, &wide).unwrap();
|
||||
std::fs::write(&second, &tall).unwrap();
|
||||
|
||||
let mut s = Session::new(wordcraft_doc::Document::new());
|
||||
run(&mut s, "insert.picture", json!({"path": first.to_string_lossy()}));
|
||||
let size = |s: &Session| {
|
||||
let mut out = (0.0f32, 0.0f32, [1.0f32; 4]);
|
||||
s.doc.objects_in_reading_order(&mut |o| {
|
||||
if let wordcraft_doc::para::InlineObject::Image { w, h, crop, .. } = o {
|
||||
out = (*w, *h, *crop);
|
||||
}
|
||||
});
|
||||
out
|
||||
};
|
||||
let (w0, h0, _) = size(&s);
|
||||
assert!((w0 / h0 - 2.0).abs() < 1e-3, "inserted at its own 2:1 shape: {w0} x {h0}");
|
||||
run(&mut s, "picture.crop", json!({"left": 0.1}));
|
||||
let (w_cropped, _, crop) = size(&s);
|
||||
assert!(crop[0] > 0.0, "cropped before the change");
|
||||
run(&mut s, "picture.change", json!({"path": second.to_string_lossy()}));
|
||||
let (w1, h1, crop) = size(&s);
|
||||
assert!((w1 - w_cropped).abs() < 1e-3, "the width is kept: {w_cropped} -> {w1}");
|
||||
assert!((h1 / w1 - 2.0).abs() < 1e-3, "the height follows the new 1:2 picture: {w1} x {h1}");
|
||||
assert_eq!(crop, [0.0; 4], "the old picture's crop is dropped");
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn json_open_repairs_hostile_run_lengths() {
|
||||
use wordcraft_doc::{Block, Document};
|
||||
|
||||
@@ -469,3 +469,43 @@ fn an_east_asian_font_is_shown_as_the_face_its_text_is_drawn_in() {
|
||||
assert_eq!(m[0].shown_as, [shown], "{m:?}");
|
||||
assert!(crate::cmd::font::east_asian_shown_as(&doc, "JetBrains Mono", "JetBrains Mono").is_none(), "installed");
|
||||
}
|
||||
|
||||
/// A semicolon-separated LibreOffice font fallback list is not reported as missing when any
|
||||
/// candidate is installed, and substitutes by candidate family when none is.
|
||||
#[test]
|
||||
fn libreoffice_font_fallback_lists_are_not_reported_as_missing_when_installed() {
|
||||
let db = wordcraft_fonts::FontDb::global();
|
||||
let doc = doc_of(vec![para_in("Text", "Liberation Sans;Arial")]);
|
||||
if db.has_family("Liberation Sans") || db.has_family("Arial") {
|
||||
let m = missing_in(doc.clone());
|
||||
assert!(m.is_empty(), "expected not to be reported as missing: {m:?}");
|
||||
}
|
||||
let bundled_fallback = doc_of(vec![para_in("Text", "NonExistentSans123;Source Sans 3")]);
|
||||
assert!(missing_in(bundled_fallback).is_empty(), "bundled fallback candidate is installed");
|
||||
|
||||
// Roundtrip through DOCX export and import:
|
||||
let docx_bytes = crate::io::save_bytes("out.docx", &doc).unwrap();
|
||||
let loaded = wordcraft_docx::read(&docx_bytes).unwrap();
|
||||
assert_eq!(loaded.body[0].as_para().unwrap().runs[0].props.font.as_deref(), Some("Liberation Sans;Arial"));
|
||||
if db.has_family("Liberation Sans") || db.has_family("Arial") {
|
||||
let m = missing_in(loaded);
|
||||
assert!(m.is_empty(), "expected not to report installed fallback font as missing: {m:?}");
|
||||
}
|
||||
|
||||
// When none of the fonts is installed, missing_fonts flags it with an appropriate substitute:
|
||||
if !db.has_family("NonExistentSans123") && !db.has_family("Times New Roman") {
|
||||
let m = missing_in(doc_of(vec![para_in("Text", "NonExistentSans123;Times New Roman")]));
|
||||
assert_eq!(m.len(), 1);
|
||||
assert_eq!(m[0].font, "NonExistentSans123;Times New Roman");
|
||||
let drawn = m[0].shown_as.first().expect("drawn face");
|
||||
assert!(drawn.contains("Serif") || drawn == "Times" || drawn == "Tinos", "drawn: {drawn}");
|
||||
}
|
||||
let m = missing_in(doc_of(vec![para_in("Text", "NonExistentSans123;NonExistentSerif123")]));
|
||||
assert_eq!(m.len(), 1);
|
||||
assert_eq!(m[0].font, "NonExistentSans123;NonExistentSerif123");
|
||||
let drawn = m[0].shown_as.first().expect("drawn face");
|
||||
assert!(
|
||||
drawn.contains("Serif") || drawn == "Times" || drawn == "Times New Roman" || drawn == "Tinos" || drawn == "Caladea" || drawn == "Georgia",
|
||||
"drawn: {drawn}"
|
||||
);
|
||||
}
|
||||
@@ -9,6 +9,19 @@ use wordcraft_doc::{Pos, StoryRef};
|
||||
|
||||
use crate::Session;
|
||||
|
||||
/// Leaf #152: deleting the empty heading above body text must not promote that text to a heading.
|
||||
/// Deleting the empty heading mark keeps the next paragraph as body text.
|
||||
#[test]
|
||||
fn deleting_a_preceding_empty_heading_keeps_the_body_paragraph_style() {
|
||||
let mut s = Session::new(wordcraft_doc::Document::from_text("\nBody paragraph."));
|
||||
run(&mut s, "para.style", json!({"style": "Heading1"}));
|
||||
run(&mut s, "text.delete", json!({}));
|
||||
let p = s.doc.para_at(&Pos::body(0, 0)).unwrap();
|
||||
assert_eq!(p.text, "Body paragraph.");
|
||||
assert_eq!(p.props.style, None);
|
||||
assert_eq!(wordcraft_layout::outline::levels(&s.doc), [wordcraft_layout::outline::BODY]);
|
||||
}
|
||||
|
||||
/// Create an empty session for command-driven typing fixtures.
|
||||
fn s() -> Session {
|
||||
Session::new(wordcraft_doc::Document::new())
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
use serde_json::{Value, json};
|
||||
use wordcraft_doc::props::CharProps;
|
||||
use wordcraft_doc::{Document, Path, Pos, StoryRef};
|
||||
use wordcraft_engine::{Selection, Session};
|
||||
use wordcraft_layout::outline;
|
||||
|
||||
/// Dispatch an engine command and report its ID if it fails.
|
||||
fn run(s: &mut Session, id: &str, params: Value) {
|
||||
s.run(id, ¶ms).unwrap_or_else(|e| panic!("{id}: {e}"));
|
||||
}
|
||||
|
||||
/// Create an empty heading, mixed-run body text and a genuine TOC heading.
|
||||
fn fixture() -> Session {
|
||||
let mut s = Session::new(Document::from_text("\nBody paragraph.\nReal heading\n"));
|
||||
run(&mut s, "para.style", json!({"style": "Heading1"}));
|
||||
s.doc.para_mut(StoryRef::Body, &Path::top(0)).unwrap().mark =
|
||||
CharProps { font: Some("Source Sans 3".into()), size: Some(40.0), bold: Some(true), ..Default::default() };
|
||||
let p = s.doc.para_mut(StoryRef::Body, &Path::top(1)).unwrap();
|
||||
p.mark = CharProps { size: Some(11.0), bold: Some(false), ..Default::default() };
|
||||
p.format(5, 14, &|c| c.italic = Some(true)).unwrap();
|
||||
s.doc.para_mut(StoryRef::Body, &Path::top(2)).unwrap().props.style = Some("Heading2".into());
|
||||
s
|
||||
}
|
||||
|
||||
/// Keyboard and selection deletes preserve body formatting through Undo and Redo.
|
||||
#[test]
|
||||
fn empty_heading_delete_backspace_and_selection_preserve_body_format_and_history() {
|
||||
for mode in ["delete", "backspace", "selection", "reverse selection"] {
|
||||
for pending in [false, true] {
|
||||
let mut s = fixture();
|
||||
let expected = s.doc.para_at(&Pos::body(1, 0)).unwrap().clone();
|
||||
s.sel = match mode {
|
||||
"backspace" => Selection::caret(Pos::body(1, 0)),
|
||||
"selection" => Selection { anchor: Pos::body(0, 0), focus: Pos::body(1, 0) },
|
||||
"reverse selection" => Selection { anchor: Pos::body(1, 0), focus: Pos::body(0, 0) },
|
||||
_ => Selection::caret(Pos::body(0, 0)),
|
||||
};
|
||||
if pending {
|
||||
s.pending = Some(CharProps { size: Some(48.0), bold: Some(true), ..Default::default() });
|
||||
}
|
||||
let before = serde_json::to_value(&s.doc.body).unwrap();
|
||||
run(&mut s, if mode == "backspace" { "text.backspace" } else { "text.delete" }, json!({}));
|
||||
let p = s.doc.para_at(&Pos::body(0, 0)).unwrap();
|
||||
assert_eq!(p.text, expected.text, "{mode}, pending={pending}");
|
||||
assert_eq!(p.props, expected.props);
|
||||
assert_eq!(p.mark, expected.mark);
|
||||
assert_eq!(serde_json::to_value(&p.runs).unwrap(), serde_json::to_value(&expected.runs).unwrap());
|
||||
let actual = s.doc.styles.resolve_char(p.props.style.as_deref(), p.props_of_char(0));
|
||||
let old = s.doc.styles.resolve_char(expected.props.style.as_deref(), expected.props_of_char(0));
|
||||
assert_eq!((actual.font, actual.size, actual.bold, actual.italic), (old.font, old.size, old.bold, old.italic));
|
||||
assert!(s.pending.is_none());
|
||||
assert_eq!(outline::levels(&s.doc)[..2], [outline::BODY, 2]);
|
||||
let after = serde_json::to_value(&s.doc.body).unwrap();
|
||||
run(&mut s, "edit.undo", json!({}));
|
||||
assert_eq!(serde_json::to_value(&s.doc.body).unwrap(), before);
|
||||
run(&mut s, "edit.redo", json!({}));
|
||||
assert_eq!(serde_json::to_value(&s.doc.body).unwrap(), after);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Saving to DOCX and generating a TOC must keep the surviving body text out of the outline.
|
||||
#[test]
|
||||
fn deleted_empty_heading_does_not_promote_body_text_into_toc_or_docx() {
|
||||
let mut s = fixture();
|
||||
run(&mut s, "text.delete", json!({}));
|
||||
assert_eq!(outline::levels(&s.doc)[..2], [outline::BODY, 2]);
|
||||
let bytes = wordcraft_docx::write(&s.doc).unwrap();
|
||||
let mut s = Session::new(wordcraft_docx::read(&bytes).unwrap());
|
||||
assert_eq!(outline::levels(&s.doc)[..2], [outline::BODY, 2]);
|
||||
let p = s.doc.para_at(&Pos::body(0, 0)).unwrap();
|
||||
assert_eq!(p.props.style.as_deref(), None);
|
||||
assert_eq!(p.mark.size, Some(11.0));
|
||||
assert_eq!(p.props_of_char(6).italic, Some(true));
|
||||
s.sel = Selection::caret(Pos::body(2, 0));
|
||||
run(&mut s, "references.toc", json!({"levels": 3}));
|
||||
let text = s.doc.plain_text(StoryRef::Body);
|
||||
assert_eq!(text.matches("Body paragraph.").count(), 1, "body text is not a TOC entry: {text}");
|
||||
assert_eq!(text.matches("Real heading").count(), 2, "the genuine heading stays in the TOC: {text}");
|
||||
}
|
||||
+377
-21
@@ -32,12 +32,27 @@ fn substitutes(family: &str) -> &'static [&'static str] {
|
||||
/// as Liberation Serif for Times New Roman, or the same design under another name), rather than
|
||||
/// the generic lists any unknown font gets?
|
||||
pub fn has_named_substitutes(family: &str) -> bool {
|
||||
if family.contains(';') {
|
||||
return list_class(family).named;
|
||||
}
|
||||
substitutes_by_name(charset_base_family(family)).1
|
||||
}
|
||||
|
||||
const SANS: &[&str] = &["Carlito", "Helvetica Neue", "Arial", "Liberation Sans", "Noto Sans", "DejaVu Sans", "Source Sans 3"];
|
||||
|
||||
/// [`substitutes`], and whether they were chosen for this family by name.
|
||||
fn substitutes_by_name(family: &str) -> (&'static [&'static str], bool) {
|
||||
const SANS: &[&str] = &["Carlito", "Helvetica Neue", "Arial", "Liberation Sans", "Noto Sans", "DejaVu Sans", "Source Sans 3"];
|
||||
if family.contains(';') {
|
||||
for cand in family.split(';').map(str::trim).filter(|s| !s.is_empty()) {
|
||||
let (subs, named) = substitutes_by_name(cand);
|
||||
if named || is_thai_family(cand) || subs != SANS {
|
||||
return (subs, named);
|
||||
}
|
||||
}
|
||||
if let Some(first) = family.split(';').map(str::trim).find(|s| !s.is_empty()) {
|
||||
return substitutes_by_name(first);
|
||||
}
|
||||
}
|
||||
const SERIF: &[&str] = &["Caladea", "Times New Roman", "Liberation Serif", "Georgia", "Noto Serif", "DejaVu Serif", "Source Serif 4"];
|
||||
const MONO: &[&str] = &["Courier New", "Liberation Mono", "Menlo", "Consolas", "DejaVu Sans Mono", "JetBrains Mono"];
|
||||
const ARIAL: &[&str] = &["Liberation Sans", "Helvetica", "Helvetica Neue", "Arimo", "Source Sans 3"];
|
||||
@@ -157,11 +172,11 @@ fn substitutes_by_name(family: &str) -> (&'static [&'static str], bool) {
|
||||
/// itself: a Symbol bullet makes its line taller than the text alone. The values are those Word's
|
||||
/// PDFs give the fonts (`/Ascent`, `/Descent`).
|
||||
pub fn symbol_font_metrics(family: &str) -> Option<(f64, f64)> {
|
||||
match family.trim().to_ascii_lowercase().as_str() {
|
||||
family.split(';').map(str::trim).filter(|f| !f.is_empty()).find_map(|f| match f.to_ascii_lowercase().as_str() {
|
||||
"symbol" | "symbol (pcl6)" => Some((1.005, 0.216)),
|
||||
"wingdings" => Some((0.899, 0.205)),
|
||||
_ => None,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/// The (ascent, descent) in em Word gives a line with a run in a symbol font, when that isn't the
|
||||
@@ -224,7 +239,10 @@ fn metric_twins(family: &str) -> impl Iterator<Item = &'static str> + '_ {
|
||||
/// wraps and paginates as in `family`.
|
||||
pub fn metric_compatible(family: &str, substitute: &str) -> bool {
|
||||
let s = substitute.trim();
|
||||
metric_twins(charset_base_family(family.trim())).any(|t| t.eq_ignore_ascii_case(s))
|
||||
family.split(';').map(str::trim).filter(|f| !f.is_empty()).any(|f| {
|
||||
let base = charset_base_family(f);
|
||||
base.eq_ignore_ascii_case(s) || metric_twins(base).any(|t| t.eq_ignore_ascii_case(s))
|
||||
})
|
||||
}
|
||||
|
||||
/// Naskh-style Persian/Arabic document fonts (the "B …" family names come from the common
|
||||
@@ -356,10 +374,63 @@ fn is_thai_serif(f: &str) -> bool {
|
||||
/// Is `family` a Thai document font ([`is_thai`])? A missing one is drawn in a Thai face for the
|
||||
/// whole run, never in a Latin substitute (#1549).
|
||||
pub fn is_thai_family(family: &str) -> bool {
|
||||
let f = family.to_ascii_lowercase();
|
||||
if family.contains(';') {
|
||||
return list_class(family).thai;
|
||||
}
|
||||
let f = family.trim().to_ascii_lowercase();
|
||||
is_thai(&f) || is_thai_serif(&f)
|
||||
}
|
||||
|
||||
/// What the name-based predicates answer about one semicolon-separated fallback list. Shaping asks
|
||||
/// them for every piece of every run set in the list, so the answer is worked out once per list
|
||||
/// rather than once per piece.
|
||||
#[derive(Clone, Copy, Default)]
|
||||
struct ListClass {
|
||||
/// [`is_thai_family`].
|
||||
thai: bool,
|
||||
/// [`has_named_substitutes`].
|
||||
named: bool,
|
||||
}
|
||||
|
||||
/// [`ListClass`] of the semicolon-separated `family`, memoised by its text.
|
||||
fn list_class(family: &str) -> ListClass {
|
||||
let key = family_list_key(family);
|
||||
let generation = FontDb::global().generation();
|
||||
{
|
||||
let mut c = fallback_list_cache().lock().unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
if c.generation != generation {
|
||||
*c = FallbackListMemo { generation, ..Default::default() };
|
||||
} else if let Some((cached, class)) = c.classes.get(&key)
|
||||
&& cached == family
|
||||
{
|
||||
return *class;
|
||||
}
|
||||
}
|
||||
let class = ListClass { thai: classify_thai(family), named: classify_named(family) };
|
||||
let mut c = fallback_list_cache().lock().unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
if c.generation == generation {
|
||||
if c.classes.len() >= FAMILY_CACHE_MAX {
|
||||
c.classes.clear();
|
||||
}
|
||||
c.classes.insert(key, (family.to_string(), class));
|
||||
}
|
||||
class
|
||||
}
|
||||
|
||||
/// Whether any family of the list is a Thai document font: what [`is_thai_family`] answers.
|
||||
fn classify_thai(family: &str) -> bool {
|
||||
family.split(';').map(str::trim).filter(|f| !f.is_empty()).any(|f| {
|
||||
let f = f.to_ascii_lowercase();
|
||||
is_thai(&f) || is_thai_serif(&f)
|
||||
})
|
||||
}
|
||||
|
||||
/// Whether any family of the list has substitutes of its own: what [`has_named_substitutes`]
|
||||
/// answers.
|
||||
fn classify_named(family: &str) -> bool {
|
||||
family.split(';').map(str::trim).filter(|f| !f.is_empty()).any(|f| substitutes_by_name(charset_base_family(f)).1)
|
||||
}
|
||||
|
||||
/// Khmer document fonts: the Khmer OS family, Khmer Mool1 and the like, Windows' and the Mac's
|
||||
/// own and Noto's. Matched by name only.
|
||||
fn is_khmer(f: &str) -> bool {
|
||||
@@ -416,8 +487,168 @@ fn resolve_cache() -> &'static Mutex<Memo> {
|
||||
}
|
||||
|
||||
const FAMILY_CACHE_MAX: usize = 4096;
|
||||
/// Family names longer than this (bytes) are resolved but never remembered.
|
||||
/// Family names longer than this (bytes) are resolved but never remembered as string keys.
|
||||
const FAMILY_KEY_MAX: usize = 256;
|
||||
/// Maximum number of direct `FontDb::has_family` catalog scans per uncached fallback list
|
||||
/// for candidate names not present in [`FontDb::families`].
|
||||
const MAX_DIRECT_CANDIDATE_PROBES: usize = 32;
|
||||
|
||||
/// Memoised fallback-list resolutions and installed-family lookups for the current [`FontDb::generation`].
|
||||
#[derive(Default)]
|
||||
struct FallbackListMemo {
|
||||
generation: u64,
|
||||
families: std::collections::HashSet<String>,
|
||||
probed: HashMap<String, bool>,
|
||||
lists: HashMap<(u64, usize), (String, (String, bool))>,
|
||||
long_resolved: HashMap<(u64, usize, bool, bool), (String, Resolved)>,
|
||||
classes: HashMap<(u64, usize), (String, ListClass)>,
|
||||
}
|
||||
|
||||
/// Shared cache for fallback-list resolutions and candidate availability checks.
|
||||
fn fallback_list_cache() -> &'static Mutex<FallbackListMemo> {
|
||||
static CACHE: OnceLock<Mutex<FallbackListMemo>> = OnceLock::new();
|
||||
CACHE.get_or_init(|| Mutex::new(FallbackListMemo::default()))
|
||||
}
|
||||
|
||||
/// Hash and byte length of `family`, used as a compact cache key for fallback lists.
|
||||
fn family_list_key(family: &str) -> (u64, usize) {
|
||||
use std::hash::{Hash, Hasher};
|
||||
let mut h = std::collections::hash_map::DefaultHasher::new();
|
||||
family.hash(&mut h);
|
||||
(h.finish(), family.len())
|
||||
}
|
||||
|
||||
/// Whether `cand` is an installed family in `db`, using the memoised family set and bounded
|
||||
/// direct catalog probes so long fallback lists do not rescan the font catalog for every candidate.
|
||||
fn candidate_has_family(db: &FontDb, cand: &str, direct_probes: &mut usize) -> bool {
|
||||
let cand = cand.trim();
|
||||
if cand.is_empty() || cand.len() > FAMILY_KEY_MAX {
|
||||
return false;
|
||||
}
|
||||
let generation = db.generation();
|
||||
let lower = cand.to_ascii_lowercase();
|
||||
{
|
||||
let mut c = fallback_list_cache().lock().unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
if c.generation != generation {
|
||||
*c = FallbackListMemo { generation, ..Default::default() };
|
||||
}
|
||||
if c.families.is_empty() {
|
||||
c.families = db.families().into_iter().map(|f| f.to_ascii_lowercase()).collect();
|
||||
}
|
||||
if c.families.contains(&lower) {
|
||||
return true;
|
||||
}
|
||||
if let Some(&hit) = c.probed.get(&lower) {
|
||||
return hit;
|
||||
}
|
||||
}
|
||||
if *direct_probes == 0 {
|
||||
return false;
|
||||
}
|
||||
*direct_probes -= 1;
|
||||
let hit = db.has_family(cand);
|
||||
let mut c = fallback_list_cache().lock().unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
if c.generation == generation {
|
||||
if c.probed.len() >= FAMILY_CACHE_MAX {
|
||||
c.probed.clear();
|
||||
}
|
||||
c.probed.insert(lower, hit);
|
||||
}
|
||||
hit
|
||||
}
|
||||
|
||||
/// Record a resolved `(family, substituted)` pair for a semicolon-separated fallback list.
|
||||
fn remember_list_resolution(generation: u64, key: (u64, usize), family: &str, result: (String, bool)) -> (String, bool) {
|
||||
let mut c = fallback_list_cache().lock().unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
if c.generation == generation {
|
||||
if c.lists.len() >= FAMILY_CACHE_MAX {
|
||||
c.lists.clear();
|
||||
}
|
||||
c.lists.insert(key, (family.to_string(), result.clone()));
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
/// Resolve a single family or semicolon-separated fallback list to `(family_name, substituted)`.
|
||||
fn resolve_family(db: &FontDb, family: &str) -> (String, bool) {
|
||||
if !family.contains(';') {
|
||||
let base = if family.trim().eq_ignore_ascii_case("Symbol (PCL6)") { "Symbol" } else { charset_base_family(family) };
|
||||
return if db.has_family(family) {
|
||||
(family.to_string(), false)
|
||||
} else if base != family && db.has_family(base) {
|
||||
(base.to_string(), false)
|
||||
} else {
|
||||
(substitute(db, base).unwrap_or(crate::FALLBACK_FAMILY).to_string(), true)
|
||||
};
|
||||
}
|
||||
|
||||
let generation = db.generation();
|
||||
let list_key = family_list_key(family);
|
||||
{
|
||||
let mut c = fallback_list_cache().lock().unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
if c.generation != generation {
|
||||
*c = FallbackListMemo { generation, ..Default::default() };
|
||||
} else if let Some((cached_family, r)) = c.lists.get(&list_key)
|
||||
&& cached_family == family
|
||||
{
|
||||
return r.clone();
|
||||
}
|
||||
}
|
||||
|
||||
let candidates: Vec<&str> = family.split(';').map(str::trim).filter(|s| !s.is_empty() && s.len() <= FAMILY_KEY_MAX).collect();
|
||||
if candidates.is_empty() {
|
||||
return remember_list_resolution(generation, list_key, family, (crate::FALLBACK_FAMILY.to_string(), true));
|
||||
}
|
||||
|
||||
let mut direct_probes = MAX_DIRECT_CANDIDATE_PROBES;
|
||||
|
||||
// 1. First installed family in the list:
|
||||
for &cand in &candidates {
|
||||
let base = if cand.eq_ignore_ascii_case("Symbol (PCL6)") { "Symbol" } else { charset_base_family(cand) };
|
||||
if candidate_has_family(db, cand, &mut direct_probes) {
|
||||
return remember_list_resolution(generation, list_key, family, (cand.to_string(), false));
|
||||
}
|
||||
if base != cand && candidate_has_family(db, base, &mut direct_probes) {
|
||||
return remember_list_resolution(generation, list_key, family, (base.to_string(), false));
|
||||
}
|
||||
}
|
||||
|
||||
// 2. None installed: fall back to an installed substitute for an entry in the list rather than generic Carlito.
|
||||
// Try Thai substitutes before metric twins of later candidates:
|
||||
for &cand in &candidates {
|
||||
let base = if cand.eq_ignore_ascii_case("Symbol (PCL6)") { "Symbol" } else { charset_base_family(cand) };
|
||||
if is_thai_family(base)
|
||||
&& let Some(sub) = substitute(db, base)
|
||||
{
|
||||
return remember_list_resolution(generation, list_key, family, (sub.to_string(), true));
|
||||
}
|
||||
let mut twin_probes = MAX_DIRECT_CANDIDATE_PROBES;
|
||||
if let Some(twin) = metric_twins(base).find(|t| candidate_has_family(db, t, &mut twin_probes)) {
|
||||
return remember_list_resolution(generation, list_key, family, (twin.to_string(), true));
|
||||
}
|
||||
}
|
||||
|
||||
// Try candidates with named or category substitutes (Thai, Hebrew, Khmer, Persian, serif, mono):
|
||||
for &cand in &candidates {
|
||||
let base = if cand.eq_ignore_ascii_case("Symbol (PCL6)") { "Symbol" } else { charset_base_family(cand) };
|
||||
let (subs, named) = substitutes_by_name(base);
|
||||
if (named || is_thai_family(base) || subs != SANS)
|
||||
&& let Some(sub) = substitute(db, base)
|
||||
{
|
||||
return remember_list_resolution(generation, list_key, family, (sub.to_string(), true));
|
||||
}
|
||||
}
|
||||
|
||||
// Fall back to the first candidate's substitute:
|
||||
if let Some(&first) = candidates.first() {
|
||||
let base = if first.eq_ignore_ascii_case("Symbol (PCL6)") { "Symbol" } else { charset_base_family(first) };
|
||||
if let Some(sub) = substitute(db, base) {
|
||||
return remember_list_resolution(generation, list_key, family, (sub.to_string(), true));
|
||||
}
|
||||
}
|
||||
|
||||
remember_list_resolution(generation, list_key, family, (crate::FALLBACK_FAMILY.to_string(), true))
|
||||
}
|
||||
|
||||
/// Resolve `family` with bold/italic to a face (memoised until user fonts are added or removed,
|
||||
/// [`FontDb::generation`]).
|
||||
@@ -427,6 +658,10 @@ pub fn resolve(family: &str, bold: bool, italic: bool) -> Resolved {
|
||||
let generation = db.generation();
|
||||
let cacheable = family.len() <= FAMILY_KEY_MAX;
|
||||
let key = (if cacheable { family.to_string() } else { String::new() }, bold, italic);
|
||||
let long_key = (!cacheable && family.contains(';')).then(|| {
|
||||
let (h, len) = family_list_key(family);
|
||||
(h, len, bold, italic)
|
||||
});
|
||||
{
|
||||
let mut c = cache.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
if c.0 != generation {
|
||||
@@ -436,24 +671,35 @@ pub fn resolve(family: &str, bold: bool, italic: bool) -> Resolved {
|
||||
return *r;
|
||||
}
|
||||
}
|
||||
// Printer-driver Symbol aliases use the same encoding as Symbol.
|
||||
let base = if family.trim().eq_ignore_ascii_case("Symbol (PCL6)") { "Symbol" } else { charset_base_family(family) };
|
||||
let (fam, substituted) = if db.has_family(family) {
|
||||
(family.to_string(), false)
|
||||
} else if base != family && db.has_family(base) {
|
||||
// Windows maps the charset names to the font itself: not a substitution.
|
||||
(base.to_string(), false)
|
||||
} else {
|
||||
(substitute(db, base).unwrap_or(crate::FALLBACK_FAMILY).to_string(), true)
|
||||
};
|
||||
if let Some(lk) = long_key {
|
||||
let mut c = fallback_list_cache().lock().unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
if c.generation != generation {
|
||||
*c = FallbackListMemo { generation, ..Default::default() };
|
||||
} else if let Some((cached_family, r)) = c.long_resolved.get(&lk)
|
||||
&& cached_family == family
|
||||
{
|
||||
return *r;
|
||||
}
|
||||
}
|
||||
let (fam, substituted) = resolve_family(db, family);
|
||||
let face = db.face(&fam, style_name(bold, italic));
|
||||
let r = Resolved { synth_bold: bold && face.weight < 600.0, synth_italic: italic && !face.italic, substituted, face: FaceRef::of(&face) };
|
||||
let mut c = cache.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
if cacheable && c.0 == generation {
|
||||
if c.1.len() >= FAMILY_CACHE_MAX {
|
||||
c.1.clear();
|
||||
if cacheable {
|
||||
let mut c = cache.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
if c.0 == generation {
|
||||
if c.1.len() >= FAMILY_CACHE_MAX {
|
||||
c.1.clear();
|
||||
}
|
||||
c.1.insert(key, r);
|
||||
}
|
||||
} else if let Some(lk) = long_key {
|
||||
let mut c = fallback_list_cache().lock().unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
if c.generation == generation {
|
||||
if c.long_resolved.len() >= FAMILY_CACHE_MAX {
|
||||
c.long_resolved.clear();
|
||||
}
|
||||
c.long_resolved.insert(lk, (family.to_string(), r));
|
||||
}
|
||||
c.1.insert(key, r);
|
||||
}
|
||||
r
|
||||
}
|
||||
@@ -787,4 +1033,114 @@ mod tests {
|
||||
assert!(!has_named_substitutes(f), "{f}");
|
||||
}
|
||||
}
|
||||
|
||||
/// Semicolon-separated fallback lists resolve to the first installed family, or to a
|
||||
/// candidate's substitute when no listed family is installed.
|
||||
#[test]
|
||||
fn libreoffice_fallback_list_resolves_first_installed_family() {
|
||||
let db = FontDb::global();
|
||||
if db.has_family("Liberation Sans") || db.has_family("Arial") {
|
||||
let r = resolve("Liberation Sans;Arial", false, false);
|
||||
assert!(!r.substituted, "should not be substituted when a candidate is installed: got {:?}", r.face.family);
|
||||
if db.has_family("Liberation Sans") {
|
||||
assert_eq!(r.face.family, "Liberation Sans");
|
||||
} else {
|
||||
assert_eq!(r.face.family, "Arial");
|
||||
}
|
||||
}
|
||||
let bundled = resolve("NonExistentSansFace123;Source Serif 4", false, false);
|
||||
assert!(!bundled.substituted);
|
||||
assert_eq!(bundled.face.family, "Source Serif 4");
|
||||
|
||||
// When none of the families is installed, fall back to a substitute for an entry in the list rather than generic Carlito:
|
||||
if !db.has_family("NonExistentSansFace123") && !db.has_family("Times New Roman") {
|
||||
let r = resolve("NonExistentSansFace123;Times New Roman", false, false);
|
||||
assert!(r.substituted);
|
||||
assert_ne!(r.face.family, "Carlito", "should not fall back to generic Carlito when an entry is Times New Roman");
|
||||
assert!(r.face.family.contains("Serif") || r.face.family == "Times" || r.face.family == "Tinos", "got {:?}", r.face.family);
|
||||
}
|
||||
let r = resolve("NonExistentSansFace123;NonExistentSerifFace123", false, false);
|
||||
assert!(r.substituted);
|
||||
assert_ne!(r.face.family, "Carlito", "should not fall back to generic Carlito when an entry is a serif family");
|
||||
assert!(
|
||||
r.face.family.contains("Serif")
|
||||
|| r.face.family == "Times"
|
||||
|| r.face.family == "Times New Roman"
|
||||
|| r.face.family == "Tinos"
|
||||
|| r.face.family == "Caladea"
|
||||
|| r.face.family == "Georgia",
|
||||
"got {:?}",
|
||||
r.face.family
|
||||
);
|
||||
|
||||
// Metric compatibility of fallback list:
|
||||
assert!(metric_compatible("Open Sans;Arial", "Liberation Sans"));
|
||||
assert!(metric_compatible("Liberation Sans;Arial", "Helvetica"));
|
||||
assert!(!metric_compatible("Open Sans;Arial", "Carlito"));
|
||||
}
|
||||
|
||||
/// In a mixed Thai-and-Latin fallback list where neither listed family is installed, an
|
||||
/// earlier Thai substitute takes precedence over a later candidate's metric twin.
|
||||
#[test]
|
||||
fn thai_fallback_list_prefers_thai_substitute_before_later_metric_twin() {
|
||||
let db = FontDb::global();
|
||||
let Some(thai_sub) = substitute(db, "NonExistentThaiUPC") else {
|
||||
eprintln!("skipped: no Thai substitute installed");
|
||||
return;
|
||||
};
|
||||
if !db.has_family("Cordia New") && !db.has_family("Arial") && metric_twins("Arial").any(|t| db.has_family(t)) {
|
||||
let r = resolve("Cordia New;Arial", false, false);
|
||||
assert!(r.substituted);
|
||||
assert!(substitutes("Cordia New").contains(&r.face.family.as_str()), "got {:?}", r.face.family);
|
||||
}
|
||||
if let Some(latin) =
|
||||
METRIC_TWINS.iter().flat_map(|g| g.iter().copied()).find(|&f| !db.has_family(f) && metric_twins(f).any(|t| db.has_family(t)))
|
||||
{
|
||||
let list = format!("NonExistentThaiUPC;{latin}");
|
||||
let r = resolve(&list, false, false);
|
||||
assert!(r.substituted);
|
||||
assert_eq!(r.face.family, thai_sub);
|
||||
}
|
||||
}
|
||||
|
||||
/// Fallback lists longer than `FAMILY_KEY_MAX` still select a later installed family and
|
||||
/// cache their resolution without growing `resolve_cache` string keys.
|
||||
#[test]
|
||||
fn long_fallback_list_selects_later_installed_family_and_caches() {
|
||||
let mut candidates: Vec<String> = (0..120).map(|i| format!("MissingCandidateFont{i}")).collect();
|
||||
candidates.push("Source Serif 4".to_string());
|
||||
let list = candidates.join(";");
|
||||
assert!(list.len() > FAMILY_KEY_MAX);
|
||||
|
||||
let r1 = resolve(&list, false, false);
|
||||
let r2 = resolve(&list, false, false);
|
||||
assert!(!r1.substituted);
|
||||
assert_eq!(r1.face.family, "Source Serif 4");
|
||||
assert_eq!(r1.face.id(), r2.face.id());
|
||||
assert!(!resolve_cache().lock().unwrap_or_else(|e| e.into_inner()).1.keys().any(|k| k.0.len() > FAMILY_KEY_MAX));
|
||||
}
|
||||
|
||||
/// The name-based predicates look at every candidate, and their answer is cached per list: a
|
||||
/// later Thai or named fallback still counts, and a list is classified once rather than for
|
||||
/// every piece of text shaped in it.
|
||||
#[test]
|
||||
fn list_classification_reads_every_candidate_and_is_cached() {
|
||||
// The Thai family is last, past any shorter bound, and still classifies the list.
|
||||
let mut candidates: Vec<String> = (0..200).map(|i| format!("MissingCandidateFont{i}")).collect();
|
||||
candidates.push("TH Sarabun New".to_string());
|
||||
let thai_list = candidates.join(";");
|
||||
assert!(is_thai_family(&thai_list), "a later Thai family still counts");
|
||||
|
||||
// A named substitute at the end classifies too (Times New Roman has Liberation Serif).
|
||||
candidates.pop();
|
||||
candidates.push("Times New Roman".to_string());
|
||||
let named_list = candidates.join(";");
|
||||
assert!(has_named_substitutes(&named_list), "a later named fallback still counts");
|
||||
|
||||
// Both are memoised by list text: a second ask answers without rescanning, and a list
|
||||
// whose text differs only after the key's hash is not mistaken for the cached one.
|
||||
assert!(is_thai_family(&thai_list) && !is_thai_family(&named_list));
|
||||
let cached = fallback_list_cache().lock().unwrap_or_else(|e| e.into_inner()).classes.len();
|
||||
assert!(cached >= 2, "{cached} lists classified");
|
||||
}
|
||||
}
|
||||
@@ -159,7 +159,7 @@ fn layout_table_block(input: TableBlockContext<'_, '_, '_>) -> bool {
|
||||
let layout = table::layout_table(ctx, story, t, path, width, depth + 1);
|
||||
let (tx, ty) = match t.props.float {
|
||||
Some(float) => {
|
||||
let legacy = if ctx.doc.settings.compat_mode < 15 { table::first_cell_left_margin(ctx, t) } else { 0.0 };
|
||||
let legacy = (ctx.doc.settings.compat_mode < 15).then(|| table::first_cell_left_margin(ctx, t));
|
||||
let (rect, area) = box_float_table_rect(frame, width, *y, &layout, &float, legacy, float_tables);
|
||||
exclusions.push((area, false, None, WrapText::BothSides, true));
|
||||
float_tables.push(area);
|
||||
@@ -464,14 +464,15 @@ pub(crate) struct BoxLayout {
|
||||
|
||||
/// Where floating table `tl` goes in a box `width` wide whose next paragraph starts at `y`, and
|
||||
/// the area text keeps clear of. Positions relative to the page or its margins need the box's
|
||||
/// `frame`; without one (a table cell) they're relative to the box.
|
||||
/// `frame`; without one (a table cell) they're relative to the box. `legacy` is the first cell's
|
||||
/// left margin before Word 2013 layout, `None` in it.
|
||||
fn box_float_table_rect(
|
||||
frame: Option<PageFrame>,
|
||||
width: f32,
|
||||
y: f32,
|
||||
tl: &table::TableLayout,
|
||||
f: &TableFloat,
|
||||
legacy: f32,
|
||||
legacy: Option<f32>,
|
||||
float_tables: &[Rect],
|
||||
) -> (Rect, Rect) {
|
||||
let h = tl.rows.iter().map(|r| r.height).sum::<f32>().clamp(0.0, 100_000.0);
|
||||
@@ -507,18 +508,19 @@ fn box_float_table_rect(
|
||||
};
|
||||
// Nor is there an area to align in: the table starts where the text is.
|
||||
let v_align = if frame.is_none() && matches!(f.v_rel, Anchor::Page | Anchor::Margin) { None } else { f.v_align };
|
||||
let x = align_in(h_area, f.x, w, PageFrame::h_align(frame.as_ref(), f.h_align)) - if f.h_align.is_none() { legacy } else { 0.0 };
|
||||
let r = clear_of_float_tables(Rect::new(x, align_in(v_area, y_off, h, v_align), w, h), f, legacy, h_area.0, float_tables);
|
||||
let x = align_in(h_area, f.x, w, PageFrame::h_align(frame.as_ref(), f.h_align)) - if f.h_align.is_none() { legacy.unwrap_or(0.0) } else { 0.0 };
|
||||
let r = clear_of_float_tables(Rect::new(x, align_in(v_area, y_off, h, v_align), w, h), f, legacy.is_none(), h_area.0, float_tables);
|
||||
let [dl, dt, dr, db] = f.dist_from_text();
|
||||
(r, r.inset(-dl, -dt, -dr, -db))
|
||||
}
|
||||
|
||||
/// Floating table `f` at `r`, moved below the floating tables in its way when it may not
|
||||
/// overlap them. In Word 2013+ layout one placed by an offset keeps its distance from the text's
|
||||
/// left edge (`text_left`).
|
||||
pub(crate) fn clear_of_float_tables(mut r: Rect, f: &TableFloat, legacy: f32, text_left: f32, others: &[Rect]) -> Rect {
|
||||
/// overlap them. In Word 2013+ layout (`modern`) one placed by an offset keeps its distance from
|
||||
/// the text's left edge (`text_left`); before it, it keeps its own left edge, also when its first
|
||||
/// cell has no left margin.
|
||||
pub(crate) fn clear_of_float_tables(mut r: Rect, f: &TableFloat, modern: bool, text_left: f32, others: &[Rect]) -> Rect {
|
||||
let [dl, dt, dr, db] = f.dist_from_text();
|
||||
let keeps_text_distance = legacy == 0.0 && f.h_align.is_none() && f.x >= 0.0 && matches!(f.h_rel, Anchor::Margin | Anchor::Column);
|
||||
let keeps_text_distance = modern && f.h_align.is_none() && f.x >= 0.0 && matches!(f.h_rel, Anchor::Margin | Anchor::Column);
|
||||
for _ in 0..64 {
|
||||
let area = r.inset(-dl, -dt, -dr, -db);
|
||||
let Some(below) = others.iter().filter(|o| !f.overlap && o.intersects(&area)).map(|o| o.bottom()).reduce(f32::max) else { break };
|
||||
|
||||
@@ -281,6 +281,17 @@ impl<'a> Flow<'a> {
|
||||
if self.pl.rp.before_auto && self.block == 0 {
|
||||
before = 0.0;
|
||||
}
|
||||
// An empty paragraph after a document's leading table, placed floating on this page, has no
|
||||
// space before (Word's PDFs: 0 pt and 12 pt before put the text after it at the same place).
|
||||
// Only that shape is observed: later pages and columns keep theirs.
|
||||
if self.block == 1
|
||||
&& pb.fresh()
|
||||
&& !pb.web
|
||||
&& pb.pages.last().is_some_and(|pg| pg.float_tables.contains(&Path::top(0)))
|
||||
&& only_markers(self.p, 0..self.p.text.len())
|
||||
{
|
||||
before = 0.0;
|
||||
}
|
||||
before
|
||||
}
|
||||
|
||||
|
||||
@@ -130,13 +130,17 @@ pub(super) const MISSING_ROMAN_FONT: &str = "Cambria";
|
||||
/// and where it isn't installed Liberation Serif has its metrics; Word never meets it missing.
|
||||
pub(super) fn lookup_family<'f>(doc: &Document, font: &'f str) -> &'f str {
|
||||
let db = wordcraft_fonts::FontDb::global();
|
||||
let roman = doc.settings.roman_fonts.iter().any(|f| f.eq_ignore_ascii_case(font));
|
||||
let roman = !doc.settings.roman_fonts.is_empty()
|
||||
&& font.split(';').map(str::trim).filter(|f| !f.is_empty()).any(|f| doc.settings.roman_fonts.iter().any(|r| r.eq_ignore_ascii_case(f)));
|
||||
if roman
|
||||
&& !wordcraft_fonts::word::is_thai_family(font)
|
||||
&& !wordcraft_doc::symbol_fonts::is_symbol_font(font)
|
||||
&& !wordcraft_fonts::word::has_named_substitutes(font)
|
||||
&& !db.has_family(font)
|
||||
&& !db.has_family(wordcraft_fonts::word::charset_base_family(font))
|
||||
&& if font.contains(';') {
|
||||
wordcraft_fonts::word::resolve(font, false, false).substituted
|
||||
} else {
|
||||
!db.has_family(font) && !db.has_family(wordcraft_fonts::word::charset_base_family(font))
|
||||
}
|
||||
{
|
||||
MISSING_ROMAN_FONT
|
||||
} else {
|
||||
|
||||
@@ -864,6 +864,9 @@ fn break_positions(text: &str, rp: &wordcraft_doc::resolve::ResolvedPara) -> Vec
|
||||
found.sort_unstable();
|
||||
found.dedup();
|
||||
}
|
||||
// UAX #14 breaks between a full stop and an opening parenthesis; Word keeps
|
||||
// "2023.(Example" whole and wraps it at the space before.
|
||||
found.retain(|&i| !(text.get(..i).is_some_and(|t| t.ends_with('.')) && text.get(i..).is_some_and(|t| t.starts_with('('))));
|
||||
// Asian typography: kinsoku and breaking Latin words anywhere.
|
||||
crate::kinsoku::apply(text, &mut found, rp.kinsoku, rp.word_wrap);
|
||||
found
|
||||
|
||||
@@ -349,7 +349,13 @@ fn place_floating_table(ctx: &mut Ctx, pb: &mut PageBuilder, tl: &table::TableLa
|
||||
let x = float_table_x(pb, w, f, legacy, ctx.mirror);
|
||||
let mut obstacles = if f.overlap { Vec::new() } else { header_table_areas(ctx, pb) };
|
||||
obstacles.extend_from_slice(&pb.float_tables);
|
||||
let r = clear_of_float_tables(Rect::new(x, align_in(v_area, f.y, h, f.v_align), w, h), f, legacy, h_area.0, &obstacles);
|
||||
let r = clear_of_float_tables(
|
||||
Rect::new(x, align_in(v_area, f.y, h, f.v_align), w, h),
|
||||
f,
|
||||
ctx.doc.settings.compat_mode >= 15,
|
||||
h_area.0,
|
||||
&obstacles,
|
||||
);
|
||||
// Like Word, a table that doesn't fit below the text before it moves to the next page with
|
||||
// its anchor: one placed relative to the paragraph when it would run off the page, one
|
||||
// placed on the page or margin when it would also cover the text already on the page.
|
||||
|
||||
@@ -9,6 +9,7 @@ mod bidi;
|
||||
mod budget;
|
||||
mod cell_floats;
|
||||
mod complex;
|
||||
mod float_top;
|
||||
mod grid;
|
||||
mod ligature;
|
||||
pub(crate) mod linear;
|
||||
@@ -7014,6 +7015,48 @@ fn floating_tables_take_no_room_and_text_wraps_beside_them() {
|
||||
assert!(bx + bleft > nx + 200.0, "body text at {}, right of the narrow table", bx + bleft);
|
||||
}
|
||||
|
||||
/// Before Word 2013 layout a floating table moved below another keeps its own left edge, also
|
||||
/// when its first cell has no left margin (docxide `displaced-anchor-*-compat12-overlap0-*`), in
|
||||
/// the body and in a table cell.
|
||||
#[test]
|
||||
fn a_floating_table_moved_below_another_keeps_its_edge_before_word_2013_layout() {
|
||||
use wordcraft_doc::para::Anchor;
|
||||
use wordcraft_doc::props::TableFloat;
|
||||
let narrow_x = |mode: u32, in_cell: bool| {
|
||||
let mut d = Document::from_text("Body.");
|
||||
d.settings.compat_mode = mode;
|
||||
let table = |w: f32, text: &str, dist: [f32; 4]| {
|
||||
let mut t = Table::new(1, 1, w);
|
||||
t.props.cell_margins = Some([0.0; 4]);
|
||||
t.rows[0].cells[0].blocks = vec![wordcraft_doc::para_block(wordcraft_doc::Paragraph::with_text(text, Default::default()))];
|
||||
t.props.float = Some(TableFloat { h_rel: Anchor::Margin, v_rel: Anchor::Paragraph, dist, overlap: false, ..Default::default() });
|
||||
t
|
||||
};
|
||||
let floats = [table(300.0, "Wide", [0.0; 4]), table(100.0, "Narrow", [9.0, 0.0, 9.0, 0.0])];
|
||||
if in_cell {
|
||||
let mut outer = Table::new(1, 1, 468.0);
|
||||
outer.rows[0].cells[0].blocks = floats.into_iter().map(|t| std::sync::Arc::new(wordcraft_doc::Block::Table(t))).collect();
|
||||
outer.rows[0].cells[0].blocks.push(wordcraft_doc::para_block(wordcraft_doc::Paragraph::with_text("Cell.", Default::default())));
|
||||
d.insert_block(StoryRef::Body, &Path::top(0), wordcraft_doc::Block::Table(outer)).unwrap();
|
||||
} else {
|
||||
for (i, t) in floats.into_iter().enumerate() {
|
||||
d.insert_block(StoryRef::Body, &Path::top(i), wordcraft_doc::Block::Table(t)).unwrap();
|
||||
}
|
||||
}
|
||||
let l = lay(&d);
|
||||
let xs: Vec<(f32, f32)> =
|
||||
l.pages[0].items.iter().filter_map(|i| if let Placed::Lines { x, y, .. } = i { Some((*x, *y)) } else { None }).collect();
|
||||
let [(wx, wy), (nx, ny), ..] = xs.as_slice() else { panic!("{xs:?}") };
|
||||
assert!(*ny > *wy + 10.0, "mode {mode}, in cell {in_cell}: narrow at {ny}, below the wide table at {wy}: {xs:?}");
|
||||
nx - wx
|
||||
};
|
||||
for in_cell in [false, true] {
|
||||
let (old, new) = (narrow_x(12, in_cell), narrow_x(15, in_cell));
|
||||
assert!(old.abs() < 0.01, "mode 12, in cell {in_cell}: narrow table {old} right of the wide one");
|
||||
assert!((new - 9.0).abs() < 0.01, "mode 15, in cell {in_cell}: narrow table {new} right of the wide one");
|
||||
}
|
||||
}
|
||||
|
||||
/// A margin-anchored floating table is placed from the anchor page's margin even when a body
|
||||
/// paragraph keeps trailing empty lines: all 24 preceding paragraphs stay on page one and every
|
||||
/// table row is drawn exactly once there.
|
||||
@@ -8937,6 +8980,34 @@ fn superscript_and_subscript_lines_are_as_tall_as_full_size_text() {
|
||||
assert!((lowered - plain - 6.0).abs() < 0.01, "lowered {lowered} vs plain {plain}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn superscript_digits_advance_at_the_half_point_size() {
|
||||
use wordcraft_doc::props::VertAlign;
|
||||
// Word sets an 11pt superscript at 7pt (0.65 × 11 = 7.15, rounded to the half point): its
|
||||
// digits advance by 7pt digit widths (Word for Mac, measured from its PDF export).
|
||||
let width = |size: f32, vert_align: VertAlign| {
|
||||
let mut p = wordcraft_doc::Paragraph::new();
|
||||
let props =
|
||||
wordcraft_doc::CharProps { font: Some(WRAP_TEST_FONT.into()), size: Some(size), vert_align: Some(vert_align), ..Default::default() };
|
||||
p.runs.push(wordcraft_doc::para::Run { len: 10, props: props.clone() });
|
||||
p.text.push_str("1234567890");
|
||||
p.mark = props;
|
||||
let mut d = Document::new();
|
||||
d.body = vec![wordcraft_doc::para_block(p)];
|
||||
let l = lay(&d);
|
||||
// From the first digit's start to the last's: nine digit advances.
|
||||
let xs = l.pages[0]
|
||||
.items
|
||||
.iter()
|
||||
.find_map(|it| if let Placed::Lines { para, .. } = it { para.lines.first().map(|x| x.xs.clone()) } else { None })
|
||||
.unwrap();
|
||||
xs.last().unwrap() - xs.first().unwrap()
|
||||
};
|
||||
let seven = width(7.0, VertAlign::Baseline);
|
||||
assert!((width(11.0, VertAlign::Superscript) - seven).abs() < 0.01, "{} vs {seven}", width(11.0, VertAlign::Superscript));
|
||||
assert!((width(11.0, VertAlign::Subscript) - seven).abs() < 0.01);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ruby_text_sits_over_its_base_and_the_line_grows_to_fit() {
|
||||
use wordcraft_doc::para::RubyAlign;
|
||||
@@ -13679,6 +13750,36 @@ fn a_hyphen_before_a_slash_is_a_break_opportunity_like_word() {
|
||||
panic!("no width in the sweep wrapped the paragraph");
|
||||
}
|
||||
|
||||
/// docxide-pdf `dot-open-token*`: UAX #14 allows a break between a full stop and an opening
|
||||
/// parenthesis, Word doesn't. The first width that wraps `Deed 2023.(Example` breaks at the space.
|
||||
#[test]
|
||||
fn a_full_stop_before_a_parenthesis_is_no_break() {
|
||||
let text = "Deed 2023.(Example";
|
||||
for m in 100..=540 {
|
||||
let mut d = Document::from_text(text);
|
||||
d.last_section.margin_right = m as f32;
|
||||
let l = lay(&d);
|
||||
let lines: Vec<String> = l.pages[0]
|
||||
.items
|
||||
.iter()
|
||||
.filter_map(|it| {
|
||||
if let Placed::Lines { para, l0, l1, .. } = it {
|
||||
Some(para.lines[*l0..*l1].iter().map(|line| text[line.start..line.stop].to_string()).collect::<Vec<_>>())
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
.flatten()
|
||||
.collect();
|
||||
if lines.len() < 2 {
|
||||
continue; // still fits on one line at this width
|
||||
}
|
||||
assert_eq!(lines, ["Deed ", "2023.(Example"], "the first wrap breaks at the space");
|
||||
return;
|
||||
}
|
||||
panic!("no width in the sweep wrapped the paragraph");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn layout_cache_invalidates_on_changed_filename() {
|
||||
let mut doc = Document::new();
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
//! An empty paragraph after a document's leading floating table takes no space before. Word's PDFs
|
||||
//! of docxide-pdf's `leading-empty-before-float-0` and `-12` put the text after the empty anchor at
|
||||
//! the same place for 0 pt and 12 pt before.
|
||||
|
||||
use super::lay;
|
||||
use crate::{Arc, Placed, StoryRef};
|
||||
use wordcraft_doc::para::{Anchor, InlineObject};
|
||||
use wordcraft_doc::props::TableFloat;
|
||||
use wordcraft_doc::{Block, Document, Paragraph, Table, para_block};
|
||||
|
||||
/// Where the lines of body block `block` start on page one.
|
||||
fn line_y(d: &Document, block: u32) -> f32 {
|
||||
lay(d).pages[0]
|
||||
.items
|
||||
.iter()
|
||||
.find_map(|i| match i {
|
||||
Placed::Lines { story: StoryRef::Body, path, y, .. } if path.0 == [block] => Some(*y),
|
||||
_ => None,
|
||||
})
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
/// `table` (a floating one) or nothing, then a paragraph of `text` with `before`, then "After".
|
||||
fn document(table: bool, text: &str, before: f32) -> Document {
|
||||
let mut p = Paragraph::with_text(text, Default::default());
|
||||
// "#" stands for a bookmark, a zero-width marker.
|
||||
if text == "#" {
|
||||
p = Paragraph::new();
|
||||
p.insert_object(0, InlineObject::BookmarkStart { name: "_GoBack".into() }, &Default::default()).unwrap();
|
||||
}
|
||||
p.props.space_before = Some(before);
|
||||
let mut d = Document::new();
|
||||
d.body = vec![para_block(p), para_block(Paragraph::with_text("After", Default::default()))];
|
||||
if table {
|
||||
let mut t = Table::new(1, 1, 200.0);
|
||||
t.rows[0].cells[0].blocks = vec![para_block(Paragraph::with_text("Table", Default::default()))];
|
||||
t.props.float = Some(TableFloat { h_rel: Anchor::Margin, v_rel: Anchor::Paragraph, ..Default::default() });
|
||||
d.body.insert(0, Arc::new(Block::Table(t)));
|
||||
}
|
||||
d
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_empty_paragraph_after_a_floating_table_at_the_page_top_takes_no_space_before() {
|
||||
let empty = line_y(&document(true, "", 12.0), 2);
|
||||
let plain = line_y(&document(false, "", 0.0), 1);
|
||||
assert!((empty - plain).abs() < 0.01, "after the empty anchor at {empty}, with no table nor space before at {plain}");
|
||||
// A bookmark alone doesn't make it a paragraph with text.
|
||||
assert!((line_y(&document(true, "#", 12.0), 2) - plain).abs() < 0.01);
|
||||
// One with text keeps it, and so does an empty first paragraph with no table before it.
|
||||
assert!(line_y(&document(true, "Text", 12.0), 2) > empty + 11.99);
|
||||
assert!(line_y(&document(false, "", 12.0), 1) > plain + 11.99);
|
||||
}
|
||||
@@ -4,7 +4,7 @@
|
||||
|
||||
> **2026-11-05:** DOCX paragraph `w:adjustRightInd` keeps its default and explicit values through style resolution and schema-ordered repeated saves.
|
||||
|
||||
> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-11-05 · **Change:** trivial (ODT pictures offered in several formats); previously trivial (ODT pictures offered in several formats); previously trivial (ODT pictures offered in several formats); previously trivial (ODT heading text of another size); previously trivial (built-in citation source lookups, #4888; current-main progress records retained); previously trivial (ODT: no settings for what the standard leaves open); previously trivial (picture brightness, contrast and transparency); previously trivial (ODT paragraph groups kept as read); previously trivial (ODT paragraphs with charts kept as read); previously trivial (ODT sections in cells and at empty paragraphs); previously trivial (ODT element audit); previously trivial (ODT reference marks written as such); previously trivial (ODT hidden sections stay hidden; lists kept as read); previously trivial (ODT manifest lists every file); previously trivial (ODT paragraphs with bookmarks, notes, objects and page-anchored frames written as read); previously trivial (ODT paragraphs with text boxes written as read); previously trivial (ODT paragraphs with comments and tracked changes written as read); previously trivial (ODT paragraphs with pictures and shapes written as read); previously trivial (ODT sections inside text boxes); previously trivial (ODT definitions sharing a name, sections around tables, chart replacement pictures, note citations, stacking order); previously trivial (ODT form definitions and indexes written as read); previously trivial (ODT gradient angles by the file's version; deviation documented); previously minor (ODT: unedited tables, pages, sections, properties and declarations written as read; no-edit loss gate); previously minor (ODT: unedited paragraphs and style definitions written as read); previously trivial (ODT author fields said not fixed); previously trivial (ODT default text colour; covered cells' content); previously trivial (ODT widow control profile case; tabs from the indent kept); previously trivial (ODT span kerning; note reference formatting; notes starting with a tab); previously trivial (ODT fixed and sender fields); previously trivial (ODT: table styles saved into their cells; ODT tables carry no Word table style; section column balance); previously trivial (ODT interpretation profile: the standard is law, what it leaves open (including unstated page margins) follows WordCraft, LibreOffice or custom settings); previously trivial (ODT form controls keep their place on the line and text areas their lines; custom shapes keep their geometry); previously trivial (ODT in-line frames placed against the text; in-line pictures saved standing on the baseline); previously trivial (ODT charts written back as they were while unchanged; frames' drawn outline; default header height); previously trivial (ODT header rows only where they repeat); previously trivial (ODT padding without a border; unequal columns; column breaks); previously trivial (ODT lines of only pictures laid out as LibreOffice does; default page without a layout); previously trivial (ODT pictures alone in a paragraph keep its size; in-line frames raised under an inherited paragraph relation); previously trivial (ODT tables fit the margins and keep their space above and below; header spacing kept); previously trivial (ODT frames anchored and placed by their style); previously trivial (ODT default font size saved; page-anchored frames placed from the page; text box paragraph styles kept; empty paragraphs keep their size); previously trivial (ODT default font size; locale-ordered dates); previously trivial (ODT page background pictures); previously trivial (ODT tables fixed layout; tables starting a page); previously trivial (emphasis marks: model, layout, DOCX `w:em`, ODT `style:text-emphasize`, `format.emphasisMark`); previously trivial (ODT variables as SET/REF fields; character styles keep what they turn off); previously trivial (ODT styles keep what they turn off; WMF/EMF pictures kept; pictures in text boxes shown by LibreOffice); previously trivial (ODT unstated page margins are LibreOffice's 2 cm; drawings outside a paragraph join the next one); previously trivial (ODT placeholders as content controls, template dates, user name fields, unsized pages, style-only templates, found with LibreOffice's own templates); previously trivial (ODT hidden form controls kept; `.ott` templates opened and saved); previously trivial (ODT fidelity re-estimated at ~90%; a kept form control made another kind is saved as that kind); previously trivial (ODT form control sizes and buttons kept); previously trivial (ODT turned text in cells and text boxes); previously trivial (ODT macros and unknown package parts kept); previously trivial (ODT table cell formulas as formula fields); previously trivial (ODT custom shapes drawn from their own geometry); previously trivial (ODT bibliography marks as citations of sources); prLine truncated
|
||||
> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-11-08 · **Change:** trivial (XML line ends in text normalised, #5897); previously trivial (ODT pictures offered in several formats); previously trivial (ODT pictures offered in several formats); previously trivial (ODT pictures offered in several formats); previously trivial (ODT heading text of another size); previously trivial (built-in citation source lookups, #4888; current-main progress records retained); previously trivial (ODT: no settings for what the standard leaves open); previously trivial (picture brightness, contrast and transparency); previously trivial (ODT paragraph groups kept as read); previously trivial (ODT paragraphs with charts kept as read); previously trivial (ODT sections in cells and at empty paragraphs); previously trivial (ODT element audit); previously trivial (ODT reference marks written as such); previously trivial (ODT hidden sections stay hidden; lists kept as read); previously trivial (ODT manifest lists every file); previously trivial (ODT paragraphs with bookmarks, notes, objects and page-anchored frames written as read); previously trivial (ODT paragraphs with text boxes written as read); previously trivial (ODT paragraphs with comments and tracked changes written as read); previously trivial (ODT paragraphs with pictures and shapes written as read); previously trivial (ODT sections inside text boxes); previously trivial (ODT definitions sharing a name, sections around tables, chart replacement pictures, note citations, stacking order); previously trivial (ODT form definitions and indexes written as read); previously trivial (ODT gradient angles by the file's version; deviation documented); previously minor (ODT: unedited tables, pages, sections, properties and declarations written as read; no-edit loss gate); previously minor (ODT: unedited paragraphs and style definitions written as read); previously trivial (ODT author fields said not fixed); previously trivial (ODT default text colour; covered cells' content); previously trivial (ODT widow control profile case; tabs from the indent kept); previously trivial (ODT span kerning; note reference formatting; notes starting with a tab); previously trivial (ODT fixed and sender fields); previously trivial (ODT: table styles saved into their cells; ODT tables carry no Word table style; section column balance); previously trivial (ODT interpretation profile: the standard is law, what it leaves open (including unstated page margins) follows WordCraft, LibreOffice or custom settings); previously trivial (ODT form controls keep their place on the line and text areas their lines; custom shapes keep their geometry); previously trivial (ODT in-line frames placed against the text; in-line pictures saved standing on the baseline); previously trivial (ODT charts written back as they were while unchanged; frames' drawn outline; default header height); previously trivial (ODT header rows only where they repeat); previously trivial (ODT padding without a border; unequal columns; column breaks); previously trivial (ODT lines of only pictures laid out as LibreOffice does; default page without a layout); previously trivial (ODT pictures alone in a paragraph keep its size; in-line frames raised under an inherited paragraph relation); previously trivial (ODT tables fit the margins and keep their space above and below; header spacing kept); previously trivial (ODT frames anchored and placed by their style); previously trivial (ODT default font size saved; page-anchored frames placed from the page; text box paragraph styles kept; empty paragraphs keep their size); previously trivial (ODT default font size; locale-ordered dates); previously trivial (ODT page background pictures); previously trivial (ODT tables fixed layout; tables starting a page); previously trivial (emphasis marks: model, layout, DOCX `w:em`, ODT `style:text-emphasize`, `format.emphasisMark`); previously trivial (ODT variables as SET/REF fields; character styles keep what they turn off); previously trivial (ODT styles keep what they turn off; WMF/EMF pictures kept; pictures in text boxes shown by LibreOffice); previously trivial (ODT unstated page margins are LibreOffice's 2 cm; drawings outside a paragraph join the next one); previously trivial (ODT placeholders as content controls, template dates, user name fields, unsized pages, style-only templates, found with LibreOffice's own templates); previously trivial (ODT hidden form controls kept; `.ott` templates opened and saved); previously trivial (ODT fidelity re-estimated at ~90%; a kept form control made another kind is saved as that kind); previously trivial (ODT form control sizes and buttons kept); previously trivial (ODT turned text in cells and text boxes); previously trivial (ODT macros and unknown package parts kept); previously trivial (ODT table cell formulas as formula fields); previously trivial (ODT custom shapes drawn from their own geometry); previouslLine truncated
|
||||
> **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.
|
||||
|
||||
> **Legacy form update:** 2026-10-22 · trivial: field names and selected drop-down labels decode numeric XML character references once.
|
||||
@@ -74,7 +74,7 @@ Weights are the share of a Word user's file traffic (estimate). Read/write: ✅
|
||||
| `.docm` / `.dotx` / `.dotm` | 3% | R/W | ✅ | ✅ | ~65% | incl. above | `macro_packages.rs`, `a_template_the_platform_hands_the_app_starts_a_new_document` | Macros and signatures kept on save (#172); macros never run. A template (`.dotx`, `.dotm`, `.dot`) the platform hands the app (file manager, command line, Finder) starts a new, untitled document attached to it, as in Word; File › Open edits the template itself (#3433) |
|
||||
| `.docx` Strict Open XML | 1% | R/W | ✅ (namespace mapping in `xml.rs`) | ❌ | ~60% | 2–4 | `strict_namespace_and_bad_numbers`, `strict_line_heights_with_a_unit_are_read_as_point_multiples`, `strict_drawings` | Writing Strict is rare; unit-suffixed measures (`13.90pt`) read as points (#2329); shapes, text boxes, groups and canvases (`wsp`, `wgp`, `wpc` in the Strict drawing namespace, a text box's `wne:txbxContent` or `txbxContent`) read as their Word 2010 names and are saved as Transitional (#3548). Charts and SmartArt in Strict namespaces are not mapped |
|
||||
| `.docm` / `.dotx` / `.dotm` | 3% | R/W | ✅ | ✅ | ~65% | incl. above | `macro_packages.rs` | Macros and signatures kept on save (#172); macros never run |
|
||||
| `.doc` Word 97-2003 (and `.dot`) | 8% | R/W | 🟡 | ❌ | ~60% | 25–40 | `crates/docbin/src/tests.rs`, `tests/drawing.rs` | Spec-based reader ([MS-DOC]): text, formatting, styles, sections, headers/footers, tables, lists, notes, fields, bookmarks; inline and floating pictures (PNG, JPEG, DIB, TIFF, EMF, WMF; Macintosh PICT as a sized placeholder) with crop, alt text, position and wrap; basic shapes, lines, groups and text boxes ([MS-ODRAW]). Table cell shading from every SHD record (`sprmTDefTableShd`, `sprmTDefTableShdRaw` and their 2nd/3rd ranges, with the Word 97 `Shd80` as fallback; a clear pattern shows its background colour); the row-end mark is not a cell, and each table has its own grid and properties (#1380). Horizontally merged rows stored as fewer, wider cells come with #1123. Freeform shapes, z-order and Word 6/95 files are dropped or refused. Password-protected files (XOR obfuscation, RC4, RC4 CryptoAPI) open after a password prompt, from the public specs; not yet tried on a file Word wrote (AES-encrypted ones are refused). No writer. DIB conversion caps BMP bytes (header included) and encoded PNG at the supplied BLIP byte limit, and uses it as the decoder's allocation limit; a BMP fallback is kept if it fits (related #1334 and #1554). Hostile files are bounded: a sector table longer than the file is refused before parsing (#3642); formatting and link runs are read once per change, not per character (#3692, #3708); copies of alt text, link targets and drawn objects share a 64 MB budget (#3709, #3710), and what it leaves out is logged once a kind per document; picture decoding shares a 512 MB budget and aliased store entries decode once (#3711, #3712). The default fonts are the stylesheet's standard fonts (`rgftcStandardChpStsh` and `ftcBi` in the `Stshi`, [MS-DOC] §2.9.273): Aptos in a Word 365 file, whose font table still starts with Times New Roman; a header too short for them, or an index past the font table, keeps the table's first font (#5066). Runs' East Asian and complex-script fonts (`sprmCRgFtc1`, `sprmCFtcBi`) are read. |
|
||||
| `.doc` Word 97-2003 (and `.dot`) | 8% | R/W | 🟡 | ❌ | ~60% | 25–40 | `crates/docbin/src/tests.rs`, `tests/drawing.rs` | Spec-based reader ([MS-DOC]): text, formatting, styles, sections, headers/footers, tables, lists, notes, fields, bookmarks; inline and floating pictures (PNG, JPEG, DIB, TIFF, EMF, WMF; Macintosh PICT as a sized placeholder) with crop, alt text, position and wrap; basic shapes, lines, groups and text boxes ([MS-ODRAW]); brackets and braces are drawn as their box, unfilled unless the file fills them (#5943). Table cell shading from every SHD record (`sprmTDefTableShd`, `sprmTDefTableShdRaw` and their 2nd/3rd ranges, with the Word 97 `Shd80` as fallback; a clear pattern shows its background colour); the row-end mark is not a cell, and each table has its own grid and properties (#1380). Horizontally merged rows stored as fewer, wider cells come with #1123. Freeform shapes, z-order and Word 6/95 files are dropped or refused. Password-protected files (XOR obfuscation, RC4, RC4 CryptoAPI) open after a password prompt, from the public specs; not yet tried on a file Word wrote (AES-encrypted ones are refused). No writer. DIB conversion caps BMP bytes (header included) and encoded PNG at the supplied BLIP byte limit, and uses it as the decoder's allocation limit; a BMP fallback is kept if it fits (related #1334 and #1554). Hostile files are bounded: a sector table longer than the file is refused before parsing (#3642); formatting and link runs are read once per change, not per character (#3692, #3708); copies of alt text, link targets and drawn objects share a 64 MB budget (#3709, #3710), and what it leaves out is logged once a kind per document; picture decoding shares a 512 MB budget and aliased store entries decode once (#3711, #3712). The default fonts are the stylesheet's standard fonts (`rgftcStandardChpStsh` and `ftcBi` in the `Stshi`, [MS-DOC] §2.9.273): Aptos in a Word 365 file, whose font table still starts with Times New Roman; a header too short for them, or an index past the font table, keeps the table's first font (#5066). Runs' East Asian and complex-script fonts (`sprmCRgFtc1`, `sprmCFtcBi`) are read. |
|
||||
| PDF export | 8% | W | — | ✅ | ~80% | 6–10 | `crates/pdf/src/tests.rs` | krilla: embedded subsets, selectable text incl. field results (#133); Hebrew and Arabic copy and search as whole words (right-to-left glyphs written in visual order, #1383, also with fonts that draw lam-alef as two glyphs at one x, #2310); All Caps and Small Caps text extracts as the capitals shown, as from Word's PDFs, and never changes the case of other text (#1473), links, outline, tagging. Outline destinations follow leading page breaks to the heading text. Fonts it can't subset become outlines (#132). Print Markup (#589): comments in balloons beside each page (scaled to its paper) and tracked changes; HTML/Markdown/text copies end with a comment list. Horizontal and vertical lines inside groups are drawn (#1474). No PDF/A option, no "best for printing/online" choice |
|
||||
| PDF open (PDF Reflow, Word for Windows) | 1% | R | ❌ | — | 0% | 30–50 | — | Converting a PDF into an editable document. A sibling app (PdfCraft) may supply the parser |
|
||||
| `.rtf` Rich Text Format | 3% | R/W | 🟡 | 🟡 | ~50% | 10–15 | `crates/formats/src/tests.rs` | ~1,500 lines: text, character/paragraph formatting, styles, lists, tables, pictures (`\shppict`, #154), fields, bookmarks. 8-bit text in the document's code page (`\ansicpg`) or the current font's (`\fcharset`, `\cpg`): Cyrillic, Central European, Greek, Turkish, Hebrew, Arabic, Baltic, Vietnamese, Thai (was Windows-1252 only), Mac OS Roman (`\mac`, `\fcharset77`) and DOS code pages (`\pc` 437, `\pca` 850, `\cpg` 437/737/850/852/857/866, `\fcharset254`) (#1653). East Asian multibyte code pages decoded as runs: 932 Shift-JIS (`\fcharset128`, Japanese), 936 GBK (134, Simplified Chinese), 949 UHC/windows-949 (129, Korean) and 950 Big5 (136, Traditional Chinese) through `\ansicpg`, `\cpg` or the font's charset, escaped or raw, a lead byte and its trail byte together; invalid sequences read as U+FFFD, and a run that starts `EF BB BF` is text, not a BOM (#1653). Pasted from the system clipboard where the platform offers it (Windows, macOS, Wayland, X11) when there is no HTML (#687). Tracked insertions and deletions read and written (`\revtbl`, `\revised`/`\deleted`, authors, `\revdttm` dates), so Accept/Reject All work after opening or a round trip; tracked paragraph marks (`\par` inside `\revised`/`\deleted` groups, including a `\sect` inside a deletion) and character and paragraph formatting revisions (`\crauth`/`\crdate` with `\oldcprops`, `\prauth`/`\prdate` with `\oldpprops`) read and written; section and table formatting revisions (`\oldsprops`, `\oldtprops`) not yet. Sections on import (#1378): each `\sect` ends a section with its own page size and orientation, margins, header/footer distance, columns, break type, page-number restart, start and format, title page and headers/footers (those it leaves out link to the previous); export writes every section too (break type, page size and orientation, margins, header/footer distance, columns, page numbering, title page; not headers/footers). On the system clipboard: Copy and Cut write RTF (Windows, macOS, Wayland; not X11), except for picture- and shape-only copies, which keep to WordCraft's own copy, and Paste reads it on all four. Thin: comments, notes, RTL |
|
||||
@@ -84,7 +84,7 @@ Weights are the share of a Word user's file traffic (estimate). Read/write: ✅
|
||||
| `.htm/.html` Web Page and Web Page, Filtered | 1% | R/W | 🟡 | 🟡 | ~65% | 5–9 | `crates/formats/src/tests.rs`, `html.rs` | Tables (#102), relative pictures embedded (#113), missing `</head>` tolerated (#154); the empty comments `<!-->` and `<!--->` end at once instead of swallowing the rest of the page (#5040). `style` element rules with simple selectors (`p`, `.x`, `p.x`; class names may start with a digit, as Word accepts) in cascade order; paragraph `margin`, `text-indent`, `line-height`; cell `width` and borders. Header cells (`<th>`) bold unless their CSS says otherwise; `text-align` on a cell, or inherited from its row, row group or table, aligns the cell's paragraphs (#1381). An opened page looks as Word opens one: Times New Roman 12 pt, single lines, browser-sized black headings, automatic 14 pt spacing where a `p` or heading states no margin (none before the first), a span's margin replacing its paragraph's. HTML gives no page size, so it opens on the region's paper (A4 or Letter), as Markdown and plain text do (#1381). No `@page`, descendant selectors or `div` margins. No Word "Web Page" (with `_files` folder) round-trip. The same reader takes HTML from the system clipboard: Paste keeps another program's formatting (a Word document, a web page; Windows' "HTML Format" header understood), Merge Formatting keeps its emphasis (#687). A pasted `<blockquote>` (LibreOffice's paragraph indented on both sides) becomes a Normal paragraph indented by the page's `blockquote` margins, else 0.5 in each side; opening an .html file keeps it as the Quote style (#1270). Equations are written as MathML (with the linear format as a UnicodeMath annotation) and read back from MathML: our annotation, else the presentation elements, else a TeX annotation (#816). A copy of one equation alone also goes in the clipboard's MathML formats (Windows "MathML" and "MathML Presentation", macOS `public.mathml.presentation` and `public.mathml`; W3C MathML 3 §6.2.4), which Microsoft documents Word reading first when it pastes math (#1297; the paste into Word is not yet verified); Ruby (furigana, phonetic guides) is written as `<ruby>base<rt>reading</rt></ruby>` in the base text's formatting and read back from `<ruby>`/`<rb>`/`<rt>` (`<rp>` fallback parentheses dropped; malformed markup keeps its text), so docx/doc → HTML → doc keeps the annotation; HTML states no alignment, size or raise, so the Phonetic Guide's defaults apply (#1681) CRLF and lone CR input newlines normalize before tokenization, preserving preformatted lines and leaving decoded character references separate (#4810). |
|
||||
| `.mht/.mhtml` Single File Web Page | <1% | R/W | 🟡 | ❌ | ~60% | 2–4 | `crates/formats/src/mht.rs` | Opens: the MIME message's page (its `start` part, else the first HTML part) in its part's charset, quoted-printable or base64, pictures found by `Content-Location` (also relative to the page) or `cid:`; then read as an HTML page, on the region's paper. Not saved |
|
||||
| `.xml` Word XML Document (Flat OPC) | <1% | R/W | ✅ | ✅ | ~65% | — | `crates/docx/tests/it/word_xml.rs`, `roundtrip.rs` (`flat_opc_round_trip_equals_docx`) | The DOCX package in one XML file, read and written by the DOCX code (#548); File › Export › Word XML Document. `.xml` files are told apart by their root element |
|
||||
| `.xml` Word 2003 XML (WordprocessingML 2003) | <1% | R/W | 🟡 | 🟡 | ~55% | 1–2 | `crates/docx/tests/it/word_xml.rs` | Opened by rewriting it as a package for the DOCX reader (#548): styles, default fonts, lists, tables, sections, headers/footers, footnotes/endnotes, comments, bookmarks, tracked insertions/deletions, hyperlinks, `w:binData` pictures, document properties. Not read: formatting changes, `w:docPr` settings, smart tags. Written (#1081) by rewriting the DOCX writer's package into the 2003 vocabulary (`crates/docx/src/wordml2003_write.rs`, `crates/docx/tests/it/wordml2003_write.rs`): core properties, styles (document defaults folded into Normal), lists, text and paragraph/character formatting, tables, hyperlinks, footnotes/endnotes inline, headers/footers in the section properties, bookmarks, comments and tracked insertions/deletions as `aml:annotation`, section page setup, and PNG/JPEG/GIF/BMP/EMF/WMF pictures as `w:pict` with `w:binData`. **Dropped on write:** shapes, text boxes, groups, charts, SmartArt, ink, equations, OLE objects, picture crop, transparency, rotation and floating position (pictures become inline; brightness and contrast are kept as VML `gain`/`blacklevel`), content-control wrappers (their content stays), formatting-change tracking, theme colours/fonts, custom XML, macros, even/odd header setting. File › Export › Word 2003 XML Document, `file.save`/`file.saveAs` with `format: "wordml2003"`, `wordcraft-cli convert … --format wordml2003`; a plain save over a 2003 file keeps that format. Never opened in Word yet (written from the published schemas; needs a tester) |
|
||||
| `.xml` Word 2003 XML (WordprocessingML 2003) | <1% | R/W | 🟡 | 🟡 | ~55% | 1–2 | `crates/docx/tests/it/word_xml.rs` | Opened by rewriting it as a package for the DOCX reader (#548): styles, default fonts, lists, tables, sections, headers/footers, footnotes/endnotes, comments, bookmarks, tracked insertions/deletions, hyperlinks, `w:binData` pictures, document properties. Line ends in text are normalised as XML 1.0 §2.11 says, so CR LF inside `w:t` is one line feed (#5897, `word_2003_xml_with_windows_line_ends_reads_one_space_per_line_break`). Not read: formatting changes, `w:docPr` settings, smart tags. Written (#1081) by rewriting the DOCX writer's package into the 2003 vocabulary (`crates/docx/src/wordml2003_write.rs`, `crates/docx/tests/it/wordml2003_write.rs`): core properties, styles (document defaults folded into Normal), lists, text and paragraph/character formatting, tables, hyperlinks, footnotes/endnotes inline, headers/footers in the section properties, bookmarks, comments and tracked insertions/deletions as `aml:annotation`, section page setup, and PNG/JPEG/GIF/BMP/EMF/WMF pictures as `w:pict` with `w:binData`. **Dropped on write:** shapes, text boxes, groups, charts, SmartArt, ink, equations, OLE objects, picture crop, transparency, rotation and floating position (pictures become inline; brightness and contrast are kept as VML `gain`/`blacklevel`), content-control wrappers (their content stays), formatting-change tracking, theme colours/fonts, custom XML, macros, even/odd header setting. File › Export › Word 2003 XML Document, `file.save`/`file.saveAs` with `format: "wordml2003"`, `wordcraft-cli convert … --format wordml2003`; a plain save over a 2003 file keeps that format. Never opened in Word yet (written from the published schemas; needs a tester) |
|
||||
| `.wps` Works 6–9, WordPerfect `.wpd` (Windows) | <1% | R | ❌ | — | 0% | 10–20 | — | Windows-only converters; low value |
|
||||
| `.xlsx`/`.xlsm`/`.xls`/`.ods` as a mail-merge recipient list | — | R (data source) | ✅ | — | ~90% | 1–3 | `crates/formats/src/sheet.rs`, `cmd/mailings.rs` | Read-only, one sheet's cell values (#334): shared/inline/rich strings, numbers, booleans, cached formula results, dates and times as ISO 8601 (`yyyy-mm-dd`) from the cell's number format, sparse cells; ODS value types, repeated rows/columns, covered cells. Select Table picks the sheet. Capped (64 MiB per part, 100,000 rows, 1,000 columns, 2 M cells, 32,767 characters a cell, 64 MiB of cell text a sheet so a shared string repeated in every cell can't exhaust memory, #1287). Legacy `.xls` (Excel 97–2003 BIFF8, #778): SST/LABELSST, LABEL, NUMBER, RK/MULRK, BOOLERR and cached FORMULA results with the same date handling; Excel 5.0/95 and password-protected `.xls` are refused with a message; a CSV/TSV named `.xls` reads as text; an HTML page or XML spreadsheet named `.xls` gets a message saying which it is (#1287). Text lists (.csv/.tsv/.txt) infer comma, semicolon or tab from the complete first record, ignoring quoted punctuation, doubled quotes and quoted line breaks. They decode UTF-8/UTF-16 by BOM or shape, else the detected legacy encoding (GBK/GB18030, Big5, Shift_JIS, EUC-KR, Windows code pages; `encoding` param overrides) (#778). Not shown: number formats other than dates (currency, percent, thousands separators); no Access or Outlook sources |
|
||||
| Encrypted/password-protected `.docx` | 1% | R/W | ✅ | ✅ | ~60% | 1–2 | `crates/docx/src/crypt.rs` (9 unit tests), `tests/malformed.rs`, `crates/engine/src/tests.rs::password_protected_documents_round_trip`, `aes_encrypted_packages_open_under_any_word_extension` | ECMA-376 / [MS-OFFCRYPTO] agile encryption (AES-256-CBC, SHA-512): a protected `.docx` opens with its password and saves encrypted (#55, #281). Standard encryption (Word 2007: AES-128/192/256-ECB, SHA-1, 50,000 spins) opens too, and either kind opens when named `.doc`/`.dot` (gap 9); saving stays agile. Synthetic files only; never checked against Word-made protected files |
|
||||
@@ -334,6 +334,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, ` ` 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-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. |
|
||||
@@ -360,6 +361,7 @@ For field results, the engine parser treats a backslash in quoted arguments as e
|
||||
| 2026-11-04 | trivial | ODT: a named section inside a table's cell (StarOffice's specification template hides some there) is written as read around the cell's paragraphs, closed in the cell; one starting or ending at a paragraph with twins elsewhere (an empty one, as in LibreOffice's agenda templates) is kept too, by that paragraph's own identity, and opened once whatever is copied. Over the corpus 126 of 133 files round-trip pixel-identical (124); over the 220 from other applications 176 (175), 218 with the same page count (216). |
|
||||
| 2026-11-04 | trivial | ODT element audit ([odt-element-audit.md](odt-element-audit.md)): every element of the ODF 1.3 schema against two corpora (365 files) and a document of our own for each a text document can hold that no file did. Found: index marks over text, plugins and floating frames weren't kept, a form's data source was taken for a control, and password, number, time and grid form controls were dropped; all kept now. |
|
||||
| 2026-11-04 | trivial | ODT, found in StarOffice's long specifications: a reference mark (§6.6.2 `text:reference-mark`, which `text:reference-ref` names) was written as a bookmark only, so references to it found nothing once its paragraph was written from the model; it is written as both (a reference mark over its text when it ends in the paragraph, else at a point). Lists without an `xml:id` (older files') are kept as read too, given one. |
|
||||
| 2026-11-07 | trivial | `.doc`: bracket and brace shapes (MSOSPT 85–88, 185, 186) are unfilled unless `fFilled` is set, the default of Word's shapetypes for them, so the box they are drawn as no longer hides the text behind it (#5943) |
|
||||
| 2026-11-04 | trivial | ODT, found checking 220 files from Microsoft Office, OpenOffice.org, StarOffice, Calligra, Lotus Symphony and others: a section hidden with `text:display="none"` showed its text once written without the section (ending in a list, or in a table's cell); what a hidden section holds is now hidden text, so it stays hidden whatever is written, and WordCraft hides it too. A top-level list whose items nobody edited is written as read whole (a label followed by a space, picture bullets, an unnumbered header: what a paragraph's own markup can't say), a list going on with it naming it; a section may begin or end with a list, which then breaks there. A damaged package's media type isn't shown in its error. Of the 220, 166 round-trip pixel-identical and 194 with the same text. |
|
||||
| 2026-11-03 | trivial | ODT, found checking files from other applications: a kept package part without a media type (LibreOffice's empty `Configurations2/accelerator/current.xml`, another application's file the manifest didn't list) was written without a manifest entry, and LibreOffice refused to open the saved file; every file written has its entry now (ODF 1.3 Part 2 §4.3), its media type empty as read. |
|
||||
| 2026-11-03 | trivial | ODT: more paragraphs written back as read while unchanged: those holding bookmarks (their names kept, so a bookmark may start in markup as read and end in a paragraph written from the model; each name written once), footnotes and endnotes (each note's text fingerprinted when read; while the note numbering is as read, as the citation is its number), formulas, charts and other embedded objects (with their files and preview pictures, only while the paragraph showing them is written as read), and those page-anchored frames LibreOffice puts in the text before a paragraph join (their markup kept before the paragraph's). Styles of different families with one name (LibreOffice's paragraph and graphic "Marginalia") are kept apart, as list styles were. The text keeps `text:use-soft-page-breaks` while markup kept as read holds soft page breaks. Still written from the model: form controls (named afresh in the forms), and a chart's paragraph (WordCraft draws the chart after reading it). |
|
||||
@@ -680,3 +682,4 @@ For field results, the engine parser treats a backslash in quoted arguments as e
|
||||
| 2026-11-03 | trivial | Opening a password-protected file named on the command line shows the Password box also when a chosen region replaced the first blank document (the window followed the replacement only on the first frame, which dropped the box, #5299) |
|
||||
| 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). |
|
||||
@@ -87,6 +87,7 @@ Page drawing indexes text-box warp lookups by paragraph and story for the curren
|
||||
| 4 | **Pagination differs from Word**: Aptos has no metric-matched substitute; legacy compatibility options only partially covered; no page-by-page comparison harness | B | SECTIONPAGES now counts shared continuous-section pages in body and header/footer fields; legacy same-style cell spacing preserved for measured modes 11/12/14, with mode 15 and non-cell controls; Contextual spacing corrected in body/header probes and repeated table-cell runs; [layout-parity.md](layout-parity.md); [typing-parity.md](typing-parity.md) Known gaps; non-overlapping body floating tables clear header tables; stray labels on page-break-only list paragraphs fixed multi-paragraph field-code blank lines fixed; centred numbered heading labels after leading page breaks fixed (#2964); Arial Unicode MS no longer selects monospace substitutes; a slash opening a word travels whole to the next line (#2963) A right, centre or decimal tab whose text doesn't fit, or a left tab past the right indent, goes to the next line with its text, so a TOC entry's page number keeps its leader (#1840). Page break before now applies at a continuous section's mid-page top (#5287); the later-column case remains unmeasured in Word. | Page counts and line breaks differ, which matters for forms, legal and academic work | 30–50 + owner (font) | [layout-parity.md](layout-parity.md) |
|
||||
| 4 | **Pagination differs from Word**: Aptos has no metric-matched substitute; legacy compatibility options only partially covered; no page-by-page comparison harness | B | SECTIONPAGES now counts shared continuous-section pages in body and header/footer fields; legacy same-style cell spacing preserved for measured modes 11/12/14, with mode 15 and non-cell controls; Contextual spacing corrected in body/header probes and repeated table-cell runs; [layout-parity.md](layout-parity.md); [typing-parity.md](typing-parity.md) Known gaps; non-overlapping body floating tables clear header tables; stray labels on page-break-only list paragraphs fixed multi-paragraph field-code blank lines fixed; centred numbered heading labels after leading page breaks fixed (#2964); Arial Unicode MS no longer selects monospace substitutes; a slash opening a word travels whole to the next line (#2963); a hyphen before a slash and a letter is also a break opportunity (#3408), while a hyphen before a slash and a digit stays whole A right, centre or decimal tab whose text doesn't fit, or a left tab past the right indent, goes to the next line with its text, so a TOC entry's page number keeps its leader (#1840). | Page counts and line breaks differ, which matters for forms, legal and academic work | 30–50 + owner (font) | [layout-parity.md](layout-parity.md) |
|
||||
| 4 | **Pagination differs from Word**: Aptos has no metric-matched substitute; legacy compatibility options only partially covered; no page-by-page comparison harness | B | SECTIONPAGES now counts shared continuous-section pages in body and header/footer fields; legacy same-style cell spacing preserved for measured modes 11/12/14, with mode 15 and non-cell controls; Contextual spacing corrected in body/header probes and repeated table-cell runs; [layout-parity.md](layout-parity.md); [typing-parity.md](typing-parity.md) Known gaps; non-overlapping body floating tables clear header tables; stray labels on page-break-only list paragraphs fixed multi-paragraph field-code blank lines fixed; centred numbered heading labels after leading page breaks fixed (#2964); Arial Unicode MS no longer selects monospace substitutes; a slash opening a word travels whole to the next line (#2963); a nested table's Can't Split row moves on with its outer row when the row splits across pages (#5456) A right, centre or decimal tab whose text doesn't fit, or a left tab past the right indent, goes to the next line with its text, so a TOC entry's page number keeps its leader (#1840). | Page counts and line breaks differ, which matters for forms, legal and academic work | 30–50 + owner (font) | [layout-parity.md](layout-parity.md) |
|
||||
| 4 | **Pagination differs from Word**: Aptos has no metric-matched substitute; legacy compatibility options only partially covered; no page-by-page comparison harness | B | SECTIONPAGES now counts shared continuous-section pages in body and header/footer fields; legacy same-style cell spacing preserved for measured modes 11/12/14, with mode 15 and non-cell controls; Contextual spacing corrected in body/header probes and repeated table-cell runs; [layout-parity.md](layout-parity.md); [typing-parity.md](typing-parity.md) Known gaps; non-overlapping body floating tables clear header tables; stray labels on page-break-only list paragraphs fixed multi-paragraph field-code blank lines fixed; centred numbered heading labels after leading page breaks fixed (#2964); Arial Unicode MS no longer selects monospace substitutes; a slash opening a word travels whole to the next line (#2963); a hyphen before a slash and a letter is also a break opportunity (#3408), while a hyphen before a slash and a digit stays whole; a full stop followed by an opening parenthesis stays on one line (#5437) A right, centre or decimal tab whose text doesn't fit, or a left tab past the right indent, goes to the next line with its text, so a TOC entry's page number keeps its leader (#1840). | Page counts and line breaks differ, which matters for forms, legal and academic work | 30–50 + owner (font) | [layout-parity.md](layout-parity.md) |
|
||||
| 2 | **Word's native ink is not drawn from its strokes**; OLE objects show only their picture | B | Every native ink form is preserved: drawing-level ink with its optional picture fallback, direct run-level content parts, and ink members of groups and drawing canvases (kept in their place among the members, drawn from their fallback picture). Drawing canvases are read and written (#344); group shapes (`wpg`) read and written (#267). Charts and SmartArt are drawn (#292) and, with OLE objects, written back with all their parts on save (#319) | Pen notes from Word show only as their picture and can't be edited; OLE objects can't be opened | 45–70 to render and edit charts and SmartArt (#13) | [file-format-parity.md](file-format-parity.md) |
|
||||
| 5 | **Content controls** landed (#381): `w:sdt` kept, shown and edited (Developer tab, Design Mode, Properties). Move tracking landed (#400): `w:moveFrom`/`w:moveTo` with their named ranges round-trip, a tracked cut and paste of a paragraph or sentence records a move, either end accepts/rejects both, green double strike/underline markup and "Moved from/to" balloons; Track Changes Options has the Moves rows (Moved from / Moved to mark and colour) and `w:doNotTrackMoves` (Track moves off: a cut and paste is a deletion and an insertion) round-trips; still missing: drag-and-drop moves (no text drag yet). Formatting revisions landed (#41): `w:rPrChange`/`w:pPrChange`/`w:tblPrChange`/`w:trPrChange`/`w:tcPrChange`/`w:sectPrChange`/`w:numberingChange` round-trip, are recorded while tracking, accepted/rejected and shown in balloons; still missing: tracked style-definition changes (table, row, cell and section property changes now record in WordCraft too, #41 follow-up). Opaque custom XML backing stores now survive saves; binding evaluation remains absent. Replace All now checks content-control locks before editing any match; a refused batch preserves the document and undo history. | B | List-label pitch corrected in synthetic metric controls; Issues #41, #400; source grep | Legal and editorial review workflows; templates with form controls | 5–8 | [file-format-parity.md](file-format-parity.md) |
|
||||
| 6 | **Print dialog depth**: desktop printer choice, copies, collation, duplex and grayscale submit through craft-print; File › Print retains preview, page ranges and odd/even (#641) and scales pages to another paper size (#2108); print-only N-up and booklets are available; driver tray/options remain; web prints through the browser (#209) | B | `ui.print` → `apps/wordcraft/src/print.rs`; issue #15 | Everyone who prints | 5–8 | [hardware-parity.md](hardware-parity.md) |
|
||||
@@ -203,6 +204,7 @@ Page Setup's margin, paper-size and document-grid number boxes scroll long text
|
||||
| 2026-11-02 | trivial | Picture hyperlinks show on hover, follow on Ctrl/⌘+click and round-trip as Word stores them (#5267); no ranked gap changed; readiness estimates unchanged |
|
||||
| 2026-11-02 | trivial | #26 narrowed: MHT opens and `w:altChunk` content is brought in on open; saving MHT remains |
|
||||
| 2026-11-01 | trivial | Preserve table rows and cells inside selected alternate-content branches (#5219); readiness estimates unchanged. |
|
||||
| 2026-11-07 | trivial | Empty paragraph deletion preserves following paragraph/mark formatting (Leaf #152); synthetic regressions only, no ranked gap closed or estimate changed. |
|
||||
| 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 | Gap 6: 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. No ranked gap changed; readiness estimates unchanged |
|
||||
@@ -232,6 +234,7 @@ Page Setup's margin, paper-size and document-grid number boxes scroll long text
|
||||
| 2026-10-17 | trivial | UI/UX gaps: copies put RTF on the clipboard (Windows, macOS, Wayland; not X11), and paste reads RTF on macOS and X11 too |
|
||||
| 2026-11-03 | trivial | Find smart-quoted text with a keyboard apostrophe (#5388); exact search modes and replacement spelling preserved. |
|
||||
| 2026-11-03 | trivial | #4 narrowed: a nested table's Can't Split row moves on with its outer row when the row splits across pages (#5456); Word shows this for a nested row first in its outer row, the split above a later one is inferred and not yet checked in Word; readiness estimates unchanged |
|
||||
| 2026-11-03 | trivial | #4 narrowed: a full stop followed by an opening parenthesis stays on one line, as in Word (#5437) |
|
||||
| 2026-11-03 | minor | #11: Arabic interface with mirrored shell (#1195); key languages missing are now hi and id (plus pt-PT); Hebrew UI still open |
|
||||
| 2026-11-03 | trivial | #8 (CP4.3): one language list: Set Proofing Language, Review › Language and the status bar offer the Language pack languages (Bulgarian was in none); every published pack named in English; Valencian is `ca-ES-valencia`; gap 8 names built-in proofing |
|
||||
| 2026-11-03 | trivial | #8 (CP4.3): the missing-pack bar offers a language before the list of packs is fetched (a fresh install, or a list cached for an older pin) and gets it on Install; with downloading on, a missing or stale list is fetched once in the background; the caret's typing language counts; the spelling context menu's heading is in the interface language |
|
||||
|
||||
@@ -25,7 +25,7 @@ Exact line spacing keeps the run font baseline around tall equations; automatic
|
||||
| Hint-reliant CJK glyph outlines (DFKai-SB / 標楷體) | 🟡 | Required TrueType interpreter runs at units-per-em size; local Windows glyph-bounds regression and synthetic DOCX page/window comparison pass. Other platforms and hint-reliant families remain unmeasured; no native Word comparison. A font asking for a twilight zone over 1,024 points or a stack over 4,096 values, or a glyph past 256 KiB of hinting buffer, is drawn unhinted; on desktop, once at least three of a face's hinted draws have taken over 50 ms and those slow draws are at least 1/16 of its hinted draws, its later glyphs are drawn unhinted (#3692). | — |
|
||||
| Subpoint table grid columns | 🟡 | Preserve positive grid widths rather than imposing a text-box minimum; ECMA-376 §17.4.16. Native Word for Mac 16.113.2 build26092012 confirms narrow trailing grid units. `layout::tests::subpoint_grid_keeps_widths`, `tiny_grid_scales_to_width`. Tiny totals normalize without overflowing the scale factor. Zero and invalid widths retain their fallback. Border painting remains separate. | n/a |
|
||||
| Line breaking (UAX #14 with Word's exceptions: no break after `/`, a break after `-` before a digit) | ✅ | `layout::tests::no_line_break_right_after_a_slash`, `layout::tests::a_hyphen_before_digits_is_a_break_opportunity_like_word`, `layout::tests::a_bookmark_is_no_line_break_opportunity; the paragraph's kinsoku (JIS X 4051 §4 行頭禁則) lists the small kana (っゃゕヶ, the katakana phonetic extensions ㇰ…ㇿ, the halfwidth small kana ァ…ッ) and the prolonged sound mark ー/ー as non-starters — CJ/NS in UAX #14, where the break before them is only a tailoring choice — as JIS X 4051 §4 specifies, inferred from the standard and the synthetic tests named here rather than checked in Word. The rule covers ordinary break opportunities only: a line too narrow for any of them takes the emergency break in `para::break_lines`, which can still start with one, as the module doc records. `layout::tests::a_line_never_starts_with_a_small_kana_or_the_prolonged_sound_mark`, `layout::kinsoku::tests::no_start_holds_every_small_kana_and_the_prolonged_sound_mark` | — |
|
||||
| Line breaking (UAX #14 with Word's exceptions: no break after `/`, a break after `-` before a digit, or before a slash and a letter (`Dataprotection-` / `/Secrecyviolation` in the #3408 report's Word PDF; `fitness-/1589` stays whole in the arabic_rice_benefits_article reference)) | ✅ | `layout::tests::no_line_break_right_after_a_slash`, `layout::tests::a_hyphen_before_digits_is_a_break_opportunity_like_word`, `layout::tests::a_hyphen_before_a_slash_is_a_break_opportunity_like_word`, `layout::tests::a_bookmark_is_no_line_break_opportunity` | — |
|
||||
| Line breaking (UAX #14 with Word's exceptions: no break after `/`, a break after `-` before a digit, or before a slash and a letter (`Dataprotection-` / `/Secrecyviolation` in the #3408 report's Word PDF; `fitness-/1589` stays whole in the arabic_rice_benefits_article reference), no break between `.` and `(`) | ✅ | `layout::tests::no_line_break_right_after_a_slash`, `layout::tests::a_hyphen_before_digits_is_a_break_opportunity_like_word`, `layout::tests::a_hyphen_before_a_slash_is_a_break_opportunity_like_word`, `layout::tests::a_full_stop_before_a_parenthesis_is_no_break`, `layout::tests::a_bookmark_is_no_line_break_opportunity` | — |
|
||||
| A break before a slash that starts a word (" /word" wraps whole; not after a no-break space) | 🟡 | `layout::tests::a_word_after_a_slash_wraps_whole`, `layout::tests::a_no_break_space_before_a_slash_still_holds`; behaviour from the #2963 report, not compared with Word yet | — |
|
||||
| Narrow table cells | ✅ | `layout::tests::narrow_cells_keep_their_width`: usable widths below 12 pt wrap without expansion; oversized glyphs still make progress | 0 |
|
||||
| Justification, Word 2013+ space shrinking (compat mode 15) | ✅ | #109; spaces before a line's first character (paragraph indented with spaces, after a line break or a tab, a bookmark, hidden text or a floating object's anchor before them) keep their width in justified and distributed lines, `layout::tests::leading_spaces_keep_their_width_in_justified_lines` | — |
|
||||
@@ -45,7 +45,7 @@ Exact line spacing keeps the run font baseline around tall equations; automatic
|
||||
| Symbol fonts (Symbol including its PCL6 alias, Wingdings, Wingdings 2/3, Webdings) without the proprietary font: each character, stored as U+F020–U+F0FF or a plain 8-bit code, is drawn as its Unicode equivalent from a face that has it; PDF text and copies read as those symbols | ✅ | `doc::symbol_fonts` tables (Adobe Symbol table and Unicode L2/12-368), `layout::tests::wingdings_text_draws_and_reads_as_its_unicode_symbols` (#776). Shapes come from Noto Sans Symbols / Symbols 2, DejaVu Sans or Segoe UI Symbol, so they differ from Word's; Wingdings 0xFF (not in Unicode) still has no symbol. A line keeps the symbol font's own height (Symbol, Wingdings: the ascent and descent Word's PDFs give them), so a Symbol bullet makes its line as tall as in Word, `layout::tests::a_symbol_bullet_sizes_its_line_by_the_symbol_font`. Apple's one-em Symbol takes the same height, as in Word for Mac (`fonts::word::tests::a_one_em_symbol_face_takes_windows_symbols_line`) | 0 |
|
||||
| List bullets stored as a private-use character (U+E000–U+F8FF) in a font that isn't installed and isn't a mapped symbol-font code: drawn as the level's default bullet (• ○ ▪), not as whatever an installed icon font has at that code | ✅ | `layout::tests::private_use_bullet_no_font_draws_shows_the_levels_default_bullet` (#1683: U+F081 showed Font Awesome's Twitter logo) | — |
|
||||
| Indents in characters (`w:firstLineChars`, `w:leftChars`…), measured in the paragraph mark's font size | ✅ | #534, `layout::tests::character_indents_use_the_paragraph_mark_size`; spacing in lines (`w:beforeLines`/`w:afterLines`) uses the stored points (needs the document grid) | — |
|
||||
| Line spacing (single, 1.5, double, at least, exactly, multiple), spacing before/after, contextual spacing | 🟡 | Contextual spacing measured in body paragraphs and headers (below), with a four-paragraph table-cell regression; list-label ascent overflow is unscaled by multiple spacing and uses descent for automatic and at-least spacing (#601); a superscript or subscript sizes its line at the full font size and its raise or drop doesn't grow the line, while a `w:position` raise or lowering does (Word for Mac); other line-spacing combinations remain (#108, #135 open) | 3–5 |
|
||||
| Line spacing (single, 1.5, double, at least, exactly, multiple), spacing before/after, contextual spacing | 🟡 | Contextual spacing measured in body paragraphs and headers (below), with a four-paragraph table-cell regression; list-label ascent overflow is unscaled by multiple spacing and uses descent for automatic and at-least spacing (#601); a superscript or subscript sizes its line at the full font size and its raise or drop doesn't grow the line, while a `w:position` raise or lowering does, and its text is set at 0.65 of its size rounded to the half point (Word for Mac); other line-spacing combinations remain (#108, #135 open) | 3–5 |
|
||||
| Kerning only where Kerning for fonts asks for it (`w:kern`, at or above its size) | ✅ | Measured in Word for Windows: no `w:kern` or `w:kern="0"` is unkerned; WordCraft kerned every run, so its lines held more text than Word's. `layout::tests::kerning_follows_kerning_for_fonts_like_word` | — |
|
||||
| OpenType ligatures as the document asks (`w14:ligatures`: standard `liga`, contextual `clig`, historical `hlig`, discretional `dlig`, any combination, in runs, styles and defaults) | 🟡 | `layout::tests::ligatures_follow_w14_ligatures`, `ligature_flags_map_to_opentype_features`. Without the property WordCraft keeps drawing standard and contextual ligatures, while Word draws none; the Font dialog's Ligatures choice is still missing | 1 |
|
||||
| Automatic (HTML-style) paragraph spacing (`w:beforeAutospacing`, `w:afterAutospacing`) | ✅ | Measured in Word for Windows: 14 pt at any size, none at the top of the document or a cell or after a cell's last paragraph, the larger of two neighbouring spaces. `layout::tests::automatic_paragraph_spacing_like_word`, `…_drops_at_cell_edges` | — |
|
||||
@@ -69,6 +69,8 @@ Exact line spacing keeps the run font baseline around tall equations; automatic
|
||||
| Tables: autofit to contents/window, fixed widths, Word 2013 edge | 🟡 | #137; #44: AutoFit Contents measures each column's narrowest and widest text and shares the width like Word, once (not live as you type); consistent a percentage width past 100% is kept, as Word does; a table with its own preferred width keeps it, however far past the margin, and only an automatic-width table over 1.5× the text width is squeezed to the margins (Word squeezes it as soon as it is wider) (#1721); cell widths override stale grid proportions when their totals agree (`cell_widths_override_grid`); conflicting widths and spans still differ | 3–5 |
|
||||
| Floating tables (`w:tblpPr`) | 🟡 | #137; one placed on the page or margin that would cover visible text before it and does not fit below it moves to the next page (#534); empty line-break lines do not count as text; a click below one, outside its cells, goes to the text beside or after it, not to its last row, as checked in Word (#2038); exact line-bottom contact counts for tables only (#3977), both in row-span selection and word skipping; exact-spacing table contact uses the specified height rather than paragraph-mark metrics, with original Source Sans 3 controls in modes 11/12/14/15; expected Word rule from [cyril265's Word for Mac 16.114.1 measurements](https://github.com/storytold/wordcraft/issues/3977#issuecomment-6374217574), not a new Word run of these originals; anchor-order replay remains #3790's scope; other overlap rules untested | 2–3 |
|
||||
| Floating tables (`w:tblpPr`) | 🟡 | #137; one placed on the page or margin that would cover visible text before it and does not fit below it moves to the next page (#534); empty line-break lines do not count as text; a click below one, outside its cells, goes to the text beside or after it, not to its last row, as checked in Word (#2038); the same for a click within 2pt of a cell whose text is turned comes from the issue and synthetic tests, not a check by hand (#4181); other overlap rules untested | 2–3 |
|
||||
| Floating tables (`w:tblpPr`) | 🟡 | #137; one placed on the page or margin that would cover visible text before it and does not fit below it moves to the next page (#534); empty line-break lines do not count as text; a click below one, outside its cells, goes to the text beside or after it, not to its last row, as checked in Word (#2038); exact line-bottom contact counts for tables only (#3977), both in row-span selection and word skipping; exact-spacing table contact uses the specified height rather than paragraph-mark metrics, with original Source Sans 3 controls in modes 11/12/14/15; expected Word rule from [cyril265's Word for Mac 16.114.1 measurements](https://github.com/storytold/wordcraft/issues/3977#issuecomment-6374217574), not a new Word run of these originals; anchor-order replay remains #3790's scope; one with `tblOverlap=never` moved below another keeps its distance from the text's left edge only in Word 2013 layout (mode 15), before it its own left edge, also when its first cell has no left margin (docxide `displaced-anchor-*`); other overlap rules untested | 2–3 |
|
||||
| Floating tables (`w:tblpPr`) | 🟡 | #137; one placed on the page or margin that would cover visible text before it and does not fit below it moves to the next page (#534); empty line-break lines do not count as text; a click below one, outside its cells, goes to the text beside or after it, not to its last row, as checked in Word (#2038); exact line-bottom contact counts for tables only (#3977), both in row-span selection and word skipping; exact-spacing table contact uses the specified height rather than paragraph-mark metrics, with original Source Sans 3 controls in modes 11/12/14/15; expected Word rule from [cyril265's Word for Mac 16.114.1 measurements](https://github.com/storytold/wordcraft/issues/3977#issuecomment-6374217574), not a new Word run of these originals; anchor-order replay remains #3790's scope; other overlap rules untested; an empty paragraph (or one of bookmarks and other markers only) after a document's leading floating table takes no space before (docxide-pdf's `leading-empty-before-float` Word PDFs, the paragraph beside the table); below a full-width one it loses it too, untested; a paragraph with text below a full-width one keeps it (`connector-paragraph-anchor-0`/`-240`); one with text beside a narrow one, and later pages and columns, are untested and keep it | 2–3 |
|
||||
| Nested tables | 🟡 | render; polish missing | 2–4 |
|
||||
| Floating pictures/shapes placement (relative to page/margin/column/paragraph/line/character) | ✅ | #136; in a header or footer table cell, a float not laid out in its cell (`layoutInCell="0"`, VML `o:allowincell="f"`, or any VML shape in a header or footer: user-checked in Word for Mac) and placed relative to the page or margins is placed on the page, drawn with the header behind or in front of its text, on every page: Word's picture watermark in a header that starts with a table is centred behind the body text (#1775, `layout::tests::header_cell_float_placed_on_the_page`). Other cell floats are still placed relative to their cell. A float anchored in a line of a long paragraph that goes onto a later page is drawn on that page (observed in Word for Mac: a picture dragged onto a page that only continues a paragraph stays there, saved with its anchor on that page and its offset from the paragraph's top there), and that page's lines are broken again around it (the #1374 relay, so not past its limit); relative to the paragraph, it is measured from where the paragraph continues there; text added on the pages before leaves it where it is. One anchored right after a page break stays on the page before (a cover page, #1632). The page is decided before the lines are placed; a float whose anchor only goes to the next page because the float itself narrows the lines stays on the first page, with the text wrapping around it; on a later page, a float anchored on a line moved to a later page by footnotes or widow control is drawn on that page, and one whose own wrap pushes its line on stays with the text wrapping around it. Not yet: a float anchored in a later column of the same page is still placed with the paragraph's first piece, as before (#4250, `layout::tests::a_float_anchored_on_a_later_page_of_its_paragraph_is_drawn_on_that_page` and the tests after it) | — |
|
||||
| Floating pictures/shapes placement (relative to page/margin/column/paragraph/line/character) | ✅ | #136; in a header or footer table cell, a float not laid out in its cell (`layoutInCell="0"`, VML `o:allowincell="f"`, or any VML shape in a header or footer: user-checked in Word for Mac) and placed relative to the page or margins is placed on the page, drawn with the header behind or in front of its text, on every page: Word's picture watermark in a header that starts with a table is centred behind the body text (#1775, `layout::tests::header_cell_float_placed_on_the_page`). Other cell floats are still placed relative to their cell. A float anchored in a line of a long paragraph that goes onto a later page is drawn on that page (observed in Word for Mac: a picture dragged onto a page that only continues a paragraph stays there, saved with its anchor on that page and its offset from the paragraph's top there), and that page's lines are broken again around it (the #1374 relay, so not past its limit); relative to the paragraph, it is measured from where the paragraph continues there; text added on the pages before leaves it where it is. One anchored right after a page break, with nothing after it that takes room (other anchors, bookmarks, comment or field marks), stays on the page before (a cover page, #1632); with anything after it that takes room (text, a space or tab, an inline picture, another break), it is on the new page with it (observed in Word for Mac: a shape dragged beside the first line after a page break inside a paragraph is saved anchored right after the break, positioned from the paragraph's top on the new page, and stays there, #4262, `layout::tests::a_float_anchored_before_text_after_a_page_break_is_drawn_on_the_next_page`). The page is decided before the lines are placed; a float whose anchor only goes to the next page because the float itself narrows the lines stays on the first page, with the text wrapping around it; on a later page, a float anchored on a line moved to a later page by footnotes or widow control is drawn on that page, and one whose own wrap pushes its line on stays with the text wrapping around it. Not yet: a float anchored in a later column of the same page is still placed with the paragraph's first piece, as before (#4250, `layout::tests::a_float_anchored_on_a_later_page_of_its_paragraph_is_drawn_on_that_page` and the tests after it) | — |
|
||||
@@ -286,6 +288,7 @@ The front-of-text opt-out is retained from the earlier implementation; this patc
|
||||
> 2026-11-01 · Text in a turned text box is drawn turned with it, about its centre, on screen and in PNG and PDF export; flipped across, upside down; flipped side to side, still readable (as Word draws it). Clicks and the caret inside a turned box still go by its upright text (`layout::tests::a_turned_text_box_turns_its_text_with_it`).
|
||||
|
||||
> 2026-11-01 · An in-line picture, shape or chart is raised or lowered by its run's position (Word's `w:position`, Font › Advanced › Position): lowered, it reaches below the baseline and the line grows down to hold it (at most until its top is on the baseline); raised, it stands clear of the baseline. It used to stand on the baseline whatever the position (`layout::tests::an_inline_picture_moves_with_its_runs_position`).
|
||||
> 2026-11-03 · Before Word 2013 layout a floating table that may not overlap another and is moved below it keeps its own left edge instead of stepping in by its distance from the text, also when its first cell has no left margin (`layout::tests::a_floating_table_moved_below_another_keeps_its_edge_before_word_2013_layout`).
|
||||
|
||||
> 2026-11-01 · Paragraph side borders lie beyond their distance from the text, as the top and bottom ones do; top and bottom reach the corners and the shading fills out to the outer edges (#5036) (`layout::tests::side_borders_lie_beyond_their_distance_from_the_text`).
|
||||
|
||||
@@ -306,6 +309,7 @@ The front-of-text opt-out is retained from the earlier implementation; this patc
|
||||
| 2026-11-03 | trivial | Body PAGE fields in tables use the active section number format; reflow views measure headers and footers with it before body placement |
|
||||
| 2026-11-06 | trivial | An in-line picture lowered further than its own height (`w:position`) was held with its top on the baseline and its line only as deep as the picture; Word keeps the top there but lowers the line as far as the position says. A 6 pt picture lowered 30 pt now makes a 30 pt line alone and the text's ascent plus 30 pt beside text, as in Word. This closes the gap noted on 2026-11-05: all 156 synthetic lines measured then now match Word within 0.35 pt, heights and picture positions. Exact spacing keeps the line's baseline as before (`tests::lowered_pictures_and_check_boxes_take_words_line_heights`, `tests::an_inline_picture_moves_with_its_runs_position`). |
|
||||
| 2026-10-31 | trivial | List labels: a level with numFmt none is an ordinary level whose own number reads as nothing: it shows its literal text (`RESPONSE:`), counts and restarts the levels below it, stays empty in legal numbering, and an empty label still tabs the text to the indent, as in Word (#4985) |
|
||||
| 2026-11-06 | trivial | Superscript and subscript text: Word for Mac sets it at 0.65 of the run's size rounded to the half point (from digit advances in its PDF export, base sizes 8–36 pt: 8 → 5, 9 → 6, 10 → 6.5, 11 → 7, 12 → 8, 13 → 8.5, 14 → 9, 16 → 10.5, 20 → 13, 24 → 15.5, 36 → 23.5). WordCraft used 0.65 exactly (11 pt → 7.15), so lines with superscripts came out up to 0.5 pt wider and some broke a word early; the `cargo xtask word-compare` superscript and author-line probes in Arial go from 95–97% of lines with Word's breaks to 100%. `doc::resolve::tests::raised_and_lowered_text_is_sized_to_the_half_point`, `layout::tests::superscript_digits_advance_at_the_half_point_size` |
|
||||
| 2026-11-05 | trivial | PR #4545: eligible cell floats retain in-bounds offsets and touch the outer cell edge only on overflow, ignoring wrap distances for object placement. Front-of-text, turned-cell and outside-cell opt-outs remain. Synthetic geometry controls; native front/behind-text parity remains unqualified. |
|
||||
| 2026-11-05 | trivial | With multiple spacing a line of only in-line pictures is as tall as they reach, raised or lowered, and never less than its font's height (a list label's font counting too): a picture shorter than the font keeps the font's ascent and descent, as noted for ODT documents below; a taller one ends at the line's bottom with no descent under it. The spacing adds its share of the font's height, not of a lowered picture's. A legacy check box sizes its line as text of its size. Measured in Word with Arial at 10 and 20 pt, single, 1.15, 1.25 and 1.5 spacing, 156 synthetic lines (`tests::lowered_pictures_and_check_boxes_take_words_line_heights`). Known gap: a picture lowered further than its own height is limited to that height where Word's line keeps growing. |
|
||||
| 2026-11-04 | trivial | PR #4609: relative REF keeps context already displayed by the own or a needed ancestor label; six synthetic legal-template cases and separate-list context follow the Word for Windows measurements reported by @pakiat in PR discussion. Existing unknown-switch/cache boundaries and readiness estimates unchanged; no new native Word run. |
|
||||
@@ -325,6 +329,9 @@ The front-of-text opt-out is retained from the earlier implementation; this patc
|
||||
| 2026-11-03 | trivial | A line breaks after a hyphen before a slash and a letter ("Dataprotection-" / "/Secrecyviolation"), not before a slash and a digit ("fitness-/1589" stays whole, as in Word), so auto-fit table columns no longer widen to the whole compound (#3408) |
|
||||
| 2026-11-03 | trivial | A section's closing paragraph with a hidden mark and only an in-line picture (or a note reference) is not an empty hidden mark: the picture keeps its room, and a continuous section after it starts below it, not over it; not observed in Word (#5288) |
|
||||
| 2026-11-03 | trivial | A row of a nested table marked Can't Split isn't split with its outer row: the outer row moves on whole when that row comes first in it, and otherwise splits above it (inferred); on a fresh page a nested row taller than the room still splits (docxide-pdf `nested-first-row-*-cant1`) |
|
||||
| 2026-11-03 | trivial | No line break between a full stop and an opening parenthesis: `2023.(Example` wraps whole at the space before, as in the docxide-pdf `dot-open-token*` Word references |
|
||||
| 2026-11-03 | trivial | Before Word 2013 layout a floating table moved below another keeps its own left edge, also when its first cell has no left margin (the mode was read from that margin being zero); readiness estimates unchanged |
|
||||
| 2026-11-03 | trivial | An empty paragraph after a document's leading floating table takes no space before, as Word's PDFs of docxide-pdf's `leading-empty-before-float` cases show (the paragraph beside the table); below a full-width one it loses it too, untested; paragraphs with text, later pages and columns keep it (`layout::tests::float_top`) |
|
||||
| 2026-11-01 | trivial | Columns: the line between them runs from the section's top on the page to the bottom of its tallest column; a continuous section mid-page gets its line (#5178) |
|
||||
| 2026-10-31 | trivial | A table row that would leave a cell empty on a page that already holds something (its inline picture or first line runs past the room) moves on whole instead of leaving the other cells' text beside the empty cell; an at-least row grown by its picture no longer splits off the picture (#4272) |
|
||||
| 2026-11-01 | trivial | A cell aligned to the middle or bottom of a row split across pages is aligned within the part left on the page when its text fits there, and starts at the top of the cell otherwise, as measured in Word |
|
||||
|
||||
@@ -584,11 +584,21 @@ command is one Undo step and still obeys editing protection and the 1–1638 pt
|
||||
limits. Original-fixture engine and Linux CLI/headless UI save/reopen checks cover
|
||||
this correction; native Windows interaction and Microsoft Word remain untested.
|
||||
|
||||
## Empty paragraph removal
|
||||
|
||||
Deleting a paragraph mark whose preceding paragraph was already empty preserves the
|
||||
following paragraph's paragraph properties and mark formatting. Existing inline runs
|
||||
remain intact. Delete, Backspace and selected marks share the model operation; joining
|
||||
two originally non-empty paragraphs retains the first paragraph's properties. Synthetic
|
||||
regressions cover body, header and cell containers, sections, outline/TOC membership,
|
||||
DOCX and Undo/Redo (Leaf #152). No new Word or native UI comparison was run.
|
||||
|
||||
## Revision history
|
||||
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-11-04 | trivial | Ctrl+K on a selection: a changed Text to display replaces the selected text before it is linked |
|
||||
| 2026-11-07 | trivial | Removing an already-empty paragraph preserves the following paragraph properties, mark and inline formatting. Delete, Backspace, selected marks, outline/TOC, DOCX and Undo/Redo have synthetic coverage (Leaf #152); no new Word or native acceptance run. |
|
||||
| 2026-10-30 | trivial | Equations: `\int` + Space waits for its limits; `_x^y` then Space builds them, Space alone builds the operator without, typing goes into its operand, and Enter or an arrow key builds it without going into the operand (#4917). Review: any character that can't continue `\name` corrects it (`\int_0^1`, `\alpha+`), and `∫∫` takes the operand into the inner operator; the Word rows are now observed in Word for Windows on #4938, Enter, arrows and `∫∫` are not |
|
||||
| 2026-11-06 | trivial | Track Changes: resolving away an empty paragraph with a section break removes it whole; the next paragraph keeps its style (#5667) |
|
||||
| 2026-11-03 | trivial | Alt+numpad codes type their character in text boxes too (Find what, dialog fields), not the digits or nothing (#4403) |
|
||||
|
||||
@@ -159,6 +159,18 @@ The messages and both transports are the shared contract of [`craft-web-host`](h
|
||||
- **Files from the host are untrusted input.** An opened file goes through the same reading as one picked with File ▸ Open or dropped on the window, with the same format limits; before it is copied into the app's memory, its size is checked against the host open limit (512 MiB) and a larger file is refused, and a name is cut to 255 characters.
|
||||
- **The event and message names are the interface.** `wordcraft:ready`, `wordcraft:open`, `wordcraft:save`, `wordcraft:dirty` and `wordcraft:failed`, and the fields they carry (the same as the `?host=parent` messages), stay stable; changing one is a breaking change.
|
||||
|
||||
### Driving it from the page (`window.wordcraft`)
|
||||
|
||||
Every WordCraft web build puts `window.wordcraft` on the page, from [`craft-web-api`](https://github.com/storytold/craft-libs/tree/main/crates/web-api) (storytold/craft-libs#413), the same shape in every Craft app. `wordcraft.request(method, params)` calls any method of the control channel the desktop app serves (`docs/control-protocol.md`), and `execute(command, params)`, `commands()`, `docInspect(params)`, `uiInspect(params)` and `screenshot(params)` are shortcuts for the common ones. Each returns a Promise that resolves with the method's result and rejects with an `Error`; a call the app does not answer within 30 seconds rejects. WordCraft's control requests cannot be withdrawn, so a call that times out in the page still runs when the app reaches it, as it does for a desktop client.
|
||||
|
||||
```js
|
||||
const doc = await wordcraft.docInspect(); // a summary of the open document
|
||||
await wordcraft.execute("text.insert", { text: "Hello" });
|
||||
const { data } = await wordcraft.screenshot(); // the canvas as PNG (base64)
|
||||
```
|
||||
|
||||
The API is there in every mode, with or without a host. Opening a file, handing a saved or exported file to the page, the file name and the unsaved flag stay with the host messages above.
|
||||
|
||||
## Renderer selection and fallback flags
|
||||
|
||||
WordCraft renders with wgpu. It uses **WebGPU** when the browser has it and falls back to
|
||||
|
||||
Reference in new issue
Block a user