mirror of
https://github.com/storytold/designcraft.git
synced 2026-10-31 05:36:46 -06:00
Merge PR #657
This commit is contained in:
@@ -5,6 +5,7 @@
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (text threads by hand; the alpha gate waits on Primary Text Frame page fixes) · **Target:** Adobe InDesign 2026
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-13 · **Change:** minor (parent canvas refresh) · **Target:** Adobe InDesign 2026
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-13 · **Change:** minor (bounded control click/drag input) · **Target:** Adobe InDesign 2026
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (text stroke behind fill) · **Target:** Adobe InDesign 2026
|
||||
|
||||
DesignCraft aims at full Adobe InDesign parity, and to be better: faster, open (documented JSON
|
||||
format + IDML), scriptable by agents (MCP), and available on the web.
|
||||
@@ -156,6 +157,7 @@ Newest first.
|
||||
- **2026-10-13** · Layout ▸ Table of Contents opens a real dialog: title and title style, Include Paragraph Styles and Other Styles lists with Add and Remove, and per style its entry style, page number position (after, before, none), separator and level. Replace Existing regenerates the current table of contents in place; otherwise OK makes a new frame on the page in view. IDML export writes the definition as a TOC style that import reads back, so Update works after a round trip. No readiness percentage change.
|
||||
- **2026-10-13** · Control-channel clicks and drags validate positive counts and the complete pending-event budget before queuing input (#732). Oversized requests leave pending gestures intact, preventing unbounded click-count allocation. No readiness percentage change.
|
||||
- **2026-10-12** · macOS Dock Quit uses the same unsaved-document prompts as window and menu Quit. Repeated requests keep the current answers; Cancel retains every tab and unsaved recovery checkpoint, and completed Save/Don't Save answers exit through the ordinary event loop. No readiness percentage change.
|
||||
- **2026-10-12** · Text strokes can sit behind the fill: the stroke lies wholly outside the letters at its full weight on screen, in PNG and in PDF, set from the Properties panel or Character Style Options. IDML `StrokeAlignment` on text imports and exports as this setting, so imported text with outside-aligned strokes keeps its letter shapes (#619). No readiness percentage change.
|
||||
- **2026-10-12** · Orderly native exit writes the latest recovery state after finalizing current quit approvals; incomplete or stale plans keep their documents, and Undo to the saved state clears stale recovery. Unavailable or failed recovery is reported. Cancelable macOS Dock Quit remains open (#544). No readiness percentage change.
|
||||
- **2026-10-12** · Recovered documents keep a checkpoint until saved, closed or reverted, including a second crash before autosave. Restarted document IDs and explicit recovery folders preserve other snapshots; failed republishing retains the original. No readiness percentage change.
|
||||
- **2026-10-12** · Book numbering and style updates prepare every chapter before saving. Only closed native chapters are changed; imported IDML remains a read-only style/PDF source. If a later disk write fails, the error names the completed chapters; the batch is not a whole-book transaction. No readiness percentage change.
|
||||
|
||||
@@ -50,6 +50,8 @@ pub struct RunStyle {
|
||||
pub stroke: String,
|
||||
pub stroke_tint: f32,
|
||||
pub stroke_weight: f64,
|
||||
/// The stroke goes under the fill at double width, so its full weight shows outside the glyphs.
|
||||
pub stroke_behind_fill: bool,
|
||||
pub underline: bool,
|
||||
pub strikethrough: bool,
|
||||
/// The underline and strikethrough bars (Underline / Strikethrough Options resolved).
|
||||
|
||||
@@ -147,6 +147,7 @@ impl StyleTable<'_> {
|
||||
stroke: p.stroke.clone(),
|
||||
stroke_tint: p.stroke_tint,
|
||||
stroke_weight: p.stroke_weight,
|
||||
stroke_behind_fill: p.stroke_behind_fill,
|
||||
underline: p.underline,
|
||||
strikethrough: p.strikethrough,
|
||||
underline_rule: crate::Rule {
|
||||
|
||||
@@ -753,6 +753,9 @@ attr_set! {
|
||||
stroke: String = "[None]".into(),
|
||||
stroke_tint: f32 = 1.0,
|
||||
stroke_weight: f64 = 1.0,
|
||||
/// Stroke behind fill: the stroke is painted under the fill and lies wholly outside the
|
||||
/// letters at its full weight, so the letters keep their shape. Off, it straddles the outline.
|
||||
stroke_behind_fill: bool = false,
|
||||
capitalization: Capitalization = Capitalization::Normal,
|
||||
position: Position = Position::Normal,
|
||||
underline: bool = false,
|
||||
@@ -989,6 +992,20 @@ mod tests {
|
||||
assert_eq!(a.rule_below, None, "null clears the override");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stroke_behind_fill_is_saved_only_when_set() {
|
||||
assert!(!CharProps::default().stroke_behind_fill);
|
||||
assert_eq!(serde_json::to_value(CharAttrs::default()).unwrap(), serde_json::json!({}));
|
||||
let mut a = CharAttrs::default();
|
||||
a.set_json("strokeBehindFill", &serde_json::json!(true)).unwrap();
|
||||
let v = serde_json::to_value(&a).unwrap();
|
||||
assert_eq!(v, serde_json::json!({"strokeBehindFill": true}));
|
||||
assert_eq!(serde_json::from_value::<CharAttrs>(v).unwrap(), a);
|
||||
let mut p = CharProps::default();
|
||||
p.apply(&a);
|
||||
assert!(p.stroke_behind_fill);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn digits_map_to_their_script() {
|
||||
assert_eq!(Digits::Default.map('7', "Arabic"), '7');
|
||||
|
||||
@@ -246,7 +246,7 @@ fn create_outlines(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cs = designcraft_compose::compose_story(d, tf.story, &Default::default());
|
||||
let Some(ft) = cs.frame(*id) else { continue };
|
||||
// One path per text look (fill, stroke).
|
||||
let mut runs: Vec<((String, f32, String, f32, f64), BezPath)> = Vec::new();
|
||||
let mut runs: Vec<((String, f32, String, f32, f64, bool), BezPath)> = Vec::new();
|
||||
for l in &ft.lines {
|
||||
for g in l.glyphs.iter().filter(|g| g.visible) {
|
||||
let st = &cs.styles[g.style as usize];
|
||||
@@ -256,7 +256,7 @@ fn create_outlines(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
let skew = if st.skew != 0.0 { Affine::new([1.0, 0.0, -st.skew.to_radians().tan(), 1.0, 0.0, 0.0]) } else { Affine::IDENTITY };
|
||||
let a = Affine::translate((g.x, l.baseline + g.y)) * skew * Affine::scale_non_uniform(g.sx, g.sy);
|
||||
let key = (st.fill.clone(), st.fill_tint, st.stroke.clone(), st.stroke_tint, st.stroke_weight);
|
||||
let key = (st.fill.clone(), st.fill_tint, st.stroke.clone(), st.stroke_tint, st.stroke_weight, st.stroke_behind_fill);
|
||||
let k = match runs.iter().position(|r| r.0 == key) {
|
||||
Some(k) => k,
|
||||
None => {
|
||||
@@ -274,14 +274,16 @@ fn create_outlines(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let (sr, _) = spread_xf(d, *id).ok_or(designcraft_doc::DocError::NoItem(*id))?;
|
||||
let at = d.spread(sr).and_then(|sp| sp.items.iter().position(|i| i.id == *id));
|
||||
let text_xf = d.text_xf(&it);
|
||||
let make = |d: &mut Document, ((fill, ft, stroke, stt, sw), bp): ((String, f32, String, f32, f64), BezPath)| {
|
||||
let make = |d: &mut Document, ((fill, ft, stroke, stt, sw, behind), bp): ((String, f32, String, f32, f64, bool), BezPath)| {
|
||||
let mut o = Item::new(ItemId(d.alloc()), it.layer, Shape::Path, PathData::from_bezpath(&bp));
|
||||
o.xf = text_xf;
|
||||
o.fill = Fill { swatch: fill, tint: ft, ..Fill::none() };
|
||||
o.stroke = if stroke == designcraft_color::swatch::NONE {
|
||||
Stroke::none()
|
||||
} else {
|
||||
Stroke { swatch: stroke, tint: stt, weight: sw, ..Stroke::default() }
|
||||
// A stroke behind the fill lies wholly outside the letters, as an outside-aligned stroke.
|
||||
let align = if behind { designcraft_doc::StrokeAlign::Outside } else { designcraft_doc::StrokeAlign::Center };
|
||||
Stroke { swatch: stroke, tint: stt, weight: sw, align, ..Stroke::default() }
|
||||
};
|
||||
o
|
||||
};
|
||||
|
||||
@@ -1164,6 +1164,9 @@ impl<'a> Ex<'a> {
|
||||
if let Some(v) = a.stroke_weight {
|
||||
el.set("StrokeWeight", num(v));
|
||||
}
|
||||
if let Some(v) = a.stroke_behind_fill {
|
||||
el.set("StrokeAlignment", if v { "OutsideAlignment" } else { "CenterAlignment" });
|
||||
}
|
||||
if let Some(v) = a.capitalization {
|
||||
el.set("Capitalization", names::caps_out(v));
|
||||
}
|
||||
|
||||
@@ -1227,6 +1227,12 @@ impl<'r> Importer<'r> {
|
||||
}
|
||||
a.stroke_tint = tint(e.num("StrokeTint"));
|
||||
a.stroke_weight = e.num("StrokeWeight");
|
||||
// Text strokes are centred on the outline or lie outside it, behind the fill.
|
||||
a.stroke_behind_fill = e.prop("StrokeAlignment").and_then(|v| match v.trim() {
|
||||
"OutsideAlignment" => Some(true),
|
||||
"CenterAlignment" => Some(false),
|
||||
_ => None,
|
||||
});
|
||||
a.capitalization = e.prop("Capitalization").and_then(|v| names::caps_in(v.trim()));
|
||||
a.position = e.prop("Position").and_then(|v| names::position_in(v.trim()));
|
||||
a.underline = e.boolean("Underline");
|
||||
|
||||
@@ -868,6 +868,25 @@ fn cjk_character_attributes_import_from_independent_xml_and_round_trip() {
|
||||
assert_eq!(a.leading_model, b.leading_model);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn text_stroke_alignment_imports_as_stroke_behind_fill_and_round_trips() {
|
||||
let story = r#"<idPkg:Story xmlns:idPkg="http://ns.adobe.com/AdobeInDesign/idml/1.0/packaging"><Story Self="s1">
|
||||
<ParagraphStyleRange AppliedParagraphStyle="ParagraphStyle/$ID/[No paragraph style]">
|
||||
<CharacterStyleRange AppliedCharacterStyle="CharacterStyle/$ID/[No character style]" StrokeColor="Color/Black" StrokeWeight="2" StrokeAlignment="OutsideAlignment"><Content>Behind</Content></CharacterStyleRange>
|
||||
<CharacterStyleRange AppliedCharacterStyle="CharacterStyle/$ID/[No character style]" StrokeColor="Color/Black" StrokeAlignment="CenterAlignment"><Content>Over</Content></CharacterStyleRange>
|
||||
<CharacterStyleRange AppliedCharacterStyle="CharacterStyle/$ID/[No character style]"><Content>Plain</Content></CharacterStyleRange>
|
||||
</ParagraphStyleRange></Story></idPkg:Story>"#;
|
||||
let flags = |d: &Document| -> Vec<(String, Option<bool>)> {
|
||||
let st = d.stories.values().find(|s| s.text.contains("Behind")).unwrap();
|
||||
st.runs().map(|(r, f)| (st.text[r].to_string(), f.over.stroke_behind_fill)).filter(|(t, _)| !t.trim().is_empty()).collect()
|
||||
};
|
||||
let want = vec![("Behind".to_string(), Some(true)), ("Over".to_string(), Some(false)), ("Plain".to_string(), None)];
|
||||
let doc = import_idml(&fixture_with_story(story)).unwrap();
|
||||
assert_eq!(flags(&doc), want);
|
||||
let back = import_idml(&export_idml(&doc)).unwrap();
|
||||
assert_eq!(flags(&back), want);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn warichu_imports_from_idml_and_round_trips() {
|
||||
let story = r#"<idPkg:Story xmlns:idPkg="http://ns.adobe.com/AdobeInDesign/idml/1.0/packaging"><Story Self="s1">
|
||||
|
||||
@@ -414,6 +414,67 @@ fn drawn(bytes: &[u8]) -> (Vec<(String, kurbo::Rect)>, Vec<kurbo::Rect>) {
|
||||
(rec.0, rec.1)
|
||||
}
|
||||
|
||||
/// The page's paint operations in order: `fill`/`stroke` + ` text`/` path` and the stroke width.
|
||||
fn paint_order(bytes: &[u8]) -> 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)>);
|
||||
impl Device<'_> for Rec {
|
||||
fn set_soft_mask(&mut self, _: Option<SoftMask<'_>>) {}
|
||||
fn set_blend_mode(&mut self, _: BlendMode) {}
|
||||
fn draw_path(&mut self, _: &kurbo::BezPath, _: kurbo::Affine, _: &Paint<'_>, m: &PathDrawMode) {
|
||||
self.0.push(match m {
|
||||
PathDrawMode::Fill(_) => ("fill path".into(), 0.0),
|
||||
PathDrawMode::Stroke(p) => ("stroke path".into(), p.line_width),
|
||||
});
|
||||
}
|
||||
fn push_clip_path(&mut self, _: &ClipPath) {}
|
||||
fn push_transparency_group(&mut self, _: f32, _: Option<SoftMask<'_>>, _: BlendMode) {}
|
||||
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),
|
||||
GlyphDrawMode::Stroke(p) => ("stroke text".into(), p.line_width),
|
||||
GlyphDrawMode::Invisible => ("invisible text".into(), 0.0),
|
||||
});
|
||||
}
|
||||
fn draw_image(&mut self, _: Image<'_, '_>, _: kurbo::Affine) {}
|
||||
fn pop_clip_path(&mut self) {}
|
||||
fn pop_transparency_group(&mut self) {}
|
||||
}
|
||||
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();
|
||||
interpret_page(page, &mut ctx, &mut rec);
|
||||
rec.0
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stroke_behind_fill_strokes_the_run_before_filling_it() {
|
||||
let export = |behind: bool| {
|
||||
let mut d = doc_with_text("I");
|
||||
let sid = *d.stories.keys().next().unwrap();
|
||||
d.story_mut(sid).unwrap().format_chars(0..1, |f| {
|
||||
f.over.stroke = Some(designcraft_color::swatch::BLACK.into());
|
||||
f.over.stroke_weight = Some(4.0);
|
||||
f.over.stroke_behind_fill = Some(behind);
|
||||
});
|
||||
let bytes = export_pdf(&d, &Cache::new(), &PdfOptions::default()).unwrap();
|
||||
let ops: Vec<(String, f32)> = paint_order(&bytes).into_iter().filter(|(op, w)| op.ends_with("text") || *w > 0.0).collect();
|
||||
(ops, extract_text(&bytes)[0].clone())
|
||||
};
|
||||
let (over, _) = export(false);
|
||||
assert_eq!(over, [("fill text".to_string(), 0.0), ("stroke text".to_string(), 4.0)]);
|
||||
let (behind, text) = export(true);
|
||||
assert_eq!(behind, [("stroke path".to_string(), 8.0), ("fill text".to_string(), 0.0)], "the double-width stroke goes first");
|
||||
assert_eq!(text.trim(), "I", "the text is not duplicated");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missing_glyphs_print_as_boxes() {
|
||||
// Source Serif 4 lacks 語: its .notdef box is drawn as a path, not as a .notdef glyph
|
||||
|
||||
+75
-24
@@ -299,16 +299,53 @@ impl Exporter<'_> {
|
||||
let hs = g0.sx / g0.sy;
|
||||
let skew = if st.skew != 0.0 { Affine::new([1.0, 0.0, -st.skew.to_radians().tan(), 1.0, 0.0, 0.0]) } else { Affine::IDENTITY };
|
||||
let origin = Affine::translate((g0.x, baseline + g0.y)) * skew * Affine::scale_non_uniform(hs, 1.0);
|
||||
// Stroke behind fill: a double-width stroke first, as outlines so the text is not
|
||||
// duplicated, then the fill over its inner half. With no fill, the stroke is clipped
|
||||
// to the outside of the glyphs instead.
|
||||
let behind = st.stroke_behind_fill && stroke.is_some();
|
||||
let weight = if behind { st.stroke_weight * 2.0 } else { st.stroke_weight };
|
||||
let mut clipped = false;
|
||||
let stroke = if behind {
|
||||
let outlines = glyph_outlines(glyphs, size);
|
||||
if fill.is_some() {
|
||||
self.paint_outlines(s, &outlines, origin, None, stroke, weight);
|
||||
None
|
||||
} else {
|
||||
clipped = self.push_outside_clip(s, &outlines, origin, weight);
|
||||
stroke
|
||||
}
|
||||
} else {
|
||||
stroke
|
||||
};
|
||||
// A missing glyph is drawn as its box: PDF/A and PDF/UA forbid showing .notdef as text.
|
||||
if g0.gid == 0 {
|
||||
self.outline_run(s, glyphs, origin, size, fill, stroke, st.stroke_weight);
|
||||
return;
|
||||
}
|
||||
let Some(font) = self.font(&face) else {
|
||||
self.paint_outlines(s, &glyph_outlines(glyphs, size), origin, fill, stroke, weight);
|
||||
} else if let Some(font) = self.font(&face) {
|
||||
self.text_run(s, glyphs, story, font, origin, size, hs, fill, stroke, weight);
|
||||
} else {
|
||||
self.warn(format!("font `{} {}` could not be embedded; its text was drawn as outlines", face.family, face.style));
|
||||
self.outline_run(s, glyphs, origin, size, fill, stroke, st.stroke_weight);
|
||||
return;
|
||||
};
|
||||
self.paint_outlines(s, &glyph_outlines(glyphs, size), origin, fill, stroke, weight);
|
||||
}
|
||||
if clipped {
|
||||
s.pop();
|
||||
}
|
||||
}
|
||||
|
||||
/// A run as real text.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn text_run(
|
||||
&mut self,
|
||||
s: &mut Surface,
|
||||
glyphs: &[PlacedGlyph],
|
||||
story: &str,
|
||||
font: Font,
|
||||
origin: Affine,
|
||||
size: f64,
|
||||
hs: f64,
|
||||
fill: Option<krilla::color::Color>,
|
||||
stroke: Option<krilla::color::Color>,
|
||||
stroke_weight: f64,
|
||||
) {
|
||||
let (text, ranges) = self.run_text(glyphs, story);
|
||||
let k = 1.0 / (hs * size);
|
||||
let kg: Vec<KrillaGlyph> = glyphs
|
||||
@@ -322,36 +359,24 @@ impl Exporter<'_> {
|
||||
.collect();
|
||||
s.push_transform(&tf(origin));
|
||||
s.set_fill(fill.map(|c| solid_fill(c, 1.0)));
|
||||
s.set_stroke(stroke.map(|c| Stroke { paint: c.into(), width: st.stroke_weight as f32, ..Default::default() }));
|
||||
s.set_stroke(stroke.map(|c| Stroke { paint: c.into(), width: stroke_weight as f32, ..Default::default() }));
|
||||
s.draw_glyphs(Point::from_xy(0.0, 0.0), &kg, font, &text, size as f32, false);
|
||||
s.set_fill(None);
|
||||
s.set_stroke(None);
|
||||
s.pop();
|
||||
}
|
||||
|
||||
/// Fallback for faces krilla can't embed: glyph outlines as paths.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn outline_run(
|
||||
/// Glyph outlines as paths (faces krilla can't embed, missing glyphs, strokes behind fill).
|
||||
fn paint_outlines(
|
||||
&mut self,
|
||||
s: &mut Surface,
|
||||
glyphs: &[PlacedGlyph],
|
||||
outlines: &BezPath,
|
||||
origin: Affine,
|
||||
size: f64,
|
||||
fill: Option<krilla::color::Color>,
|
||||
stroke: Option<krilla::color::Color>,
|
||||
stroke_weight: f64,
|
||||
) {
|
||||
let db = FontDb::global();
|
||||
let g0 = &glyphs[0];
|
||||
let k = size / g0.face.upem;
|
||||
let mut bp = BezPath::new();
|
||||
for g in glyphs {
|
||||
let a = Affine::translate(((g.x - g0.x) / (g0.sx / g0.sy), 0.0)) * Affine::scale(k);
|
||||
let mut o = (*db.outline(&g.face, g.gid)).clone();
|
||||
o.apply_affine(a);
|
||||
bp.extend(o.iter());
|
||||
}
|
||||
let Some(p) = to_path(&bp) else { return };
|
||||
let Some(p) = to_path(outlines) else { return };
|
||||
s.push_transform(&tf(origin));
|
||||
s.set_fill(fill.map(|c| solid_fill(c, 1.0)));
|
||||
s.set_stroke(stroke.map(|c| Stroke { paint: c.into(), width: stroke_weight as f32, ..Default::default() }));
|
||||
@@ -361,6 +386,17 @@ impl Exporter<'_> {
|
||||
s.pop();
|
||||
}
|
||||
|
||||
/// Clip to everything outside the glyphs (a frame around them plus their outlines,
|
||||
/// even-odd). False when there was nothing to clip with.
|
||||
fn push_outside_clip(&mut self, s: &mut Surface, outlines: &BezPath, origin: Affine, weight: f64) -> bool {
|
||||
let m = weight * 2.0 + 10.0;
|
||||
let mut outside = outlines.bounding_box().inflate(m, m).to_path(0.1);
|
||||
outside.extend(outlines.iter());
|
||||
let Some(p) = to_path(&(origin * outside)) else { return false };
|
||||
s.push_clip_path(&p, &krilla::paint::FillRule::EvenOdd);
|
||||
true
|
||||
}
|
||||
|
||||
/// One line of plain text in the bundled UI face (page information mark).
|
||||
pub(crate) fn plain_text(&mut self, s: &mut Surface, text: &str, at: (f64, f64), size: f64, color: krilla::color::Color) {
|
||||
let face = FontDb::global().face(designcraft_fonts::FALLBACK_FAMILY, "Regular");
|
||||
@@ -390,3 +426,18 @@ impl Exporter<'_> {
|
||||
s.set_fill(None);
|
||||
}
|
||||
}
|
||||
|
||||
/// A run's glyph outlines at `size`, relative to its first glyph's origin.
|
||||
fn glyph_outlines(glyphs: &[PlacedGlyph], size: f64) -> BezPath {
|
||||
let db = FontDb::global();
|
||||
let mut bp = BezPath::new();
|
||||
let Some(g0) = glyphs.first() else { return bp };
|
||||
let k = size / g0.face.upem;
|
||||
for g in glyphs {
|
||||
let a = Affine::translate(((g.x - g0.x) / (g0.sx / g0.sy), 0.0)) * Affine::scale(k);
|
||||
let mut o = (*db.outline(&g.face, g.gid)).clone();
|
||||
o.apply_affine(a);
|
||||
bp.extend(o.iter());
|
||||
}
|
||||
bp
|
||||
}
|
||||
|
||||
+60
-36
@@ -374,32 +374,9 @@ impl Renderer {
|
||||
let lg = &fg.lines[i];
|
||||
self.stats.glyphs += lg.glyphs;
|
||||
for (si, bp) in &lg.runs {
|
||||
if let Some(c) = fill_of(*si) {
|
||||
let st = &cs.styles[*si as usize];
|
||||
let op = crate::overprints(f, &st.fill, st.fill_tint, false);
|
||||
if op {
|
||||
ctx.push_layer(
|
||||
None,
|
||||
Some(vello_cpu::peniko::BlendMode::new(vello_cpu::peniko::Mix::Multiply, vello_cpu::peniko::Compose::SrcOver)),
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
);
|
||||
}
|
||||
ctx.set_paint(c);
|
||||
ctx.fill_path(bp);
|
||||
if op {
|
||||
ctx.pop_layer();
|
||||
}
|
||||
}
|
||||
let st = &cs.styles[*si as usize];
|
||||
if st.stroke != designcraft_color::swatch::NONE
|
||||
&& let Some(c) = doc.resolve_color(&st.stroke, st.stroke_tint)
|
||||
{
|
||||
ctx.set_paint(color_of(&c, 1.0));
|
||||
ctx.set_stroke(kurbo::Stroke::new(st.stroke_weight));
|
||||
ctx.stroke_path(bp);
|
||||
}
|
||||
let fill = fill_of(*si).map(|c| (c, crate::overprints(f, &st.fill, st.fill_tint, false)));
|
||||
paint_glyphs(ctx, bp, fill, stroke_of(doc, st), st.stroke_weight, st.stroke_behind_fill);
|
||||
}
|
||||
}
|
||||
for &i in &shown {
|
||||
@@ -472,6 +449,62 @@ impl Renderer {
|
||||
}
|
||||
|
||||
/// Kurbo stroke for a table edge.
|
||||
/// A run's stroke colour, if it has one.
|
||||
fn stroke_of(doc: &designcraft_doc::Document, st: &designcraft_compose::RunStyle) -> Option<peniko::Color> {
|
||||
if st.stroke == designcraft_color::swatch::NONE {
|
||||
return None;
|
||||
}
|
||||
doc.resolve_color(&st.stroke, st.stroke_tint).map(|c| color_of(&c, 1.0))
|
||||
}
|
||||
|
||||
/// Paint glyph outlines: the fill (multiplied when it overprints), then the stroke over it. With
|
||||
/// stroke behind fill a double-width stroke goes first and the fill covers its inner half; with
|
||||
/// no fill to cover it, the glyph shapes are cut out of the stroke instead.
|
||||
fn paint_glyphs(
|
||||
ctx: &mut RenderContext,
|
||||
bp: &BezPath,
|
||||
fill: Option<(peniko::Color, bool)>,
|
||||
stroke: Option<peniko::Color>,
|
||||
weight: f64,
|
||||
behind: bool,
|
||||
) {
|
||||
let paint_fill = |ctx: &mut RenderContext| {
|
||||
let Some((c, overprint)) = fill else { return };
|
||||
if overprint {
|
||||
ctx.push_layer(None, Some(peniko::BlendMode::new(peniko::Mix::Multiply, peniko::Compose::SrcOver)), None, None, None);
|
||||
}
|
||||
ctx.set_paint(c);
|
||||
ctx.fill_path(bp);
|
||||
if overprint {
|
||||
ctx.pop_layer();
|
||||
}
|
||||
};
|
||||
let Some(sc) = stroke else {
|
||||
paint_fill(ctx);
|
||||
return;
|
||||
};
|
||||
if !behind {
|
||||
paint_fill(ctx);
|
||||
ctx.set_paint(sc);
|
||||
ctx.set_stroke(kurbo::Stroke::new(weight));
|
||||
ctx.stroke_path(bp);
|
||||
return;
|
||||
}
|
||||
ctx.set_paint(sc);
|
||||
ctx.set_stroke(kurbo::Stroke::new(weight * 2.0));
|
||||
if fill.is_some() {
|
||||
ctx.stroke_path(bp);
|
||||
paint_fill(ctx);
|
||||
} else {
|
||||
ctx.push_layer(None, None, None, None, None);
|
||||
ctx.stroke_path(bp);
|
||||
ctx.push_layer(None, Some(peniko::BlendMode::new(peniko::Mix::Normal, peniko::Compose::DestOut)), None, None, None);
|
||||
ctx.fill_path(bp);
|
||||
ctx.pop_layer();
|
||||
ctx.pop_layer();
|
||||
}
|
||||
}
|
||||
|
||||
fn cell_stroke(s: &designcraft_doc::CellStroke) -> kurbo::Stroke {
|
||||
let st = kurbo::Stroke::new(s.weight).with_caps(kurbo::Cap::Square);
|
||||
match &s.kind {
|
||||
@@ -500,17 +533,8 @@ impl Renderer {
|
||||
ctx.set_transform(f.view * xf);
|
||||
for (si, bp) in designcraft_compose::path_glyphs(cs, ft, path, pt) {
|
||||
let st = &cs.styles[si as usize];
|
||||
if let Some(c) = doc.resolve_color(&st.fill, st.fill_tint) {
|
||||
ctx.set_paint(color_of(&c, 1.0));
|
||||
ctx.fill_path(&bp);
|
||||
}
|
||||
if st.stroke != designcraft_color::swatch::NONE
|
||||
&& let Some(c) = doc.resolve_color(&st.stroke, st.stroke_tint)
|
||||
{
|
||||
ctx.set_paint(color_of(&c, 1.0));
|
||||
ctx.set_stroke(kurbo::Stroke::new(st.stroke_weight));
|
||||
ctx.stroke_path(&bp);
|
||||
}
|
||||
let fill = doc.resolve_color(&st.fill, st.fill_tint).map(|c| (color_of(&c, 1.0), false));
|
||||
paint_glyphs(ctx, &bp, fill, stroke_of(doc, st), st.stroke_weight, st.stroke_behind_fill);
|
||||
self.stats.glyphs += 1;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
//! Stroke behind fill on text: the fill covers the inner half of a double-width stroke.
|
||||
use designcraft_color::{Color, swatch::Swatch};
|
||||
use designcraft_compose::Cache;
|
||||
use designcraft_doc::build::NewDocument;
|
||||
use designcraft_doc::{Document, ParaFormat, SpreadRef};
|
||||
use designcraft_geom::Rect;
|
||||
use designcraft_render::{RenderOptions, Rendered, Renderer};
|
||||
|
||||
const WHITE: [u8; 4] = [255, 255, 255, 255];
|
||||
const RED: [u8; 4] = [255, 0, 0, 255];
|
||||
const BLUE: [u8; 4] = [0, 0, 255, 255];
|
||||
const WEIGHT: f64 = 12.0;
|
||||
|
||||
/// A 200 pt "I" filled with `fill`, stroked red at [`WEIGHT`] (none when `stroke` is false).
|
||||
fn render(fill: &str, stroke: bool, behind: bool) -> Rendered {
|
||||
let mut d = Document::new(&NewDocument { width: 400.0, height: 400.0, facing_pages: false, ..Default::default() });
|
||||
d.swatches.push(Swatch::color("Red", Color::rgb8(255, 0, 0)));
|
||||
d.swatches.push(Swatch::color("Blue", Color::rgb8(0, 0, 255)));
|
||||
let lid = d.default_layer();
|
||||
let (_, sid) = d.add_text_frame(SpreadRef::Doc(0), Rect::new(40.0, 0.0, 400.0, 400.0), lid, "I", ParaFormat::default()).unwrap();
|
||||
d.story_mut(sid).unwrap().format_chars(0..1, |f| {
|
||||
f.over.size = Some(200.0);
|
||||
f.over.fill = Some(fill.into());
|
||||
f.over.stroke = Some(if stroke { "Red" } else { designcraft_color::swatch::NONE }.into());
|
||||
f.over.stroke_weight = Some(WEIGHT);
|
||||
f.over.stroke_behind_fill = Some(behind);
|
||||
});
|
||||
let mut r = Renderer::new();
|
||||
r.threads = 0;
|
||||
let cache = Cache::new();
|
||||
let cs = cache.get(&d, sid, None);
|
||||
assert!(!cs.frames[0].lines.is_empty(), "the glyph is set");
|
||||
r.render_page(&d, &cache, 0, 1.0, false, &RenderOptions { paper: false, background: Some(WHITE), ..Default::default() }).unwrap()
|
||||
}
|
||||
|
||||
/// The left edge of the stem on a row through the middle of the unstroked glyph.
|
||||
fn stem_edge() -> (u32, u32) {
|
||||
let img = render("Blue", false, false);
|
||||
let blue: Vec<(u32, u32)> = (0..img.height).flat_map(|y| (0..img.width).map(move |x| (x, y))).filter(|&(x, y)| img.pixel(x, y) == BLUE).collect();
|
||||
assert!(!blue.is_empty(), "the glyph is drawn");
|
||||
let y0 = blue.iter().map(|p| p.1).min().unwrap();
|
||||
let y1 = blue.iter().map(|p| p.1).max().unwrap();
|
||||
let y = (y0 + y1) / 2;
|
||||
let x = (0..img.width).find(|&x| img.pixel(x, y) == BLUE).unwrap();
|
||||
(x, y)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stroke_behind_fill_shows_only_outside_the_letter() {
|
||||
let (x, y) = stem_edge();
|
||||
let inner = x + 3; // inside the outline, within the inner half of a centred stroke
|
||||
let outer = x - 9; // outside the outline, beyond the outer half of a centred stroke
|
||||
let over = render("Blue", true, false);
|
||||
assert_eq!(over.pixel(inner, y), RED, "a centred stroke covers the fill's edge");
|
||||
assert_eq!(over.pixel(outer, y), WHITE, "a centred stroke reaches half its weight outside");
|
||||
let behind = render("Blue", true, true);
|
||||
assert_eq!(behind.pixel(inner, y), BLUE, "the fill covers the stroke behind it");
|
||||
assert_eq!(behind.pixel(outer, y), RED, "the full stroke weight lies outside the letter");
|
||||
assert_eq!(behind.pixel(x - 15, y), WHITE, "and no further");
|
||||
let hollow = render(designcraft_color::swatch::NONE, true, true);
|
||||
assert_eq!(hollow.pixel(inner, y), WHITE, "with no fill the letter stays empty");
|
||||
assert_eq!(hollow.pixel(outer, y), RED);
|
||||
}
|
||||
@@ -3271,6 +3271,9 @@ fn character_color(lang: &str, ui: &mut egui::Ui, d: &mut Dialog, cf: &CharField
|
||||
crate::rtl::label(ui, crate::i18n::tr(lang, "Weight:"));
|
||||
char_number(ui, d, cf, "strokeWeight", " pt", 1.0, (0.0, 800.0));
|
||||
ui.end_row();
|
||||
ui.label("");
|
||||
char_check(ui, d, cf, "strokeBehindFill", crate::i18n::tr(lang, "Stroke Behind Fill"));
|
||||
ui.end_row();
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -1030,6 +1030,10 @@ fn text_appearance_section(app: &mut DesignApp, ui: &mut Ui) {
|
||||
}
|
||||
ui.add_space(4.0);
|
||||
}
|
||||
let mut behind = c["strokeBehindFill"].as_bool().unwrap_or(false);
|
||||
if ui.checkbox(&mut behind, crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, "Stroke Behind Fill"))).changed() {
|
||||
let _ = app.run("type.char", json!({"attrs": {"strokeBehindFill": behind}}));
|
||||
}
|
||||
}
|
||||
|
||||
/// Character section: font family (with search), style, size/leading, kerning/tracking, •••.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Typography and composition parity
|
||||
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-10 · **Change:** major (first version, from the full re-measure) · **Target:** Adobe InDesign 2026 (21.6)
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (character stroke behind fill) · **Target:** Adobe InDesign 2026 (21.6)
|
||||
|
||||
Typesetting is what InDesign is bought for. A feature can exist and still set text differently
|
||||
from InDesign: different line breaks, different page count, overset where InDesign fits. This
|
||||
@@ -40,6 +40,7 @@ Fidelity: **unmeasured** unless stated.
|
||||
| Spelling | per-language dictionaries | English (Moby) + document/user dictionary | ~ | |
|
||||
| Kerning | Metrics, Optical, manual, none | Metrics, manual, none | ~ | **Optical treated as Metrics** (#181): imported display text sets wider and can overset |
|
||||
| Tracking, scale, skew, baseline shift | ✓ | ✓ | ✓ | |
|
||||
| Character fill and stroke | fill, stroke, tint, weight, stroke aligned to centre or outside | fill, stroke, tints, weight, stroke behind fill (screen, PNG, PDF, IDML `StrokeAlignment`) | ~ | text strokes have no join or miter limit settings (#619 for stroke behind fill) |
|
||||
| OpenType features | ligatures, discretionary, swash, fractions, ordinals, figures, small caps, stylistic sets, positional forms | through harfrust | ✓ | |
|
||||
| Variable fonts | axes in Character panel | named instances and free axis values; PDF embeds the instance | ✓ | |
|
||||
| Leading | auto (120%), absolute, leading model | ✓ | ✓ | CJK leading models in cjk-typography.md |
|
||||
|
||||
Reference in New Issue
Block a user