diff --git a/ROADMAP.md b/ROADMAP.md index eca844178..93c489299 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -457,6 +457,7 @@ Newest first. - **2026-10-20** · With the Type tool, the Control panel's font size, leading and tracking fields have a spinner and a list of common values, as in InDesign (auto leading in parentheses). No readiness percentage change. - **2026-10-21** · The ¶ (Paragraph Formatting Controls) mode of the text Control panel has InDesign's Bulleted List and Numbered List buttons (with the default hanging indent) and Shading and Border: a checkbox, a button opening Paragraph Borders and Shading on that tab, and the colour. Found in the Learn Skills Daily course, chapter 21. No readiness percentage change. - **2026-10-21** · The Bulleted and Numbered List buttons (Properties, and Type › Bulleted & Numbered Lists › Apply Bullets / Apply Numbers) work as InDesign's: a paragraph with no indents gets the default hanging indent (0.25 in, −0.25 in), so the text lines up after the bullet instead of at the next default tab stop, and pressing the button again removes the list and that indent (`list.bullets`, `list.numbers`). Found in the Learn Skills Daily course, chapter 21. No readiness percentage change. +- **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-17** · Running headers: *first/last on page* read a page in text order (story order, then column by column), not by height — a multi-column page's right header showed the lowest entry of column 1 instead of the page's last entry. No readiness percentage change. - **2026-10-16** · Type › Reload Fonts (`font.reload`): fonts installed, edited or removed while DesignCraft runs are read again without a restart, and the open documents recompose with them (#328, #547). An edited font file is noticed by its length and modification time; bundled, added and document fonts stay as they are. InDesign does this by itself, so noticing changes automatically is still open (gap 6). No readiness percentage change. - **2026-10-16** · Parents can be deleted from their Pages-panel context menu or `layout.parents.delete {id}`. Applied-page and based-on references are cleared, local override copies survive, and Undo restores the parent. Addresses the deletion portion of #956; no readiness percentage change. diff --git a/crates/pdf/src/export/walk.rs b/crates/pdf/src/export/walk.rs index af6429b23..cf96dac25 100644 --- a/crates/pdf/src/export/walk.rs +++ b/crates/pdf/src/export/walk.rs @@ -308,13 +308,16 @@ impl Exporter<'_> { if frame_art { s.end_tagged(); } - self.draw_leaf_content(s, it, bp, path, page); + // A text frame's anchored objects are drawn under its text when they can be. + let drawn = self.draw_leaf_content(s, it, xf, bp, path, page); self.draw_leaf_stroke(s, it, bp, path, frame_art); s.pop(); for _ in 0..pushes { s.pop(); } - if let Content::Text(tfr) = &it.content { + if let Content::Text(tfr) = &it.content + && !drawn + { let cs = self.composed(tfr.story, page); if let Some(ft) = cs.frame(it.id) { self.frame_objects(s, ft, xf * self.doc.text_local(it), page); @@ -347,8 +350,10 @@ impl Exporter<'_> { } } - fn draw_leaf_content(&mut self, s: &mut Surface, it: &Item, bp: &BezPath, path: Option<&Path>, page: Option) { + /// The item's content; returns whether a text frame's anchored objects were drawn with it. + fn draw_leaf_content(&mut self, s: &mut Surface, it: &Item, xf: Affine, bp: &BezPath, path: Option<&Path>, page: Option) -> bool { let doc = self.doc; + 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() }; @@ -391,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())); - self.frame_text(s, &cs, ft, text); - self.text_area = None; + // 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 = outer; if local != Affine::IDENTITY { s.pop(); } @@ -401,6 +409,7 @@ impl Exporter<'_> { } _ => {} } + drawn } fn draw_leaf_stroke(&mut self, s: &mut Surface, it: &Item, bp: &BezPath, path: Option<&Path>, frame_art: bool) { @@ -419,7 +428,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 024c469d7..6870fb7fd 100644 --- a/crates/pdf/src/tests.rs +++ b/crates/pdf/src/tests.rs @@ -473,13 +473,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) {} @@ -490,7 +496,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), @@ -501,13 +511,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 } @@ -928,6 +942,63 @@ fn a_swatch_colours_a_greyscale_image() { 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:?}"); +} + +/// 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:?}"); +} + /// The export asks for each story's composition once per page it is shown on: a parent page's /// page number still reads its own page everywhere, and a story threaded across pages shows each /// frame's own lines. diff --git a/crates/pdf/src/text.rs b/crates/pdf/src/text.rs index ba44cf713..fdef09fae 100644 --- a/crates/pdf/src/text.rs +++ b/crates/pdf/src/text.rs @@ -262,7 +262,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) { @@ -277,6 +287,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() { @@ -301,12 +322,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 } /// One table cell's graphic, or its text composed in the cell's own area. @@ -322,7 +344,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/tests.rs b/crates/render/src/tests.rs index 295a0da5f..0ea80e7fe 100644 --- a/crates/render/src/tests.rs +++ b/crates/render/src/tests.rs @@ -669,3 +669,29 @@ fn content_crop_box_is_measured_without_rendering_the_page() { assert!(frac.iter().zip(want).all(|(a, b)| (a - b).abs() < 1e-6), "{frac:?} vs {want:?}"); assert!((size.0 - 400.0).abs() < 1e-3 && (size.1 - 497.0).abs() < 1e-3, "{size:?}"); } + +/// 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"); +} diff --git a/crates/render/src/text.rs b/crates/render/src/text.rs index 3797f508e..d10fd30f9 100644 --- a/crates/render/src/text.rs +++ b/crates/render/src/text.rs @@ -387,6 +387,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); + } draw_text_indicators(ctx, f, cs, ft, m); let scale = m.determinant().abs().sqrt(); let greek_px = f.opts.greek_below_px; @@ -461,10 +466,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); - } } }