mirror of
https://github.com/storytold/designcraft.git
synced 2026-10-29 20:56:47 -06:00
FID2: preserve decoration color resets, geometry and paint order (#230)
This commit is contained in:
@@ -14,6 +14,7 @@ DesignCraft aims at full Adobe InDesign parity — and to be better: faster, ope
|
||||
**Working today**
|
||||
- Document model: facing/non-facing spreads, parent pages with page-number markers, sections, layers, frames (text / graphic / unassigned), threaded stories, paragraph/character/object styles with based-on, swatches (process/spot/tints/gradients), corner options, text wrap, drop shadow, opacity/blend.
|
||||
- Text engine: Knuth–Plass paragraph composer and single-line composer, dictionary hyphenation (public-domain Moby list + our own trained Liang patterns), justification with word/letter/glyph-scaling ranges, keep options (keep with next, keep lines together, widows/orphans), balance ragged lines, optical margin alignment, hyphenation zone/limit, columns, threading across frames and spreads, tabs, bullets/numbering, rules, shading, baseline grid, first-baseline options, vertical justification, wrap exclusions, overset detection, caret/hit testing.
|
||||
- FID2: IDML underline/strikethrough Text Color resets survive style inheritance and roundtrip. Raster and PDF paint underlines behind glyphs, retain strikethrough above, and center rules on their offsets; synthetic overlap tests cover both backends.
|
||||
- Rendering: vello_cpu (SIMD, multithreaded) — pages, items, images with mip levels, gradients, composed text.
|
||||
- UI (egui): application bar + menus, Control panel (object and text modes), Tools panel with flyouts, document tabs, rulers, pasteboard, guides (margins, columns, bleed, baseline grid), frame edges, selection handles, text ports and threads, Properties / Pages / Layers dock, Swatches, Styles, Character, Paragraph, Stroke, Text Wrap panels, New Document and Preferences dialogs, Quick Apply (⌘Return), 4 brightness themes.
|
||||
- Pen tool, Direct Selection anchor/handle editing, rotate from corners, Rotate/Scale/Shear tools, Eyedropper, snapping & smart guides, Align/Distribute, Find/Change (text + GREP), Story Editor, Paragraph Style Options, Effects panel, hidden characters, native macOS menu bar, measured InDesign 2026 look (Medium Dark): Properties sections per selection state, 21 pt spinner fields with arithmetic, Contextual Task Bar, inverse (black) text selection, Pages panel with drag-and-drop reorder / parent apply, Layers panel with per-object rows.
|
||||
|
||||
@@ -71,7 +71,7 @@ pub struct RunStyle {
|
||||
pub kenten_character: String,
|
||||
}
|
||||
|
||||
/// An underline or strikethrough bar: its top edge `offset` below the baseline (negative =
|
||||
/// An underline or strikethrough bar: its centre `offset` below the baseline (negative =
|
||||
/// above), thickness and colour.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct Rule {
|
||||
@@ -81,13 +81,21 @@ pub struct Rule {
|
||||
pub tint: f32,
|
||||
}
|
||||
|
||||
impl Rule {
|
||||
/// The stroke is centred on the offset, matching Underline / Strikethrough Options.
|
||||
pub fn rect(&self, x0: f64, x1: f64, baseline: f64) -> Rect {
|
||||
let center = baseline + self.offset;
|
||||
Rect::new(x0, center - self.weight / 2.0, x1, center + self.weight / 2.0)
|
||||
}
|
||||
}
|
||||
|
||||
impl RunStyle {
|
||||
/// The bars this run draws between `x0` and `x1` on a line at `baseline`.
|
||||
pub fn rules(&self, x0: f64, x1: f64, baseline: f64) -> impl Iterator<Item = (&Rule, Rect)> {
|
||||
[(self.underline, &self.underline_rule), (self.strikethrough, &self.strike_rule)]
|
||||
.into_iter()
|
||||
.filter(|(on, _)| *on)
|
||||
.map(move |(_, r)| (r, Rect::new(x0, baseline + r.offset, x1, baseline + r.offset + r.weight)))
|
||||
.map(move |(_, r)| (r, r.rect(x0, x1, baseline)))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,165 @@
|
||||
//! The same synthetic overlapping runs must paint correctly on screen and in exported PDF.
|
||||
use designcraft_color::{Color, Swatch};
|
||||
use designcraft_compose::{Cache, compose_story};
|
||||
use designcraft_doc::{CharAttrs, Document, ParaFormat, SpreadRef, build::NewDocument};
|
||||
use designcraft_geom::{Affine, Rect};
|
||||
use designcraft_pdf::{PdfOptions, export_pdf};
|
||||
use designcraft_render::{Placed, RenderOptions, Rendered, Renderer};
|
||||
|
||||
fn document(decoration: u8, hide_first: bool, hide_second: bool) -> Document {
|
||||
let mut d = Document::new(&NewDocument { width: 240.0, height: 240.0, facing_pages: false, ..Default::default() });
|
||||
d.swatches.push(Swatch::color("Cyan rule", Color::rgb(0.0, 1.0, 1.0)));
|
||||
let (_, sid) = d
|
||||
.add_text_frame(
|
||||
SpreadRef::Doc(0),
|
||||
Rect::new(0.0, 0.0, 240.0, 180.0),
|
||||
d.default_layer(),
|
||||
"HI",
|
||||
ParaFormat {
|
||||
chars: CharAttrs { font_family: Some("Source Sans 3".into()), size: Some(72.0), fill: Some("[Black]".into()), ..Default::default() },
|
||||
..Default::default()
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
d.story_mut(sid).unwrap().format_chars(0..1, |f| {
|
||||
f.over.skew = Some(45.0);
|
||||
if hide_first {
|
||||
f.over.fill = Some("[None]".into());
|
||||
}
|
||||
});
|
||||
d.story_mut(sid).unwrap().format_chars(1..2, |f| {
|
||||
if hide_second {
|
||||
f.over.fill = Some("[None]".into());
|
||||
}
|
||||
f.over.underline = Some(decoration == 1 || decoration == 3);
|
||||
f.over.strikethrough = Some(decoration == 2);
|
||||
f.over.underline_color = Some(if decoration == 3 { "[None]" } else { "Cyan rule" }.into());
|
||||
f.over.strikethrough_color = Some("Cyan rule".into());
|
||||
f.over.underline_weight = Some(Some(18.0));
|
||||
f.over.strikethrough_weight = Some(Some(18.0));
|
||||
f.over.underline_offset = Some(Some(-24.0));
|
||||
// Strike offsets are positive above the baseline in the document model.
|
||||
f.over.strikethrough_offset = Some(Some(24.0));
|
||||
});
|
||||
d
|
||||
}
|
||||
|
||||
fn raster(d: &Document, threads: u16) -> Rendered {
|
||||
let cache = Cache::new();
|
||||
let mut renderer = Renderer::new();
|
||||
renderer.threads = threads;
|
||||
let placed = [Placed { spread: SpreadRef::Doc(0), xf: Affine::IDENTITY }];
|
||||
let options = RenderOptions { paper: false, background: Some([255; 4]), greek_below_px: 0.0, ..Default::default() };
|
||||
let first = renderer.render(d, &cache, &placed, 480, 480, Affine::scale(2.0), &options);
|
||||
let cached = renderer.render(d, &cache, &placed, 480, 480, Affine::scale(2.0), &options);
|
||||
assert_eq!(first.pixels, cached.pixels, "decoration cache hits must retain paint order");
|
||||
first
|
||||
}
|
||||
|
||||
fn pdf(d: &Document) -> Rendered {
|
||||
let bytes = export_pdf(d, &Cache::new(), &PdfOptions::default()).unwrap();
|
||||
let image = designcraft_render::render_pdf_page(&bytes, 0, 480).unwrap();
|
||||
assert_eq!((image.width(), image.height()), (480, 480));
|
||||
// hayro renders on transparent paper; composite onto the same white backdrop as the canvas.
|
||||
let pixels = image
|
||||
.data()
|
||||
.iter()
|
||||
.flat_map(|p| {
|
||||
let white = 255 - p.a;
|
||||
[p.r.saturating_add(white), p.g.saturating_add(white), p.b.saturating_add(white), 255]
|
||||
})
|
||||
.collect();
|
||||
Rendered { width: 480, height: 480, pixels }
|
||||
}
|
||||
|
||||
fn dark(p: [u8; 4]) -> bool {
|
||||
// Process black is color managed (about 52/51/52 with the bundled profile).
|
||||
p[0] < 80 && p[1] < 80 && p[2] < 80
|
||||
}
|
||||
fn white(p: [u8; 4]) -> bool {
|
||||
p[0] > 245 && p[1] > 245 && p[2] > 245
|
||||
}
|
||||
fn cyan(p: [u8; 4]) -> bool {
|
||||
p[0] < 35 && p[1] > 220 && p[2] > 220
|
||||
}
|
||||
|
||||
fn check_paint_order(render: impl Fn(&Document) -> Rendered) {
|
||||
let plain_doc = document(0, false, false);
|
||||
let plain = render(&plain_doc);
|
||||
let first_only = render(&document(0, false, true));
|
||||
let second_only = render(&document(0, true, false));
|
||||
let under = render(&document(1, false, false));
|
||||
let strike = render(&document(2, false, false));
|
||||
assert_eq!(render(&document(3, false, false)).pixels, plain.pixels, "explicit None must draw no rule");
|
||||
let sid = *plain_doc.stories.keys().next().unwrap();
|
||||
let cs = compose_story(&plain_doc, sid, &Default::default());
|
||||
let line = &cs.frames[0].lines[0];
|
||||
let g = line.glyphs.iter().find(|g| g.byte == 1).unwrap();
|
||||
let rect = Rect::new(g.x, line.baseline - 24.0 - 9.0, g.x + g.adv, line.baseline - 24.0 + 9.0);
|
||||
let mut cross_run = 0;
|
||||
let mut exposed = 0;
|
||||
for y in (rect.y0 * 2.0).ceil() as u32 + 2..(rect.y1 * 2.0).floor() as u32 - 2 {
|
||||
for x in (rect.x0 * 2.0).ceil() as u32 + 2..(rect.x1 * 2.0).floor() as u32 - 2 {
|
||||
// The first run's skewed H overlaps the SECOND run's rule. A per-run under/glyph
|
||||
// loop would still cover these pixels, so the witness is stronger than one run.
|
||||
if dark(first_only.pixel(x, y)) && white(second_only.pixel(x, y)) {
|
||||
cross_run += 1;
|
||||
assert!(dark(under.pixel(x, y)), "underline covered an earlier run at {x},{y}: {:?}", under.pixel(x, y));
|
||||
assert!(cyan(strike.pixel(x, y)), "strike must remain above glyphs at {x},{y}");
|
||||
}
|
||||
if white(plain.pixel(x, y)) {
|
||||
exposed += 1;
|
||||
assert!(cyan(under.pixel(x, y)), "the visible underline must retain its fill");
|
||||
}
|
||||
}
|
||||
}
|
||||
assert!(cross_run > 20, "fixture needs cross-run interior-ink witnesses, got {cross_run}");
|
||||
assert!(exposed > 20, "fixture needs visible rule pixels, got {exposed}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn raster_underlines_are_behind_all_runs_and_strikes_remain_above() {
|
||||
for threads in [0, 2] {
|
||||
check_paint_order(|d| raster(d, threads));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pdf_underlines_are_behind_all_runs_and_strikes_remain_above() {
|
||||
check_paint_order(pdf);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn decoration_offsets_are_centers_for_explicit_and_automatic_rules() {
|
||||
let mut d = document(1, false, false);
|
||||
let sid = *d.stories.keys().next().unwrap();
|
||||
for (automatic, size) in [(false, 72.0), (true, 28.0)] {
|
||||
d.story_mut(sid).unwrap().format_chars(0..2, |f| {
|
||||
f.over.size = Some(size);
|
||||
f.over.underline = Some(true);
|
||||
f.over.strikethrough = Some(true);
|
||||
f.over.underline_color = Some(String::new());
|
||||
f.over.strikethrough_color = Some(String::new());
|
||||
f.over.fill_tint = Some(0.4);
|
||||
f.over.underline_tint = Some(0.9);
|
||||
f.over.strikethrough_tint = Some(0.8);
|
||||
f.over.underline_weight = Some(if automatic { None } else { Some(12.0) });
|
||||
f.over.strikethrough_weight = Some(if automatic { None } else { Some(6.0) });
|
||||
f.over.underline_offset = Some(if automatic { None } else { Some(-3.0) });
|
||||
f.over.strikethrough_offset = Some(if automatic { None } else { Some(8.0) });
|
||||
});
|
||||
let cs = compose_story(&d, sid, &Default::default());
|
||||
for style in &cs.styles {
|
||||
let baseline = 84.222687;
|
||||
let rules: Vec<_> = style.rules(10.0, 50.0, baseline).collect();
|
||||
assert_eq!(rules.len(), 2);
|
||||
let expected = if automatic { [(size * 0.12, size / 14.0), (-size * 0.3, size / 14.0)] } else { [(-3.0, 12.0), (-8.0, 6.0)] };
|
||||
for ((rule, rect), (offset, weight)) in rules.iter().zip(expected) {
|
||||
assert!((rect.center().y - (baseline + offset)).abs() < 1e-9, "offset must locate the center: {rect:?}");
|
||||
assert!((rect.height() - weight).abs() < 1e-9);
|
||||
assert_eq!(rule.color, style.fill);
|
||||
assert_eq!(rule.tint, 0.4);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1098,8 +1098,8 @@ impl<'a> Ex<'a> {
|
||||
if let Some(o) = o {
|
||||
el.set(&format!("{k}Offset"), num(o.unwrap_or(-9999.0)));
|
||||
}
|
||||
if let Some(c) = c.as_ref().filter(|c| !c.is_empty()) {
|
||||
el.set(&format!("{k}Color"), self.sw(c));
|
||||
if let Some(c) = c {
|
||||
el.set(&format!("{k}Color"), if c.is_empty() { "Text Color".into() } else { self.sw(c) });
|
||||
}
|
||||
if let Some(t) = t {
|
||||
el.set(&format!("{k}Tint"), num(t as f64 * 100.0));
|
||||
|
||||
@@ -1095,7 +1095,9 @@ impl<'r> Importer<'r> {
|
||||
// Underline / strikethrough options (-9999 = automatic).
|
||||
for k in ["Underline", "StrikeThru"] {
|
||||
let num = |n: &str| e.num(&format!("{k}{n}")).filter(|v| *v > -9000.0);
|
||||
let color = e.prop(&format!("{k}Color")).map(|r| self.swatch_ref(r.trim())).filter(|r| r != "Text Color" && !r.is_empty());
|
||||
// An explicit Text Color resets an inherited swatch; absence still inherits.
|
||||
// Resolve the sentinel before swatch_ref, which maps unknown names to [None].
|
||||
let color = e.prop(&format!("{k}Color")).map(|r| if r.trim() == "Text Color" { String::new() } else { self.swatch_ref(r.trim()) });
|
||||
let tint = e.num(&format!("{k}Tint")).filter(|v| *v >= 0.0).map(|v| (v / 100.0) as f32);
|
||||
if k == "Underline" {
|
||||
a.underline_weight = num("Weight").map(Some);
|
||||
|
||||
@@ -7,6 +7,8 @@ use designcraft_geom::{Rect, shapes};
|
||||
|
||||
use super::*;
|
||||
|
||||
mod decorations;
|
||||
|
||||
fn zip_files(files: &[(&str, &str)]) -> Vec<u8> {
|
||||
use zip::write::SimpleFileOptions;
|
||||
let mut w = zip::ZipWriter::new(std::io::Cursor::new(Vec::new()));
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
use super::*;
|
||||
|
||||
fn fixture(local: &str) -> Document {
|
||||
let styles = r#"<idPkg:Styles xmlns:idPkg="http://ns.adobe.com/AdobeInDesign/idml/1.0/packaging">
|
||||
<RootParagraphStyleGroup>
|
||||
<ParagraphStyle Self="ParagraphStyle/PBase" Name="PBase" Underline="true" StrikeThru="true" UnderlineColor="Color/Black" StrikeThruColor="Color/Black"/>
|
||||
<ParagraphStyle Self="ParagraphStyle/PReset" Name="PReset" UnderlineColor=" Text Color " StrikeThruColor="Text Color"><Properties><BasedOn type="object">ParagraphStyle/PBase</BasedOn></Properties></ParagraphStyle>
|
||||
<ParagraphStyle Self="ParagraphStyle/PLeaf" Name="PLeaf"><Properties><BasedOn type="object">ParagraphStyle/PReset</BasedOn></Properties></ParagraphStyle>
|
||||
</RootParagraphStyleGroup>
|
||||
<RootCharacterStyleGroup>
|
||||
<CharacterStyle Self="CharacterStyle/CBase" Name="CBase" UnderlineColor="Color/Black" StrikeThruColor="Color/Black"/>
|
||||
<CharacterStyle Self="CharacterStyle/CReset" Name="CReset"><Properties><BasedOn type="object">CharacterStyle/CBase</BasedOn><UnderlineColor type="string">Text Color</UnderlineColor><StrikeThruColor type="string">Text Color</StrikeThruColor></Properties></CharacterStyle>
|
||||
<CharacterStyle Self="CharacterStyle/CLeaf" Name="CLeaf"><Properties><BasedOn type="object">CharacterStyle/CReset</BasedOn></Properties></CharacterStyle>
|
||||
<CharacterStyle Self="CharacterStyle/CInherit" Name="CInherit"><Properties><BasedOn type="object">CharacterStyle/CBase</BasedOn></Properties></CharacterStyle>
|
||||
</RootCharacterStyleGroup>
|
||||
</idPkg:Styles>"#;
|
||||
let story = format!(
|
||||
r#"<idPkg:Story xmlns:idPkg="http://ns.adobe.com/AdobeInDesign/idml/1.0/packaging"><Story Self="s"><ParagraphStyleRange AppliedParagraphStyle="ParagraphStyle/PBase"><CharacterStyleRange AppliedCharacterStyle="CharacterStyle/CBase" {local}><Content>Ink</Content></CharacterStyleRange></ParagraphStyleRange></Story></idPkg:Story>"#
|
||||
);
|
||||
let map = r#"<Document xmlns:idPkg="http://ns.adobe.com/AdobeInDesign/idml/1.0/packaging"><idPkg:Styles src="Resources/Styles.xml"/><idPkg:Story src="Stories/s.xml"/><Layer Self="L" Name="Art"/><idPkg:Spread src="Spreads/s.xml"/></Document>"#;
|
||||
let spread = r#"<idPkg:Spread xmlns:idPkg="http://ns.adobe.com/AdobeInDesign/idml/1.0/packaging"><Spread Self="sp"><Page Self="p" GeometricBounds="0 0 240 240"/><TextFrame Self="frame" ParentStory="s" ItemLayer="L" GeometricBounds="0 0 200 200"><Properties><PathGeometry><GeometryPathType><PathPointArray><PathPointType Anchor="0 0"/><PathPointType Anchor="200 0"/><PathPointType Anchor="200 200"/><PathPointType Anchor="0 200"/></PathPointArray></GeometryPathType></PathGeometry></Properties></TextFrame></Spread></idPkg:Spread>"#;
|
||||
import_idml(&zip_files(&[("designmap.xml", map), ("Resources/Styles.xml", styles), ("Stories/s.xml", &story), ("Spreads/s.xml", spread)]))
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
fn assert_style_resets(d: &Document) {
|
||||
let p = d.styles.para("PReset").unwrap();
|
||||
let c = d.styles.char_style("CReset").unwrap();
|
||||
for attrs in [&p.chars, &c.chars] {
|
||||
assert_eq!(attrs.underline_color.as_deref(), Some(""), "an explicit reset must remain a sparse value");
|
||||
assert_eq!(attrs.strikethrough_color.as_deref(), Some(""));
|
||||
}
|
||||
let (_, base) = d.styles.resolve_para_style("PBase");
|
||||
let (_, reset) = d.styles.resolve_para_style("PLeaf");
|
||||
assert_eq!(base.underline_color, "[Black]");
|
||||
assert_eq!(base.strikethrough_color, "[Black]");
|
||||
assert!(reset.underline_color.is_empty() && reset.strikethrough_color.is_empty());
|
||||
let reset = d.styles.resolve_char(&base, &designcraft_doc::CharFormat { style: "CLeaf".into(), ..Default::default() });
|
||||
assert!(reset.underline_color.is_empty() && reset.strikethrough_color.is_empty());
|
||||
let inherited = d.styles.resolve_char(&base, &designcraft_doc::CharFormat { style: "CInherit".into(), ..Default::default() });
|
||||
assert_eq!(inherited.underline_color, "[Black]");
|
||||
assert_eq!(inherited.strikethrough_color, "[Black]");
|
||||
assert!(d.styles.char_style("CInherit").unwrap().chars.underline_color.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn text_color_resets_paragraph_and_character_style_chains_through_roundtrip() {
|
||||
let d = fixture("");
|
||||
assert_style_resets(&d);
|
||||
assert_style_resets(&import_idml(&export_idml(&d)).unwrap());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn local_text_color_and_explicit_none_remain_distinct_through_roundtrip() {
|
||||
for (local, expected) in [
|
||||
(r#"UnderlineColor="Text Color" StrikeThruColor="Text Color""#, ""),
|
||||
(r#"UnderlineColor="n" StrikeThruColor="n""#, "[None]"),
|
||||
(r#"UnderlineColor="" StrikeThruColor="""#, "[None]"),
|
||||
("", "[Black]"),
|
||||
] {
|
||||
let imported = fixture(local);
|
||||
let reopened = import_idml(&export_idml(&imported)).unwrap();
|
||||
for d in [&imported, &reopened] {
|
||||
let story = d.stories.values().next().unwrap();
|
||||
let (_, para) = d.styles.resolve_para(&story.paras[0]);
|
||||
let actual = d.styles.resolve_char(¶, story.char_format_at(0));
|
||||
assert_eq!(actual.underline_color, expected);
|
||||
assert_eq!(actual.strikethrough_color, expected);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn exporting_native_text_color_reset_writes_the_sentinel() {
|
||||
let mut d = fixture("");
|
||||
let attrs = &mut d.styles_mut().para_mut("PReset").unwrap().chars;
|
||||
attrs.underline_color = Some(String::new());
|
||||
attrs.strikethrough_color = Some(String::new());
|
||||
let attrs = &mut d.styles_mut().char_style_mut("CReset").unwrap().chars;
|
||||
attrs.underline_color = Some(String::new());
|
||||
attrs.strikethrough_color = Some(String::new());
|
||||
assert_style_resets(&import_idml(&export_idml(&d)).unwrap());
|
||||
}
|
||||
+34
-14
@@ -39,6 +39,28 @@ fn same_run(a: &PlacedGlyph, b: &PlacedGlyph) -> bool {
|
||||
&& (a.y - b.y).abs() < 1e-6
|
||||
}
|
||||
|
||||
/// Merge bars of one paint layer; underlines and strikes must never be merged together.
|
||||
fn text_rules(cs: &ComposedStory, ft: &FrameText, underline: bool) -> Vec<(designcraft_compose::Rule, Rect)> {
|
||||
let mut bars: Vec<(designcraft_compose::Rule, Rect)> = Vec::new();
|
||||
for line in &ft.lines {
|
||||
for g in line.glyphs.iter().filter(|g| g.visible) {
|
||||
let style = &cs.styles[g.style as usize];
|
||||
let (on, rule) = if underline { (style.underline, &style.underline_rule) } else { (style.strikethrough, &style.strike_rule) };
|
||||
if !on {
|
||||
continue;
|
||||
}
|
||||
let rect = rule.rect(g.x, g.x + g.adv, line.baseline);
|
||||
match bars.last_mut() {
|
||||
Some((last_rule, last)) if last_rule == rule && (last.y0 - rect.y0).abs() < 1e-6 && (rect.x0 - last.x1).abs() < 0.5 => {
|
||||
last.x1 = rect.x1;
|
||||
}
|
||||
_ => bars.push((rule.clone(), rect)),
|
||||
}
|
||||
}
|
||||
}
|
||||
bars
|
||||
}
|
||||
|
||||
impl Exporter<'_> {
|
||||
pub(crate) fn font(&mut self, face: &FontFace) -> Option<Font> {
|
||||
self.fonts
|
||||
@@ -108,7 +130,17 @@ impl Exporter<'_> {
|
||||
me.tables(s, ft);
|
||||
}
|
||||
});
|
||||
let mut deco: Vec<(designcraft_compose::Rule, Rect)> = Vec::new();
|
||||
// All underlines precede all glyph runs, including ink overlapping from another run.
|
||||
let underlines = text_rules(cs, ft, true);
|
||||
if !underlines.is_empty() {
|
||||
self.as_artifact(s, |me, s| {
|
||||
for (rule, r) in underlines {
|
||||
if let Some(c) = me.swatch_color(&rule.color, rule.tint) {
|
||||
me.fill_rect(s, r, c);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
for l in &ft.lines {
|
||||
let gs = &l.glyphs;
|
||||
let mut i = 0;
|
||||
@@ -135,21 +167,9 @@ impl Exporter<'_> {
|
||||
self.tagged_run(s, cs, &gs[i..j], l.baseline, story);
|
||||
i = j;
|
||||
}
|
||||
// Underline / strikethrough, merged per style along the line.
|
||||
for g in gs.iter().filter(|g| g.visible) {
|
||||
let st = &cs.styles[g.style as usize];
|
||||
for (rule, r) in st.rules(g.x, g.x + g.adv, l.baseline) {
|
||||
match deco.last_mut() {
|
||||
Some((last_rule, last)) if *last_rule == *rule && (last.y0 - r.y0).abs() < 1e-6 && (r.x0 - last.x1).abs() < 0.5 => {
|
||||
last.x1 = r.x1
|
||||
}
|
||||
_ => deco.push((rule.clone(), r)),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
self.as_artifact(s, |me, s| {
|
||||
for (rule, r) in deco {
|
||||
for (rule, r) in text_rules(cs, ft, false) {
|
||||
if let Some(c) = me.swatch_color(&rule.color, rule.tint) {
|
||||
me.fill_rect(s, r, c);
|
||||
}
|
||||
|
||||
+26
-13
@@ -25,8 +25,9 @@ pub(crate) struct LineGlyphs {
|
||||
pub bounds: Option<Rect>,
|
||||
/// Glyph outlines batched per run style.
|
||||
pub runs: Vec<(u32, BezPath)>,
|
||||
/// Underline / strikethrough bars: colour swatch, tint, rectangle.
|
||||
pub decos: Vec<(String, f32, Rect)>,
|
||||
/// Bars below/above the glyph paint pass: colour swatch, tint, rectangle.
|
||||
pub underlines: Vec<(String, f32, Rect)>,
|
||||
pub strikes: Vec<(String, f32, Rect)>,
|
||||
pub glyphs: usize,
|
||||
}
|
||||
|
||||
@@ -116,15 +117,18 @@ fn union(r: Option<Rect>, b: Rect) -> Option<Rect> {
|
||||
|
||||
fn build_line(db: &FontDb, cs: &ComposedStory, l: &Line, vertical: bool) -> LineGlyphs {
|
||||
let mut runs: Vec<(u32, BezPath)> = Vec::new();
|
||||
let mut decos = Vec::new();
|
||||
let mut underlines = Vec::new();
|
||||
let mut strikes = Vec::new();
|
||||
let mut glyphs = 0;
|
||||
for g in &l.glyphs {
|
||||
if !g.visible {
|
||||
continue;
|
||||
}
|
||||
let style = &cs.styles[g.style as usize];
|
||||
for (rule, r) in style.rules(g.x, g.x + g.adv, l.baseline) {
|
||||
decos.push((rule.color.clone(), rule.tint, r));
|
||||
for (on, rule, bars) in [(style.underline, &style.underline_rule, &mut underlines), (style.strikethrough, &style.strike_rule, &mut strikes)] {
|
||||
if on {
|
||||
bars.push((rule.color.clone(), rule.tint, rule.rect(g.x, g.x + g.adv, l.baseline)));
|
||||
}
|
||||
}
|
||||
let outline = db.outline(&g.face, g.gid);
|
||||
if outline.elements().is_empty() {
|
||||
@@ -155,10 +159,10 @@ fn build_line(db: &FontDb, cs: &ComposedStory, l: &Line, vertical: bool) -> Line
|
||||
let sw = if st.stroke != designcraft_color::swatch::NONE { st.stroke_weight } else { 0.0 };
|
||||
bounds = union(bounds, bp.bounding_box().inflate(sw, sw));
|
||||
}
|
||||
for (_, _, r) in &decos {
|
||||
for (_, _, r) in underlines.iter().chain(&strikes) {
|
||||
bounds = union(bounds, *r);
|
||||
}
|
||||
LineGlyphs { bounds, runs, decos, glyphs }
|
||||
LineGlyphs { bounds, runs, underlines, strikes, glyphs }
|
||||
}
|
||||
|
||||
fn build(cs: &ComposedStory, ft: &FrameText) -> FrameGlyphs {
|
||||
@@ -339,12 +343,19 @@ impl Renderer {
|
||||
doc.resolve_color(&st.fill, st.fill_tint).map(|c| color_of(&c, 1.0))
|
||||
})
|
||||
};
|
||||
for i in shown {
|
||||
let lg = &fg.lines[i];
|
||||
let Some(b) = lg.bounds else { continue };
|
||||
if !rect_overlaps(b, vis) {
|
||||
continue;
|
||||
shown.retain(|&i| fg.lines[i].bounds.is_some_and(|b| rect_overlaps(b, vis)));
|
||||
// Paint all underlines before any glyphs: skewed runs and tightly spaced lines
|
||||
// can overlap a later run's rule. A per-run paint pass would still cover ink.
|
||||
for &i in &shown {
|
||||
for (sw, tint, r) in &fg.lines[i].underlines {
|
||||
if let Some(c) = doc.resolve_color(sw, *tint) {
|
||||
ctx.set_paint(color_of(&c, 1.0));
|
||||
ctx.fill_rect(r);
|
||||
}
|
||||
}
|
||||
}
|
||||
for &i in &shown {
|
||||
let lg = &fg.lines[i];
|
||||
self.stats.glyphs += lg.glyphs;
|
||||
for (si, bp) in &lg.runs {
|
||||
if let Some(c) = fill_of(*si) {
|
||||
@@ -374,7 +385,9 @@ impl Renderer {
|
||||
ctx.stroke_path(bp);
|
||||
}
|
||||
}
|
||||
for (sw, tint, r) in &lg.decos {
|
||||
}
|
||||
for &i in &shown {
|
||||
for (sw, tint, r) in &fg.lines[i].strikes {
|
||||
if let Some(c) = doc.resolve_color(sw, *tint) {
|
||||
ctx.set_paint(color_of(&c, 1.0));
|
||||
ctx.fill_rect(r);
|
||||
|
||||
Reference in New Issue
Block a user