Text wrap: a relinked image's new outline recomposes the text around it

- each traced Alpha Channel outline has a serial number, and the composition signatures include the outlines in use, so a relink or link update that changes only the image data no longer leaves text laid out around the old image
- a wrap with the default alpha threshold is saved without it, as before thresholds were stored
- docs/typography-parity.md says Alpha Channel contours follow the image's visible pixels
This commit is contained in:
dotCooCoo committed 2026-11-11 21:33:53 -08:00
1 parent 1455093812
commit 544ebaf571
5 files changed
+74 -3

No files matched your search

+13
View File
@@ -85,6 +85,18 @@ fn doc_signature(doc: &Document) -> u64 {
(Arc::as_ptr(&doc.styles) as usize).hash(&mut h);
format!("{:?}", doc.text_variables).hash(&mut h);
sections_hash(doc).hash(&mut h);
outlines_hash(doc).hash(&mut h);
h.finish()
}
/// The Alpha Channel outlines text wraps around: a relink traces a new one while the items and
/// stories stay the same.
fn outlines_hash(doc: &Document) -> u64 {
use std::hash::{Hash, Hasher};
let mut h = std::collections::hash_map::DefaultHasher::new();
for o in &doc.alpha_outlines {
o.serial.hash(&mut h);
}
h.finish()
}
@@ -405,6 +417,7 @@ fn signature(doc: &Document, story: &Arc<Story>) -> (Vec<usize>, Vec<Arc<Item>>)
sections_hash(doc) as usize,
doc.font_scope as usize,
usize::from(g.relative_to == designcraft_doc::GridRelative::TopMargin),
outlines_hash(doc) as usize,
];
sig.extend([g.start, g.increment].into_iter().flat_map(bits));
let mut keep = Vec::new();
+5
View File
@@ -303,12 +303,17 @@ pub struct TextWrap {
#[serde(skip_serializing_if = "std::ops::Not::not")]
pub inside_edges: bool,
/// Alpha Channel contour: pixels more opaque than this (0..254) count as the image.
#[serde(skip_serializing_if = "is_default_alpha_threshold")]
pub alpha_threshold: u8,
}
/// DesignCraft's default alpha threshold for Alpha Channel contour wraps.
pub const DEFAULT_ALPHA_THRESHOLD: u8 = 25;
fn is_default_alpha_threshold(t: &u8) -> bool {
*t == DEFAULT_ALPHA_THRESHOLD
}
impl Default for TextWrap {
fn default() -> Self {
TextWrap {
+9
View File
@@ -674,9 +674,18 @@ pub struct AlphaOutline {
/// under the same id, and the outline is traced again. (Weak, so it doesn't keep old image
/// data alive, and its address can't be reused by new data while it's held.)
pub source: std::sync::Weak<Vec<u8>>,
/// A number no other outline traced in this process has ([`AlphaOutline::next_serial`]):
/// composition caches compare it to tell a new outline from the one they used.
pub serial: u64,
}
impl AlphaOutline {
/// A serial number for a new outline, unique within the process.
pub fn next_serial() -> u64 {
static NEXT: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(1);
NEXT.fetch_add(1, std::sync::atomic::Ordering::Relaxed)
}
/// Traced from `data`, the asset's current image data.
pub fn traced_from(&self, data: &Arc<Vec<u8>>) -> bool {
std::ptr::eq(self.source.as_ptr(), Arc::as_ptr(data))
+46 -2
View File
@@ -62,7 +62,16 @@ pub(crate) fn sync(doc: &mut Document) {
/// The empty outline standing for an image `trace` found none in: zero-size, which composition
/// takes as no outline, and dropped like a traced one when the asset's data changes.
fn no_outline(data: &Arc<Vec<u8>>, asset: AssetId, threshold: u8) -> AlphaOutline {
AlphaOutline { asset, threshold, width: 0, height: 0, outer: Default::default(), holes: Default::default(), source: Arc::downgrade(data) }
AlphaOutline {
asset,
threshold,
width: 0,
height: 0,
outer: Default::default(),
holes: Default::default(),
source: Arc::downgrade(data),
serial: AlphaOutline::next_serial(),
}
}
/// The outline of the pixels of `data` more opaque than `threshold`. None for an image that
@@ -112,7 +121,8 @@ pub(crate) fn trace(data: &Arc<Vec<u8>>, asset: AssetId, threshold: u8) -> Optio
PathData::from_bezpath(&p)
};
let source = Arc::downgrade(data);
return Some(AlphaOutline { asset, threshold, width: w, height: h, outer: path(&outer), holes: path(&holes), source });
let serial = AlphaOutline::next_serial();
return Some(AlphaOutline { asset, threshold, width: w, height: h, outer: path(&outer), holes: path(&holes), source, serial });
}
finer = Some((mask, cols, rows));
grid /= 2;
@@ -428,6 +438,40 @@ mod tests {
assert_eq!((b.x0, b.x1), (24.0, 40.0), "the new image's outline");
}
/// A wrap with the default threshold saves as it did before thresholds were stored; another
/// threshold is saved.
#[test]
fn the_default_threshold_is_left_out_of_a_saved_wrap() {
let v = serde_json::to_value(designcraft_doc::TextWrap::default()).unwrap();
assert!(v.get("alphaThreshold").is_none(), "{v}");
let back: designcraft_doc::TextWrap = serde_json::from_value(v).unwrap();
assert_eq!(back.alpha_threshold, designcraft_doc::DEFAULT_ALPHA_THRESHOLD);
let w = designcraft_doc::TextWrap { alpha_threshold: 200, ..Default::default() };
assert_eq!(serde_json::to_value(w).unwrap()["alphaThreshold"], 200);
}
/// A relink that changes only the image data recomposes the text around the new outline.
#[test]
fn text_is_composed_again_around_a_relinked_images_outline() {
let (mut s, image, story) = page(&png(|x, _| x < 32), [180.0, 150.0, 220.0, 178.0]);
s.execute("object.textWrap", &json!({"ids": [image], "contour": "alphaChannel"})).unwrap();
assert!(!overset(&s, story), "the left half is visible: the caption on the right is set");
let Some(designcraft_doc::Content::Graphic(g)) =
s.doc().unwrap().doc.item(designcraft_doc::ItemId(image.as_u64().unwrap())).map(|it| it.content.clone())
else {
panic!("a graphic")
};
let right = Arc::new(png(|x, _| x >= 32));
s.edit(|d, _| {
let mut a = (**d.assets.get(&g.asset).unwrap()).clone();
a.data = right.clone();
d.assets.insert(g.asset, Arc::new(a));
Ok(())
})
.unwrap();
assert!(overset(&s, story), "the right half is visible now: it covers the caption");
}
/// An image anchored in text keeps its outline: anchored objects wrap the lines after their
/// anchor, and they are kept in the story, not on the spread.
#[test]
+1 -1
View File
@@ -59,7 +59,7 @@ Fidelity: **unmeasured** unless stated.
| Paragraph rules, borders, shading | ✓ | ✓ | ✓ | |
| Balance ragged lines | ✓ | ✓ | ✓ | |
| Optical margin alignment | per story with a size, Ignore Optical Margin per paragraph and style | ✓ (hang table of our own, scaled by the story's size; Story panel; IDML `StoryPreference`, `IgnoreEdgeAlignment`) | ~ | hang amounts are ours, InDesign's unknown (#947) |
| Text wrap | bounding box, shape, jump object/column, wrap sides (text on both sides of an object), contour options, on page items and anchored objects | ✓ | ~ | Detect Edges, Alpha Channel and Photoshop Path contours follow the frame's path (composition doesn't read the image) |
| Text wrap | bounding box, shape, jump object/column, wrap sides (text on both sides of an object), contour options, on page items and anchored objects | ✓ | ~ | Alpha Channel contours follow the outline of the image's visible pixels; Detect Edges and Photoshop Path contours follow the frame's path |
| Anchored objects | inline, above line, custom | ✓ | ~ | anchored objects in table cells not drawn (#163) |
| Footnotes, endnotes | ✓ | ✓ | ~ | footnotes don't split across columns yet |
| Type on a path | ✓ | ✓ | ✓ | |