From afc6d31764ec11d966789db890b3f1d5f06c5a5d Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 17 Oct 2026 16:15:32 +0000 Subject: [PATCH 1/8] Inline objects paint under their frame's text Anchored objects were painted after all of their frame's text, so an inline picture whose frame reaches below its baseline (a negative Y offset, common in IDML) covered the top of the next line with its white background. InDesign paints them under the text. Both the renderer and the PDF export now draw a frame's anchored objects after its paragraph shading and before its text. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01CgGsJkVe5GYG9qK6VFdECa --- ROADMAP.md | 1 + crates/pdf/src/export.rs | 11 +++++++---- crates/pdf/src/tests.rs | 21 +++++++++++++++++++++ crates/pdf/src/text.rs | 28 +++++++++++++++++++++++++--- crates/render/src/lib.rs | 26 ++++++++++++++++++++++++++ crates/render/src/text.rs | 9 +++++---- 6 files changed, 85 insertions(+), 11 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 9bd0274ef..a26f9e84b 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -348,6 +348,7 @@ list) · [roadmap.md](docs/roadmap.md) (milestones, plan) · [parity-checklist.m Newest first. +- **2026-10-17** · Inline and anchored objects paint under their frame's text, on screen and in PDF, as InDesign draws them: a picture whose frame reaches into the next line (a negative Y offset) no longer hides the top of that line. No readiness percentage change. - **2026-10-16** · Threading: the loaded text cursor threads into a text frame inside a group (it hit the group and showed it could not thread); a plain click still selects the group (#1075). Gap 27 closed. No readiness percentage change. - **2026-10-16** · Greek text hyphenates. The patterns were never compiled in, so Greek set unhyphenated however narrow the column; `"Greek"`, `"Greek: Greece"`, `el` and `el-GR` all resolve now and ανθρωπος breaks αν-θρω-πος. They are the monotonic patterns (hyph-el-monoton, Dimitrios Filippou, under MIT), which is what polytonic text hyphenates by too. Five of InDesign's ~40 hyphenation languages. No readiness percentage change. - **2026-10-16** · A swatch applied to a placed greyscale or bitmap image colours it, as in InDesign: IDML's `` is read and written, greyscale pixels become a tint of the swatch on opaque white and a bitmap's white lets the frame show through, on screen and in PDF (spot swatches print on their plate). Colour images keep their colours. Applying the swatch from the Swatches panel comes next. No readiness percentage change. diff --git a/crates/pdf/src/export.rs b/crates/pdf/src/export.rs index 3afe22015..54f252b25 100644 --- a/crates/pdf/src/export.rs +++ b/crates/pdf/src/export.rs @@ -965,7 +965,8 @@ impl Exporter<'_> { if frame_art { s.end_tagged(); } - // Content. + // Content. A text frame's anchored objects are drawn under its text when it can be. + let mut drawn = false; match &it.content { Content::Graphic(g) => { let g = &if it.media.is_some() { it.drawn_graphic(&self.doc.assets).unwrap_or_else(|| g.clone()) } else { g.clone() }; @@ -996,7 +997,7 @@ impl Exporter<'_> { s.push_transform(&tf(local)); } self.text_area = (local.determinant().abs() > 1e-12).then(|| local.inverse().transform_rect_bbox(it.inner_bounds())); - self.frame_text(s, &cs, ft, text); + drawn = self.frame_text(s, &cs, ft, text, Some((xf * local, page))); self.text_area = None; if local != Affine::IDENTITY { s.pop(); @@ -1021,7 +1022,9 @@ impl Exporter<'_> { for _ in 0..pushes { s.pop(); } - if let Content::Text(tfr) = &it.content { + if let Content::Text(tfr) = &it.content + && !drawn + { let cs = self.cache.get(doc, tfr.story, page); if let Some(ft) = cs.frame(it.id) { self.frame_objects(s, ft, xf * doc.text_local(it), page); @@ -1031,7 +1034,7 @@ impl Exporter<'_> { /// Draw embedded page items after restoring the surface, retaining spread-space /// transforms for page culling (also for anonymous note and cell stories). - fn frame_objects(&mut self, s: &mut Surface, ft: &designcraft_compose::FrameText, xf: Affine, page: Option) { + pub(crate) fn frame_objects(&mut self, s: &mut Surface, ft: &designcraft_compose::FrameText, xf: Affine, page: Option) { let mut pending = vec![(ft, xf)]; while let Some((frame, transform)) = pending.pop() { for object in &frame.objects { diff --git a/crates/pdf/src/tests.rs b/crates/pdf/src/tests.rs index ed6a1ac1f..f72903cbf 100644 --- a/crates/pdf/src/tests.rs +++ b/crates/pdf/src/tests.rs @@ -819,3 +819,24 @@ fn a_swatch_colours_a_greyscale_image() { let (s, _) = export(&rgb, Some("PANTONE Test")); assert!(!s.contains("PANTONE#20Test"), "a colour image keeps its colours"); } + +/// An inline object is painted under its frame's text, as InDesign draws it: a picture whose +/// frame reaches down into the next line (a negative Y offset) doesn't hide that line. +#[test] +fn inline_objects_are_painted_under_the_text() { + use designcraft_doc::{AnchorPosition, AnchoredObject}; + let mut d = doc_with_text("A\nWWWW"); + let sid = *d.stories.keys().next().unwrap(); + let mut item = Item::new( + ItemId(d.alloc()), + d.default_layer(), + designcraft_doc::Shape::Rectangle, + designcraft_geom::shapes::rectangle(Rect::new(0.0, 0.0, 200.0, 30.0)), + ); + item.fill = Fill::swatch(designcraft_color::swatch::BLACK); + d.story_mut(sid).unwrap().insert_object(1, AnchoredObject::new(item, AnchorPosition::Inline { y_offset: -20.0 })); + let order = paint_order(&export_pdf(&d, &Cache::new(), &PdfOptions::default()).unwrap()); + let first_text = order.iter().position(|(op, _)| op.ends_with("text")).unwrap(); + let the_box = order.iter().position(|(op, _)| op == "fill path").unwrap(); + assert!(the_box < first_text, "{order:?}"); +} diff --git a/crates/pdf/src/text.rs b/crates/pdf/src/text.rs index 300a47d93..f059bb605 100644 --- a/crates/pdf/src/text.rs +++ b/crates/pdf/src/text.rs @@ -233,7 +233,17 @@ impl Exporter<'_> { } } - pub(crate) fn frame_text(&mut self, s: &mut Surface, cs: &ComposedStory, ft: &FrameText, story: &str) { + /// The frame's text. With `objects` (the frame text space → page transform and the page), + /// the frame's anchored objects are drawn too, over its paragraph shading and under its text; + /// returns whether they were. + pub(crate) fn frame_text( + &mut self, + s: &mut Surface, + cs: &ComposedStory, + ft: &FrameText, + story: &str, + objects: Option<(Affine, Option)>, + ) -> bool { // Paragraph shading and rules under the text. self.as_artifact(s, |me, s| { for d in ft.decos.iter().filter(|d| !d.nonprinting) { @@ -248,6 +258,17 @@ impl Exporter<'_> { me.tables(s, ft); } }); + // Anchored objects under the text (as InDesign draws them): a picture whose frame reaches + // into the next line doesn't hide that line. Objects keep their page-space transforms. + let drawn = match objects { + Some((xf, page)) if xf.determinant().abs() > 1e-12 => { + s.push_transform(&tf(xf.inverse())); + self.frame_objects(s, ft, xf, page); + s.pop(); + true + } + _ => false, + }; // All underlines precede all glyph runs, including ink overlapping from another run. let underlines = text_rules(cs, ft, true); if !underlines.is_empty() { @@ -295,12 +316,13 @@ impl Exporter<'_> { if let Some(nft) = n.text.frames.first() { s.push_transform(&tf(Affine::translate(n.origin.to_vec2()))); let area = std::mem::replace(&mut me.text_area, lines_area(nft)); - me.frame_text(s, &n.text, nft, &n.source); + me.frame_text(s, &n.text, nft, &n.source, None); me.text_area = area; s.pop(); } } }); + drawn } /// Table fragments: cell fills, cell text (real text), edges and the border. @@ -325,7 +347,7 @@ impl Exporter<'_> { } else if let Some(cft) = c.text.frames.first() { s.push_transform(&tf(Affine::translate(c.origin.to_vec2()))); let area = std::mem::replace(&mut self.text_area, lines_area(cft)); - self.frame_text(s, &c.text, cft, &c.source); + self.frame_text(s, &c.text, cft, &c.source, None); self.text_area = area; s.pop(); } diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs index c42d8c897..86ae9c65b 100644 --- a/crates/render/src/lib.rs +++ b/crates/render/src/lib.rs @@ -1503,6 +1503,32 @@ mod tests { assert!(image_size(corrupt).is_none()); } + /// An inline object is painted under its frame's text, as InDesign draws it: a picture + /// whose frame reaches down into the next line (a negative Y offset) doesn't hide that line. + #[test] + fn inline_objects_are_painted_under_the_text() { + use designcraft_doc::{AnchorPosition, AnchoredObject, ItemId, Shape, SpreadRef}; + let mut d = Document::new(&NewDocument::default()); + let (fid, sid) = + d.add_text_frame(SpreadRef::Doc(0), Rect::new(20.0, 20.0, 300.0, 300.0), d.default_layer(), "A\nWWWW", ParaFormat::default()).unwrap(); + let mut item = + Item::new(ItemId(d.alloc()), d.default_layer(), Shape::Rectangle, designcraft_geom::shapes::rectangle(Rect::new(0.0, 0.0, 250.0, 60.0))); + item.fill = Fill::swatch(designcraft_color::swatch::PAPER); + d.story_mut(sid).unwrap().insert_object(1, AnchoredObject::new(item, AnchorPosition::Inline { y_offset: -40.0 })); + let cache = Cache::new(); + let cs = cache.get(&d, sid, None); + let (ft, obj) = (&cs.frames[0], &cs.frames[0].objects[0]); + let l = ft.lines.iter().find(|l| l.range.start > 1).unwrap(); + // The second line lies inside the white box. + assert!(obj.origin.y + obj.size.1 > l.baseline && obj.origin.y < l.baseline - l.ascent, "{:?} {}", obj.origin, l.baseline); + let m = d.text_xf(d.item(fid).unwrap()); + let image = Renderer::new().render_page(&d, &cache, 0, 1.0, false, &RenderOptions::default()).unwrap(); + let (a, b) = (m * designcraft_geom::Point::new(l.x0, l.baseline - l.ascent), m * designcraft_geom::Point::new(l.end_x, l.baseline)); + let dark = + (a.y as u32..b.y as u32).flat_map(|y| (a.x as u32..b.x as u32).map(move |x| (x, y))).filter(|&(x, y)| image.pixel(x, y)[0] < 100).count(); + assert!(dark > 20, "the line's glyphs show over the box: {dark} dark pixels"); + } + #[test] fn footnote_vector_objects_are_painted() { use designcraft_doc::{AnchorPosition, AnchoredObject, ItemId, Shape, SpreadRef}; diff --git a/crates/render/src/text.rs b/crates/render/src/text.rs index 70f31b4f6..a40e666ab 100644 --- a/crates/render/src/text.rs +++ b/crates/render/src/text.rs @@ -267,6 +267,11 @@ impl Renderer { if !ft.tables.is_empty() { self.draw_tables(ctx, f, ft, xf); } + // Anchored objects ride on their lines, under the frame's text (as InDesign draws them): + // a picture whose frame reaches into the next line doesn't hide that line. + for o in &ft.objects { + self.draw_item(ctx, f, &o.object.item, xf * Affine::translate(o.origin.to_vec2()), ShownOn::Own); + } // Text in fonts that aren't installed: pink behind the glyphs (screen only). if f.opts.highlight_missing_fonts && cs.styles.iter().any(|s| s.missing_font) { ctx.set_transform(m); @@ -448,10 +453,6 @@ impl Renderer { self.draw_text(ctx, f, &n.text, nft, xf * Affine::translate(n.origin.to_vec2())); } } - // Anchored objects ride on their lines. - for o in &ft.objects { - self.draw_item(ctx, f, &o.object.item, xf * Affine::translate(o.origin.to_vec2()), ShownOn::Own); - } } } From 2d979051897a38aeeeff61a71c28a9421dbd3019 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 19 Oct 2026 18:01:55 +0000 Subject: [PATCH 2/8] Test: anchored objects share their frame's transparency in PDF Review request (#1238): a host frame at reduced opacity. The anchored box is painted inside the frame's transparency group, after its fill and before its text and stroke, as the screen renderer already paints it on main. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01CgGsJkVe5GYG9qK6VFdECa --- crates/pdf/src/tests.rs | 58 ++++++++++++++++++++++++++++++++++++++--- 1 file changed, 54 insertions(+), 4 deletions(-) diff --git a/crates/pdf/src/tests.rs b/crates/pdf/src/tests.rs index f72903cbf..99ec3b550 100644 --- a/crates/pdf/src/tests.rs +++ b/crates/pdf/src/tests.rs @@ -420,13 +420,19 @@ fn drawn(bytes: &[u8]) -> (Vec<(String, kurbo::Rect)>, Vec) { /// The page's paint operations in order: `fill`/`stroke` + ` text`/` path` and the stroke width. fn paint_order(bytes: &[u8]) -> Vec<(String, f32)> { + paint_events(bytes, false) +} + +/// [`paint_order`], with transparency groups when `groups`: `group` (and its opacity) and +/// `end group`. +fn paint_events(bytes: &[u8], groups: bool) -> Vec<(String, f32)> { use hayro_interpret::font::Glyph; use hayro_interpret::{ BlendMode, ClipPath, Context, Device, GlyphDrawMode, Image, InterpreterCache, InterpreterSettings, Paint, PathDrawMode, SoftMask, interpret_page, }; #[derive(Default)] - struct Rec(Vec<(String, f32)>); + struct Rec(Vec<(String, f32)>, bool); impl Device<'_> for Rec { fn set_soft_mask(&mut self, _: Option>) {} fn set_blend_mode(&mut self, _: BlendMode) {} @@ -437,7 +443,11 @@ fn paint_order(bytes: &[u8]) -> Vec<(String, f32)> { }); } fn push_clip_path(&mut self, _: &ClipPath) {} - fn push_transparency_group(&mut self, _: f32, _: Option>, _: BlendMode) {} + fn push_transparency_group(&mut self, opacity: f32, _: Option>, _: BlendMode) { + if self.1 { + self.0.push(("group".into(), opacity)); + } + } fn draw_glyph(&mut self, _: &Glyph<'_>, _: kurbo::Affine, _: kurbo::Affine, _: &Paint<'_>, m: &GlyphDrawMode) { self.0.push(match m { GlyphDrawMode::Fill => ("fill text".into(), 0.0), @@ -447,13 +457,17 @@ fn paint_order(bytes: &[u8]) -> Vec<(String, f32)> { } fn draw_image(&mut self, _: Image<'_, '_>, _: kurbo::Affine) {} fn pop_clip_path(&mut self) {} - fn pop_transparency_group(&mut self) {} + fn pop_transparency_group(&mut self) { + if self.1 { + self.0.push(("end group".into(), 0.0)); + } + } } let pdf = hayro_syntax::Pdf::new(bytes.to_vec()).expect("parse"); let cache = InterpreterCache::new(); let page = &pdf.pages()[0]; let mut ctx = Context::new(kurbo::Affine::IDENTITY, kurbo::Rect::new(0.0, 0.0, 1.0, 1.0), &cache, pdf.xref(), InterpreterSettings::default()); - let mut rec = Rec::default(); + let mut rec = Rec(Vec::new(), groups); interpret_page(page, &mut ctx, &mut rec); rec.0 } @@ -840,3 +854,39 @@ fn inline_objects_are_painted_under_the_text() { let the_box = order.iter().position(|(op, _)| op == "fill path").unwrap(); assert!(the_box < first_text, "{order:?}"); } + +/// An anchored object is painted as part of its host frame, as the screen renderer paints it: +/// inside the frame's transparency group (so a half-transparent frame shows its inline picture +/// half-transparent too), after the frame's fill and before its text and stroke. +#[test] +fn inline_objects_share_their_frames_transparency() { + use designcraft_doc::{AnchorPosition, AnchoredObject}; + let mut d = doc_with_text("A\nWWWW"); + let sid = *d.stories.keys().next().unwrap(); + let fid = d.stories[&sid].frames[0]; + { + let host = d.item_mut(fid).unwrap(); + host.opacity = 0.5; + host.fill = Fill::swatch(designcraft_color::swatch::PAPER); + host.stroke.weight = 3.0; + host.stroke.swatch = designcraft_color::swatch::BLACK.into(); + } + let mut item = Item::new( + ItemId(d.alloc()), + d.default_layer(), + designcraft_doc::Shape::Rectangle, + designcraft_geom::shapes::rectangle(Rect::new(0.0, 0.0, 200.0, 30.0)), + ); + item.fill = Fill::swatch(designcraft_color::swatch::BLACK); + d.story_mut(sid).unwrap().insert_object(1, AnchoredObject::new(item, AnchorPosition::Inline { y_offset: -20.0 })); + let ev = paint_events(&export_pdf(&d, &Cache::new(), &PdfOptions::default()).unwrap(), true); + let at = |op: &str| ev.iter().position(|(o, _)| o == op).unwrap_or_else(|| panic!("no {op}: {ev:?}")); + let group = ev.iter().position(|(o, a)| o == "group" && (*a - 0.5).abs() < 1e-3).unwrap_or_else(|| panic!("no half-opacity group: {ev:?}")); + let end = group + ev[group..].iter().position(|(o, _)| o == "end group").unwrap(); + // Fills in order: the frame's, then the anchored box's. + let fills: Vec = ev.iter().enumerate().filter(|(_, (o, _))| o == "fill path").map(|(i, _)| i).collect(); + assert!(fills.len() >= 2, "{ev:?}"); + let the_box = fills[1]; + assert!(group < the_box && the_box < end, "the box is inside the frame's group: {ev:?}"); + assert!(the_box < at("fill text") && at("fill text") < at("stroke path") && at("stroke path") < end, "{ev:?}"); +} From 4f03e105943c4e061a1d41830b77d09e5b266713 Mon Sep 17 00:00:00 2001 From: NetterSilver <133535268+Silvertree2010@users.noreply.github.com> Date: Mon, 26 Oct 2026 21:37:07 +0000 Subject: [PATCH 3/8] Reject invalid XML tag names in xml.mapStyle Found while testing crates/engine/src/cmd/xml.rs:~290 (the xml.mapStyle command; the export is in export_xml) Co-Authored-By: Claude Haiku 5.5 Co-Authored-By: Claude Sonnet 5.5 --- crates/engine/src/cmd/xml.rs | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/crates/engine/src/cmd/xml.rs b/crates/engine/src/cmd/xml.rs index 8cd50a2cd..8905c2368 100644 --- a/crates/engine/src/cmd/xml.rs +++ b/crates/engine/src/cmd/xml.rs @@ -268,6 +268,11 @@ pub fn specs() -> Vec { |s, p| { let style = str_param(p, "style").unwrap_or("").to_string(); let tag = str_param(p, "tag").map(str::to_string); + if let Some(t) = &tag + && !valid_name(t) + { + return Err(bad("xml.mapStyle", format!("`{t}` isn't a valid XML name"))); + } if s.doc()?.doc.styles.para(&style).is_none() { return Err(bad("xml.mapStyle", format!("no paragraph style `{style}`"))); } @@ -475,6 +480,19 @@ mod tests { assert!(s.execute("xml.validate", &json!({})).is_err()); } + #[test] + fn map_style_rejects_invalid_tag_name() { + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + s.execute("style.paragraph.create", &json!({"name": "Head"})).unwrap(); + assert!(s.execute("xml.mapStyle", &json!({"style": "Head", "tag": "a b> Date: Wed, 28 Oct 2026 17:22:28 +0100 Subject: [PATCH 4/8] =?UTF-8?q?Skip=20signed=20hex=20codes=20in=20Tagged?= =?UTF-8?q?=20Text=20<0x=E2=80=A6>=20tags?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Found while testing crates/textimport/src/tagged.rs:~190 (the `n.starts_with("0x")` branch of import: `u32::from_str_radix(&n[2..], 16)`) Co-Authored-By: Claude Haiku 5.5 Co-Authored-By: Claude Sonnet 5.5 --- crates/textimport/src/tagged.rs | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/crates/textimport/src/tagged.rs b/crates/textimport/src/tagged.rs index 74806ae4c..e903e98fd 100644 --- a/crates/textimport/src/tagged.rs +++ b/crates/textimport/src/tagged.rs @@ -176,7 +176,10 @@ pub fn import(bytes: &[u8]) -> Result { let (name, value) = tag.split_once(':').map_or((tag.as_str(), ""), |(a, b)| (a, b)); match name { n if n.starts_with("0x") => { - if let Ok(u) = u32::from_str_radix(&n[2..], 16) + // `from_str_radix` accepts a leading '+', so check the digits first. + if let Some(digits) = n.get(2..) + && digits.bytes().all(|b| b.is_ascii_hexdigit()) + && let Ok(u) = u32::from_str_radix(digits, 16) && let Some(ch) = units.push(u) { push(&mut paras, ch, &fmt); @@ -278,6 +281,16 @@ mod tests { assert_eq!(import("\r\nA<0xD83D>B<0xDE00>C".as_bytes()).unwrap().story.text, "ABC"); } + /// `from_str_radix` accepts a leading `+`, so `<0x+41>` inserted an 'A'. A signed or empty + /// code is not a code point tag and is skipped. + #[test] + fn a_signed_hex_code_tag_is_skipped() { + for t in ["<0x+41>", "<0x-41>", "<0x>", "<0xZZ>"] { + let src = format!("\r\nA{t}B"); + assert_eq!(import(src.as_bytes()).unwrap().story.text, "AB", "{t}"); + } + } + /// A size does not have to be non-finite to be nonsense: a file can hold `1e300`, `0` or a /// negative, and tagged text took all three and set them on the text (#1593). They are now /// dropped, as RTF and DOCX already dropped them, so the text keeps its style's size. From e248b631659c79b169413d2005290bc38256b9f4 Mon Sep 17 00:00:00 2001 From: VoiceLessQ Date: Wed, 28 Oct 2026 21:46:56 -0200 Subject: [PATCH 5/8] Autoflow: a frame a Start Paragraph option ends early sets no fill rate (fixes #1209) fit() counted the frame before a paragraph that starts on the next column, frame, page or odd/even page as a full frame. With a short title before such a paragraph, autoflow estimated a page per few bytes left and added up to 2000 pages before removing them again: placing 2000 words took 35.6 s in the app, now 0.36 s, with the same pages. The option is resolved through the paragraph style, so an imported Word style counts the same as a local override. --- ROADMAP.md | 1 + crates/engine/src/cmd/place_text.rs | 15 ++++++--- crates/engine/src/cmd/place_text/tests.rs | 37 +++++++++++++++++++++++ 3 files changed, 49 insertions(+), 4 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index c02176bab..20525e4a6 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -380,6 +380,7 @@ list) · [roadmap.md](docs/roadmap.md) (milestones, plan) · [parity-checklist.m Newest first. +- **2026-10-28** · Autoflow no longer sizes the rest of a story by a frame that a Start Paragraph option (next column, frame, page, or odd or even page) ends early (#1209). With a title before a chapter that starts on the next odd page, it estimated a page per few characters, added up to 2000 pages (the autoflow limit) and removed them again: placing 2000 words took 36 s in the app, now 0.4 s, with the same pages. No readiness percentage change. - **2026-10-27** · An iconic panel's flyout stays open while you work in the canvas or a text field: Auto-Collapse Iconic Panels is off by default, and preferences saved with it on start with it off. Preferences › Interface › Panels holds the setting next to the column bar's menu item. No readiness percentage change. - **2026-10-24** · Column rules run from the top line's ascent to the bottom line's descent, as InDesign draws them, instead of from the top to the bottom of the columns, on screen, in PDF and in PowerPoint export. The top and bottom insets shorten the rule from those lines. Existing documents draw their column rules at the new length. No readiness percentage change. - **2026-10-20** · Edit › Keyboard Shortcuts is craft-keymap's shared dialog: commands grouped by menu with Show and Filter, recording that asks before taking a key from another command, undo and redo, named shortcut sets (edits of Default become Custom; an imported set becomes a set of its own), an on-screen US keyboard, InDesign Show Set files reviewed before they apply, JSON export and an HTML summary. ui.json keeps the `shortcuts` map earlier versions read, for the active set. No readiness percentage change. diff --git a/crates/engine/src/cmd/place_text.rs b/crates/engine/src/cmd/place_text.rs index 4f9aaf1aa..7932a185c 100644 --- a/crates/engine/src/cmd/place_text.rs +++ b/crates/engine/src/cmd/place_text.rs @@ -3,7 +3,7 @@ //! until the whole story fits (InDesign's Shift-click with a loaded text cursor). use designcraft_doc::{ - CharacterStyle, Content, Document, ParaFormat, ParagraphStyle, SpreadRef, Story, StoryId, TextDirection, TextFileLink, TextSel, + CharacterStyle, Content, Document, ParaFormat, ParagraphStyle, SpreadRef, StartParagraph, Story, StoryId, TextDirection, TextFileLink, TextSel, }; use designcraft_geom::Rect; use serde_json::{Value, json}; @@ -199,14 +199,21 @@ pub(crate) fn reimport( /// Is the story overset in its frames, and how much text do its full frames hold (bytes, frames)? /// A frame a column, frame or page break ends early is left out: it says nothing about how much -/// a frame holds. +/// a frame holds. So is one a Start Paragraph option ends early (#1209). fn fit(d: &Document, sid: StoryId) -> (Option, usize, usize) { let cs = designcraft_compose::compose_story(d, sid, &Default::default()); - let text = d.story(sid).map_or("", |s| s.text.as_str()); + let Some(story) = d.story(sid) else { return (cs.overset_at, 0, 0) }; + let text = story.text.as_str(); + // A frame's range stops before the paragraph's return. + let moved_on = |end: usize| { + let next = if text.as_bytes().get(end) == Some(&b'\n') { end.saturating_add(1) } else { end }; + next.checked_sub(1).is_some_and(|i| text.as_bytes().get(i) == Some(&b'\n')) + && story.paras.get(story.para_at(next)).is_some_and(|p| d.styles.resolve_para(p).0.start_paragraph != StartParagraph::Anywhere) + }; let (mut held, mut full) = (0, 0); for f in &cs.frames { let Some(shown) = text.get(f.range.clone()) else { continue }; - if !shown.is_empty() && !shown.chars().any(designcraft_doc::is_break_char) { + if !shown.is_empty() && !shown.chars().any(designcraft_doc::is_break_char) && !moved_on(f.range.end) { held += shown.len(); full += 1; } diff --git a/crates/engine/src/cmd/place_text/tests.rs b/crates/engine/src/cmd/place_text/tests.rs index 006762ab2..bdce2a975 100644 --- a/crates/engine/src/cmd/place_text/tests.rs +++ b/crates/engine/src/cmd/place_text/tests.rs @@ -307,3 +307,40 @@ fn autoflow_adds_the_pages_odd_and_even_page_breaks_ask_for() { assert_eq!(designcraft_compose::compose_story(&d, sid, &Default::default()).overset_at, None, "{brk:?}"); } } + +#[test] +fn autoflow_sizes_no_frame_by_one_a_start_option_ends_early() { + // Regression (#1209): the title frame a start option ends early isn't a frame's worth of text. + use std::io::Write; + const W: &str = r#"xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main""#; + let para = |style: &str, text: &str| format!(r#"{text}"#); + let body = [para("Title", "Front"), para("Chapter", "Chapter one"), para("Body", "lorem ipsum dolor sit amet ".repeat(60).trim())].concat(); + let styles: String = ["Title", "Chapter", "Body"] + .iter() + .map(|s| format!(r#""#)) + .collect(); + let mut w = zip::ZipWriter::new(std::io::Cursor::new(Vec::new())); + let o = zip::write::SimpleFileOptions::default(); + for (n, c) in [ + ("word/document.xml", format!(r#"{body}"#)), + ("word/styles.xml", format!(r#"{styles}"#)), + ] { + w.start_file(n, o).unwrap(); + w.write_all(c.as_bytes()).unwrap(); + } + let docx = w.finish().unwrap().into_inner(); + for (start, pages) in [("nextOddPage", 5), ("nextEvenPage", 4)] { + let mut s = Session::new(); + s.execute("file.new", &json!({"pages": 3, "facingPages": true})).unwrap(); + s.execute("style.paragraph.create", &json!({"name": "Chapter", "para": {"startParagraph": start}})).unwrap(); + let ids = s.doc().unwrap().doc.next_id; + let r = s + .execute("file.place", &json!({"base64": super::super::file::base64_encode(&docx), "name": "front.docx", "page": 3, "autoflow": true})) + .unwrap(); + assert_eq!(r["overset"], false, "{start}: {r}"); + let d = &s.doc().unwrap().doc; + assert_eq!(d.page_count(), pages, "{start}: {r}"); + // A few ids per page it keeps, not hundreds for pages it builds and removes. + assert!(d.next_id - ids < 100, "{start}: {} ids", d.next_id - ids); + } +} From ca85be89d2c3a4a1df225a807be9eb4fe2ab8ab7 Mon Sep 17 00:00:00 2001 From: VoiceLessQ Date: Wed, 28 Oct 2026 22:32:07 -0200 Subject: [PATCH 6/8] Autoflow: skip deleted paragraphs and ask for every forced frame at once (#1209) The start option lookup now reads the next paragraph composing places, past tracked-deleted ones. Frames that start options and breaks force are counted and asked for in one round, so a story with a paragraph per page no longer gets one frame per round. --- ROADMAP.md | 2 +- crates/compose/src/lib.rs | 4 +-- crates/engine/src/cmd/place_text.rs | 34 ++++++++++++++++------- crates/engine/src/cmd/place_text/tests.rs | 33 ++++++++++++++++++++++ 4 files changed, 60 insertions(+), 13 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 20525e4a6..deda0dc4f 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -380,7 +380,7 @@ list) · [roadmap.md](docs/roadmap.md) (milestones, plan) · [parity-checklist.m Newest first. -- **2026-10-28** · Autoflow no longer sizes the rest of a story by a frame that a Start Paragraph option (next column, frame, page, or odd or even page) ends early (#1209). With a title before a chapter that starts on the next odd page, it estimated a page per few characters, added up to 2000 pages (the autoflow limit) and removed them again: placing 2000 words took 36 s in the app, now 0.4 s, with the same pages. No readiness percentage change. +- **2026-10-28** · Autoflow no longer sizes the rest of a story by a frame that a Start Paragraph option (next column, frame, page, or odd or even page) ends early (#1209). With a title before a chapter that starts on the next odd page, it estimated a page per few characters, added up to 2000 pages (the autoflow limit) and removed them again: placing 2000 words took 36 s in the app, now 0.4 s, with the same pages. It looks past tracked-deleted paragraphs for the option, as composing does, and asks for every frame that start options and breaks force in one round: 200 one-line chapters on their own pages take 9 ms (58 ms before). No readiness percentage change. - **2026-10-27** · An iconic panel's flyout stays open while you work in the canvas or a text field: Auto-Collapse Iconic Panels is off by default, and preferences saved with it on start with it off. Preferences › Interface › Panels holds the setting next to the column bar's menu item. No readiness percentage change. - **2026-10-24** · Column rules run from the top line's ascent to the bottom line's descent, as InDesign draws them, instead of from the top to the bottom of the columns, on screen, in PDF and in PowerPoint export. The top and bottom insets shorten the rule from those lines. Existing documents draw their column rules at the new length. No readiness percentage change. - **2026-10-20** · Edit › Keyboard Shortcuts is craft-keymap's shared dialog: commands grouped by menu with Show and Filter, recording that asks before taking a key from another command, undo and redo, named shortcut sets (edits of Default become Custom; an imported set becomes a set of its own), an on-screen US keyboard, InDesign Show Set files reviewed before they apply, JSON export and an HTML summary. ui.json keeps the `shortcuts` map earlier versions read, for the active set. No readiness percentage change. diff --git a/crates/compose/src/lib.rs b/crates/compose/src/lib.rs index 88becd85f..131e5273c 100644 --- a/crates/compose/src/lib.rs +++ b/crates/compose/src/lib.rs @@ -3483,8 +3483,8 @@ fn rewind( } /// Paragraphs that tracked changes delete: all their text is deleted, and so is their return or -/// the return before them (then they join a paragraph and add nothing to it). -fn deleted_paras(story: &Story, ranges: &[Range]) -> Vec { +/// the return before them (then they join a paragraph and add nothing to it). Composing skips them. +pub fn deleted_paras(story: &Story, ranges: &[Range]) -> Vec { let mut spans: Vec> = Vec::new(); for (r, f) in story.runs() { if f.over.change != Some(designcraft_doc::ChangeMark::Deleted) { diff --git a/crates/engine/src/cmd/place_text.rs b/crates/engine/src/cmd/place_text.rs index 7932a185c..961f6f630 100644 --- a/crates/engine/src/cmd/place_text.rs +++ b/crates/engine/src/cmd/place_text.rs @@ -197,18 +197,24 @@ pub(crate) fn reimport( Ok(imp.warnings) } -/// Is the story overset in its frames, and how much text do its full frames hold (bytes, frames)? +/// Is the story overset in its frames, how much text do its full frames hold (bytes, frames), and +/// how many frames does the overset text start on its own (breaks, Start Paragraph options)? /// A frame a column, frame or page break ends early is left out: it says nothing about how much /// a frame holds. So is one a Start Paragraph option ends early (#1209). -fn fit(d: &Document, sid: StoryId) -> (Option, usize, usize) { +fn fit(d: &Document, sid: StoryId) -> (Option, usize, usize, usize) { let cs = designcraft_compose::compose_story(d, sid, &Default::default()); - let Some(story) = d.story(sid) else { return (cs.overset_at, 0, 0) }; + let Some(story) = d.story(sid) else { return (cs.overset_at, 0, 0, 0) }; let text = story.text.as_str(); - // A frame's range stops before the paragraph's return. + let deleted = designcraft_compose::deleted_paras(story, &story.para_ranges()); + let starts = |p: usize| { + !deleted.get(p).copied().unwrap_or(false) + && story.paras.get(p).is_some_and(|pf| d.styles.resolve_para(pf).0.start_paragraph != StartParagraph::Anywhere) + }; + // A frame's range stops before the paragraph's return; composing skips deleted paragraphs. let moved_on = |end: usize| { let next = if text.as_bytes().get(end) == Some(&b'\n') { end.saturating_add(1) } else { end }; next.checked_sub(1).is_some_and(|i| text.as_bytes().get(i) == Some(&b'\n')) - && story.paras.get(story.para_at(next)).is_some_and(|p| d.styles.resolve_para(p).0.start_paragraph != StartParagraph::Anywhere) + && (story.para_at(next)..story.paras.len()).find(|&p| !deleted.get(p).copied().unwrap_or(false)).is_some_and(starts) }; let (mut held, mut full) = (0, 0); for f in &cs.frames { @@ -218,7 +224,14 @@ fn fit(d: &Document, sid: StoryId) -> (Option, usize, usize) { full += 1; } } - (cs.overset_at, held, full) + let forced = cs.overset_at.map_or(0, |at| { + let first = story.para_at(at); + // The paragraph the overset text starts with counts when it starts there. + let from = if at.checked_sub(1).is_none_or(|i| text.as_bytes().get(i) == Some(&b'\n')) { first } else { first.saturating_add(1) }; + let breaks = text.get(at..).map_or(0, |t| t.chars().filter(|&c| designcraft_doc::is_break_char(c)).count()); + (from..story.paras.len()).filter(|&p| starts(p)).count().saturating_add(breaks) + }); + (cs.overset_at, held, full, forced) } /// The frames autoflow sets on a page: one per column inside the margins, in the order a story @@ -263,12 +276,13 @@ pub(crate) fn autoflow(d: &mut Document, sid: StoryId, max_pages: usize, cmd: &s // The next page of the document autoflow may set frames on; it only moves forward. let mut following = 0; loop { - let (overset, held, full) = fit(d, sid); + let (overset, held, full, forced) = fit(d, sid); let Some(at) = overset else { break }; let len = d.story(sid).map_or(0, |s| s.len()); - // Frames still needed at the rate the full ones are filled (at least one); with no full - // frame to go by yet, one more. - let want = held.checked_div(full).map_or(1, |per_frame| len.saturating_sub(at).div_ceil(per_frame.max(1))).max(1); + // Frames still needed at the rate the full ones are filled, plus one per frame the text + // starts on its own (at least one); with neither to go by yet, one more. + let rate = held.checked_div(full).map_or(0, |per_frame| len.saturating_sub(at).div_ceil(per_frame.max(1))); + let want = rate.saturating_add(forced).max(1); let last = *d.story(sid).and_then(|s| s.frames.last()).ok_or_else(|| bad(cmd, "the story has no frame"))?; let page = d.page_of_item(last).unwrap_or(d.page_count().saturating_sub(1)); let layer = d.default_layer(); diff --git a/crates/engine/src/cmd/place_text/tests.rs b/crates/engine/src/cmd/place_text/tests.rs index bdce2a975..183a46074 100644 --- a/crates/engine/src/cmd/place_text/tests.rs +++ b/crates/engine/src/cmd/place_text/tests.rs @@ -344,3 +344,36 @@ fn autoflow_sizes_no_frame_by_one_a_start_option_ends_early() { assert!(d.next_id - ids < 100, "{start}: {} ids", d.next_id - ids); } } + +#[test] +fn autoflow_looks_past_a_deleted_paragraph_for_the_start_option() { + // The title frame ends before a tracked deletion; the chapter after it starts on an odd page. + use designcraft_doc::{ChangeMark, build::NewDocument}; + let mut d = Document::new(&NewDocument { pages: 1, facing_pages: false, primary_text_frame: true, ..Default::default() }); + let sid = d.settings.primary_story.unwrap(); + let st = d.story_mut(sid).unwrap(); + st.insert(0, &format!("Front\nGone\nChapter one\n{}", "lorem ipsum dolor sit amet ".repeat(400).trim())); + st.paras[2].para.start_paragraph = Some(StartParagraph::NextOddPage); + st.format_chars(6..11, |f| f.over.change = Some(ChangeMark::Deleted)); + let ids = d.next_id; + assert_eq!(autoflow(&mut d, sid, 2000, "file.place", false).unwrap(), 4); + assert_eq!(designcraft_compose::compose_story(&d, sid, &Default::default()).overset_at, None); + assert!(d.next_id - ids < 100, "{} ids", d.next_id - ids); +} + +#[test] +fn autoflow_asks_for_every_frame_the_start_options_ask_for_at_once() { + // A paragraph per page: no frame sets a fill rate, yet one round shouldn't add one frame. + use designcraft_doc::build::NewDocument; + let mut d = Document::new(&NewDocument { pages: 1, facing_pages: false, primary_text_frame: true, ..Default::default() }); + let sid = d.settings.primary_story.unwrap(); + let st = d.story_mut(sid).unwrap(); + st.insert(0, (0..200).map(|i| format!("Chapter {i}\n")).collect::().trim_end()); + for p in &mut st.paras { + p.para.start_paragraph = Some(StartParagraph::NextPage); + } + let ids = d.next_id; + assert_eq!(autoflow(&mut d, sid, 2000, "file.place", false).unwrap(), 199); + assert_eq!(designcraft_compose::compose_story(&d, sid, &Default::default()).overset_at, None); + assert!(d.next_id - ids < 2000, "{} ids", d.next_id - ids); +} From 89ccfbb18c9e21e0bc4a222f2dfb051019e5a123 Mon Sep 17 00:00:00 2001 From: VoiceLessQ Date: Wed, 28 Oct 2026 23:08:41 -0200 Subject: [PATCH 7/8] Autoflow: count each forced move once, and only visible breaks (#1209) fit() counted every break character in the overset text and every Start Paragraph option. A break followed by a paragraph whose option it already meets moved the text once but asked for two frames, and breaks that tracked changes delete or a hidden condition hides asked for frames that composing never uses. Autoflow added those pages and removed them again: 200 chapters that end in page breaks and start on the next page took 61095 ids and 430 ms, now 798 ids and 16 ms. Each paragraph composing places now counts once, when a visible break ends the paragraph before it or its start option sends it on; after a break it is at the top of a frame, where the option only fixes odd or even page parity. A visible break inside a paragraph counts once more. previous_visible_break and the new visible_break in designcraft-compose are public, so the estimate and the composer use one rule. --- ROADMAP.md | 2 +- crates/compose/src/lib.rs | 21 +++++++---- crates/engine/src/cmd/place_text.rs | 35 +++++++++++++----- crates/engine/src/cmd/place_text/tests.rs | 44 +++++++++++++++++++++++ 4 files changed, 86 insertions(+), 16 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index deda0dc4f..435dd5d39 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -380,7 +380,7 @@ list) · [roadmap.md](docs/roadmap.md) (milestones, plan) · [parity-checklist.m Newest first. -- **2026-10-28** · Autoflow no longer sizes the rest of a story by a frame that a Start Paragraph option (next column, frame, page, or odd or even page) ends early (#1209). With a title before a chapter that starts on the next odd page, it estimated a page per few characters, added up to 2000 pages (the autoflow limit) and removed them again: placing 2000 words took 36 s in the app, now 0.4 s, with the same pages. It looks past tracked-deleted paragraphs for the option, as composing does, and asks for every frame that start options and breaks force in one round: 200 one-line chapters on their own pages take 9 ms (58 ms before). No readiness percentage change. +- **2026-10-28** · Autoflow no longer sizes the rest of a story by a frame that a Start Paragraph option (next column, frame, page, or odd or even page) ends early (#1209). With a title before a chapter that starts on the next odd page, it estimated a page per few characters, added up to 2000 pages (the autoflow limit) and removed them again: placing 2000 words took 36 s in the app, now 0.4 s, with the same pages. It looks past tracked-deleted paragraphs for the option, as composing does, and asks for every frame that start options and breaks force in one round: 200 one-line chapters on their own pages take 9 ms (58 ms before). A break and the start option it meets count once, and breaks that tracked changes delete or a hidden condition hides don't count. No readiness percentage change. - **2026-10-27** · An iconic panel's flyout stays open while you work in the canvas or a text field: Auto-Collapse Iconic Panels is off by default, and preferences saved with it on start with it off. Preferences › Interface › Panels holds the setting next to the column bar's menu item. No readiness percentage change. - **2026-10-24** · Column rules run from the top line's ascent to the bottom line's descent, as InDesign draws them, instead of from the top to the bottom of the columns, on screen, in PDF and in PowerPoint export. The top and bottom insets shorten the rule from those lines. Existing documents draw their column rules at the new length. No readiness percentage change. - **2026-10-20** · Edit › Keyboard Shortcuts is craft-keymap's shared dialog: commands grouped by menu with Show and Filter, recording that asks before taking a key from another command, undo and redo, named shortcut sets (edits of Default become Custom; an imported set becomes a set of its own), an on-screen US keyboard, InDesign Show Set files reviewed before they apply, JSON export and an HTML summary. ui.json keeps the `shortcuts` map earlier versions read, for the active set. No readiness percentage change. diff --git a/crates/compose/src/lib.rs b/crates/compose/src/lib.rs index 131e5273c..177733f99 100644 --- a/crates/compose/src/lib.rs +++ b/crates/compose/src/lib.rs @@ -930,18 +930,25 @@ fn line_has_wrapping_exclusion(frame: &FrameSpec, range: Range, column: R }) } -fn previous_visible_break(doc: &Document, story: &Story, ranges: &[Range], deleted: &[bool], pi: usize) -> Option { +/// The visible break ending the last placed paragraph before `pi`, which sends `pi` on. +pub fn previous_visible_break(doc: &Document, story: &Story, ranges: &[Range], deleted: &[bool], pi: usize) -> Option { (0..pi).rev().find(|&p| !deleted.get(p).copied().unwrap_or(false)).and_then(|p| { let range = ranges.get(p)?; - let (index, character) = story.text.get(range.clone())?.char_indices().next_back()?; - let start = break_start(character)?; - let (_, chars) = doc.styles.resolve_para(story.paras.get(p)?); - let props = doc.styles.resolve_char(&chars, story.char_format_at(range.start + index + character.len_utf8())); - let gone = props.change == designcraft_doc::ChangeMark::Deleted || doc.conditions_hide(&props.conditions); - (!gone).then_some(start) + let (index, _) = story.text.get(range.clone())?.char_indices().next_back()?; + visible_break(doc, story, p, range.start + index) }) } +/// The start option of the break at byte `at` in paragraph `p`, unless deleted or hidden. +pub fn visible_break(doc: &Document, story: &Story, p: usize, at: usize) -> Option { + let character = story.text.get(at..)?.chars().next()?; + let start = break_start(character)?; + let (_, chars) = doc.styles.resolve_para(story.paras.get(p)?); + let props = doc.styles.resolve_char(&chars, story.char_format_at(at + character.len_utf8())); + let gone = props.change == designcraft_doc::ChangeMark::Deleted || doc.conditions_hide(&props.conditions); + (!gone).then_some(start) +} + fn named_list_labels(doc: &Document, story: &Story, ranges: &[Range], deleted: &[bool]) -> Vec> { let mut counters: std::collections::HashMap = std::collections::HashMap::new(); story diff --git a/crates/engine/src/cmd/place_text.rs b/crates/engine/src/cmd/place_text.rs index 961f6f630..852db171e 100644 --- a/crates/engine/src/cmd/place_text.rs +++ b/crates/engine/src/cmd/place_text.rs @@ -205,16 +205,15 @@ fn fit(d: &Document, sid: StoryId) -> (Option, usize, usize, usize) { let cs = designcraft_compose::compose_story(d, sid, &Default::default()); let Some(story) = d.story(sid) else { return (cs.overset_at, 0, 0, 0) }; let text = story.text.as_str(); - let deleted = designcraft_compose::deleted_paras(story, &story.para_ranges()); - let starts = |p: usize| { - !deleted.get(p).copied().unwrap_or(false) - && story.paras.get(p).is_some_and(|pf| d.styles.resolve_para(pf).0.start_paragraph != StartParagraph::Anywhere) - }; + let ranges = story.para_ranges(); + let deleted = designcraft_compose::deleted_paras(story, &ranges); + let placed = |p: usize| !deleted.get(p).copied().unwrap_or(false); + let starts = |p: usize| placed(p) && story.paras.get(p).is_some_and(|pf| d.styles.resolve_para(pf).0.start_paragraph != StartParagraph::Anywhere); // A frame's range stops before the paragraph's return; composing skips deleted paragraphs. let moved_on = |end: usize| { let next = if text.as_bytes().get(end) == Some(&b'\n') { end.saturating_add(1) } else { end }; next.checked_sub(1).is_some_and(|i| text.as_bytes().get(i) == Some(&b'\n')) - && (story.para_at(next)..story.paras.len()).find(|&p| !deleted.get(p).copied().unwrap_or(false)).is_some_and(starts) + && (story.para_at(next)..story.paras.len()).find(|&p| placed(p)).is_some_and(starts) }; let (mut held, mut full) = (0, 0); for f in &cs.frames { @@ -228,8 +227,28 @@ fn fit(d: &Document, sid: StoryId) -> (Option, usize, usize, usize) { let first = story.para_at(at); // The paragraph the overset text starts with counts when it starts there. let from = if at.checked_sub(1).is_none_or(|i| text.as_bytes().get(i) == Some(&b'\n')) { first } else { first.saturating_add(1) }; - let breaks = text.get(at..).map_or(0, |t| t.chars().filter(|&c| designcraft_doc::is_break_char(c)).count()); - (from..story.paras.len()).filter(|&p| starts(p)).count().saturating_add(breaks) + // Once per paragraph: after a break, its start option only fixes odd or even page parity. + let sent = (from..ranges.len()) + .filter(|&p| starts(p) || (placed(p) && designcraft_compose::previous_visible_break(d, story, &ranges, &deleted, p).is_some())) + .count(); + // A break inside a paragraph moves the rest of it. + let inside: usize = (first..ranges.len()) + .filter(|&p| placed(p)) + .filter_map(|p| Some((p, ranges.get(p)?.clone()))) + .map(|(p, r)| { + let Some(t) = text.get(r.clone()) else { return 0 }; + let last = t.char_indices().next_back().map(|(i, _)| i); + t.char_indices() + .filter(|&(i, c)| { + designcraft_doc::is_break_char(c) + && Some(i) != last + && r.start.saturating_add(i) >= at + && designcraft_compose::visible_break(d, story, p, r.start.saturating_add(i)).is_some() + }) + .count() + }) + .sum(); + sent.saturating_add(inside) }); (cs.overset_at, held, full, forced) } diff --git a/crates/engine/src/cmd/place_text/tests.rs b/crates/engine/src/cmd/place_text/tests.rs index 183a46074..8275e7ab2 100644 --- a/crates/engine/src/cmd/place_text/tests.rs +++ b/crates/engine/src/cmd/place_text/tests.rs @@ -377,3 +377,47 @@ fn autoflow_asks_for_every_frame_the_start_options_ask_for_at_once() { assert_eq!(designcraft_compose::compose_story(&d, sid, &Default::default()).overset_at, None); assert!(d.next_id - ids < 2000, "{} ids", d.next_id - ids); } + +#[test] +fn autoflow_asks_no_frame_for_a_deleted_or_hidden_break() { + // Composing skips breaks in the overset text that tracked changes delete or a condition hides. + use designcraft_doc::{ChangeMark, Condition, PAGE_BREAK, build::NewDocument}; + for hide in [false, true] { + let mut d = Document::new(&NewDocument { pages: 1, facing_pages: false, primary_text_frame: true, ..Default::default() }); + d.conditions.push(Condition { name: "Hidden".into(), color: [0, 0, 0], visible: false }); + let sid = d.settings.primary_story.unwrap(); + let st = d.story_mut(sid).unwrap(); + let body = "lorem ipsum dolor sit amet ".repeat(300); + let gone = format!("x{PAGE_BREAK}\n").repeat(200); + st.insert(0, &format!("{}\n{gone}{}", body.trim(), body.trim())); + let at = body.trim().len() + 1; + if hide { + for (i, _) in gone.match_indices(PAGE_BREAK) { + st.format_chars(at + i..at + i + PAGE_BREAK.len_utf8(), |f| f.over.conditions = Some(vec!["Hidden".into()])); + } + } else { + st.format_chars(at..at + gone.len(), |f| f.over.change = Some(ChangeMark::Deleted)); + } + let ids = d.next_id; + autoflow(&mut d, sid, 2000, "file.place", false).unwrap(); + assert_eq!(designcraft_compose::compose_story(&d, sid, &Default::default()).overset_at, None, "hide {hide}"); + assert!(d.next_id - ids < 100, "hide {hide}: {} pages, {} ids", d.page_count(), d.next_id - ids); + } +} + +#[test] +fn autoflow_counts_a_break_and_the_start_option_it_meets_once() { + // Each chapter ends in a page break and starts on the next page: one new page each, not two. + use designcraft_doc::{PAGE_BREAK, build::NewDocument}; + let mut d = Document::new(&NewDocument { pages: 1, facing_pages: false, primary_text_frame: true, ..Default::default() }); + let sid = d.settings.primary_story.unwrap(); + let st = d.story_mut(sid).unwrap(); + st.insert(0, (0..200).map(|i| format!("Chapter {i}{PAGE_BREAK}\n")).collect::().trim_end()); + for p in &mut st.paras { + p.para.start_paragraph = Some(StartParagraph::NextPage); + } + let ids = d.next_id; + assert_eq!(autoflow(&mut d, sid, 2000, "file.place", false).unwrap(), 199); + assert_eq!(designcraft_compose::compose_story(&d, sid, &Default::default()).overset_at, None); + assert!(d.next_id - ids < 2000, "{} ids", d.next_id - ids); +} From 56eebe9556b55eda9fc624968f764dafb3c062ab Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Thu, 29 Oct 2026 21:40:24 -0400 Subject: [PATCH 8/8] Keep the host frame's text area across anchored text frames in PDF export Co-Authored-By: Claude Opus 5.5 --- crates/pdf/src/export/walk.rs | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/crates/pdf/src/export/walk.rs b/crates/pdf/src/export/walk.rs index d8a4d2739..cf96dac25 100644 --- a/crates/pdf/src/export/walk.rs +++ b/crates/pdf/src/export/walk.rs @@ -396,9 +396,12 @@ impl Exporter<'_> { if local != Affine::IDENTITY { s.push_transform(&tf(local)); } - self.text_area = (local.determinant().abs() > 1e-12).then(|| local.inverse().transform_rect_bbox(it.inner_bounds())); + // Anchored text frames now draw inside `frame_text`: restore the host's area + // after, so an object drawn there doesn't clear it for the host's glyphs. + let area = (local.determinant().abs() > 1e-12).then(|| local.inverse().transform_rect_bbox(it.inner_bounds())); + let outer = std::mem::replace(&mut self.text_area, area); drawn = self.frame_text(s, &cs, ft, text, Some((xf * local, page))); - self.text_area = None; + self.text_area = outer; if local != Affine::IDENTITY { s.pop(); }