374 lines
14 KiB
Rust
374 lines
14 KiB
Rust
//! Packet 2A-iii, Deliverable 2: turns a `round2_textkit::SpikeResolvedText`
|
|
//! into a reference raster via `round2_svgref`'s explicit-glyph emitter, and
|
|
//! derives `round2_diff::GlyphRegion` values from the emitter's own returned
|
|
//! bounds — never from a second, independently computed geometry.
|
|
//!
|
|
//! **This crate does not modify `round2-svgref` or `round2-diff`.** Both are
|
|
//! reviewed and settled (packet rule). It only calls their public APIs.
|
|
|
|
use std::collections::{BTreeMap, HashSet};
|
|
|
|
use round2_diff::GlyphRegion;
|
|
use round2_svgref::{DrawGlyph, DrawnBounds};
|
|
use round2_textkit::faces::LoadedFace;
|
|
use round2_textkit::hittest;
|
|
use round2_textkit::types::SpikeResolvedText;
|
|
|
|
pub const WIDTH: u32 = round2_textkit::TARGET_WIDTH as u32;
|
|
pub const HEIGHT: u32 = round2_textkit::TARGET_HEIGHT as u32;
|
|
|
|
/// **Resolved, not worked around.** An earlier version of this file sliced
|
|
/// the `<path>` fragment out of `round2_svgref::emit_svg`'s complete document
|
|
/// by searching for that crate's background-rect and `</svg>` markers, because
|
|
/// a multi-face run (F-B and F-D each mix face 0 and face 1) needs paths from
|
|
/// two faces inside one document and `emit_svg` takes a single face. That
|
|
/// worked, and it was a trap: any formatting change in `round2-svgref` would
|
|
/// have broken it silently — no compiler error, no failing test, just a
|
|
/// reference raster that came out wrong. `round2-svgref` now exposes
|
|
/// [`round2_svgref::emit_glyph_paths`] and [`round2_svgref::wrap_document`],
|
|
/// so the composition is an API call and a rename would be a build failure.
|
|
fn correlate_bounds<'a>(
|
|
glyphs: &[DrawGlyph],
|
|
bounds: &'a [DrawnBounds],
|
|
empty: &[u16],
|
|
) -> Vec<Option<&'a DrawnBounds>> {
|
|
let empty_set: HashSet<u16> = empty.iter().copied().collect();
|
|
let mut bi = 0usize;
|
|
let mut out = Vec::with_capacity(glyphs.len());
|
|
for g in glyphs {
|
|
if empty_set.contains(&g.glyph_id) {
|
|
out.push(None);
|
|
} else {
|
|
let b = &bounds[bi];
|
|
assert_eq!(
|
|
b.glyph_id,
|
|
g.glyph_id,
|
|
"bounds/glyph correlation mismatch at input index {} — emit_svg's returned \
|
|
bounds order must match its input glyphs order",
|
|
out.len()
|
|
);
|
|
out.push(Some(b));
|
|
bi += 1;
|
|
}
|
|
}
|
|
assert_eq!(
|
|
bi,
|
|
bounds.len(),
|
|
"not every returned DrawnBounds was consumed — correlation logic under-counted"
|
|
);
|
|
out
|
|
}
|
|
|
|
/// Whether the emitter enforces that a segment's declared face actually
|
|
/// covers that segment's own codepoints.
|
|
///
|
|
/// **This is recipe §11's M6 refusal, implemented.** Revision 2 of the recipe
|
|
/// claimed "emitter refuses; if forced, D4" for a host-substituted face, and
|
|
/// nothing implemented the first half — [`build_fixture_raster`] simply used
|
|
/// whatever face index the segment carried. A claim that a structural
|
|
/// safeguard exists, when it does not, is worse than no claim: it is the
|
|
/// safeguard everyone downstream believes is standing.
|
|
///
|
|
/// The check is cheap and exact: for each segment with `face: Some(i)`, every
|
|
/// `char` in the segment's source range must have a `cmap` entry in face `i`.
|
|
/// Face resolution (`round2_textkit::shape`) walks the declared chain in order
|
|
/// and only ever assigns a face that covers the codepoint, so `Enforce` never
|
|
/// fires on an honestly-generated fixture — it fires on a *tampered* one,
|
|
/// which is the whole point.
|
|
#[derive(Copy, Clone, PartialEq, Eq, Debug)]
|
|
pub enum FacePolicy {
|
|
/// Every real caller, including `bin/generate_reference`.
|
|
Enforce,
|
|
/// **Only** the M6 mutation harness (`bin/text_mutations`), which must get
|
|
/// past the refusal in order to measure what D4 says about a substitution
|
|
/// that a real pipeline could never produce. Named this verbosely so that
|
|
/// any other use of it is visible in a grep.
|
|
AllowUncoveredForM6Only,
|
|
}
|
|
|
|
/// Refuses a segment whose declared face cannot represent its own text.
|
|
fn enforce_face_coverage(
|
|
fixture_id: &str,
|
|
seg_idx: usize,
|
|
face_idx: u32,
|
|
face: &LoadedFace,
|
|
text: &str,
|
|
range: &std::ops::Range<u32>,
|
|
) -> Result<(), String> {
|
|
let sub = text
|
|
.get(range.start as usize..range.end as usize)
|
|
.ok_or_else(|| {
|
|
format!("{fixture_id}: segment {seg_idx} source range is not on a UTF-8 boundary")
|
|
})?;
|
|
let parsed = ttf_parser::Face::parse(&face.bytes, face.identity.face_index)
|
|
.map_err(|e| format!("{fixture_id}: face {face_idx} failed to parse: {e}"))?;
|
|
for ch in sub.chars() {
|
|
if parsed.glyph_index(ch).is_none() {
|
|
return Err(format!(
|
|
"{fixture_id}: segment {seg_idx} declares face {face_idx} ({}), which has no cmap \
|
|
entry for U+{:04X} — this is a host substitution, exactly what W3 §5 check 2 \
|
|
forbids, and the emitter refuses it rather than drawing whatever glyph id \
|
|
happens to land in that face's outline table",
|
|
face.identity.family, ch as u32
|
|
));
|
|
}
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
/// Everything measured while turning one fixture into a reference raster.
|
|
pub struct FixtureRasterResult {
|
|
pub svg: String,
|
|
pub rgba: Vec<u8>,
|
|
pub regions: Vec<GlyphRegion>,
|
|
pub drawn_glyph_count: usize,
|
|
pub empty_glyph_count: usize,
|
|
/// Segments with `face: None` (F-C's uncovered Arabic letter) — each
|
|
/// contributes zero glyphs to `drawn_glyph_count + empty_glyph_count` by
|
|
/// construction (`SpikeShapedSegment::glyphs` is always empty for these,
|
|
/// W3-F3 / `invariants::assert_unresolved_clusters_are_diagnostic`), so
|
|
/// this count is how that fact stays *visible* rather than silently
|
|
/// absent from the report.
|
|
pub unresolved_segment_count: usize,
|
|
pub stored_glyph_count: usize,
|
|
}
|
|
|
|
/// Builds one fixture's reference SVG + raster, deriving every
|
|
/// `round2_diff::GlyphRegion` from the bounds `emit_svg` itself returned.
|
|
///
|
|
/// Each segment's own `size` (staff-space em, recipe §3 — `1.28` for every
|
|
/// segment in this recipe, but read from the data, never hard-coded) and
|
|
/// each glyph's own `offset` (relative to `rt.origin`, recipe §5) are
|
|
/// converted to device space via `round2_textkit::hittest::to_device` —
|
|
/// reused rather than re-implemented, so there is exactly one transform
|
|
/// implementation in this whole packet, not two that could quietly diverge.
|
|
pub fn build_fixture_raster(
|
|
fixture_id: &str,
|
|
rt: &SpikeResolvedText,
|
|
faces: &[LoadedFace],
|
|
width: u32,
|
|
height: u32,
|
|
policy: FacePolicy,
|
|
) -> Result<FixtureRasterResult, String> {
|
|
struct Entry {
|
|
seg_idx: usize,
|
|
glyph_idx: usize,
|
|
draw: DrawGlyph,
|
|
}
|
|
|
|
let mut by_face: BTreeMap<u32, Vec<Entry>> = BTreeMap::new();
|
|
let mut unresolved_segment_count = 0usize;
|
|
let mut stored_glyph_count = 0usize;
|
|
|
|
for (seg_idx, seg) in rt.segments.iter().enumerate() {
|
|
stored_glyph_count += seg.glyphs.len();
|
|
let Some(face_idx) = seg.face else {
|
|
// F-C's uncovered codepoint: no face resolved, and per W3-F3 /
|
|
// invariant 4, `seg.glyphs` is guaranteed empty here — nothing to
|
|
// draw, nothing added to `by_face`. Shaping was never attempted
|
|
// against a face that cannot represent the codepoint (see
|
|
// `SpikeShapedSegment::face`'s own doc comment), so there is no
|
|
// "draw the .notdef glyph" fallback to suppress here either.
|
|
unresolved_segment_count += 1;
|
|
continue;
|
|
};
|
|
if policy == FacePolicy::Enforce {
|
|
let face = faces.get(face_idx as usize).ok_or_else(|| {
|
|
format!(
|
|
"{fixture_id}: segment {seg_idx} resolved to face {face_idx}, but only {} \
|
|
faces were loaded",
|
|
faces.len()
|
|
)
|
|
})?;
|
|
enforce_face_coverage(fixture_id, seg_idx, face_idx, face, &rt.text, &seg.source)?;
|
|
}
|
|
let em_px = seg.size.0 * hittest::DEVICE_SCALE;
|
|
for (glyph_idx, g) in seg.glyphs.iter().enumerate() {
|
|
let device = hittest::to_device(rt, &g.offset);
|
|
by_face.entry(face_idx).or_default().push(Entry {
|
|
seg_idx,
|
|
glyph_idx,
|
|
draw: DrawGlyph {
|
|
glyph_id: g.glyph_id as u16,
|
|
origin_x: device.x,
|
|
origin_y: device.y,
|
|
em_px,
|
|
},
|
|
});
|
|
}
|
|
}
|
|
|
|
let mut path_fragments = Vec::new();
|
|
let mut regions = Vec::new();
|
|
let mut drawn_glyph_count = 0usize;
|
|
let mut empty_glyph_count = 0usize;
|
|
|
|
for (face_idx, entries) in &by_face {
|
|
let face = faces.get(*face_idx as usize).ok_or_else(|| {
|
|
format!(
|
|
"{fixture_id}: a segment resolved to face {face_idx}, but only {} faces were \
|
|
loaded",
|
|
faces.len()
|
|
)
|
|
})?;
|
|
let draw_glyphs: Vec<DrawGlyph> = entries.iter().map(|e| e.draw).collect();
|
|
let (fragments, bounds, empty) =
|
|
round2_svgref::emit_glyph_paths(&face.bytes, face.identity.face_index, &draw_glyphs)?;
|
|
|
|
let correlated = correlate_bounds(&draw_glyphs, &bounds, &empty);
|
|
for (entry, maybe_bounds) in entries.iter().zip(correlated.iter()) {
|
|
match maybe_bounds {
|
|
Some(b) => {
|
|
let label = format!(
|
|
"{fixture_id} seg{}.glyph{} (face {face_idx}, gid {})",
|
|
entry.seg_idx, entry.glyph_idx, entry.draw.glyph_id
|
|
);
|
|
regions.push(GlyphRegion {
|
|
label,
|
|
x0: b.x0.floor().max(0.0) as u32,
|
|
y0: b.y0.floor().max(0.0) as u32,
|
|
x1: b.x1.ceil().max(0.0) as u32,
|
|
y1: b.y1.ceil().max(0.0) as u32,
|
|
});
|
|
drawn_glyph_count += 1;
|
|
}
|
|
None => empty_glyph_count += 1,
|
|
}
|
|
}
|
|
|
|
path_fragments.extend(fragments);
|
|
}
|
|
|
|
let final_svg = round2_svgref::wrap_document(width, height, &path_fragments);
|
|
round2_svgref::assert_no_text_elements(&final_svg)?;
|
|
let rgba = round2_svgref::rasterize(&final_svg, width, height)?;
|
|
|
|
Ok(FixtureRasterResult {
|
|
svg: final_svg,
|
|
rgba,
|
|
regions,
|
|
drawn_glyph_count,
|
|
empty_glyph_count,
|
|
unresolved_segment_count,
|
|
stored_glyph_count,
|
|
})
|
|
}
|
|
|
|
/// A discrete ink-pixel count — distinct from `round2_diff::ink_mass`'s
|
|
/// continuous sum. Reimplements the same Rec. 601 luma weights
|
|
/// `round2_diff` documents (its own `luma` helper is private), so "ink"
|
|
/// means the same thing here as it does inside the differential: `luma <
|
|
/// round2_diff::INK_LUMA_THRESHOLD`.
|
|
pub fn count_ink_pixels(rgba: &[u8]) -> usize {
|
|
rgba.chunks(4)
|
|
.filter(|p| {
|
|
let luma = (299 * p[0] as u32 + 587 * p[1] as u32 + 114 * p[2] as u32) / 1000;
|
|
luma < round2_diff::INK_LUMA_THRESHOLD as u32
|
|
})
|
|
.count()
|
|
}
|
|
|
|
/// Serializable mirror of `round2_diff::GlyphRegion` (which carries no
|
|
/// `serde` derive — it is a working type in a dependency-free crate, not a
|
|
/// wire type). Same boundary-mirror pattern `round2_textkit::types` uses for
|
|
/// `epiphany_layout_ir` types, for the same reason.
|
|
#[derive(serde::Serialize)]
|
|
pub struct RegionRecord {
|
|
pub label: String,
|
|
pub x0: u32,
|
|
pub y0: u32,
|
|
pub x1: u32,
|
|
pub y1: u32,
|
|
}
|
|
|
|
impl From<&GlyphRegion> for RegionRecord {
|
|
fn from(r: &GlyphRegion) -> Self {
|
|
RegionRecord {
|
|
label: r.label.clone(),
|
|
x0: r.x0,
|
|
y0: r.y0,
|
|
x1: r.x1,
|
|
y1: r.y1,
|
|
}
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn multi_face_composition_goes_through_the_api_not_a_substring_search() {
|
|
// Regression guard for the finding at the top of this file: composing
|
|
// a two-face document must use round2-svgref's own API. If that crate
|
|
// ever renames or reshapes these functions, this fails to COMPILE,
|
|
// which is the entire point — the substring version failed silently.
|
|
let paths = vec![
|
|
"<path fill=\"#000000\" d=\"M0 0 L1 1 Z\"/>".to_string(),
|
|
"<path fill=\"#000000\" d=\"M2 2 L3 3 Z\"/>".to_string(),
|
|
];
|
|
let doc = round2_svgref::wrap_document(64, 32, &paths);
|
|
assert!(doc.starts_with("<svg"));
|
|
assert!(doc.ends_with("</svg>"));
|
|
assert_eq!(doc.matches("<path").count(), 2);
|
|
assert!(round2_svgref::assert_no_text_elements(&doc).is_ok());
|
|
}
|
|
|
|
#[test]
|
|
fn correlate_bounds_matches_empty_and_nonempty_glyphs_by_id() {
|
|
let glyphs = [
|
|
DrawGlyph {
|
|
glyph_id: 5,
|
|
origin_x: 0.0,
|
|
origin_y: 0.0,
|
|
em_px: 10.0,
|
|
},
|
|
DrawGlyph {
|
|
glyph_id: 1,
|
|
origin_x: 1.0,
|
|
origin_y: 0.0,
|
|
em_px: 10.0,
|
|
}, // empty (e.g. space)
|
|
DrawGlyph {
|
|
glyph_id: 5,
|
|
origin_x: 2.0,
|
|
origin_y: 0.0,
|
|
em_px: 10.0,
|
|
},
|
|
];
|
|
let bounds = vec![
|
|
DrawnBounds {
|
|
glyph_id: 5,
|
|
x0: 0.0,
|
|
y0: 0.0,
|
|
x1: 1.0,
|
|
y1: 1.0,
|
|
},
|
|
DrawnBounds {
|
|
glyph_id: 5,
|
|
x0: 2.0,
|
|
y0: 0.0,
|
|
x1: 3.0,
|
|
y1: 1.0,
|
|
},
|
|
];
|
|
let empty = vec![1u16];
|
|
let correlated = correlate_bounds(&glyphs, &bounds, &empty);
|
|
assert_eq!(correlated.len(), 3);
|
|
assert!(correlated[0].is_some());
|
|
assert!(correlated[1].is_none());
|
|
assert!(correlated[2].is_some());
|
|
assert!((correlated[2].unwrap().x0 - 2.0).abs() < 1e-9);
|
|
}
|
|
|
|
#[test]
|
|
fn count_ink_pixels_matches_a_hand_built_buffer() {
|
|
// 2x2: one black (ink), three white (background).
|
|
let mut rgba = vec![255u8; 2 * 2 * 4];
|
|
rgba[0] = 0;
|
|
rgba[1] = 0;
|
|
rgba[2] = 0;
|
|
assert_eq!(count_ink_pixels(&rgba), 1);
|
|
}
|
|
}
|