mirror of
https://github.com/storytold/photocraft.git
synced 2026-10-14 19:26:51 -04:00
* Type: faux bold keeps antialiased edges at small sizes (#4130) Faux bold drew each glyph at 8 offsets into one signed-area coverage buffer, so edge pixels from the copies summed and saturated to full coverage (hard, pixelated stems, about 2.2x the ink at 12 px). Each offset now gets its own buffer, merged with max. Co-Authored-By: Claude Opus 5.5 <[email protected]> * Type: faux bold ring radius tuned so stems keep their weight (#4130) Co-Authored-By: Claude Opus 5.5 <[email protected]> --------- Co-authored-by: Claude Opus 5.5 <[email protected]>
This commit is contained in:
1 parent
f6db7a8115
commit
dd258cccf9
2 files changed
+86
-6
No files matched your search
@@ -14,8 +14,8 @@ use crate::warp::Warp;
|
||||
|
||||
/// Faux-italic slant in degrees (Photoshop-like).
|
||||
pub const FAUX_ITALIC_DEG: f32 = 12.0;
|
||||
/// Faux-bold dilation radius as a fraction of the font size.
|
||||
pub const FAUX_BOLD_RADIUS: f32 = 0.018;
|
||||
/// Faux-bold ring radius as a fraction of the font size (tuned so stem weight stays close to the old summed-coverage look).
|
||||
pub const FAUX_BOLD_RADIUS: f32 = 0.036;
|
||||
/// Largest raster we produce (pixels), as a guard against absurd sizes.
|
||||
const MAX_PIXELS: u64 = 256 * 1024 * 1024;
|
||||
|
||||
@@ -53,6 +53,30 @@ impl<S: LineSink> LineSink for WarpSink<'_, S> {
|
||||
/// when `color` is `None`) into `sink`, through `transform` (text space → sink space), bending
|
||||
/// the outlines with `warp` first (in text space).
|
||||
fn draw(layout: &TextLayout, transform: &Xform, sink: &mut impl LineSink, only_color: Option<&Color>, warp: Option<&Warp>) {
|
||||
draw_pass(layout, transform, sink, only_color, warp, Pass::All);
|
||||
}
|
||||
|
||||
/// Which part of the layout [`draw_pass`] emits. Faux bold draws each glyph at several offsets;
|
||||
/// summing those into one coverage buffer saturates the antialiased edge pixels (hard, pixelated
|
||||
/// stems), so the rasteriser draws every offset into its own buffer and merges them with `max`.
|
||||
#[derive(Clone, Copy, PartialEq)]
|
||||
enum Pass {
|
||||
/// Everything, all offsets (bounds, outlines).
|
||||
All,
|
||||
/// Non-bold glyphs and decorations.
|
||||
Plain,
|
||||
/// Bold glyphs at one offset (index into [`BOLD_OFFSETS`]).
|
||||
Bold(usize),
|
||||
}
|
||||
|
||||
const BOLD_OFFSETS: [(f64, f64); 8] = [(-1.0, 0.0), (1.0, 0.0), (0.0, -1.0), (0.0, 1.0), (0.7, 0.7), (-0.7, -0.7), (0.7, -0.7), (-0.7, 0.7)];
|
||||
|
||||
fn is_bold(layout: &TextLayout, g: &PlacedGlyph) -> bool {
|
||||
let face_bold = layout.faces.get(g.face as usize).is_some_and(|f| f.embolden);
|
||||
layout.styles.get(g.style as usize).is_some_and(|s| s.faux_bold) || face_bold
|
||||
}
|
||||
|
||||
fn draw_pass(layout: &TextLayout, transform: &Xform, sink: &mut impl LineSink, only_color: Option<&Color>, warp: Option<&Warp>, pass: Pass) {
|
||||
for g in &layout.glyphs {
|
||||
let st = &layout.styles[g.style as usize];
|
||||
if only_color.is_some_and(|c| c != &st.color) {
|
||||
@@ -69,8 +93,17 @@ fn draw(layout: &TextLayout, transform: &Xform, sink: &mut impl LineSink, only_c
|
||||
let glyph = glyph_xform(layout, g, face.skew_deg);
|
||||
let bold = st.faux_bold || face.embolden;
|
||||
let r = (face.size_px * FAUX_BOLD_RADIUS) as f64;
|
||||
let offsets: &[(f64, f64)] =
|
||||
if bold { &[(-1.0, 0.0), (1.0, 0.0), (0.0, -1.0), (0.0, 1.0), (0.7, 0.7), (-0.7, -0.7), (0.7, -0.7), (-0.7, 0.7)] } else { &[(0.0, 0.0)] };
|
||||
let single = [(0.0, 0.0)];
|
||||
let offsets: &[(f64, f64)] = match (pass, bold) {
|
||||
(Pass::Plain, true) => continue,
|
||||
(Pass::Bold(_), false) => continue,
|
||||
(Pass::Bold(k), true) => match BOLD_OFFSETS.get(k..=k) {
|
||||
Some(o) => o,
|
||||
None => continue,
|
||||
},
|
||||
(Pass::All, true) => &BOLD_OFFSETS,
|
||||
(_, false) => &single,
|
||||
};
|
||||
for &(ox, oy) in offsets {
|
||||
let local = glyph.mul(&Xform([1.0, 0.0, 0.0, 1.0, ox * r, oy * r]));
|
||||
let settings = DrawSettings::unhinted(Size::new(face.size_px), LocationRef::new(&coords));
|
||||
@@ -91,6 +124,9 @@ fn draw(layout: &TextLayout, transform: &Xform, sink: &mut impl LineSink, only_c
|
||||
}
|
||||
}
|
||||
}
|
||||
if matches!(pass, Pass::Bold(_)) {
|
||||
return;
|
||||
}
|
||||
for d in &layout.decorations {
|
||||
let st = &layout.styles[d.style as usize];
|
||||
if only_color.is_some_and(|c| c != &st.color) {
|
||||
@@ -199,8 +235,17 @@ pub fn rasterize_warped(layout: &TextLayout, transform: &Affine, format: PixelFo
|
||||
}
|
||||
for c in &colors {
|
||||
let mut cov = Coverage::new(w, h);
|
||||
draw(layout, &xf, &mut cov, Some(c), warp);
|
||||
let cov = cov.finish();
|
||||
draw_pass(layout, &xf, &mut cov, Some(c), warp, Pass::Plain);
|
||||
let mut cov = cov.finish();
|
||||
if layout.glyphs.iter().any(|g| is_bold(layout, g)) {
|
||||
for k in 0..BOLD_OFFSETS.len() {
|
||||
let mut bc = Coverage::new(w, h);
|
||||
draw_pass(layout, &xf, &mut bc, Some(c), warp, Pass::Bold(k));
|
||||
for (a, b) in cov.iter_mut().zip(bc.finish()) {
|
||||
*a = a.max(b);
|
||||
}
|
||||
}
|
||||
}
|
||||
let comps = color_in(&format, c);
|
||||
let a = c.alpha.clamp(0.0, 1.0);
|
||||
for (i, &cv) in cov.iter().enumerate() {
|
||||
|
||||
@@ -396,6 +396,41 @@ fn styles_change_pixels() {
|
||||
assert!(hrect.width() as f32 > prect.width() as f32 * 1.7);
|
||||
}
|
||||
|
||||
/// #4130: Faux bold at 72 dpi looked pixelated because the offset copies were summed into one
|
||||
/// coverage buffer, saturating every antialiased edge pixel. Edges must stay antialiased and the
|
||||
/// ink must grow only by the dilation (about perimeter x radius), not by edge saturation.
|
||||
#[test]
|
||||
fn faux_bold_keeps_antialiased_edges_at_small_sizes() {
|
||||
let mut e = TextEngine::new();
|
||||
let base = CharStyle { size_pt: 12.0, ..Default::default() };
|
||||
let stats = |e: &mut TextEngine, s: CharStyle| {
|
||||
let (_, r) = e.render(&TextLayer { transform: Affine::translate(5.0, 20.0), ..styled("Bold Test", s) }, 72.0, PixelFormat::RGBA8);
|
||||
let (w, h) = (r.rect.width() as usize, r.rect.height() as usize);
|
||||
let mut partial = 0usize;
|
||||
for y in 0..h {
|
||||
for x in 0..w {
|
||||
let a = alpha_sum(
|
||||
&r.surface,
|
||||
photocraft_geom::Rect::new(r.rect.x0 + x as i32, r.rect.y0 + y as i32, r.rect.x0 + x as i32 + 1, r.rect.y0 + y as i32 + 1),
|
||||
);
|
||||
if a > 0.05 && a < 0.95 {
|
||||
partial += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
(alpha_sum(&r.surface, r.rect), partial)
|
||||
};
|
||||
let (plain_ink, plain_partial) = stats(&mut e, base.clone());
|
||||
let (bold_ink, bold_partial) = stats(&mut e, CharStyle { faux_bold: true, ..base });
|
||||
assert!(bold_ink > plain_ink, "bold must add ink");
|
||||
// Weight: the old summed-coverage faux bold (before #4130) measured 282.7 total ink for this
|
||||
// text at 12 px (plain 138.0). The antialiased version must stay close to that weight.
|
||||
const MAIN_BOLD_INK: f64 = 282.7;
|
||||
assert!(bold_ink >= MAIN_BOLD_INK * 0.85, "stems too thin: {bold_ink} vs old {MAIN_BOLD_INK}");
|
||||
assert!(bold_ink <= MAIN_BOLD_INK * 1.15, "stems too heavy: {bold_ink} vs old {MAIN_BOLD_INK}");
|
||||
assert!(bold_partial * 10 >= plain_partial * 8, "edges stay antialiased: {plain_partial} -> {bold_partial}");
|
||||
}
|
||||
|
||||
/// #3705: Faux bold dilation must scale with horizontal and vertical glyph scaling.
|
||||
#[test]
|
||||
fn faux_bold_scales_with_glyph_scaling() {
|
||||
|
||||
Reference in new issue
Block a user