mirror of
https://github.com/aphrody-labs/wordcraft.git
synced 2026-10-13 19:26:53 +00:00
A Word 365 cover page lost its look. The missing fills came from theme-coloured shapes (scheme colours and wps:style fill references), which the reporter's 0.5.0 build didn't read; #611 fixed that on main. Two causes were still open on main: - A freeform (a:custGeom) inside a wpg group was drawn as a rectangle. read_wsp now reads custom geometry, so group members get it as well. - Text boxes ignored wps:bodyPr lIns/tIns/rIns/bIns and a:spAutoFit. Layout always used Word's default margins, so a title box with no margins that was just tall enough for its text lost its second line. TextBody now carries the box's own margins and auto_fit. Layout uses those margins and doesn't cut the text of a box that resizes to fit. Both are written back on save. Co-authored-by: Dmitrii Khlapov <19991457+dkhlapov@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:
12 files changed
+209
-40
No files matched your search
+3
-1
@@ -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-12 · **Change:** trivial (Alt+Q opens command search, #819; previously trivial: Turkish interface catalog completed, #774; previously trivial: Hungarian interface landed; previously trivial: Quick Access Toolbar KeyTips and a saved enable/disable option, #620; previously trivial: new documents follow the system region, #838; previously minor: Polish interface and Polish proofing; 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-12 · **Change:** trivial (Word 365 cover pages, #743; previously trivial: Turkish interface catalog completed, #774; previously trivial: Hungarian interface landed; previously trivial: Quick Access Toolbar KeyTips and a saved enable/disable option, #620; previously trivial: new documents follow the system region, #838; previously minor: Polish interface and Polish proofing; 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
|
||||
|
||||
WordCraft aims for complete parity with Microsoft Word, then goes further on speed, openness and
|
||||
agent control. This page is the summary; the assessment is
|
||||
@@ -137,6 +137,7 @@ Ranked, to beta (detail and remaining milestones in [docs/roadmap.md](docs/roadm
|
||||
| 2026-10-12 | 32-bit Windows starts on OpenGL. DirectX 12 graphics pipeline creation returns "The parameter is incorrect" and the app exited before the window appeared |
|
||||
| 2026-10-12 | New documents follow the system region (#838): A4 and centimetres outside the US (Letter in the CLDR Letter territories, inches in the US), lengths written with the region's decimal separator (`2,54 cm`) and typed with either; an explicit Measurement units choice wins; `file.options {"region"}` for agents |
|
||||
| 2026-10-12 | Paste from other programs keeps their formatting (#687): Ctrl+V and Home › Paste read the system clipboard's HTML (and RTF on Windows and Wayland); Merge Formatting takes the destination's font and paragraph and keeps bold, italic, underline and strikethrough; Paste Special lists what the system clipboard holds (HTML, RTF, picture, text). Linux: pictures copied in a browser or Spectacle paste on KDE and other Wayland desktops (data-control clipboard), and a paste that finds nothing usable names what the clipboard holds instead of "empty" (#833) |
|
||||
| 2026-10-12 | Word 365 cover pages (#743): a freeform inside a group keeps its shape (it was drawn as a rectangle); text boxes keep their own internal margins and Resize shape to fit text, so a tight title box no longer loses its second line; both survive saving. The missing fills in the report were theme-coloured shapes, fixed by #611 |
|
||||
|---|---|
|
||||
| 2026-10-12 | Alt+Q moves to "Tell me" command search, as in Word for Windows (#819); not on macOS, where Option+Q types a character |
|
||||
| 2026-10-12 | PDF export of long documents with many internal links (a linked table of contents) is linear: NIH's 67-page protocol template 10.6 s → 0.9 s |
|
||||
@@ -244,6 +245,7 @@ Ranked, to beta (detail and remaining milestones in [docs/roadmap.md](docs/roadm
|
||||
| 2026-10-11 | trivial | DOCX complex-script formatting inheritance fix and round-trip coverage |
|
||||
| 2026-10-11 | minor | Czech (`cs`) interface catalog added |
|
||||
| 2026-10-11 | trivial | Progress log: German interface (#574) |
|
||||
| 2026-10-12 | trivial | Progress log: Word 365 cover pages (#743) |
|
||||
| 2026-10-11 | trivial | Progress log: tables resize with the mouse (#217) |
|
||||
| 2026-10-11 | trivial | Progress log: Animation effects setting |
|
||||
| 2026-10-11 | trivial | Progress log: built-in file browser when the system file dialog is unavailable (#576) |
|
||||
|
||||
@@ -279,12 +279,28 @@ pub struct TextBody {
|
||||
/// continues in when it doesn't fit this one. The linked box shows the rest of this box's
|
||||
/// story; its own blocks are unused. See [`Document::text_box_chains`].
|
||||
pub next: Option<u32>,
|
||||
/// Internal margins (left, top, right, bottom), EMU, when the file gives its own
|
||||
/// (`wps:bodyPr` `lIns`… ECMA-376 Part 1 §21.1.2.1.1); `None` is Word's defaults.
|
||||
pub inset: Option<[u32; 4]>,
|
||||
/// Resize shape to fit text (`a:spAutoFit`): Word grows the box to its text, so none of
|
||||
/// the text is cut.
|
||||
pub auto_fit: bool,
|
||||
}
|
||||
|
||||
/// Word's default text box margins (left, top, right, bottom), EMU: 0.1" and 0.05".
|
||||
pub const TEXT_BOX_INSET: [u32; 4] = [91_440, 45_720, 91_440, 45_720];
|
||||
/// Widest text box margin kept, EMU (22", the largest page side).
|
||||
pub const MAX_TEXT_BOX_INSET: u32 = 20_116_800;
|
||||
|
||||
impl TextBody {
|
||||
pub fn is_default(&self) -> bool {
|
||||
*self == TextBody::default()
|
||||
}
|
||||
|
||||
/// The internal margins (left, top, right, bottom) in points.
|
||||
pub fn insets_pt(&self) -> [f32; 4] {
|
||||
self.inset.unwrap_or(TEXT_BOX_INSET).map(|v| v.min(MAX_TEXT_BOX_INSET) as f32 / 12_700.0)
|
||||
}
|
||||
}
|
||||
|
||||
/// Most boxes one chain of linked text boxes runs through (hostile files).
|
||||
|
||||
@@ -936,17 +936,14 @@ impl Reader<'_> {
|
||||
} else {
|
||||
let wsp = gd.find("wps:wsp")?;
|
||||
let mut obj = self.read_wsp(sc, wsp, rels, w, h, float);
|
||||
// A freeform: its custom geometry, and whether it's ink WordCraft wrote.
|
||||
if let InlineObject::Shape { kind, freeform, .. } = &mut obj
|
||||
&& let Some(sppr) = wsp.child("wps:spPr")
|
||||
&& let Some(mut f) = super::freeform::cust_geom(sppr, w, h)
|
||||
// A freeform: whether it's ink WordCraft wrote.
|
||||
if let InlineObject::Shape { freeform: Some(f), .. } = &mut obj
|
||||
&& let Some(name) = c.child("wp:docPr").and_then(|p| p.attr("name"))
|
||||
&& let Some(tool) = super::freeform::ink_tool(name)
|
||||
{
|
||||
f.alpha = super::freeform::line_alpha(sppr.child("a:ln"));
|
||||
let name = c.child("wp:docPr").and_then(|p| p.attr("name"));
|
||||
f.ink = name.and_then(super::freeform::ink_tool);
|
||||
f.order = name.filter(|_| f.ink.is_some()).and_then(super::freeform::ink_order);
|
||||
*kind = ShapeKind::Freeform;
|
||||
*freeform = Some(Arc::new(f));
|
||||
let f = Arc::make_mut(f);
|
||||
f.ink = Some(tool);
|
||||
f.order = super::freeform::ink_order(name);
|
||||
}
|
||||
obj
|
||||
};
|
||||
@@ -1217,6 +1214,15 @@ impl Reader<'_> {
|
||||
if let Some(a) = bp.attr("anchor") {
|
||||
part.body.anchor = wordcraft_doc::props::valign_from_anchor(a);
|
||||
}
|
||||
// Internal margins, each defaulting to Word's (#743).
|
||||
let mut inset = wordcraft_doc::TEXT_BOX_INSET;
|
||||
for (slot, n) in inset.iter_mut().zip(["lIns", "tIns", "rIns", "bIns"]) {
|
||||
if let Some(v) = bp.attr(n).and_then(int) {
|
||||
*slot = u32::try_from(v.clamp(0, i64::from(wordcraft_doc::MAX_TEXT_BOX_INSET))).unwrap_or(0);
|
||||
}
|
||||
}
|
||||
part.body.inset = (inset != wordcraft_doc::TEXT_BOX_INSET).then_some(inset);
|
||||
part.body.auto_fit = bp.child("a:spAutoFit").is_some();
|
||||
}
|
||||
let mut extra = ShapeExtra::default();
|
||||
// Arrowheads: any end type but `none` (ECMA-376 §20.1.10.33) is drawn as a triangle.
|
||||
@@ -1236,7 +1242,13 @@ impl Reader<'_> {
|
||||
self.glued.extend(extra.start.iter().chain(extra.end.iter()).map(|e| e.id));
|
||||
}
|
||||
extra.warp = wsp.child("wps:bodyPr").and_then(|b| b.child("a:prstTxWarp")).and_then(text_warp);
|
||||
InlineObject::Shape { kind, w, h, fill, stroke, stroke_width, float, story, freeform: None, effects, extra }
|
||||
// A freeform: its custom geometry (#743: in a group too).
|
||||
let freeform = sppr.and_then(|s| super::freeform::cust_geom(s, w, h)).map(|mut f| {
|
||||
f.alpha = super::freeform::line_alpha(ln);
|
||||
kind = ShapeKind::Freeform;
|
||||
Arc::new(f)
|
||||
});
|
||||
InlineObject::Shape { kind, w, h, fill, stroke, stroke_width, float, story, freeform, effects, extra }
|
||||
}
|
||||
|
||||
/// Keep drawing id `id` on a shape read from a file (see [`Reader::glued`]).
|
||||
|
||||
@@ -1031,13 +1031,25 @@ impl Writer<'_> {
|
||||
if body.anchor != wordcraft_doc::props::VAlign::Top {
|
||||
attrs.push(("anchor", wordcraft_doc::props::anchor_ooxml(body.anchor)));
|
||||
}
|
||||
match extra.warp.as_ref().and_then(|t| t.sanitized()) {
|
||||
Some(warp) => {
|
||||
w.open("wps:bodyPr", &attrs);
|
||||
text_warp(w, &warp);
|
||||
w.close("wps:bodyPr");
|
||||
// Internal margins, when the box has its own.
|
||||
let inset = body.inset.map(|i| i.map(|v| v.min(wordcraft_doc::MAX_TEXT_BOX_INSET).to_string()));
|
||||
if let Some(inset) = &inset {
|
||||
for (n, v) in ["lIns", "tIns", "rIns", "bIns"].into_iter().zip(inset) {
|
||||
attrs.push((n, v));
|
||||
}
|
||||
None => w.empty("wps:bodyPr", &attrs),
|
||||
}
|
||||
let warp = extra.warp.as_ref().and_then(|t| t.sanitized());
|
||||
if warp.is_none() && !body.auto_fit {
|
||||
w.empty("wps:bodyPr", &attrs);
|
||||
} else {
|
||||
w.open("wps:bodyPr", &attrs);
|
||||
if let Some(warp) = &warp {
|
||||
text_warp(w, warp);
|
||||
}
|
||||
if body.auto_fit {
|
||||
w.empty("a:spAutoFit", &[]);
|
||||
}
|
||||
w.close("wps:bodyPr");
|
||||
}
|
||||
w.close("wps:wsp");
|
||||
}
|
||||
|
||||
@@ -1514,6 +1514,76 @@ fn vml_lines_and_rectangles() {
|
||||
assert!(matches!(&objs[1], InlineObject::Shape { kind: ShapeKind::Rectangle, fill: Some(Rgb(255, 0, 0)), stroke: None, w: 30.0, h: 20.0, .. }));
|
||||
}
|
||||
|
||||
/// #743: a cover page from Word: a group holding a rectangle filled through its style, a
|
||||
/// freeform band and a title box, beside a box that resizes to fit its two lines and has no
|
||||
/// internal margins. The fills, the band's outline, the margins and the resizing are all read
|
||||
/// and survive saving.
|
||||
#[test]
|
||||
fn cover_page_group_fills_freeforms_and_text_box_margins() {
|
||||
let style = r#"<wps:style><a:lnRef idx="2"><a:schemeClr val="accent1"><a:shade val="50000"/></a:schemeClr></a:lnRef><a:fillRef idx="1"><a:schemeClr val="accent1"/></a:fillRef><a:effectRef idx="0"><a:schemeClr val="accent1"/></a:effectRef><a:fontRef idx="minor"><a:schemeClr val="lt1"/></a:fontRef></wps:style>"#;
|
||||
let member = |id: u32, txbox: bool, off: (u32, u32), ext: (u32, u32), geom: &str, paint: &str, inner: &str| {
|
||||
let nv = if txbox { r#"<wps:cNvSpPr txBox="1"/>"# } else { "<wps:cNvSpPr/>" };
|
||||
format!(
|
||||
r#"<wps:wsp><wps:cNvPr id="{id}" name="Member {id}"/>{nv}<wps:spPr><a:xfrm><a:off x="{}" y="{}"/><a:ext cx="{}" cy="{}"/></a:xfrm>{geom}{paint}</wps:spPr>{style}{inner}</wps:wsp>"#,
|
||||
off.0, off.1, ext.0, ext.1
|
||||
)
|
||||
};
|
||||
let rect = r#"<a:prstGeom prst="rect"><a:avLst/></a:prstGeom>"#;
|
||||
let band = r#"<a:custGeom><a:avLst/><a:gdLst/><a:ahLst/><a:cxnLst/><a:rect l="0" t="0" r="r" b="b"/><a:pathLst><a:path w="100" h="100"><a:moveTo><a:pt x="0" y="100"/></a:moveTo><a:lnTo><a:pt x="100" y="0"/></a:lnTo><a:lnTo><a:pt x="100" y="100"/></a:lnTo><a:close/></a:path></a:pathLst></a:custGeom>"#;
|
||||
let title = r#"<wps:txbx><w:txbxContent><w:p><w:r><w:t>Cover Title</w:t></w:r></w:p></w:txbxContent></wps:txbx><wps:bodyPr rot="0" vert="horz" wrap="square" lIns="0" tIns="0" rIns="0" bIns="0" anchor="t"><a:noAutofit/></wps:bodyPr>"#;
|
||||
let members = [
|
||||
member(11, false, (0, 0), (5486400, 5486400), rect, r#"<a:ln><a:noFill/></a:ln>"#, "<wps:bodyPr/>"),
|
||||
member(
|
||||
12,
|
||||
false,
|
||||
(914400, 4572000),
|
||||
(4572000, 914400),
|
||||
band,
|
||||
r#"<a:solidFill><a:schemeClr val="bg1"/></a:solidFill><a:ln><a:noFill/></a:ln>"#,
|
||||
"<wps:bodyPr/>",
|
||||
),
|
||||
member(13, true, (457200, 457200), (4572000, 1828800), rect, r#"<a:noFill/><a:ln><a:noFill/></a:ln>"#, title),
|
||||
]
|
||||
.concat();
|
||||
let group = format!(
|
||||
r##"<w:r><mc:AlternateContent><mc:Choice Requires="wpg"><w:drawing><wp:anchor distT="0" distB="0" distL="0" distR="0" simplePos="0" relativeHeight="1" behindDoc="1" locked="0" layoutInCell="1" allowOverlap="1"><wp:simplePos x="0" y="0"/><wp:positionH relativeFrom="margin"><wp:align>center</wp:align></wp:positionH><wp:positionV relativeFrom="margin"><wp:align>top</wp:align></wp:positionV><wp:extent cx="5486400" cy="5486400"/><wp:effectExtent l="0" t="0" r="0" b="0"/><wp:wrapNone/><wp:docPr id="1" name="Group 1"/><a:graphic><a:graphicData uri="http://schemas.microsoft.com/office/word/2010/wordprocessingGroup"><wpg:wgp><wpg:cNvGrpSpPr/><wpg:grpSpPr><a:xfrm><a:off x="0" y="0"/><a:ext cx="5486400" cy="5486400"/><a:chOff x="0" y="0"/><a:chExt cx="5486400" cy="5486400"/></a:xfrm></wpg:grpSpPr>{members}</wpg:wgp></a:graphicData></a:graphic></wp:anchor></w:drawing></mc:Choice><mc:Fallback><w:pict><v:rect style="width:10pt;height:10pt" fillcolor="#156082 [3204]" stroked="f"/></w:pict></mc:Fallback></mc:AlternateContent></w:r>"##
|
||||
);
|
||||
let subtitle = anchored_wsp(
|
||||
"Subtitle",
|
||||
5753100,
|
||||
420000,
|
||||
r#"<a:prstGeom prst="rect"><a:avLst/></a:prstGeom><a:noFill/><a:ln><a:noFill/></a:ln>"#,
|
||||
"",
|
||||
r#"<wps:cNvSpPr txBox="1"/><wps:txbx><w:txbxContent><w:p><w:r><w:t>Plan</w:t></w:r></w:p><w:p><w:r><w:t>Author</w:t></w:r></w:p></w:txbxContent></wps:txbx>"#,
|
||||
)
|
||||
// `anchored_wsp` writes an empty `wps:bodyPr`: give this one margins and Resize to fit.
|
||||
.replace("<wps:bodyPr/>", r#"<wps:bodyPr lIns="0" tIns="12700" rIns="0" bIns="0"><a:spAutoFit/></wps:bodyPr>"#);
|
||||
let check = |d: &Document| {
|
||||
let theme = &d.settings.theme_colors;
|
||||
let objs = ¶s(d)[0].objects;
|
||||
let InlineObject::Group { children, .. } = &objs[0] else { panic!("{:?}", objs[0]) };
|
||||
assert_eq!(children.len(), 3);
|
||||
let shape = |i: usize| match &children[i].obj {
|
||||
InlineObject::Shape { kind, fill, freeform, story, .. } => (*kind, *fill, freeform.is_some(), *story),
|
||||
o => panic!("{o:?}"),
|
||||
};
|
||||
let (kind, fill, free, _) = shape(0);
|
||||
assert_eq!((kind, fill, free), (ShapeKind::Rectangle, Some(theme[4]), false), "the style's accent fill");
|
||||
let (kind, fill, free, _) = shape(1);
|
||||
assert_eq!((kind, fill, free), (ShapeKind::Freeform, Some(theme[1]), true), "a freeform in a group keeps its outline");
|
||||
let (kind, _, _, story) = shape(2);
|
||||
assert_eq!(kind, ShapeKind::TextBox);
|
||||
let body = d.parts[&story.unwrap()].body;
|
||||
assert_eq!((body.inset, body.auto_fit), (Some([0; 4]), false), "the title box has no margins");
|
||||
let InlineObject::Shape { story: Some(sub), .. } = &objs[1] else { panic!("{:?}", objs[1]) };
|
||||
let body = d.parts[sub].body;
|
||||
assert_eq!((body.inset, body.auto_fit), (Some([0, 12700, 0, 0]), true), "the subtitle resizes to fit its text");
|
||||
};
|
||||
let d = read_body(&format!("<w:p>{group}{subtitle}</w:p>"));
|
||||
check(&d);
|
||||
check(&wordcraft_docx::read(&wordcraft_docx::write(&d).unwrap()).unwrap());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn embedded_fonts_survive_a_round_trip() {
|
||||
let table = r#"<w:fonts xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships"><w:font w:name="Roboto"><w:embedRegular r:id="rId1"/><w:embedBold r:id="rId2"/></w:font></w:fonts>"#;
|
||||
|
||||
@@ -53,9 +53,9 @@ fn text_box(d: &mut Document, text: &str, body: TextBody) -> InlineObject {
|
||||
#[test]
|
||||
fn direction_alignment_and_links_round_trip() {
|
||||
let mut d = Document::from_text("a\nb\nc");
|
||||
let a = text_box(&mut d, "Flows on", TextBody { vert: TextVert::Vert, anchor: VAlign::Center, next: None });
|
||||
let a = text_box(&mut d, "Flows on", TextBody { vert: TextVert::Vert, anchor: VAlign::Center, ..Default::default() });
|
||||
let b = text_box(&mut d, "", TextBody::default());
|
||||
let c = text_box(&mut d, "", TextBody { vert: TextVert::Stacked, anchor: VAlign::Bottom, next: None });
|
||||
let c = text_box(&mut d, "", TextBody { vert: TextVert::Stacked, anchor: VAlign::Bottom, ..Default::default() });
|
||||
for (i, o) in [a, b, c].into_iter().enumerate() {
|
||||
d.insert_object(&wordcraft_doc::Pos::body(i, 1), o, &Default::default()).unwrap();
|
||||
}
|
||||
|
||||
@@ -1026,8 +1026,11 @@ fn clear_of_float_tables(mut r: Rect, f: &TableFloat, legacy: f32, text_left: f3
|
||||
r
|
||||
}
|
||||
|
||||
/// A text box's internal margins (Word's defaults: 0.1" left/right, 0.05" top/bottom).
|
||||
/// A text box's default internal margins (Word's: 0.1" left/right, 0.05" top/bottom); a box's
|
||||
/// own come from its [`wordcraft_doc::TextBody`].
|
||||
#[cfg(test)]
|
||||
const BOX_INSET_X: f32 = 7.2;
|
||||
#[cfg(test)]
|
||||
const BOX_INSET_Y: f32 = 3.6;
|
||||
|
||||
/// Keep what fits in a box `height` tall (items relative to its top): lines that don't fit are
|
||||
|
||||
@@ -133,3 +133,48 @@ fn hostile_link_chains_stay_bounded() {
|
||||
assert_eq!(lines(&l, id).len(), 1, "box {id} shows its own text");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn own_margins_and_resize_to_fit_keep_the_text_in_view() {
|
||||
// #743: a cover page's two-line box with no margins (`lIns="0"`…) is just tall enough for
|
||||
// its text; with Word's default margins its second line would be cut.
|
||||
let two = |h: f32, body: TextBody| {
|
||||
let mut d = Document::from_text("x");
|
||||
let id = text_box(&mut d, 0, "One", 144.0, h, body);
|
||||
d.parts.get_mut(&id).unwrap().blocks =
|
||||
vec![para_block(Paragraph::with_text("One", Default::default())), para_block(Paragraph::with_text("Two", Default::default()))];
|
||||
let l = lay(&d);
|
||||
let shown: usize = lines(&l, id).iter().map(|(.., l0, l1)| l1 - l0).sum();
|
||||
(shown, lines(&l, id).first().map(|(x, y, ..)| (*x, *y)), frame_of(&l, id))
|
||||
};
|
||||
// How deep the two lines reach without margins, from a box with room to spare.
|
||||
let zero = TextBody { inset: Some([0; 4]), ..Default::default() };
|
||||
let mut d = Document::from_text("x");
|
||||
let id = text_box(&mut d, 0, "One", 144.0, 400.0, zero);
|
||||
d.parts.get_mut(&id).unwrap().blocks =
|
||||
vec![para_block(Paragraph::with_text("One", Default::default())), para_block(Paragraph::with_text("Two", Default::default()))];
|
||||
let l = lay(&d);
|
||||
let r = frame_of(&l, id);
|
||||
let need = l.pages[0]
|
||||
.items
|
||||
.iter()
|
||||
.filter_map(|it| match it {
|
||||
Placed::Lines { story: StoryRef::Part(s), para, l0, l1, y, .. } if *s == id => {
|
||||
let (first, last) = (para.lines.get(*l0)?, para.lines.get(l1.checked_sub(1)?)?);
|
||||
Some(y + last.top + last.height - first.top - r.y)
|
||||
}
|
||||
_ => None,
|
||||
})
|
||||
.fold(0.0f32, f32::max);
|
||||
assert!(need > 10.0, "{need}");
|
||||
let (shown, at, r) = two(need + 1.0, zero);
|
||||
assert_eq!(shown, 2, "both lines fit without margins");
|
||||
let (x, y) = at.unwrap();
|
||||
assert!((x - r.x).abs() < 0.5 && (y - r.y).abs() < 0.5, "text starts at the box's corner: {x},{y} in {r:?}");
|
||||
assert_eq!(two(need + 1.0, TextBody::default()).0, 1, "Word's default margins cut the second line");
|
||||
let (shown, ..) = two(need / 2.0, TextBody { auto_fit: true, ..Default::default() });
|
||||
assert_eq!(shown, 2, "a box that resizes to fit its text shows it all");
|
||||
// Hostile margins wider than the box still lay out.
|
||||
let (shown, ..) = two(20.0, TextBody { inset: Some([u32::MAX; 4]), ..Default::default() });
|
||||
assert!(shown >= 1);
|
||||
}
|
||||
@@ -14,7 +14,7 @@ use wordcraft_doc::props::{TextDirection, TextVert, VAlign};
|
||||
use wordcraft_doc::{Block, Blocks, Path, StoryRef, TextBody};
|
||||
use wordcraft_geom::Rect;
|
||||
|
||||
use crate::{BOX_INSET_X, BOX_INSET_Y, Ctx, Placed, fit_box, layout_box};
|
||||
use crate::{Ctx, Placed, fit_box, layout_box};
|
||||
|
||||
/// Linked text boxes for one layout, worked out when a linked box is first laid out.
|
||||
#[derive(Default)]
|
||||
@@ -54,12 +54,14 @@ pub(crate) fn shown_story(ctx: &mut Ctx, id: u32) -> u32 {
|
||||
}
|
||||
}
|
||||
|
||||
/// Line length and the depth lines stack in, for a `w` × `h` box whose text runs `vert`.
|
||||
fn frame(vert: TextVert, w: f32, h: f32) -> (f32, f32) {
|
||||
/// Line length and the depth lines stack in, for a `w` × `h` box whose text runs as `body`
|
||||
/// says, inside its internal margins.
|
||||
fn frame(body: &TextBody, w: f32, h: f32) -> (f32, f32) {
|
||||
let (w, h) = (wordcraft_geom::finite(w), wordcraft_geom::finite(h));
|
||||
let (along, across) = match vert.turn() {
|
||||
TextDirection::Horizontal => (w - 2.0 * BOX_INSET_X, h - 2.0 * BOX_INSET_Y),
|
||||
_ => (h - 2.0 * BOX_INSET_Y, w - 2.0 * BOX_INSET_X),
|
||||
let [l, t, r, b] = body.insets_pt();
|
||||
let (along, across) = match body.vert.turn() {
|
||||
TextDirection::Horizontal => (w - l - r, h - t - b),
|
||||
_ => (h - t - b, w - l - r),
|
||||
};
|
||||
(along.max(12.0), across)
|
||||
}
|
||||
@@ -97,7 +99,7 @@ fn lay(ctx: &mut Ctx, id: u32, blocks: &Blocks, len: f32, vert: TextVert, depth:
|
||||
/// share of a linked chain's, cut to what fits, aligned and turned as the box says.
|
||||
pub(crate) fn box_items(ctx: &mut Ctx, id: u32, rect: Rect, depth: usize) -> Vec<Placed> {
|
||||
let body = ctx.doc.parts.get(&id).map(|p| p.body).unwrap_or_default();
|
||||
let (len, across) = frame(body.vert, rect.w, rect.h);
|
||||
let (len, across) = frame(&body, rect.w, rect.h);
|
||||
let items = match chain_share(ctx, id, depth) {
|
||||
Some(items) => items,
|
||||
None => {
|
||||
@@ -107,8 +109,9 @@ pub(crate) fn box_items(ctx: &mut Ctx, id: u32, rect: Rect, depth: usize) -> Vec
|
||||
let blocks = ctx.doc.parts.get(&id).map(|p| p.blocks.clone()).unwrap_or_default();
|
||||
let inner = lay(ctx, id, &blocks, len, body.vert, depth);
|
||||
ctx.boxes.leave();
|
||||
// Text that doesn't fit inside the margins is hidden, as in Word.
|
||||
fit_box(inner, across)
|
||||
// Text that doesn't fit inside the margins is hidden, as in Word; a box that resizes
|
||||
// to fit its text shows it all (#743).
|
||||
if body.auto_fit { inner } else { fit_box(inner, across) }
|
||||
}
|
||||
};
|
||||
place(items, &body, rect, across)
|
||||
@@ -124,11 +127,12 @@ fn place(mut items: Vec<Placed>, body: &TextBody, rect: Rect, across: f32) -> Ve
|
||||
VAlign::Bottom => (across - used).max(0.0),
|
||||
};
|
||||
let turn = body.vert.turn();
|
||||
let [l, t, r, b] = body.insets_pt();
|
||||
let (ox, oy) = match turn {
|
||||
TextDirection::Horizontal => (rect.x + BOX_INSET_X, rect.y + BOX_INSET_Y + off),
|
||||
TextDirection::Horizontal => (rect.x + l, rect.y + t + off),
|
||||
// Top-to-bottom lines stack from the right edge, bottom-to-top ones from the left.
|
||||
TextDirection::Down => (rect.right() - BOX_INSET_X - off, rect.y + BOX_INSET_Y),
|
||||
TextDirection::Up => (rect.x + BOX_INSET_X + off, rect.bottom() - BOX_INSET_Y),
|
||||
TextDirection::Down => (rect.right() - r - off, rect.y + t),
|
||||
TextDirection::Up => (rect.x + l + off, rect.bottom() - b),
|
||||
};
|
||||
for it in &mut items {
|
||||
it.turn(turn, ox, oy);
|
||||
@@ -204,7 +208,7 @@ fn flow(ctx: &mut Ctx, chain: &[u32], depth: usize) -> Vec<Vec<Placed>> {
|
||||
};
|
||||
let body = ctx.doc.parts.get(id).map(|p| p.body).unwrap_or_default();
|
||||
let (w, h) = sizes.get(id).copied().unwrap_or((0.0, 0.0));
|
||||
let (len, across) = frame(body.vert, w, h);
|
||||
let (len, across) = frame(&body, w, h);
|
||||
let key = (len.to_bits(), body.vert);
|
||||
let items = match layouts.iter().find(|(k, _)| *k == key) {
|
||||
Some((_, items)) => items.clone(),
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# File-format parity with Microsoft Word
|
||||
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** trivial (font table and embedded fonts kept on save, #770; previously trivial: native save temporary-file ownership and failure protection; previously trivial: Print Markup: comments in PDF/print/copies, #589; previously trivial: SmartArt written from WordCraft's model, #497; 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-11 · **Change:** 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;
|
||||
@@ -60,9 +60,9 @@ 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 |
|
||||
| DrawingML shapes and text boxes (`wps:`) | ✅ | ✅ | Preset geometries subset (callouts and flowchart boxes drawn as the nearest shape, #611); 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`, 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) (#416) |
|
||||
| DrawingML shapes and text boxes (`wps:`) | ✅ | ✅ | Preset geometries subset (callouts and flowchart boxes drawn as the nearest shape, #611); 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) (#416) |
|
||||
| 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 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 |
|
||||
| Group shapes (`wpg:`), drawing canvas (`wpc:`) | ✅ | ✅ | Groups (#267) and canvases (#344) with pictures, shapes, freeforms (custom geometry, #743) 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 |
|
||||
| VML (`w:pict`, `v:shape`, `v:textbox`) | 🟡 | ❌ | Pictures, text boxes, lines (with arrowheads), rectangles and ovals, best effort (#242, #611) |
|
||||
| Alt text of drawings (`wp:docPr`/`cNvPr` `descr` and `title`, decorative mark `adec:decorative` from [MS-ODRAWXML]) and tables (`w:tblCaption`, `w:tblDescription`) | ✅ | ✅ | Pictures, shapes, text boxes, groups and their members, charts and SmartArt; OLE objects get theirs written into the kept XML (#386) |
|
||||
| Charts (`c:chart`), SmartArt (`dgm`) | ✅ | ✅ | Drawn from the chart's cached data and SmartArt's stored drawing (#292), with data labels (`c:showVal`). Charts made in WordCraft (Insert › Chart, #409) are written as a `c:chartSpace` part with their data as literals (`c:strLit`/`c:numLit`, no embedded workbook, so Word shows them and offers to create a workbook on Edit Data) and read back as editable; a chart part holding anything more (any chart from Word, or one re-saved by Word) stays read-only. Saved back as read, with every part they reach (chart, embedded workbook, colours and style; diagram data, layout, quick style, colours and drawing) and their content types; a moved or resized one keeps its new frame, and a pasted copy gets its own parts (#319). SmartArt made in WordCraft (#497) is written from its model: data (`dgm:dataModel` points and connections), our own layout, quick-style and colour definitions under the built-in layout identifiers, and a `dsp:drawing` with every shape placed; it reads back editable only when its data part is exactly what WordCraft writes |
|
||||
@@ -179,6 +179,7 @@ raise readiness estimates. No Word-derived files are fixtures.
|
||||
| 2026-10-11 | trivial | Files with an extension WordCraft doesn't know open by content: text (`.m3u`/`.m3u8` playlists, logs…) through the encoding-detecting text import, misnamed Word/RTF files by signature, binary files refused with an error (#569) |
|
||||
| 2026-10-11 | trivial | Restrict Editing: Word's legacy password hash (`w:cryptAlgorithmSid`, `w:hash`, `w:salt`, `w:cryptSpinCount`) verified per ECMA-376 Part 4 §14.8.1; unsupported hash forms refused (#412) |
|
||||
| 2026-10-11 | trivial | Restrict Editing: `w:documentProtection` (edit, formatting, password hash), `w:locked` styles and `w:permStart`/`w:permEnd` exceptions read and written (#412) |
|
||||
| 2026-10-12 | trivial | Word 365 cover page (#743): text-box internal margins and `a:spAutoFit` read and written; freeforms inside groups keep their custom geometry |
|
||||
| 2026-10-11 | trivial | DOCX diagrams (#611): shapes and connectors styled by the theme (`wps:style`) show their outline and fill; preset/system colours; VML lines and rectangles; lines run top-left to bottom-right as in DrawingML |
|
||||
| 2026-10-11 | trivial | WordArt text effects (`w14:` run properties), text warps (`a:prstTxWarp`) and connectors with glued ends read and written (#416) |
|
||||
| 2026-10-11 | trivial | Content controls read and written with their properties and nesting (#381) |
|
||||
|
||||
+4
-1
@@ -1,6 +1,6 @@
|
||||
# Where WordCraft falls short of Word
|
||||
|
||||
> **Last reviewed:** 2026-10-12 · **Last updated:** 2026-10-12 · **Change:** trivial (32-bit Windows no longer exits at launch on DirectX 12; previously trivial: Word XML Document and Word 2003 XML landed, #548) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4
|
||||
> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-11 · **Change:** trivial (#1: a field report from Word 365 fixed, #743; previously trivial: formatted paste from other programs and Wayland clipboard pictures, #687, #833; previously trivial: DOCX complex-script formatting inheritance fixed; broader RTL work remains; previously trivial: #24 narrowed: Customize Keyboard and Customize Ribbon landed; 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
|
||||
|
||||
The work list. One entry per shortfall, ranked by how much it stops a Word user from switching
|
||||
(**B** = blocks beta). Each says what's missing, the evidence, who it hurts, an Opus 5.5 agent
|
||||
@@ -13,6 +13,8 @@ Native save reliability: synthetic cases on macOS reproduced loss through the pr
|
||||
|
||||
| # | Gap | B | Evidence | Impact | Hours | Doc |
|
||||
|---|---|---|---|---|---|---|
|
||||
| 1 | **DOCX never tested on real-world files.** No corpus of files from Word, Google Docs, LibreOffice and Pages has been opened, rendered and round-tripped and compared with Word page by page | B | No corpus in the repo; `crates/docx/tests` are synthetic fixtures (including omitted/empty paragraph defaults and imported TOC range preservation); "DOCX fidelity corpus: not started" since 2026-10-06. Field reports fixed one by one: a cover page's theme-coloured shapes (#611), freeforms inside groups and text-box margins and Resize to fit (#743) | Everyone: the files people receive are the files that break | 30–50 | [file-format-parity.md](file-format-parity.md) |
|
||||
| 2 | **Ink and drawing canvases are dropped on open and lost on save, silently**; OLE objects show only their picture | B | No ink or drawing-canvas (`wpc`) handling in `crates/docx`; 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) | Documents with diagrams drawn in Word or pen notes lose content without warning | 4–8 to preserve ink and drawing canvases; 45–70 to render and edit charts and SmartArt (#13) | [file-format-parity.md](file-format-parity.md) |
|
||||
| 1 | **DOCX never tested on real-world files.** No corpus of files from Word, Google Docs, LibreOffice and Pages has been opened, rendered and round-tripped and compared with Word page by page | B | No corpus in the repo; `crates/docx/tests` are synthetic fixtures (including omitted/empty paragraph defaults and imported TOC range preservation); "DOCX fidelity corpus: not started" since 2026-10-06 | Everyone: the files people receive are the files that break | 30–50 | [file-format-parity.md](file-format-parity.md) |
|
||||
| 2 | **Remaining native ink forms are dropped on open and lost on save, silently**; OLE objects show only their picture | B | Drawing-level native ink is preserved with its optional picture fallback; direct run-level content parts and ink nested in groups/canvases remain unsupported. 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) | Documents with diagrams drawn in Word or pen notes lose content without warning | 4–8 to preserve remaining native ink forms; 45–70 to render and edit charts and SmartArt (#13) | [file-format-parity.md](file-format-parity.md) |
|
||||
| 3 | **Crashes and freezes in the field**: startup crash on Intel UHD Graphics (#170) (lag on GPUs without a DirectX 12 driver fixed, #316; 32-bit Windows exited at launch because DirectX 12 graphics pipeline creation failed, now OpenGL), freezes (#77, #59 Thai fonts, #31 font selector), no writes without admin on Windows 11 (#218), broken shape tool (#142), images can't be moved (#82) | B | 62 open issues, ~15 of them crashes, hangs or data-path failures | Anyone hit loses work or trust; the never-crash standard covers panics, not hangs or GPU driver faults | 20–30 | [target-app-parity.md](target-app-parity.md) (Stability) |
|
||||
@@ -72,6 +74,7 @@ The same gaps grouped the way the parity documents are, for agents working in on
|
||||
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-10-12 | trivial | #1: Word 365 cover page (#743): freeforms in groups keep their outline; text boxes keep their own margins and Resize shape to fit text, so no line is cut |
|
||||
| 2026-10-12 | trivial | #3: 32-bit Windows no longer exits at launch; DirectX 12 graphics pipelines fail to create, so that build starts on OpenGL |
|
||||
| 2026-10-12 | trivial | Custom dictionary persists across sessions (`custom-dictionary.txt` in the settings folder; #40) |
|
||||
| 2026-10-11 | trivial | #8 narrowed: runs in a proofing language without a dictionary are skipped instead of flagged against the English dictionary (#100); dictionaries still English-only |
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Layout and pagination parity with Microsoft Word
|
||||
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** trivial (complex-script shaping and Myanmar line breaking, #604; previously trivial: body page totals, table-cell footnotes and bookmark link pages fixed, #450 #451 #455; previously major: first version; line breaking, pagination and object placement checked against Word's behaviour from the layout source on origin/main) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** trivial (text boxes use their own margins; Resize shape to fit text never cuts text, #743; previously trivial: complex-script shaping and Myanmar line breaking, #604; previously trivial: body page totals, table-cell footnotes and bookmark link pages fixed, #450 #451 #455; previously major: first version; line breaking, pagination and object placement checked against Word's behaviour from the layout source on origin/main) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4
|
||||
|
||||
A document only "opens correctly" if its pages break where Word breaks them: the same lines, the
|
||||
same page count, footnotes and pictures on the same pages. This checklist covers line breaking,
|
||||
@@ -44,7 +44,7 @@ font can't match Word's metrics.
|
||||
| Floating pictures/shapes placement (relative to page/margin/column/paragraph/line/character) | ✅ | #136 | — |
|
||||
| Text wrap: square, top-and-bottom, behind, in front | ✅ | 2026-10-06 | — |
|
||||
| Text wrap: tight, through (contour, wrap polygon) | 🟡 | #354: lines follow the wrap polygon, or the outline worked out from the object (shape geometry; a picture's opaque pixels as a convex hull; otherwise the rectangle), turned and flipped with it, kept the left/right distance from text; Through also uses gaps inside the outline. Wrap side (`wrapText`: both sides, left only, right only, largest side per line) applies to square, tight and through wraps, turned objects included; picture outlines are convex; not compared page-for-page with Word | 1–2 |
|
||||
| Text boxes, overflow, linked text boxes | ✅ | #46; linked text boxes (#369): the text runs on through each box in turn at that box's line length, overflow past the last box hidden; Align Text top / middle / bottom | — |
|
||||
| Text boxes, overflow, linked text boxes | ✅ | #46; linked text boxes (#369): the text runs on through each box in turn at that box's line length, overflow past the last box hidden; Align Text top / middle / bottom; the box's own internal margins, and a box set to Resize shape to fit text shows all its text (#743) | — |
|
||||
| Rotated objects and text | 🟡 | #332: pictures, shapes, charts and groups drawn turned and flipped about their centre (screen and PDF); square wrap and inline lines keep clear of the rotated bounds; hit testing on the turned shape. Text in a rotated text box stays upright | 2–3 |
|
||||
| Page borders (from page edge or text), page colour, watermark | ✅ | | — |
|
||||
| Line numbers (restart per page/section, count by) | ✅ | | — |
|
||||
@@ -85,6 +85,7 @@ font can't match Word's metrics.
|
||||
| 2026-10-11 | trivial | Mirrored margins on even pages, tall and linked footers/headers push the body, long endnotes continue, caret in continued notes (#452, #453, #454, #456, #457) |
|
||||
| 2026-10-11 | trivial | Hyphenation zone, words in capitals and consecutive-hyphen limit follow the document settings; Manual Hyphenation (#407) |
|
||||
| 2026-10-11 | trivial | Document grid: line and character pitch (#391) |
|
||||
| 2026-10-12 | trivial | Text boxes use their own internal margins; Resize shape to fit text never cuts text (#743) |
|
||||
| 2026-10-11 | trivial | Linked text boxes, Align Text and text-box text direction (#369) |
|
||||
| 2026-10-11 | trivial | Right-to-left tables and sections laid out mirrored (#362) |
|
||||
| 2026-10-10 | trivial | The document's own note separators and continuation notice are drawn (#352) |
|
||||
|
||||
Reference in new issue
Block a user