Merge pull request #1238 from seanth/inline-objects-under-text

[fix] Inline objects paint under their frame's text
This commit is contained in:
Brandon Thomas authored and GitHub committed 2026-10-29 21:55:02 -04:00
commit 7137006d55
6 files changed
+148 -18

No files matched your search

+1
View File
@@ -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.
+16 -7
View File
@@ -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<usize>) {
/// 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<usize>) -> 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<usize>) {
pub(crate) fn frame_objects(&mut self, s: &mut Surface, ft: &designcraft_compose::FrameText, xf: Affine, page: Option<usize>) {
let mut pending = vec![(ft, xf)];
while let Some((frame, transform)) = pending.pop() {
for object in &frame.objects {
+75 -4
View File
@@ -473,13 +473,19 @@ fn drawn(bytes: &[u8]) -> (Vec<(String, kurbo::Rect)>, Vec<kurbo::Rect>) {
/// 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<SoftMask<'_>>) {}
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<SoftMask<'_>>, _: BlendMode) {}
fn push_transparency_group(&mut self, opacity: f32, _: Option<SoftMask<'_>>, _: 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<usize> = 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.
+25 -3
View File
@@ -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<usize>)>,
) -> 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();
}
+26
View File
@@ -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");
}
+5 -4
View File
@@ -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);
}
}
}