mirror of
https://github.com/storytold/wordcraft.git
synced 2026-11-02 06:47:11 -06:00
The legacy run properties w:outline, w:shadow, w:emboss and w:imprint were read, resolved and saved but never drawn. Layout now turns them into text effects (hollow outlined letters; a sharp grey copy down-right for shadow and emboss, up-left for imprint, as Word's PDFs show them) and draws them through the existing WordArt path, so screen and PDF both get them and the PDF keeps the text. WordArt w14 effects on the same run take precedence. Text drawn from outlines keeps synthetic bold (a widened outline, or one in a solid fill's colour and transparency). Colour glyphs (emoji, no outlines) and top-to-bottom runs with upright characters are drawn as plain glyphs, and tracked insertions and deletions keep their legacy effects. Co-authored-by: Naxvog <4963183+naxvog@users.noreply.github.com> Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> Co-authored-by: Brandon Thomas <echelon@gmail.com>
This commit is contained in:
co-authored by
Naxvog
Claude Opus 5.5
Brandon Thomas
parent
8fb4d44d2c
commit
2e062168f0
@@ -868,18 +868,20 @@ fn draw(ctx: &mut RenderContext, doc: &Document, it: &Draw, view: Affine, visibl
|
||||
ctx.stroke_path(&path);
|
||||
}
|
||||
}
|
||||
Draw::Art { path, fx, color: c, alpha } => {
|
||||
Draw::Art { path, fx, color: c, alpha, text_bg } => {
|
||||
let Some(b) = wordcraft_layout::wordart::path_box(path) else { return };
|
||||
if !b.inflate(200.0, 200.0).overlaps(*visible) {
|
||||
return;
|
||||
}
|
||||
ctx.set_transform(view);
|
||||
for l in wordcraft_layout::wordart::art_layers(path, fx, *c, *alpha) {
|
||||
// The letters read against their highlight like plain text (#1252); shadows don't.
|
||||
let ink = |c: Rgb| if l.letters { opts.ink_text(c, *text_bg) } else { opts.ink(c) };
|
||||
match &l.paint {
|
||||
ArtPaint::Solid(c, a) => ctx.set_paint(color(opts.ink(*c), *a)),
|
||||
ArtPaint::Solid(c, a) => ctx.set_paint(color(ink(*c), *a)),
|
||||
ArtPaint::Linear { p0, p1, stops } => {
|
||||
let stops: Vec<peniko::ColorStop> =
|
||||
stops.iter().map(|(o, c, a)| peniko::ColorStop::from((o.clamp(0.0, 1.0), color(opts.ink(*c), *a)))).collect();
|
||||
stops.iter().map(|(o, c, a)| peniko::ColorStop::from((o.clamp(0.0, 1.0), color(ink(*c), *a)))).collect();
|
||||
let g = peniko::Gradient::new_linear((f64::from(p0.0), f64::from(p0.1)), (f64::from(p1.0), f64::from(p1.1)))
|
||||
.with_stops(stops.as_slice());
|
||||
ctx.set_paint(g);
|
||||
@@ -1447,6 +1449,47 @@ mod tests {
|
||||
assert!(light > 30, "light {light}");
|
||||
}
|
||||
|
||||
/// #2534: legacy font effects are drawn: outlined letters are hollow; shadowed and embossed
|
||||
/// ones have a grey copy down-right, imprinted ones up-left.
|
||||
#[test]
|
||||
fn legacy_font_effects_are_drawn() {
|
||||
use wordcraft_doc::CharProps;
|
||||
// Dark and grey pixel counts, and the grey pixels' mean offset from the dark ones.
|
||||
let shot = |c: CharProps| {
|
||||
let mut d = Document::from_text("x");
|
||||
let cp = CharProps { size: Some(40.0), bold: Some(true), ..c };
|
||||
d.body = vec![wordcraft_doc::para_block(wordcraft_doc::Paragraph::with_text("HHHH", cp))];
|
||||
let l = layout(&d, &mut LayoutCache::new(), &LayoutOptions::default());
|
||||
let img = render_page(&d, &l.pages[0], 2.0, &RenderOptions::default());
|
||||
let (mut dark, mut grey) = ((0.0, 0.0, 0.0f64), (0.0, 0.0, 0.0f64));
|
||||
for (x, y) in (100..800).flat_map(|x| (100..300).map(move |y| (x, y))) {
|
||||
let v = img.pixel(x, y)[0];
|
||||
let acc = match v {
|
||||
0..60 => &mut dark,
|
||||
100..=160 => &mut grey,
|
||||
_ => continue,
|
||||
};
|
||||
*acc = (acc.0 + 1.0, acc.1 + f64::from(x), acc.2 + f64::from(y));
|
||||
}
|
||||
let mean = |a: (f64, f64, f64)| (a.1 / a.0.max(1.0), a.2 / a.0.max(1.0));
|
||||
let ((dx, dy), (gx, gy)) = (mean(dark), mean(grey));
|
||||
(dark.0, grey.0, (gx - dx, gy - dy))
|
||||
};
|
||||
let (plain_dark, plain_grey, _) = shot(CharProps::default());
|
||||
assert!(plain_dark > 2000.0, "plain {plain_dark}");
|
||||
let (dark, _, _) = shot(CharProps { outline: Some(true), ..Default::default() });
|
||||
assert!(dark < plain_dark / 3.0, "outline is hollow: {dark} vs {plain_dark}");
|
||||
for (c, sign) in [
|
||||
(CharProps { shadow: Some(true), ..Default::default() }, 1.0),
|
||||
(CharProps { emboss: Some(true), ..Default::default() }, 1.0),
|
||||
(CharProps { engrave: Some(true), ..Default::default() }, -1.0),
|
||||
] {
|
||||
let (_, grey, (ox, oy)) = shot(c);
|
||||
assert!(grey > plain_grey * 2.0, "a grey copy: {grey} vs {plain_grey}");
|
||||
assert!(ox * sign > 1.0 && oy * sign > 1.0, "grey copy offset {ox} {oy}");
|
||||
}
|
||||
}
|
||||
|
||||
/// A document whose only text is a yellow-highlighted run.
|
||||
fn yellow_highlight_doc() -> (Document, wordcraft_layout::DocLayout) {
|
||||
let cp = wordcraft_doc::CharProps { highlight: Some(wordcraft_doc::props::Highlight::Yellow), size: Some(24.0), ..Default::default() };
|
||||
@@ -1551,6 +1594,34 @@ mod tests {
|
||||
assert!(r8 > 0xC0 && g8 > 0xC0 && b8 > 0xC0, "underline draws light, got {:?}", (r8, g8, b8));
|
||||
}
|
||||
|
||||
/// #2534 review: Automatic text with a legacy shadow (drawn from its outlines) on a black
|
||||
/// highlight draws light on a dark page, like plain text does, not dark grey on near-black.
|
||||
#[test]
|
||||
fn dark_page_shadowed_text_on_a_dark_highlight_reads() {
|
||||
use wordcraft_doc::props::Highlight;
|
||||
let cp = wordcraft_doc::CharProps { highlight: Some(Highlight::Black), shadow: Some(true), size: Some(24.0), ..Default::default() };
|
||||
let mut d = Document::from_text("x");
|
||||
d.body = vec![wordcraft_doc::para_block(wordcraft_doc::Paragraph::with_text("HHHH HHHH", cp))];
|
||||
let l = layout(&d, &mut LayoutCache::new(), &LayoutOptions::default());
|
||||
let items = page_display(&d, &l.pages[0], &DisplayOptions::default());
|
||||
let b = items
|
||||
.iter()
|
||||
.find_map(|i| match i {
|
||||
Draw::Art { path, text_bg, .. } => {
|
||||
assert!(text_bg.is_some(), "the run's highlight is the letters' background");
|
||||
wordcraft_layout::wordart::path_box(path)
|
||||
}
|
||||
_ => None,
|
||||
})
|
||||
.unwrap();
|
||||
let dark = render_page(&d, &l.pages[0], 2.0, &RenderOptions { dark: true, ..Default::default() });
|
||||
let light = (b.x0 as u32 * 2..b.x1 as u32 * 2)
|
||||
.flat_map(|x| (b.y0 as u32 * 2..b.y1 as u32 * 2).map(move |y| (x, y)))
|
||||
.filter(|&(x, y)| dark.pixel(x, y)[..3].iter().all(|&v| v > 0xE0))
|
||||
.count();
|
||||
assert!(light > 200, "letters draw light, {light} light pixels");
|
||||
}
|
||||
|
||||
/// One EMF record: type, size, payload (a multiple of 4 bytes).
|
||||
fn emf_rec(typ: u32, payload: &[u8]) -> Vec<u8> {
|
||||
let mut v = typ.to_le_bytes().to_vec();
|
||||
|
||||
@@ -24,6 +24,19 @@ fn emoji_are_drawn_in_colour() {
|
||||
}
|
||||
}
|
||||
|
||||
/// #2534: text effects (drawn from outlines) leave colour emoji, which have none, drawn in colour.
|
||||
/// Skips like [`emoji_are_drawn_in_colour`].
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
#[test]
|
||||
fn emoji_with_text_effects_stay_drawn() {
|
||||
let cp = wordcraft_doc::CharProps { outline: Some(true), shadow: Some(true), ..Default::default() };
|
||||
let Some((img, boxes)) = emoji_page_with(&RenderOptions::default(), cp) else { return };
|
||||
for (x0, y0, x1, y1) in boxes {
|
||||
let n = (x0..x1).flat_map(|x| (y0..y1).map(move |y| (x, y))).filter(|(x, y)| colourful(img.pixel(*x, *y))).count();
|
||||
assert!(n > 20, "only {n} coloured pixels in emoji box {x0},{y0}..{x1},{y1}");
|
||||
}
|
||||
}
|
||||
|
||||
/// A dark page (View › Switch Modes) doesn't invert the emoji: they keep the font's colours, the
|
||||
/// same as pictures. The pixels well inside each emoji (coloured with coloured neighbours, so the
|
||||
/// paper doesn't show through) match the light page's. Skips like [`emoji_are_drawn_in_colour`].
|
||||
@@ -51,12 +64,19 @@ fn emoji_keep_their_colours_on_a_dark_page() {
|
||||
/// pixels; None (the test skips) when no colour emoji font is installed.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn emoji_page(opts: &RenderOptions) -> Option<(Rendered, Vec<(u32, u32, u32, u32)>)> {
|
||||
emoji_page_with(opts, wordcraft_doc::CharProps::default())
|
||||
}
|
||||
|
||||
/// [`emoji_page`] with the text formatted `cp`.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn emoji_page_with(opts: &RenderOptions, cp: wordcraft_doc::CharProps) -> Option<(Rendered, Vec<(u32, u32, u32, u32)>)> {
|
||||
let has_colour_font = FontDb::global().fallback_for('🧠', u32::MAX).is_some_and(|f| COLOR_EMOJI.contains(&f.family.as_str()));
|
||||
if !has_colour_font {
|
||||
eprintln!("skipped: no colour emoji font installed");
|
||||
return None;
|
||||
}
|
||||
let d = Document::from_text("Emoji test: 🧠 brain, ✅ check.");
|
||||
let mut d = Document::from_text("x");
|
||||
d.body = vec![wordcraft_doc::para_block(wordcraft_doc::Paragraph::with_text("Emoji test: 🧠 brain, ✅ check.", cp))];
|
||||
let l = layout(&d, &mut LayoutCache::new(), &LayoutOptions::default());
|
||||
let page = &l.pages[0];
|
||||
let scale = 2.0;
|
||||
|
||||
Reference in New Issue
Block a user