mirror of
https://github.com/storytold/designcraft.git
synced 2026-11-10 20:41:55 +00:00
Merge pull request #1238 from seanth/inline-objects-under-text
[fix] Inline objects paint under their frame's text
This commit is contained in:
6 files changed
+148
-18
No files matched your search
@@ -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.
|
||||
|
||||
@@ -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
@@ -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
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user