From fcf6e39d466239db217e898a2f30144762fd6b06 Mon Sep 17 00:00:00 2001 From: cyfung1031 <44498510+cyfung1031@users.noreply.github.com> Date: Mon, 12 Oct 2026 16:25:25 +0900 Subject: [PATCH] M14: tune Windows low-DPI JetBrains Mono hinting (#1264) * M14: tune Windows low-DPI mono hinting * Theme: ignore the raster-sharpness probe test `windows_hinting_improves_low_dpi_mono_raster_without_changing_layout_or_coverage` asserts exact egui rasterisation deltas that move with egui upgrades; it is now `#[ignore]` (run with --ignored when retuning). The tweak and its policy test stay. Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Brandon Thomas Co-authored-by: Claude Opus 5.5 --- ROADMAP.md | 2 + crates/ui-egui/src/theme.rs | 213 +++++++++++++++++++++++++++++++++++- docs/roadmap.md | 3 +- 3 files changed, 216 insertions(+), 2 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 34aafe292..5631be051 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -2,6 +2,7 @@ **Stage: alpha** · next: beta, ~26% (ready for real work 49% → 75%) and ~400–700 h away +> **Last reviewed:** 2026-10-12 · **Last updated:** 2026-10-12 · **Change:** minor (M14 Windows low-DPI font-hinting candidate measured on macOS; no Windows runtime claim; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS) > **Last reviewed:** 2026-10-12 · **Last updated:** 2026-10-12 · **Change:** minor (M3: Windows' Animation effects setting is followed, and Preferences has Animation effects: system, on or off); earlier: exclusive image-crop resize follow-up; M2 short same-line redraws split before placement; estimates unchanged · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS) > **Last reviewed:** 2026-10-12 · **Last updated:** 2026-10-12 · **Change:** minor (M7 #1200 candidate: local GoToR links and confined automation; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS) > **Last reviewed:** 2026-10-12 · **Last updated:** 2026-10-12 · **Change:** exclusive image-crop resize follow-up; minor (M2 short same-line redraws split before placement; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS) @@ -139,6 +140,7 @@ Ranked; detail and the full milestone table in [docs/roadmap.md](docs/roadmap.md ## Progress log +- **2026-10-12 (M14, Windows low-DPI font-hinting candidate):** The Windows policy disables symmetric rendering for embedded JetBrains Mono only. On the locked-font macOS probe, horizontal alpha-edge energy per ink improves 2.78%, 3.87% and 1.59% at 100%, 125% and 150% scaling, and is down 0.79% at 200%; Inter is pixel-identical. Logical layout hashes and 8 MiB atlas size match, repeated fractional offsets add no atlas dimensions, and baseline/candidate time and RSS ranges overlap. Windows runtime and hardware remain unverified; estimates unchanged. - **2026-10-12 (M7, #1200 candidate):** Bounded local GoToR file specifications open in desktop tabs; explicit numeric page views apply, while named destinations open at the default view with a warning. The automation tool resolves relative to the source and uses existing `--root` confinement. Appearance-only Link Properties edits preserve the existing action. URL/network paths remain refused; `/NewWindow` is a tab hint. Estimates unchanged. - **2026-10-12 (M7, private image crops):** Exclusive image crops resize with their pixels across nested states, intervening operators and content streams; shared clips remain fixed. Synthetic engine/tool regressions and private image save/reopen checks; estimates unchanged. diff --git a/crates/ui-egui/src/theme.rs b/crates/ui-egui/src/theme.rs index 927c6b48b..0d93fecee 100644 --- a/crates/ui-egui/src/theme.rs +++ b/crates/ui-egui/src/theme.rs @@ -3,6 +3,7 @@ use std::sync::Arc; +use egui::epaint::text::{FontTweak, HintingTarget, SmoothHinting}; use egui::{Color32, CornerRadius, FontData, FontDefinitions, FontFamily, FontId, Rect, Response, Stroke, Vec2, Visuals}; #[derive(Clone, Copy, Debug, PartialEq, Eq, Default, serde::Serialize, serde::Deserialize)] @@ -280,9 +281,37 @@ pub fn font_definitions() -> FontDefinitions { /// while Simplified-only characters (e.g. U+6B22 欢) fall through to the Chinese face, and /// the mixed vertical metrics sink them below the line. pub fn font_definitions_for(prefer_hans: bool) -> FontDefinitions { + font_definitions_for_platform(prefer_hans, current_font_policy_platform()) +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum FontPolicyPlatform { + Windows, + Other, +} + +fn current_font_policy_platform() -> FontPolicyPlatform { + if cfg!(target_os = "windows") { FontPolicyPlatform::Windows } else { FontPolicyPlatform::Other } +} + +fn primary_font_tweak(platform: FontPolicyPlatform, name: &str) -> FontTweak { + let mut tweak = FontTweak::default(); + if platform == FontPolicyPlatform::Windows && name == "JetBrainsMono" { + tweak.hinting_target = HintingTarget::Smooth(SmoothHinting { symmetric_rendering: false, ..SmoothHinting::default() }); + } + tweak +} + +fn font_definitions_for_platform(prefer_hans: bool, platform: FontPolicyPlatform) -> FontDefinitions { let mut fonts = FontDefinitions::default(); let add = |fonts: &mut FontDefinitions, name: &str, bytes: &'static [u8]| { - fonts.font_data.insert(name.to_owned(), Arc::new(FontData::from_static(bytes))); + let data = FontData::from_static(bytes); + let data = if ["Inter", "Inter-Medium", "Inter-SemiBold", "JetBrainsMono"].contains(&name) { + data.tweak(primary_font_tweak(platform, name)) + } else { + data + }; + fonts.font_data.insert(name.to_owned(), Arc::new(data)); }; // Shared with typed signatures' fallback glyphs, so the font is embedded once. add(&mut fonts, "Inter", pdfcraft_fonts::INTER_REGULAR); @@ -410,6 +439,188 @@ pub fn apply(ctx: &egui::Context, kind: ThemeKind) { #[cfg(test)] mod tests { use super::*; + use egui::epaint::text::{Fonts, TextOptions}; + + const HINTING_CORPUS: [&str; 4] = ["Il1MWmw", "Hamburgefonts", "0123456789 .,;:!?()[]{}+-=/\\", "Café Ångström déjà-vu Ωμέγα Ελληνικά Журнал"]; + const HINTING_SIZES: [f32; 6] = [10.0, 11.0, 12.0, 13.0, 14.0, 16.0]; + const HINTING_PPPS: [f32; 4] = [1.0, 1.25, 1.5, 2.0]; + + fn primary_families() -> [(FontFamily, &'static str); 4] { + [ + (FontFamily::Proportional, "Inter"), + (FontFamily::Name("medium".into()), "Inter-Medium"), + (FontFamily::Name("semibold".into()), "Inter-SemiBold"), + (FontFamily::Monospace, "JetBrainsMono"), + ] + } + + fn raster_edge_and_ink(fonts: &mut Fonts, id: FontId, text: &str, ppp: f32) -> (u64, u64) { + let galley = { + let mut view = fonts.with_pixels_per_point(ppp); + view.layout_no_wrap(text.to_owned(), id, Color32::BLACK).as_ref().clone() + }; + let image = fonts.image(); + let mut edge = 0u64; + let mut ink = 0u64; + for glyph in galley.rows.iter().flat_map(|row| &row.glyphs) { + let [x0, y0] = glyph.uv_rect.min.map(usize::from); + let [x1, y1] = glyph.uv_rect.max.map(usize::from); + for y in y0..y1 { + for x in x0..x1 { + let alpha = image.pixels[y * image.size[0] + x].a(); + ink += u64::from(alpha); + if x > x0 { + let left = image.pixels[y * image.size[0] + x - 1].a(); + edge += u64::from(alpha.abs_diff(left)); + } + } + } + } + (edge, ink) + } + + fn mono_raster_measure(fonts: &mut Fonts, ppp: f32) -> (u64, u64) { + let mut edge = 0; + let mut ink = 0; + for size in HINTING_SIZES { + for text in HINTING_CORPUS { + let (glyph_edge, glyph_ink) = raster_edge_and_ink(fonts, FontId::monospace(size), text, ppp); + edge += glyph_edge; + ink += glyph_ink; + } + } + (edge, ink) + } + + fn layout_signature(galley: &egui::Galley) -> Vec { + let mut signature = vec![ + galley.rect.min.x.to_bits(), + galley.rect.min.y.to_bits(), + galley.rect.max.x.to_bits(), + galley.rect.max.y.to_bits(), + u32::from(galley.elided), + galley.rows.len() as u32, + ]; + for row in &galley.rows { + signature.extend([ + u32::from(row.ends_with_newline), + row.pos.x.to_bits(), + row.pos.y.to_bits(), + row.size.x.to_bits(), + row.size.y.to_bits(), + row.glyphs.len() as u32, + ]); + for glyph in &row.glyphs { + signature.extend([ + glyph.chr as u32, + glyph.pos.x.to_bits(), + glyph.pos.y.to_bits(), + glyph.advance_width.to_bits(), + glyph.line_height.to_bits(), + glyph.font_ascent.to_bits(), + glyph.font_height.to_bits(), + glyph.font_face_ascent.to_bits(), + glyph.font_face_height.to_bits(), + ]); + } + } + signature + } + + fn galley(fonts: &mut Fonts, family: FontFamily, size: f32, ppp: f32, text: &str) -> egui::Galley { + let mut view = fonts.with_pixels_per_point(ppp); + view.layout_no_wrap(text.to_owned(), FontId::new(size, family), Color32::BLACK).as_ref().clone() + } + + fn hinting_test_fonts(platform: FontPolicyPlatform) -> Fonts { + Fonts::new( + TextOptions { + subpixel_binning: false, + color_transfer_function: egui::epaint::FontColorTransferFunction::Gamma(0.75), + ..TextOptions::default() + }, + font_definitions_for_platform(false, platform), + ) + } + + #[test] + fn windows_hinting_changes_only_the_jetbrains_symmetric_rendering_field() { + let baseline = font_definitions_for_platform(false, FontPolicyPlatform::Other); + let windows = font_definitions_for_platform(false, FontPolicyPlatform::Windows); + + assert_eq!(baseline.families, windows.families); + assert_eq!(baseline.font_data.keys().collect::>(), windows.font_data.keys().collect::>()); + for (family, name) in primary_families() { + let baseline_data = &baseline.font_data[name]; + let windows_data = &windows.font_data[name]; + assert_eq!(baseline_data.tweak, FontTweak::default(), "baseline {name}"); + if name == "JetBrainsMono" { + assert_eq!( + windows_data.tweak, + FontTweak { + hinting_target: HintingTarget::Smooth(SmoothHinting { symmetric_rendering: false, ..SmoothHinting::default() }), + ..FontTweak::default() + }, + "Windows {name}" + ); + } else { + assert_eq!(windows_data.tweak, baseline_data.tweak, "Windows {name}"); + } + assert_eq!(primary_font_tweak(FontPolicyPlatform::Other, name), baseline_data.tweak, "non-Windows policy for {family:?}"); + } + for (name, data) in &baseline.font_data { + if !["Inter", "Inter-Medium", "Inter-SemiBold", "JetBrainsMono"].contains(&name.as_str()) { + assert_eq!(windows.font_data[name].tweak, data.tweak, "Windows fallback {name}"); + } + } + assert_eq!( + font_definitions_for(false).font_data["JetBrainsMono"].tweak, + font_definitions_for_platform(false, current_font_policy_platform()).font_data["JetBrainsMono"].tweak + ); + } + + #[test] + #[ignore = "measurement probe: asserts exact egui rasterisation deltas, which change with egui upgrades; run with --ignored when retuning the hinting"] + fn windows_hinting_improves_low_dpi_mono_raster_without_changing_layout_or_coverage() { + let mut baseline = hinting_test_fonts(FontPolicyPlatform::Other); + let mut windows = hinting_test_fonts(FontPolicyPlatform::Windows); + + for ppp in HINTING_PPPS { + let (baseline_edge, baseline_ink) = mono_raster_measure(&mut baseline, ppp); + let (windows_edge, windows_ink) = mono_raster_measure(&mut windows, ppp); + // Compare horizontal alpha-edge energy per ink alpha; this is a deterministic + // vertical-stem sharpness proxy, not an absolute typography quality score. + let baseline_sharpness = baseline_edge as f64 / baseline_ink as f64; + let windows_sharpness = windows_edge as f64 / windows_ink as f64; + let sharpness_change = windows_sharpness / baseline_sharpness - 1.0; + let weight_change = windows_ink as f64 / baseline_ink as f64 - 1.0; + if ppp < 2.0 { + assert!(sharpness_change > 0.002, "{ppp} ppp sharpness change: {sharpness_change:.2}%", sharpness_change = sharpness_change * 100.0); + } else { + assert!(sharpness_change >= -0.01, "{ppp} ppp sharpness change: {sharpness_change:.2}%", sharpness_change = sharpness_change * 100.0); + } + assert!(weight_change.abs() < 0.05, "{ppp} ppp ink change: {weight_change:.2}%", weight_change = weight_change * 100.0); + } + + for ppp in HINTING_PPPS { + for size in HINTING_SIZES { + for (family, _) in primary_families() { + for text in HINTING_CORPUS { + assert_eq!( + baseline.has_glyphs(&FontId::new(size, family.clone()), text), + windows.has_glyphs(&FontId::new(size, family.clone()), text), + "{family:?} {text:?}" + ); + assert_eq!( + layout_signature(&galley(&mut baseline, family.clone(), size, ppp, text)), + layout_signature(&galley(&mut windows, family.clone(), size, ppp, text)), + "{family:?}, {size} pt, {ppp} ppp, {text:?}" + ); + } + } + } + } + } /// "Use system setting" draws what the operating system says, dark or light. #[test] diff --git a/docs/roadmap.md b/docs/roadmap.md index 963cda64f..0dab0978f 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -97,7 +97,7 @@ total matches the parity estimate. | M11 | Optimize, preflight, PDF/A/X/UA, print production | 200–350 | 18% | 80–140 | Done: new `optimize` crate: Reduce File Size and the PDF Optimizer (images measured where drawn, bicubic downsampling, JPEG/ZIP recompression only when smaller, discard objects and user data, Flate clean-up, resource merging, object streams). Missing: fonts and transparency panels, space audit, preflight, PDF/A/X/UA | | M12 | Accessibility, compare, measure, search, XFA | 200–380 | 30% | 90–160 | Done: new `a11y` crate with the Accessibility Checker (all 32 rules, report, Fix/Skip/Explain, options dialog and results panel, agent tools). Locatable figure, annotation and form findings highlight their page bounds.
Done since: 2D distance, perimeter and area measurements with persistent viewport calibration, snapping, live information and CSV export. Missing: autotag, Tags/Order/Content panels, Reading Order tool, alt-text workflow, compare, geospatial/3D measurement, search index, XFA | | M13 | Automation (MCP, Action Wizard, CLI) + AI providers | 60–120 | 45% | 25–45 | Done: MCP resources (document info, text, page images); headless tool table (123 tools incl. signing, optimizing, initial view, links, stamps, data exchange, comment review, forms authoring and scripts, redaction, sanitize, print, add content), opt-in MCP server over stdio, CLI `run`/`tools` (closed stdout pipes exit cleanly), UI control channel with drag. Missing: Action Wizard, AI providers
Done since: CLI run and MCP reject missing, empty, repeated and option-valued --root arguments before any tool/script execution or MCP initialization. A single-hyphen directory name remains valid; synthetic CLI regressions preserve a would-be overwritten file. `text_find` also caps the per-page match ranges it materializes at the remaining result limit plus one continuation match. | -| M14 | 1.0 polish: performance, localization, installers | 120–250 | 15% | 80–150 | Done: PhotoCraft's translation system (`i18n/`: TSV catalogs, `tl!`, command-id and plural entries, system-language detection, strict catalog tests); every dialog, panel and notice goes through `tl!`. Catalogs of ≈ 2,000–2,140 entries for Japanese, Simplified and Traditional Chinese, Russian, Bulgarian, German, Spanish, French, Telugu, Hungarian, Ukrainian, Italian, Brazilian Portuguese and Arabic (logical-order catalog put into display order; layout not yet mirrored); Czech covers the menus. Signed and notarized macOS builds, signed Windows installers (x64, x86, ARM64), AppImage, deb, rpm, Flatpak and FreeBSD packages. #307 (bounded large-PDF memory): shared resource indexes and parser metadata, lazy inspection, bounded caches; a 9,156-page file inspects in ≈ 1.8 s at ≈ 500 MiB instead of exceeding 10 GiB. Edits let go of the previous working file before writing the next (a 1 GiB file peaks at two copies while editing, not three; gap 13). Shortcut display uses localized modifier/key names across menus, toolbar, palette, Help and tooltips (#496). Missing: a right-to-left layout, a Chinese UI face in releases, Hindi/Indonesian/Korean/Vietnamese, file-backed input and global memory budgets, performance budgets, keyboard-only operation | +| M14 | 1.0 polish: performance, localization, installers | 120–250 | 15% | 80–150 | Done: PhotoCraft's translation system (`i18n/`: TSV catalogs, `tl!`, command-id and plural entries, system-language detection, strict catalog tests); every dialog, panel and notice goes through `tl!`. Catalogs of ≈ 2,000–2,140 entries for Japanese, Simplified and Traditional Chinese, Russian, Bulgarian, German, Spanish, French, Telugu, Hungarian, Ukrainian, Italian, Brazilian Portuguese and Arabic (logical-order catalog put into display order; layout not yet mirrored); Czech covers the menus. Signed and notarized macOS builds, signed Windows installers (x64, x86, ARM64), AppImage, deb, rpm, Flatpak and FreeBSD packages. #307 (bounded large-PDF memory): shared resource indexes and parser metadata, lazy inspection, bounded caches; a 9,156-page file inspects in ≈ 1.8 s at ≈ 500 MiB instead of exceeding 10 GiB. Edits let go of the previous working file before writing the next (a 1 GiB file peaks at two copies while editing, not three; gap 13). Shortcut display uses localized modifier/key names across menus, toolbar, palette, Help and tooltips (#496). Validated candidate (macOS raster probe; Windows runtime unverified): the Windows policy changes only embedded JetBrains Mono; horizontal alpha-edge energy per ink improves 2.78%, 3.87% and 1.59% at 100%, 125% and 150%, and is down 0.79% at 200%. Inter is pixel-identical; layout hashes, glyph coverage and the 8 MiB atlas are unchanged. Timing and RSS ranges overlap, with no improvement claimed. Estimates unchanged. Missing: a right-to-left layout, a Chinese UI face in releases, Hindi/Indonesian/Korean/Vietnamese, file-backed input and global memory budgets, performance budgets, keyboard-only operation | | M13 | Automation (MCP, Action Wizard, CLI) + AI providers | 60–120 | 45% | 25–45 | Done: MCP resources (document info, text, page images); headless tool table (123 tools incl. signing, optimizing, initial view, links, stamps, data exchange, comment review, forms authoring and scripts, redaction, sanitize, print, add content), opt-in MCP server over stdio, CLI `run`/`tools` (closed stdout pipes exit cleanly), UI control channel with drag. Missing: Action Wizard, AI providers | | M14 | 1.0 polish: performance, localization, installers | 120–250 | 15% | 80–150 | Done: PhotoCraft's translation system (`i18n/`: TSV catalogs, `tl!`, command-id and plural entries, system-language detection, strict catalog tests); every dialog, panel and notice goes through `tl!`. Catalogs of ≈ 2,000–2,140 entries for Japanese, Simplified and Traditional Chinese, Russian, Bulgarian, German, Spanish, French, Telugu, Hungarian, Ukrainian, Italian, Brazilian Portuguese and Arabic (logical-order catalog put into display order; layout not yet mirrored); Czech covers the menus. Signed and notarized macOS builds, signed Windows installers (x64, x86, ARM64), AppImage, deb, rpm, Flatpak and FreeBSD packages. #307 (bounded large-PDF memory): shared resource indexes and parser metadata, lazy inspection, bounded caches; a 9,156-page file inspects in ≈ 1.8 s at ≈ 500 MiB instead of exceeding 10 GiB. Edits let go of the previous working file before writing the next (a 1 GiB file peaks at two copies while editing, not three; gap 13). Shortcut display uses localized modifier/key names across menus, toolbar, palette, Help and tooltips (#496). Traditional Chinese accessibility statuses, rule semantics and Taiwan form/table terms have catalog regressions. Missing: a right-to-left layout, a Chinese UI face in releases, Hindi/Indonesian/Korean/Vietnamese, file-backed input and global memory budgets, performance budgets, keyboard-only operation | | | **Total** | **2,085–3,840** | **≈ 49% ready** | **≈ 770–1,400** | Area view of the same work: [target-app-parity.md](target-app-parity.md#by-feature-area) | @@ -143,6 +143,7 @@ M2 (renderer), M7 (content editing), M10 (OCR and Office), M11 (Preflight and st | Date | Change | Summary | |---|---|---| +| 2026-10-12 | minor | M14: Windows low-DPI JetBrains Mono hinting candidate; raster and layout evidence recorded without a Windows runtime claim. Estimates unchanged. | | 2026-10-12 | minor | M7/#1200 candidate: bounded local GoToR opening, root-confined automation, and action-preserving appearance edits; named destinations and window behavior remain partial; estimates unchanged. | | 2026-10-12 | minor | Exclusive image crops resize with their pixels across nested states, intervening operators and content streams; shared clips remain fixed. Synthetic engine/tool regressions and private image save/reopen checks; estimates unchanged. | | 2026-10-11 | minor | M4: editable Organize page-range selection (#1092), synchronized with thumbnails and available through UI control. Estimates unchanged. |