From afc6d31764ec11d966789db890b3f1d5f06c5a5d Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 17 Oct 2026 16:15:32 +0000 Subject: [PATCH 1/3] 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/3] 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 56eebe9556b55eda9fc624968f764dafb3c062ab Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Thu, 29 Oct 2026 21:40:24 -0400 Subject: [PATCH 3/3] 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(); }