Text effects: legacy outline, shadow, emboss and imprint are drawn (#2534) (#2548)

The legacy run properties w:outline, w:shadow, w:emboss and w:imprint were
read, resolved and saved but never drawn. Layout now turns them into text
effects (hollow outlined letters; a sharp grey copy down-right for shadow
and emboss, up-left for imprint, as Word's PDFs show them) and draws them
through the existing WordArt path, so screen and PDF both get them and the
PDF keeps the text. WordArt w14 effects on the same run take precedence.

Text drawn from outlines keeps synthetic bold (a widened outline, or one
in a solid fill's colour and transparency). Colour glyphs (emoji, no
outlines) and top-to-bottom runs with upright characters are drawn as
plain glyphs, and tracked insertions and deletions keep their legacy
effects.

Co-authored-by: Naxvog <4963183+naxvog@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
This commit is contained in:
authored and GitHub committed 2026-10-18 03:50:15 -04:00
1 parent 8fb4d44d2c
commit 2e062168f0
9 files changed
+305 -29

No files matched your search

+3 -1
View File
@@ -2,7 +2,7 @@
**Stage: alpha** · next: beta, ~15 points (60% → 75% ready for real work) and ~150–230 h away
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-18 · **Change:** trivial (progress log: Indonesian proofing, #498; previously trivial: progress log: DOCX glossary documents (building blocks); previously trivial: progress log: Read Mode keeps the keyboard and shows whole, unobscured columns, #1772); previously trivial: progress log: font list entries of tall-ascent fonts no longer cut off, #2570); previously trivial: progress log: Split Cells and Convert Text to Table dialogs follow the Word check, #2592; previously trivial: a new source's Tag is made like Word's, three letters of the author and two digits of the year with a number added when taken, and an edit keeps the Tag, #2094; previously trivial: Web Layout fills the window width, #1876; previously trivial: progress log: Different First Page headers reachable from page 1, #2826; previously trivial: Language dialog keeps inherited proofing language and no-proof choices (#2717)); previously trivial (table style editor formats every part, #714); previously trivial: VML freeforms read and kept on save; previously trivial: progress log: consistent preferred cell widths; previously trivial: multi-paragraph field instructions no longer add blank lines; previously trivial: page-break-only list paragraphs hide their labels; previously trivial: progress log: grouped `flowChartOr` shapes draw their circle and crossing spokes; previously trivial: progress log: copies keep their formatting in other programs, #1523; previously trivial: progress log: privacy options on save, #1062; previously trivial: progress log: equation pictures in theme colours on Windows, #1002; previously trivial: symbol fonts, #776; previously trivial: the save prompt answers from the keyboard, #861; previously trivial: table column and row markers on the rulers, #700; previously trivial: table borders and the resize handle drag on the page, #217; previously trivial: formatting revisions landed, #41; previously trivial: Draw tab ink, #307, and Draw Table and Eraser, #303, landed: catalog 402/434; previously minor: readiness table with hours per audience; full number back to the additive weighted sum, 60%; mainstream and essentials numbers added; alpha gate checked; previously major: full re-measure against Word for Mac 16.113.4; restructured to the craftrules progress-docs standard) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-18 · **Change:** trivial (progress log: legacy outline, shadow, emboss and imprint text effects drawn, #2534; previously trivial: progress log: Indonesian proofing, #498; previously trivial: progress log: DOCX glossary documents (building blocks); previously trivial: progress log: Read Mode keeps the keyboard and shows whole, unobscured columns, #1772); previously trivial: progress log: font list entries of tall-ascent fonts no longer cut off, #2570); previously trivial: progress log: Split Cells and Convert Text to Table dialogs follow the Word check, #2592; previously trivial: a new source's Tag is made like Word's, three letters of the author and two digits of the year with a number added when taken, and an edit keeps the Tag, #2094; previously trivial: Web Layout fills the window width, #1876; previously trivial: progress log: Different First Page headers reachable from page 1, #2826; previously trivial: Language dialog keeps inherited proofing language and no-proof choices (#2717)); previously trivial (table style editor formats every part, #714); previously trivial: VML freeforms read and kept on save; previously trivial: progress log: consistent preferred cell widths; previously trivial: multi-paragraph field instructions no longer add blank lines; previously trivial: page-break-only list paragraphs hide their labels; previously trivial: progress log: grouped `flowChartOr` shapes draw their circle and crossing spokes; previously trivial: progress log: copies keep their formatting in other programs, #1523; previously trivial: progress log: privacy options on save, #1062; previously trivial: progress log: equation pictures in theme colours on Windows, #1002; previously trivial: symbol fonts, #776; previously trivial: the save prompt answers from the keyboard, #861; previously trivial: table column and row markers on the rulers, #700; previously trivial: table borders and the resize handle drag on the page, #217; previously trivial: formatting revisions landed, #41; previously trivial: Draw tab ink, #307, and Draw Table and Eraser, #303, landed: catalog 402/434; previously minor: readiness table with hours per audience; full number back to the additive weighted sum, 60%; mainstream and essentials numbers added; alpha gate checked; previously major: full re-measure against Word for Mac 16.113.4; restructured to the craftrules progress-docs standard) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-17 · **Change:** trivial (progress log: pasted blockquotes are indented paragraphs, #1270; previously trivial: progress log: print-area strings translated in every catalog; previously trivial: progress log: comments readable in the dark theme, #2045; previously trivial: progress log: ODT sections, #1379; previously trivial: progress log: table deletes, Change Case and Unlink Fields respect Track Changes and locked content controls, #1329; previously trivial: progress log: rounded rectangle corners, #1476; previously trivial: progress log: Save As type list, #1399; previously trivial: progress log: privacy options on save, #1062; previously trivial: progress log: equation pictures in theme colours on Windows, #1002; previously trivial: symbol fonts, #776; previously trivial: the save prompt answers from the keyboard, #861; previously trivial: table column and row markers on the rulers, #700; previously trivial: table borders and the resize handle drag on the page, #217; previously trivial: formatting revisions landed, #41; previously trivial: Draw tab ink, #307, and Draw Table and Eraser, #303, landed: catalog 402/434; previously minor: readiness table with hours per audience; full number back to the additive weighted sum, 60%; mainstream and essentials numbers added; alpha gate checked; previously major: full re-measure against Word for Mac 16.113.4; restructured to the craftrules progress-docs standard) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4
> **Printing update:** 2026-10-12 · direct desktop printing through craft-print; details below.
@@ -154,6 +154,7 @@ Ranked, to beta (detail and remaining milestones in [docs/roadmap.md](docs/roadm
| 2026-10-12 | Fields keep `w:dirty` through DOCX saves (#877): a begin marker flagged by a generator that couldn't compute a result (pandoc-style TOC placeholders) keeps the flag on open/save — as a buffered field or a range field — so Word still refreshes it on open; computing a result (Update Field, Update Table) clears it, like Word's F9 |
| 2026-10-18 | Different First Page works from page 1 (#2826): Insert › Header and Edit Header with the caret on page 1, and a double-click there while another header is being edited, go into page 1's own header instead of the default one page 1 no longer shows. |
| 2026-10-17 | Language dialog keeps inherited proofing language and no-proof choices (#2717). Opening and accepting unchanged no longer replaces style-derived values with English US and proofing enabled. |
| 2026-10-16 | Text with the legacy Outline, Shadow, Emboss or Engrave font effects (`w:outline`, `w:shadow`, `w:emboss`, `w:imprint`) is drawn with them on screen and in PDFs, no longer as plain text: hollow outlined letters, or a grey copy behind the letters; the PDF keeps the text (#2534) |
| 2026-10-14 | Consistent preferred cell widths override a stale table grid when both totals agree, for fixed and AutoFit tables. Conflicting widths, spans and percentages remain unchanged. |
| 2026-10-14 | Legacy VML freeforms (`v:shape` with its own `path`), such as the rule lines in many older headers, are read as freeform shapes: they show on screen and in PDFs and are saved back as DrawingML custom geometry instead of vanishing |
| 2026-10-14 | Field instructions spanning paragraphs no longer add blank lines during pagination; empty result paragraphs still take a line. |
@@ -431,6 +432,7 @@ Ranked, to beta (detail and remaining milestones in [docs/roadmap.md](docs/roadm
| 2026-10-16 | trivial | Progress log: Read Mode keeps the keyboard, whole columns, page-turn bar clear (#1772) |
| 2026-10-16 | trivial | Progress log: comments readable in the dark theme (#2045) |
| 2026-10-16 | trivial | Progress log: Indonesian proofing (#498) |
| 2026-10-16 | trivial | Progress log: legacy outline, shadow, emboss and imprint text effects drawn (#2534) |
| 2026-10-14 | trivial | Progress log: row table border exceptions |
| 2026-10-12 | minor | Czech proofing review: short-word splits rank after deletion, NOSUGGEST excludes suggestions while accepting typed forms, affix comments apply only to headers, ratios keep spaces and repeated-word messages use Czech quotes; synthetic engine end-to-end and latency checks |
| 2026-10-12 | minor | Czech spelling from the installed Hunspell dictionary, runtime-table suggestions and generic checks; Czech interface writes cs-CZ documents; no Czech hyphenation patterns bundled; estimates unchanged |
+32 -15
View File
@@ -105,8 +105,8 @@ pub enum Draw {
Clip { rect: Rect, items: Vec<Draw> },
/// Text drawn from its outlines with WordArt effects (and bent, in a warped text box):
/// `path` in page coordinates, painted as [`crate::wordart::art_layers`] says. `color` is the
/// run's own colour, the fill when the effects set none.
Art { path: BezPath, fx: Box<wordcraft_doc::wordart::TextEffects>, color: Rgb, alpha: f32 },
/// run's own colour, the fill when the effects set none. `text_bg`: as for [`Draw::Glyphs`].
Art { path: BezPath, fx: Box<wordcraft_doc::wordart::TextEffects>, color: Rgb, alpha: f32, text_bg: Option<Rgb> },
/// A formatting-mark label (a section break's name) in the UI's mark colour, left edge at
/// `x`, drawn with the same face as [`Draw::Mark`]. Screen only, like every mark.
MarkText { x: f32, baseline: f32, size: f32, text: String },
@@ -458,7 +458,7 @@ fn warped_text(
let mut hidden = Vec::new();
for d in &raw {
match d {
Draw::Art { path, fx, color, alpha } => arts.push((path.clone(), fx.clone(), *color, *alpha)),
Draw::Art { path, fx, color, alpha, .. } => arts.push((path.clone(), fx.clone(), *color, *alpha)),
Draw::Glyphs { face, size, glyphs, color, alpha, synth_italic, .. } => {
if *alpha > 0.0 {
arts.push((crate::wordart::glyph_path(face, *size, glyphs, *synth_italic), Box::default(), *color, *alpha));
@@ -480,7 +480,8 @@ fn warped_text(
return;
};
for (path, fx, color, alpha) in arts {
out.push(Draw::Art { path: crate::wordart::warp_path(&path, src, &w), fx, color, alpha });
// No text background: the bent letters leave their highlight behind.
out.push(Draw::Art { path: crate::wordart::warp_path(&path, src, &w), fx, color, alpha, text_bg: None });
}
out.extend(hidden);
}
@@ -600,7 +601,7 @@ fn item_in(doc: &Document, it: &Placed, opts: &DisplayOptions, alpha: f32, outer
Placed::Cell { .. } | Placed::Object { .. } => {}
Placed::Lines { story, path, para, l0, l1, x, y, turn } if turn.is_turned() => {
let mut items = Vec::new();
lines(doc, *story, path, para, *l0, *l1, 0.0, 0.0, opts, alpha, &mut items);
lines(doc, *story, path, para, *l0, *l1, 0.0, 0.0, opts, alpha, *turn == TextDirection::Down, &mut items);
// Link areas are axis-aligned page rectangles: turned text gets none.
for d in &mut items {
if let Draw::Glyphs { link, .. } = d {
@@ -612,7 +613,7 @@ fn item_in(doc: &Document, it: &Placed, opts: &DisplayOptions, alpha: f32, outer
}
out.push(Draw::Turned { x: *x, y: *y, turn: *turn, items });
}
Placed::Lines { story, path, para, l0, l1, x, y, .. } => lines(doc, *story, path, para, *l0, *l1, *x, *y, opts, alpha, out),
Placed::Lines { story, path, para, l0, l1, x, y, .. } => lines(doc, *story, path, para, *l0, *l1, *x, *y, opts, alpha, false, out),
}
}
@@ -627,6 +628,7 @@ fn lines(
y: f32,
opts: &DisplayOptions,
alpha: f32,
down: bool,
out: &mut Vec<Draw>,
) {
let Some(first) = pl.lines.get(l0) else { return };
@@ -902,12 +904,27 @@ fn lines(
g.0 = gid;
}
}
// WordArt effects draw the letters from their outlines; the glyphs stay, invisible,
// for the PDF's text.
let art = rc.text_effects.as_ref().filter(|_| ins.is_none() && del.is_none() && !glyphs.is_empty());
if let Some(fx) = art {
let path = crate::wordart::glyph_path(&st.face, st.size, &glyphs, st.synth_italic);
out.push(Draw::Art { path, fx: fx.clone(), color, alpha });
let synth_bold = st.synth_bold || ins_mark == Some(InsertMark::Bold);
let synth_italic = st.synth_italic || ins_mark == Some(InsertMark::Italic);
// Text effects draw the letters from their outlines; the glyphs stay, invisible, for
// the PDF's text. Not for colour glyphs (no outlines), top-to-bottom text with upright
// characters (their outlines would lie on their side), `#` deletion marks, or WordArt
// in revision colours.
let upright = down && text.chars().any(upright_in_vertical);
let art = (!upright
&& !glyphs.is_empty()
&& del_mark != Some(DeleteMark::Hash)
&& (rc.text_effects.is_none() || ins.is_none() && del.is_none()))
.then(|| crate::wordart::run_effects(rc, color))
.flatten()
.filter(|_| !st.face.has_color_glyphs());
let has_art = art.is_some();
if let Some(mut fx) = art {
if synth_bold {
crate::wordart::embolden(&mut fx, color, st.size);
}
let path = crate::wordart::glyph_path(&st.face, st.size, &glyphs, synth_italic);
out.push(Draw::Art { path, fx: Box::new(fx), color, alpha, text_bg });
}
if !glyphs.is_empty() {
out.push(Draw::Glyphs {
@@ -915,9 +932,9 @@ fn lines(
size: st.size,
glyphs,
color,
alpha: if art.is_some() { 0.0 } else { alpha },
synth_bold: st.synth_bold || ins_mark == Some(InsertMark::Bold),
synth_italic: st.synth_italic || ins_mark == Some(InsertMark::Italic),
alpha: if has_art { 0.0 } else { alpha },
synth_bold,
synth_italic,
text,
link: rc.link.clone(),
ranges,
+152 -6
View File
@@ -7,8 +7,10 @@
//! [`wordcraft_doc::effects::bands`], as for shapes.
use kurbo::{Affine, BezPath, PathEl, Point, Shape};
use wordcraft_doc::effects::Shadow;
use wordcraft_doc::props::Rgb;
use wordcraft_doc::wordart::{TextEffects, TextFill, TextWarp};
use wordcraft_doc::resolve::ResolvedChar;
use wordcraft_doc::wordart::{TextEffects, TextFill, TextOutline, TextWarp};
use wordcraft_fonts::FaceRef;
use wordcraft_geom::Rect;
@@ -30,6 +32,9 @@ pub struct ArtLayer {
pub path: BezPath,
pub paint: ArtPaint,
pub stroke: Option<f32>,
/// The letters themselves (fill, outline), not a shadow, glow or reflection of them: a dark
/// page inks these against the text's background.
pub letters: bool,
}
/// The outlines of `glyphs` (glyph id, pen x, baseline y in page points) of `face` at `size`, as
@@ -103,6 +108,7 @@ pub fn art_layers(path: &BezPath, fx: &TextEffects, color: Rgb, alpha: f32) -> V
path: mirror,
paint: ArtPaint::Linear { p0: (x, top), p1: (x, bottom), stops: vec![(0.0, ink, a), (1.0, ink, 0.0)] },
stroke: None,
letters: false,
});
}
if let Some(sh) = fx.shadow {
@@ -110,28 +116,74 @@ pub fn art_layers(path: &BezPath, fx: &TextEffects, color: Rgb, alpha: f32) -> V
let moved = Affine::translate((f64::from(dx), f64::from(dy))) * path.clone();
for (g, a) in wordcraft_doc::effects::bands(0.0, sh.blur / 2.0, sh.opacity() * alpha) {
let paint = ArtPaint::Solid(sh.color, a);
out.push(ArtLayer { path: moved.clone(), paint: paint.clone(), stroke: None });
out.push(ArtLayer { path: moved.clone(), paint: paint.clone(), stroke: None, letters: false });
if g > 0.05 {
out.push(ArtLayer { path: moved.clone(), paint, stroke: Some(2.0 * g) });
out.push(ArtLayer { path: moved.clone(), paint, stroke: Some(2.0 * g), letters: false });
}
}
}
if let Some(gl) = fx.glow {
for (g, a) in wordcraft_doc::effects::bands(0.0, gl.size, gl.opacity() * alpha) {
if g > 0.05 {
out.push(ArtLayer { path: path.clone(), paint: ArtPaint::Solid(gl.color, a), stroke: Some(2.0 * g) });
out.push(ArtLayer { path: path.clone(), paint: ArtPaint::Solid(gl.color, a), stroke: Some(2.0 * g), letters: false });
}
}
}
if let Some(p) = fill_paint(path) {
out.push(ArtLayer { path: path.clone(), paint: p, stroke: None });
out.push(ArtLayer { path: path.clone(), paint: p, stroke: None, letters: true });
}
if let Some((c, w, a)) = outline {
out.push(ArtLayer { path: path.clone(), paint: ArtPaint::Solid(c, a), stroke: Some(w) });
out.push(ArtLayer { path: path.clone(), paint: ArtPaint::Solid(c, a), stroke: Some(w), letters: true });
}
out
}
/// The effects a run is drawn with: its WordArt effects on top of the legacy font effects
/// (`w:outline`, `w:shadow`, `w:emboss`, `w:imprint`, ECMA-376 §17.3.2), as Word's PDFs draw
/// them: hollow letters outlined in `color`; a sharp grey copy behind, down-right (shadow,
/// emboss) or up-left (imprint). `None`: plain text.
pub fn run_effects(rc: &ResolvedChar, color: Rgb) -> Option<TextEffects> {
if !(rc.outline || rc.shadow || rc.emboss || rc.engrave) {
return rc.text_effects.as_deref().cloned();
}
let mut fx = TextEffects::default();
if rc.outline {
fx.fill = Some(TextFill::None);
fx.outline = Some(TextOutline { color: Some(color), width: 0.5, transparency: 0.0 });
}
if rc.shadow || rc.emboss || rc.engrave {
let distance = (rc.size / 20.0).clamp(0.5, 6.0);
let angle = if rc.engrave { 225.0 } else { 45.0 };
fx.shadow = Some(Shadow { color: Rgb(128, 128, 128), transparency: 0.0, blur: 0.0, distance, angle, rot_with_shape: false });
}
if let Some(w) = &rc.text_effects {
fx.overlay(w);
}
Some(fx)
}
/// Synthetic bold for text drawn from its outlines: the letters' outline widened by `size` × 0.03
/// (the raster glyphs' synthetic-bold stroke), or such an outline in a solid fill's colour and
/// transparency (`color` for the default fill) when there is none.
pub fn embolden(fx: &mut TextEffects, color: Rgb, size: f32) {
let w = if size.is_finite() { (size * 0.03).clamp(0.0, wordcraft_doc::wordart::MAX_OUTLINE) } else { 0.0 };
match &mut fx.outline {
Some(o) if o.color.is_some() => o.width += w,
_ => {
// shortcut: gradient fills get no bold rim (a one-colour rim would show); stroke with
// the gradient if bold gradients matter.
let rim = match &fx.fill {
None => Some((color, 0.0)),
Some(TextFill::Solid { color, transparency }) => Some((*color, *transparency)),
Some(TextFill::None | TextFill::Gradient { .. }) => None,
};
if let Some((c, transparency)) = rim {
fx.outline = Some(TextOutline { color: Some(c), width: w, transparency });
}
}
}
}
/// How a warp bends text: two edges (top and bottom, the text stretched between them), or an arc
/// (the text running along an ellipse, its letters standing out or in).
#[derive(Clone, Copy, Debug)]
@@ -318,6 +370,100 @@ mod tests {
assert!(d.map(0.5, 1.0).1 > 119.0);
}
/// #2534: the legacy font effects become text effects, under any WordArt effects.
#[test]
fn legacy_font_effects_are_drawn_as_text_effects() {
use wordcraft_doc::CharProps;
let rc = |c: CharProps| wordcraft_doc::styles::StyleSheet::default().resolve_char(None, &CharProps { size: Some(20.0), ..c });
let red = Rgb(200, 0, 0);
assert_eq!(run_effects(&rc(CharProps::default()), red), None);
let o = run_effects(&rc(CharProps { outline: Some(true), ..Default::default() }), red).unwrap();
assert_eq!(o.fill, Some(TextFill::None));
assert_eq!(o.outline.and_then(|l| l.color), Some(red));
assert!(o.shadow.is_none());
let off = |c: CharProps| run_effects(&rc(c), red).and_then(|f| f.shadow).map(|s| s.offset()).unwrap();
let (sx, sy) = off(CharProps { shadow: Some(true), ..Default::default() });
assert!(sx > 0.5 && sy > 0.5, "down-right {sx} {sy}");
assert_eq!(off(CharProps { emboss: Some(true), ..Default::default() }), (sx, sy));
let (ix, iy) = off(CharProps { engrave: Some(true), ..Default::default() });
assert!(ix < -0.5 && iy < -0.5, "up-left {ix} {iy}");
// WordArt effects win over the legacy ones.
let fill = TextFill::Solid { color: red, transparency: 0.0 };
let fx = TextEffects { fill: Some(fill.clone()), ..Default::default() };
let both = run_effects(&rc(CharProps { outline: Some(true), text_effects: Some(Box::new(fx)), ..Default::default() }), red).unwrap();
assert_eq!(both.fill, Some(fill));
// Every layer is a plain fill or stroke of the letters.
let mut p = BezPath::new();
p.move_to((0.0, 0.0));
p.line_to((10.0, 0.0));
p.line_to((10.0, 10.0));
p.close_path();
assert_eq!(art_layers(&p, &o, red, 1.0).len(), 1, "outline only: one stroke, no fill");
}
/// #2534: synthetic bold widens the outline, or adds one in the fill's colour.
#[test]
fn embolden_widens_or_adds_the_outline() {
let red = Rgb(200, 0, 0);
let mut plain = TextEffects::default();
embolden(&mut plain, red, 20.0);
assert_eq!(plain.outline, Some(TextOutline { color: Some(red), width: 20.0 * 0.03, transparency: 0.0 }));
let mut hollow = TextEffects { fill: Some(TextFill::None), outline: Some(TextOutline::default()), ..Default::default() };
embolden(&mut hollow, red, 20.0);
assert_eq!(hollow.outline.map(|o| o.width), Some(0.75 + 20.0 * 0.03));
let mut nothing = TextEffects { fill: Some(TextFill::None), ..Default::default() };
embolden(&mut nothing, red, f32::NAN);
assert_eq!(nothing.outline, None);
// A translucent fill gets an equally translucent rim; a gradient none.
let mut glass = TextEffects { fill: Some(TextFill::Solid { color: red, transparency: 40.0 }), ..Default::default() };
embolden(&mut glass, Rgb::BLACK, 20.0);
assert_eq!(glass.outline, Some(TextOutline { color: Some(red), width: 20.0 * 0.03, transparency: 40.0 }));
let stops = vec![wordcraft_doc::wordart::GradStop { pos: 0.0, color: red, transparency: 0.0 }];
let mut grad = TextEffects { fill: Some(TextFill::Gradient { stops, angle: 0.0 }), ..Default::default() };
embolden(&mut grad, red, 20.0);
assert_eq!(grad.outline, None);
}
/// #2534: tracked insertions keep their effects (in the revision's bold mark); turned text
/// draws its glyphs plainly.
#[test]
fn effects_on_tracked_and_turned_text() {
use crate::display::{DisplayOptions, Draw, InsertMark, page_display};
use wordcraft_doc::props::TextDirection;
use wordcraft_doc::{CharProps, Document, Paragraph, Revision, RevisionKind, para_block};
let mut d = Document::new();
d.revisions.push(Revision { kind: RevisionKind::Insert, author: "Ada".into(), date: String::new(), move_name: None });
let cp = CharProps { outline: Some(true), size: Some(20.0), ins: Some(0), ..Default::default() };
d.body = vec![para_block(Paragraph::with_text("Inserted", cp))];
let mut opts = DisplayOptions { markup: true, ..Default::default() };
opts.revisions.insert_mark = InsertMark::Bold;
let arts = |d: &Document, opts: &DisplayOptions| {
let l = crate::layout(d, &mut crate::LayoutCache::new(), &crate::LayoutOptions::default());
// Turned text's draws are inside its `Draw::Turned`.
page_display(d, &l.pages[0], opts)
.into_iter()
.flat_map(|x| match x {
Draw::Turned { items, .. } => items,
x => vec![x],
})
.filter_map(|x| match x {
Draw::Art { fx, .. } => fx.outline.map(|o| o.width),
_ => None,
})
.collect::<Vec<_>>()
};
assert_eq!(arts(&d, &opts), vec![0.5 + 20.0 * 0.03], "outlined and emboldened");
// Top to bottom: WordArt without upright characters keeps its effects; ideographs, drawn
// upright, are drawn from their glyphs.
d.last_section.text_direction = TextDirection::Down;
let fx = TextEffects { outline: Some(TextOutline::default()), ..Default::default() };
let art = CharProps { size: Some(20.0), text_effects: Some(Box::new(fx)), ..Default::default() };
d.body = vec![para_block(Paragraph::with_text("WordArt", art.clone()))];
assert_eq!(arts(&d, &opts), vec![0.75], "turned WordArt keeps its effects");
d.body = vec![para_block(Paragraph::with_text("日本語", art))];
assert!(arts(&d, &opts).is_empty(), "upright ideographs are drawn from their glyphs");
}
#[test]
fn flat_and_unknown_warps_draw_flat() {
let frame = Rect::new(0.0, 0.0, 100.0, 50.0);
+1 -1
View File
@@ -1023,7 +1023,7 @@ impl Exporter<'_> {
s.set_fill(None);
s.set_stroke(None);
}
Draw::Art { path, fx, color, alpha } => {
Draw::Art { path, fx, color, alpha, .. } => {
for l in wordcraft_layout::wordart::art_layers(path, fx, *color, *alpha) {
let mut pb = PathBuilder::new();
append_path(&mut pb, &l.path);
+17
View File
@@ -1535,6 +1535,23 @@ fn bundled_unicode_glyphs_are_visible_and_extractable() {
assert_eq!(squash(&extract_text(&bytes).concat()), TEXT);
}
/// #2534: text with legacy font effects is drawn from its outlines and keeps its text, once.
#[test]
fn legacy_font_effects_keep_their_text() {
let mut d = Document::new();
d.body = [
CharProps { outline: Some(true), ..Default::default() },
CharProps { shadow: Some(true), ..Default::default() },
CharProps { emboss: Some(true), ..Default::default() },
CharProps { engrave: Some(true), ..Default::default() },
]
.into_iter()
.map(|c| para_block(Paragraph::with_text("Effect", c)))
.collect();
let text = extract_text(&export(&d, &PdfOptions::default()).unwrap()).concat();
assert_eq!(text.matches("Effect").count(), 4, "{text}");
}
#[test]
fn shape_dash_arrays_export() {
use wordcraft_doc::{
+74 -3
View File
@@ -868,18 +868,20 @@ fn draw(ctx: &mut RenderContext, doc: &Document, it: &Draw, view: Affine, visibl
ctx.stroke_path(&path);
}
}
Draw::Art { path, fx, color: c, alpha } => {
Draw::Art { path, fx, color: c, alpha, text_bg } => {
let Some(b) = wordcraft_layout::wordart::path_box(path) else { return };
if !b.inflate(200.0, 200.0).overlaps(*visible) {
return;
}
ctx.set_transform(view);
for l in wordcraft_layout::wordart::art_layers(path, fx, *c, *alpha) {
// The letters read against their highlight like plain text (#1252); shadows don't.
let ink = |c: Rgb| if l.letters { opts.ink_text(c, *text_bg) } else { opts.ink(c) };
match &l.paint {
ArtPaint::Solid(c, a) => ctx.set_paint(color(opts.ink(*c), *a)),
ArtPaint::Solid(c, a) => ctx.set_paint(color(ink(*c), *a)),
ArtPaint::Linear { p0, p1, stops } => {
let stops: Vec<peniko::ColorStop> =
stops.iter().map(|(o, c, a)| peniko::ColorStop::from((o.clamp(0.0, 1.0), color(opts.ink(*c), *a)))).collect();
stops.iter().map(|(o, c, a)| peniko::ColorStop::from((o.clamp(0.0, 1.0), color(ink(*c), *a)))).collect();
let g = peniko::Gradient::new_linear((f64::from(p0.0), f64::from(p0.1)), (f64::from(p1.0), f64::from(p1.1)))
.with_stops(stops.as_slice());
ctx.set_paint(g);
@@ -1447,6 +1449,47 @@ mod tests {
assert!(light > 30, "light {light}");
}
/// #2534: legacy font effects are drawn: outlined letters are hollow; shadowed and embossed
/// ones have a grey copy down-right, imprinted ones up-left.
#[test]
fn legacy_font_effects_are_drawn() {
use wordcraft_doc::CharProps;
// Dark and grey pixel counts, and the grey pixels' mean offset from the dark ones.
let shot = |c: CharProps| {
let mut d = Document::from_text("x");
let cp = CharProps { size: Some(40.0), bold: Some(true), ..c };
d.body = vec![wordcraft_doc::para_block(wordcraft_doc::Paragraph::with_text("HHHH", cp))];
let l = layout(&d, &mut LayoutCache::new(), &LayoutOptions::default());
let img = render_page(&d, &l.pages[0], 2.0, &RenderOptions::default());
let (mut dark, mut grey) = ((0.0, 0.0, 0.0f64), (0.0, 0.0, 0.0f64));
for (x, y) in (100..800).flat_map(|x| (100..300).map(move |y| (x, y))) {
let v = img.pixel(x, y)[0];
let acc = match v {
0..60 => &mut dark,
100..=160 => &mut grey,
_ => continue,
};
*acc = (acc.0 + 1.0, acc.1 + f64::from(x), acc.2 + f64::from(y));
}
let mean = |a: (f64, f64, f64)| (a.1 / a.0.max(1.0), a.2 / a.0.max(1.0));
let ((dx, dy), (gx, gy)) = (mean(dark), mean(grey));
(dark.0, grey.0, (gx - dx, gy - dy))
};
let (plain_dark, plain_grey, _) = shot(CharProps::default());
assert!(plain_dark > 2000.0, "plain {plain_dark}");
let (dark, _, _) = shot(CharProps { outline: Some(true), ..Default::default() });
assert!(dark < plain_dark / 3.0, "outline is hollow: {dark} vs {plain_dark}");
for (c, sign) in [
(CharProps { shadow: Some(true), ..Default::default() }, 1.0),
(CharProps { emboss: Some(true), ..Default::default() }, 1.0),
(CharProps { engrave: Some(true), ..Default::default() }, -1.0),
] {
let (_, grey, (ox, oy)) = shot(c);
assert!(grey > plain_grey * 2.0, "a grey copy: {grey} vs {plain_grey}");
assert!(ox * sign > 1.0 && oy * sign > 1.0, "grey copy offset {ox} {oy}");
}
}
/// A document whose only text is a yellow-highlighted run.
fn yellow_highlight_doc() -> (Document, wordcraft_layout::DocLayout) {
let cp = wordcraft_doc::CharProps { highlight: Some(wordcraft_doc::props::Highlight::Yellow), size: Some(24.0), ..Default::default() };
@@ -1551,6 +1594,34 @@ mod tests {
assert!(r8 > 0xC0 && g8 > 0xC0 && b8 > 0xC0, "underline draws light, got {:?}", (r8, g8, b8));
}
/// #2534 review: Automatic text with a legacy shadow (drawn from its outlines) on a black
/// highlight draws light on a dark page, like plain text does, not dark grey on near-black.
#[test]
fn dark_page_shadowed_text_on_a_dark_highlight_reads() {
use wordcraft_doc::props::Highlight;
let cp = wordcraft_doc::CharProps { highlight: Some(Highlight::Black), shadow: Some(true), size: Some(24.0), ..Default::default() };
let mut d = Document::from_text("x");
d.body = vec![wordcraft_doc::para_block(wordcraft_doc::Paragraph::with_text("HHHH HHHH", cp))];
let l = layout(&d, &mut LayoutCache::new(), &LayoutOptions::default());
let items = page_display(&d, &l.pages[0], &DisplayOptions::default());
let b = items
.iter()
.find_map(|i| match i {
Draw::Art { path, text_bg, .. } => {
assert!(text_bg.is_some(), "the run's highlight is the letters' background");
wordcraft_layout::wordart::path_box(path)
}
_ => None,
})
.unwrap();
let dark = render_page(&d, &l.pages[0], 2.0, &RenderOptions { dark: true, ..Default::default() });
let light = (b.x0 as u32 * 2..b.x1 as u32 * 2)
.flat_map(|x| (b.y0 as u32 * 2..b.y1 as u32 * 2).map(move |y| (x, y)))
.filter(|&(x, y)| dark.pixel(x, y)[..3].iter().all(|&v| v > 0xE0))
.count();
assert!(light > 200, "letters draw light, {light} light pixels");
}
/// One EMF record: type, size, payload (a multiple of 4 bytes).
fn emf_rec(typ: u32, payload: &[u8]) -> Vec<u8> {
let mut v = typ.to_le_bytes().to_vec();
+21 -1
View File
@@ -24,6 +24,19 @@ fn emoji_are_drawn_in_colour() {
}
}
/// #2534: text effects (drawn from outlines) leave colour emoji, which have none, drawn in colour.
/// Skips like [`emoji_are_drawn_in_colour`].
#[cfg(not(target_arch = "wasm32"))]
#[test]
fn emoji_with_text_effects_stay_drawn() {
let cp = wordcraft_doc::CharProps { outline: Some(true), shadow: Some(true), ..Default::default() };
let Some((img, boxes)) = emoji_page_with(&RenderOptions::default(), cp) else { return };
for (x0, y0, x1, y1) in boxes {
let n = (x0..x1).flat_map(|x| (y0..y1).map(move |y| (x, y))).filter(|(x, y)| colourful(img.pixel(*x, *y))).count();
assert!(n > 20, "only {n} coloured pixels in emoji box {x0},{y0}..{x1},{y1}");
}
}
/// A dark page (View › Switch Modes) doesn't invert the emoji: they keep the font's colours, the
/// same as pictures. The pixels well inside each emoji (coloured with coloured neighbours, so the
/// paper doesn't show through) match the light page's. Skips like [`emoji_are_drawn_in_colour`].
@@ -51,12 +64,19 @@ fn emoji_keep_their_colours_on_a_dark_page() {
/// pixels; None (the test skips) when no colour emoji font is installed.
#[cfg(not(target_arch = "wasm32"))]
fn emoji_page(opts: &RenderOptions) -> Option<(Rendered, Vec<(u32, u32, u32, u32)>)> {
emoji_page_with(opts, wordcraft_doc::CharProps::default())
}
/// [`emoji_page`] with the text formatted `cp`.
#[cfg(not(target_arch = "wasm32"))]
fn emoji_page_with(opts: &RenderOptions, cp: wordcraft_doc::CharProps) -> Option<(Rendered, Vec<(u32, u32, u32, u32)>)> {
let has_colour_font = FontDb::global().fallback_for('🧠', u32::MAX).is_some_and(|f| COLOR_EMOJI.contains(&f.family.as_str()));
if !has_colour_font {
eprintln!("skipped: no colour emoji font installed");
return None;
}
let d = Document::from_text("Emoji test: 🧠 brain, ✅ check.");
let mut d = Document::from_text("x");
d.body = vec![wordcraft_doc::para_block(wordcraft_doc::Paragraph::with_text("Emoji test: 🧠 brain, ✅ check.", cp))];
let l = layout(&d, &mut LayoutCache::new(), &LayoutOptions::default());
let page = &l.pages[0];
let scale = 2.0;
+3 -1
View File
@@ -1,6 +1,6 @@
# File-format parity with Microsoft Word
> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-17 · **Change:** trivial (DOCX glossary documents (building blocks) read, kept and written; previously trivial: cached field results keep their visible run formatting; previously trivial: cached REF results retain hyperlink destinations; previously trivial: CLI conversions can explicitly refresh fields; previously trivial: a copied equation also goes in the clipboard's MathML formats, #1297; previously trivial: a pasted HTML `<blockquote>` is an indented paragraph, not the Quote style, #1270); previously trivial: every table style part round-trips, #714; previously trivial: VML freeforms read and kept on save; previously trivial: multi-paragraph field instructions no longer add blank lines; previously trivial: `flowChartOr` draws its circle and cross spokes, as in Word; previously trivial: RTF: Mac OS Roman and DOS text, #1653; previously trivial: VML text boxes keep their internal margins, fit to their text and vertical alignment; hidden drawings not shown; previously trivial: PDF export draws horizontal and vertical lines inside groups, #1474; previously trivial: symbol-font text exported as Unicode, #776; previously trivial: text-box margins, Resize shape to fit text and freeforms in groups, #743; previously trivial: formatted paste from the system clipboard's HTML and RTF, #687; previously trivial: independent complex-script formatting inheritance and round-trip regression tests; previously trivial: import/export fixes for TXT, RTF, ODT, HTML and LaTeX, #433–#440; previously major: first version; every format Word reads or writes, measured from the readers and writers on origin/main) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4, plus Word for Windows' extra formats
> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-18 · **Change:** trivial (legacy outline, shadow, emboss and imprint text effects drawn, #2534; previously trivial: DOCX glossary documents (building blocks) read, kept and written; previously trivial: cached field results keep their visible run formatting; previously trivial: cached REF results retain hyperlink destinations; previously trivial: CLI conversions can explicitly refresh fields; previously trivial: a copied equation also goes in the clipboard's MathML formats, #1297; previously trivial: a pasted HTML `<blockquote>` is an indented paragraph, not the Quote style, #1270); previously trivial: every table style part round-trips, #714; previously trivial: VML freeforms read and kept on save; previously trivial: multi-paragraph field instructions no longer add blank lines; previously trivial: `flowChartOr` draws its circle and cross spokes, as in Word; previously trivial: RTF: Mac OS Roman and DOS text, #1653; previously trivial: VML text boxes keep their internal margins, fit to their text and vertical alignment; hidden drawings not shown; previously trivial: PDF export draws horizontal and vertical lines inside groups, #1474; previously trivial: symbol-font text exported as Unicode, #776; previously trivial: text-box margins, Resize shape to fit text and freeforms in groups, #743; previously trivial: formatted paste from the system clipboard's HTML and RTF, #687; previously trivial: independent complex-script formatting inheritance and round-trip regression tests; previously trivial: import/export fixes for TXT, RTF, ODT, HTML and LaTeX, #433–#440; previously major: first version; every format Word reads or writes, measured from the readers and writers on origin/main) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4, plus Word for Windows' extra formats
Every format Word opens or saves, what WordCraft does with it, and how it's tested. Fidelity is the
share of a typical real-world file's content and formatting that survives (read: shows correctly;
@@ -81,6 +81,7 @@ Measured by grepping `crates/docx/src/read` and `src/write` for the OOXML elemen
| Legacy form fields (`w:ffData`), check boxes, drop-downs | ❌ | ❌ | |
| DrawingML pictures inline and anchored (`wp:anchor`), wrap square/tight/through/top-bottom | ✅ | ✅ | Tight/through read and written with their `wp:wrapPolygon` (and `edited`); without one, a shape's outline is written (a picture's rectangle). `wrapText` (both sides, left, right, largest) read and written for square, tight and through |
| WordArt text effects (`w14:textFill`, `w14:textOutline`, `w14:shadow`, `w14:glow`, `w14:reflection` in `w:rPr`) | ✅ | ✅ | Solid, gradient (linear) and no fill, outline colour/width, shadow, glow, reflection (#416); `w14:scene3d`/`w14:props3d` dropped |
| Legacy font effects (`w:outline`, `w:shadow`, `w:emboss`, `w:imprint` in `w:rPr`) | ✅ | ✅ | Drawn on screen and in PDFs as Word's PDFs show them (#2534): hollow outlined letters; a sharp grey copy down-right (shadow, emboss) or up-left (imprint); WordArt effects on the same run win; tracked insertions and deletions keep them. Not drawn on colour emoji, or on top-to-bottom text with upright East Asian characters. Emboss and imprint are approximations (no highlight edge) |
| DrawingML shapes and text boxes (`wps:`) | ✅ | ✅ | Preset geometries subset (callouts and flowchart boxes drawn as the nearest shape, #611); `flowChartOr` draws a circle with horizontal and vertical spokes; rounded rectangles keep their own corner (`roundRect` `adj`: read, written back, drawn on screen and in PDF, #1476); fill and outline from the shape or from the theme through its style references (`a:fillRef`/`a:lnRef`, #611), scheme, preset, system, scRGB and HSL colours; rotation and flips (`a:xfrm` `rot`/`flipH`/`flipV`, pictures and groups too, #332), the effect extent covering the rotated bounds; `wps:bodyPr` `vert` and `anchor`, internal margins (`lIns`/`tIns`/`rIns`/`bIns`) and Resize shape to fit text (`a:spAutoFit`: the text is never cut, #743), linked text boxes (`wps:txbx/@id`, `wps:linkedTxbx` `id`/`seq`, #369); text warps (`a:prstTxWarp` with adjust values, all 41 presets kept; unknown ones dropped), connectors (`straightConnector1`, `bentConnector2`–`5`, `curvedConnector2`–`5` read; 3-segment forms written) with glued ends (`wps:cNvCnPr` `a:stCxn`/`a:endCxn`; the glued shapes keep their drawing ids) and arrowheads (`a:headEnd`/`a:tailEnd`, drawn as triangles), including custom-geometry paths (#416). Hidden drawings (`wp:docPr/@hidden`, a group member's `cNvPr/@hidden`) are kept and saved but neither drawn nor laid out, nor exported to other formats, as in Word for Windows; `.doc` and RTF shapes don't read their hidden flag yet |
| Freeform shapes (`a:custGeom`: `a:moveTo`, `a:lnTo`, Bézier curves flattened) and WordCraft ink | ✅ | ✅ | Ink is written as a freeform `wps:wsp` in a `wp:anchor` (round caps, alpha for highlighter) and recognised again by its drawing name (#307). It is saved as a custom-geometry shape, not as Word's own ink (InkML in `w14:contentPart`), so Word shows WordCraft ink as a freeform shape it can move and recolour but not erase with its ink eraser; arcs drawn straight |
| Group shapes (`wpg:`), drawing canvas (`wpc:`) | ✅ | ✅ | Groups (#267) and canvases (#344) with pictures, shapes, freeforms (custom geometry, #743; a path drawn back to its start without `a:close` is filled, as in Word) and text boxes; nested groups flattened. The canvas schema has no transform, so a turned or flipped canvas keeps its turn in our own `wpc:extLst` extension (`urn:wordcraft:canvas-xfrm`; Word shows it unturned). Legacy VML canvases (`v:group editas="canvas"`) aren't read as canvases |
@@ -221,6 +222,7 @@ Imported text fields use the first nonempty cached result run's formatting on th
| 2026-10-16 | trivial | Cached textual `REF` results with `\h` export links to their bookmarks. Code view and existing enclosing hyperlinks remain unchanged; unresolved bookmarks emit no PDF link. |
| 2026-10-16 | trivial | Cached text fields retain result-run formatting instead of field-begin formatting. |
| 2026-10-17 | trivial | Glossary document (building blocks) read into the model, kept verbatim while unchanged and written from the model once edited; split from the `w:altChunk`/sub-documents row |
| 2026-10-16 | trivial | Legacy `w:outline`, `w:shadow`, `w:emboss` and `w:imprint` run effects drawn on screen and in PDFs (#2534) |
| 2026-10-13 | trivial | A cell's explicit top vertical alignment (`w:vAlign w:val="top"`) is kept on read and write and beats a table style that centres or bottom-aligns (#1288) |
| 2026-10-14 | trivial | Row table border exceptions survive DOCX saves |
| 2026-10-12 | trivial | Fields keep `w:dirty` through DOCX round trips — on buffered fields and range-field begin markers alike — and computing a result (Update Field, Update Table) clears it like Word's F9, so a generator's placeholder TOC still refreshes when Word opens the file until WordCraft replaces the placeholder (#877) |
+2 -1
View File
@@ -1,6 +1,6 @@
# Where WordCraft falls short of Word
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-18 · **Change:** trivial (#8 narrowed: Indonesian proofing from the installed Hunspell dictionary, #498; previously trivial: glossary/building blocks closed; previously trivial: Split Cells and Convert Text to Table follow the Word check, #2592; previously trivial: Language dialog keeps inherited proofing language and no-proof choices (#2717); previously trivial: #29: a copied equation also goes in the MathML clipboard formats, #1297; previously trivial: VML freeforms read and kept on save; previously trivial: consistent preferred cell widths; previously trivial: multi-paragraph field instructions no longer add blank lines; previously trivial: page-break-only list paragraphs hide their labels; previously trivial: floating-table overlap ignores empty line-break lines; previously trivial: copies keep their formatting on the clipboard as HTML, #1523; previously trivial: sample-text formulas on Enter, #1067; previously trivial: #29: copied equations as MathML, OMML open as #1297, #816; previously trivial: the Font list no longer keeps every previewed font in memory, #848; previously trivial: table column and row markers on the rulers, #700; previously trivial: table borders and the resize handle drag on the page, #217; previously trivial: formatting revisions landed, #41; previously trivial: Draw Table and Eraser landed, #303; previously minor: beta distance restated: ~15 points, ~150–230 h; previously: first version; every known shortfall from the 2026-10-10 re-measure, ranked) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-18 · **Change:** trivial (legacy outline, shadow, emboss and imprint text effects drawn, #2534; previously trivial: #8 narrowed: Indonesian proofing from the installed Hunspell dictionary, #498; previously trivial: glossary/building blocks closed; previously trivial: Split Cells and Convert Text to Table follow the Word check, #2592; previously trivial: Language dialog keeps inherited proofing language and no-proof choices (#2717); previously trivial: #29: a copied equation also goes in the MathML clipboard formats, #1297; previously trivial: VML freeforms read and kept on save; previously trivial: consistent preferred cell widths; previously trivial: multi-paragraph field instructions no longer add blank lines; previously trivial: page-break-only list paragraphs hide their labels; previously trivial: floating-table overlap ignores empty line-break lines; previously trivial: copies keep their formatting on the clipboard as HTML, #1523; previously trivial: sample-text formulas on Enter, #1067; previously trivial: #29: copied equations as MathML, OMML open as #1297, #816; previously trivial: the Font list no longer keeps every previewed font in memory, #848; previously trivial: table column and row markers on the rulers, #700; previously trivial: table borders and the resize handle drag on the page, #217; previously trivial: formatting revisions landed, #41; previously trivial: Draw Table and Eraser landed, #303; previously minor: beta distance restated: ~15 points, ~150–230 h; previously: first version; every known shortfall from the 2026-10-10 re-measure, ranked) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4
> **Printing update:** 2026-10-12 · direct desktop printing through craft-print; details below.
@@ -98,6 +98,7 @@ The Language dialog reads effective character formatting, including document def
| 2026-10-18 | trivial | #7: Split Cells and Convert Text to Table follow the Word check (#2592) |
| 2026-10-16 | trivial | #7: Split Cells and Convert Text to Table dialogs (#2592), 58 modal dialogs (matches ui-parity.md) |
| 2026-10-16 | trivial | #8 narrowed: Indonesian proofing (id-ID spelling from the installed Hunspell dictionary, generic checks, #498) |
| 2026-10-16 | trivial | Legacy outline, shadow, emboss and imprint text effects drawn (#2534) |
| 2026-10-14 | trivial | Row table border exceptions fixed; other row table exceptions remain unsupported |
| 2026-10-12 | minor | Czech proofing review: short-word splits rank after deletion, NOSUGGEST excludes suggestions while accepting typed forms, affix comments apply only to headers, ratios keep spaces and repeated-word messages use Czech quotes; synthetic engine end-to-end and latency checks |
| 2026-10-12 | minor | Czech spelling from the installed Hunspell dictionary, runtime-table suggestions and generic checks; Czech interface writes cs-CZ documents; no Czech hyphenation patterns bundled; estimates unchanged |