diff --git a/docs/text-ir-v2.md b/docs/text-ir-v2.md index 112ff711c..c2d1646e3 100644 --- a/docs/text-ir-v2.md +++ b/docs/text-ir-v2.md @@ -246,6 +246,27 @@ the default public replay path. strict payloads still reject and the schema-v1 compatibility export keeps the `TextRun` fallback. +## P25 Exact Font Replay Proof Corpus + +P25 widens the exact-font replay proof corpus while keeping public glyph-run +fallback behavior conservative. + +- CanvasKit selection now rejects variable-font glyph-run instances with + `variationUnsupported` until an exact variation constructor is proven for the + public backend. +- CanvasKit selection also rejects non-default TTC/OTC face indexes with + `faceIndexUnsupported`; default face index `0` remains the positive control. +- Native Skia proof now distinguishes missing font blob bytes, exported + `dataRef` mismatch, and digest mismatch between the interned bytes and the + font metadata. Metadata mismatch is treated as a failed portable contract, + not as a best-effort construction case. +- Native Skia still reports variation axes, non-zero collection face indexes, + and the intentionally unimplemented exact typeface constructor as separate + proof reasons. This keeps later exact-construction work from silently changing + fallback policy. +- The glyph id field remains `u32` in Text IR, but backend selection/proof keeps + the current range guard before direct glyph replay. + Every overlay removal requires a Canvas2D-vs-CanvasKit fixture. Rasterizer output can use fuzzy PNG comparison, but semantic decisions must be exact: selected variant id, fallback reason, resource resolution, effect preprocessing diff --git a/src/renderer/layer_renderer.rs b/src/renderer/layer_renderer.rs index 5173d6e01..26005a8f4 100644 --- a/src/renderer/layer_renderer.rs +++ b/src/renderer/layer_renderer.rs @@ -128,6 +128,8 @@ pub enum VariantRejectReason { BackendDoesNotSupportVariant, FontNotPortable, ExternalFontNotVerified, + FaceIndexUnsupported, + VariationUnsupported, GlyphIdOutOfRange, MissingGlyph, ClusterMismatch, @@ -155,6 +157,8 @@ impl VariantRejectReason { Self::BackendDoesNotSupportVariant => "backendDoesNotSupportVariant", Self::FontNotPortable => "fontNotPortable", Self::ExternalFontNotVerified => "externalFontNotVerified", + Self::FaceIndexUnsupported => "faceIndexUnsupported", + Self::VariationUnsupported => "variationUnsupported", Self::GlyphIdOutOfRange => "glyphIdOutOfRange", Self::MissingGlyph => "missingGlyph", Self::ClusterMismatch => "clusterMismatch", @@ -409,7 +413,7 @@ impl TextVariantCandidate { reasons.insert(VariantRejectReason::BackendDoesNotSupportVariant); } for run in &self.glyph_runs { - collect_glyph_run_reject_reasons(run, options, &mut reasons); + collect_glyph_run_reject_reasons(run, options, resources, &mut reasons); } } TextVariantKind::GlyphOutline => { @@ -510,6 +514,7 @@ fn collect_text_variant_groups( fn collect_glyph_run_reject_reasons( run: &LayerGlyphRunPaint, options: TextVariantSelectionOptions, + resources: &ResourceArena, reasons: &mut BTreeSet, ) { if !run.paint_style.is_fill_only_glyph_replay() { @@ -518,6 +523,23 @@ fn collect_glyph_run_reject_reasons( if !matches!(run.orientation, GlyphRunOrientation::Horizontal) { reasons.insert(VariantRejectReason::UnsupportedPaintEffect); } + if matches!( + options.backend, + VariantSelectionBackend::CanvasKit | VariantSelectionBackend::NativeSkia + ) { + if !run.shape_key.font_instance.variations.is_empty() { + reasons.insert(VariantRejectReason::VariationUnsupported); + } + if resources + .font_resources() + .faces + .iter() + .find(|face| face.id == run.shape_key.font_instance.face_key) + .is_some_and(|face| face.face_index != 0) + { + reasons.insert(VariantRejectReason::FaceIndexUnsupported); + } + } collect_text_variant_diagnostics_reject_reasons(&run.diagnostics, options, reasons); if run .glyph_ids @@ -658,15 +680,15 @@ mod tests { ColorGradientStop, ColorLayersPayload, ColorLinearGradient, ColorPaintGraphNode, ColorPaintGraphNodeKind, ColorPaintGraphPayload, ColorPaintLinearGradientPathNode, ColorPaintRadialGradientPathNode, ColorPaintSolidPathNode, ColorPaintSweepGradientPathNode, - ColorRadialGradient, ColorSweepGradient, FontColorGlyphRef, FontFaceKey, - FontFallbackPolicyId, FontInstanceKey, GlyphCluster, GlyphOutlineFillRule, - GlyphOutlinePaintOrder, GlyphOutlinePayloadKind, GlyphOutlineStrokeCap, - GlyphOutlineStrokeJoin, GlyphOutlineStrokeStyle, GlyphRange, GlyphRunDiagnostics, - GlyphRunOrientation, ImageResourceId, LayerAffineTransform, LayerGlyphOutlinePath, - LayerNode, LayerPoint, LayerVector, PaintTextStyle, PaintVariantMeta, ResolvedColor, - ResourceArena, ScriptTag, ShapeKey, ShapingEngineId, SvgGlyphPayload, SvgResourceId, - TextDirection, TextRunPlacement, TextSourceId, TextSourceRange, TextSourceSpan, - WritingMode, + ColorRadialGradient, ColorSweepGradient, FontBlobKey, FontColorGlyphRef, FontFaceKey, + FontFaceResource, FontFallbackPolicyId, FontInstanceKey, GlyphCluster, + GlyphOutlineFillRule, GlyphOutlinePaintOrder, GlyphOutlinePayloadKind, + GlyphOutlineStrokeCap, GlyphOutlineStrokeJoin, GlyphOutlineStrokeStyle, GlyphRange, + GlyphRunDiagnostics, GlyphRunOrientation, ImageResourceId, LayerAffineTransform, + LayerGlyphOutlinePath, LayerNode, LayerPoint, LayerVector, PaintTextStyle, + PaintVariantMeta, ResolvedColor, ResourceArena, ScriptTag, ShapeKey, ShapingEngineId, + SvgGlyphPayload, SvgResourceId, TextDirection, TextRunPlacement, TextSourceId, + TextSourceRange, TextSourceSpan, VariationAxisValue, WritingMode, }; use crate::renderer::render_tree::{BoundingBox, FieldMarkerType, TextRunNode}; use crate::renderer::{PathCommand, TextStyle}; @@ -1080,6 +1102,13 @@ mod tests { .unwrap() } + fn native_skia_options() -> TextVariantSelectionOptions { + TextVariantSelectionOptions { + backend: VariantSelectionBackend::NativeSkia, + ..TextVariantSelectionOptions::canvaskit() + } + } + #[test] fn canvaskit_selects_strict_glyph_run() { let report = first_report( @@ -1109,6 +1138,157 @@ mod tests { ); } + #[test] + fn canvaskit_keeps_default_face_without_variation_as_font_proof_control() { + let report = first_report_with_resource_setup( + vec![text_op(), glyph_run(diagnostics(), 42)], + TextVariantSelectionOptions::canvaskit(), + |resources| { + resources.font_resources_mut().faces.push(FontFaceResource { + id: FontFaceKey("face-0".to_string()), + blob_key: FontBlobKey("blob-0".to_string()), + face_index: 0, + postscript_name: None, + family_names: Vec::new(), + style_names: Vec::new(), + weight_class: None, + width_class: None, + italic: None, + }); + }, + ); + + assert_eq!(report.selected_variant_id.as_deref(), Some("glyphRun")); + assert!(!report.fallback_required); + assert!(report.rejected_variants.is_empty()); + } + + #[test] + fn canvaskit_rejects_variation_instances_until_exact_construction_is_proven() { + let mut op = glyph_run(diagnostics(), 42); + if let PaintOp::GlyphRun { run, .. } = &mut op { + run.shape_key.font_instance.variations = vec![VariationAxisValue { + tag: "wght".to_string(), + value: 700.0, + }]; + } + let report = first_report( + vec![text_op(), op], + TextVariantSelectionOptions::canvaskit(), + ); + + assert_eq!(report.selected_variant_kind, Some(TextVariantKind::TextRun)); + assert!(report.fallback_required); + assert!(report.rejected_variants[0] + .reasons + .contains(&VariantRejectReason::VariationUnsupported)); + assert_eq!( + VariantRejectReason::VariationUnsupported.as_str(), + "variationUnsupported" + ); + } + + #[test] + fn canvaskit_rejects_non_default_collection_face_until_exact_construction_is_proven() { + let report = first_report_with_resource_setup( + vec![text_op(), glyph_run(diagnostics(), 42)], + TextVariantSelectionOptions::canvaskit(), + |resources| { + resources.font_resources_mut().faces.push(FontFaceResource { + id: FontFaceKey("face-0".to_string()), + blob_key: FontBlobKey("blob-0".to_string()), + face_index: 1, + postscript_name: None, + family_names: Vec::new(), + style_names: Vec::new(), + weight_class: None, + width_class: None, + italic: None, + }); + }, + ); + + assert_eq!(report.selected_variant_kind, Some(TextVariantKind::TextRun)); + assert!(report.fallback_required); + assert!(report.rejected_variants[0] + .reasons + .contains(&VariantRejectReason::FaceIndexUnsupported)); + assert_eq!( + VariantRejectReason::FaceIndexUnsupported.as_str(), + "faceIndexUnsupported" + ); + } + + #[test] + fn native_skia_keeps_default_face_without_variation_as_font_proof_control() { + let report = first_report_with_resource_setup( + vec![text_op(), glyph_run(diagnostics(), 42)], + native_skia_options(), + |resources| { + resources.font_resources_mut().faces.push(FontFaceResource { + id: FontFaceKey("face-0".to_string()), + blob_key: FontBlobKey("blob-0".to_string()), + face_index: 0, + postscript_name: None, + family_names: Vec::new(), + style_names: Vec::new(), + weight_class: None, + width_class: None, + italic: None, + }); + }, + ); + + assert_eq!(report.selected_variant_id.as_deref(), Some("glyphRun")); + assert!(!report.fallback_required); + assert!(report.rejected_variants.is_empty()); + } + + #[test] + fn native_skia_rejects_variation_instances_until_exact_construction_is_proven() { + let mut op = glyph_run(diagnostics(), 42); + if let PaintOp::GlyphRun { run, .. } = &mut op { + run.shape_key.font_instance.variations = vec![VariationAxisValue { + tag: "wght".to_string(), + value: 700.0, + }]; + } + let report = first_report(vec![text_op(), op], native_skia_options()); + + assert_eq!(report.selected_variant_kind, Some(TextVariantKind::TextRun)); + assert!(report.fallback_required); + assert!(report.rejected_variants[0] + .reasons + .contains(&VariantRejectReason::VariationUnsupported)); + } + + #[test] + fn native_skia_rejects_non_default_collection_face_until_exact_construction_is_proven() { + let report = first_report_with_resource_setup( + vec![text_op(), glyph_run(diagnostics(), 42)], + native_skia_options(), + |resources| { + resources.font_resources_mut().faces.push(FontFaceResource { + id: FontFaceKey("face-0".to_string()), + blob_key: FontBlobKey("blob-0".to_string()), + face_index: 1, + postscript_name: None, + family_names: Vec::new(), + style_names: Vec::new(), + weight_class: None, + width_class: None, + italic: None, + }); + }, + ); + + assert_eq!(report.selected_variant_kind, Some(TextVariantKind::TextRun)); + assert!(report.fallback_required); + assert!(report.rejected_variants[0] + .reasons + .contains(&VariantRejectReason::FaceIndexUnsupported)); + } + #[test] fn canvaskit_rejects_unsupported_text_effects() { let mut op = glyph_run(diagnostics(), 42); diff --git a/src/renderer/skia/renderer.rs b/src/renderer/skia/renderer.rs index a16a667b2..4a7d8e4c9 100644 --- a/src/renderer/skia/renderer.rs +++ b/src/renderer/skia/renderer.rs @@ -46,6 +46,8 @@ pub enum NativeGlyphRunReplayProofReason { FontBlobMissing, FontBlobNotPortable, FontBlobBytesMissing, + FontBlobDataRefMismatch, + FontBlobDigestMismatch, FaceIndexUnsupported, FontVariationUnsupported, TypefaceConstructionNotImplemented, @@ -73,6 +75,8 @@ impl NativeGlyphRunReplayProofReason { Self::FontBlobMissing => "fontBlobMissing", Self::FontBlobNotPortable => "fontBlobNotPortable", Self::FontBlobBytesMissing => "fontBlobBytesMissing", + Self::FontBlobDataRefMismatch => "fontBlobDataRefMismatch", + Self::FontBlobDigestMismatch => "fontBlobDigestMismatch", Self::FaceIndexUnsupported => "faceIndexUnsupported", Self::FontVariationUnsupported => "fontVariationUnsupported", Self::TypefaceConstructionNotImplemented => "typefaceConstructionNotImplemented", @@ -180,8 +184,20 @@ pub fn native_skia_glyph_run_replay_proof( } else if let crate::paint::FontPortability::PortableBlob { data_ref, .. } = &blob.portability { - if resources.font_blob_bytes_for_ref(data_ref).is_none() { - contract_reasons.insert(NativeGlyphRunReplayProofReason::FontBlobBytesMissing); + if blob.data_ref.as_ref() != Some(data_ref) { + contract_reasons + .insert(NativeGlyphRunReplayProofReason::FontBlobDataRefMismatch); + } + match resources.font_blob_bytes_for_ref(data_ref) { + Some(bytes) if font_blob_digest_matches(bytes, blob) => {} + Some(_) => { + contract_reasons + .insert(NativeGlyphRunReplayProofReason::FontBlobDigestMismatch); + } + None => { + contract_reasons + .insert(NativeGlyphRunReplayProofReason::FontBlobBytesMissing); + } } } } else { @@ -235,6 +251,25 @@ pub fn native_skia_glyph_run_replay_proof( } } +fn font_blob_digest_matches(bytes: &[u8], blob: &crate::paint::FontBlobResource) -> bool { + let actual = crate::paint::resource_digest_hex(bytes); + let portability_digest_matches = match &blob.portability { + crate::paint::FontPortability::PortableBlob { digest, .. } => { + font_digest_matches_resource_bytes(digest, &actual) + } + _ => false, + }; + let blob_digest_matches = blob + .digest + .as_ref() + .is_none_or(|digest| font_digest_matches_resource_bytes(digest, &actual)); + portability_digest_matches && blob_digest_matches +} + +fn font_digest_matches_resource_bytes(digest: &crate::paint::FontDigest, actual: &str) -> bool { + digest.algorithm == crate::paint::RESOURCE_KEY_ALGORITHM && digest.value == actual +} + pub struct SkiaLayerRenderer { font_mgr: FontMgr, /// 사용자 지정 폰트 디렉토리에서 미리 로드한 폰트 캐시. @@ -1598,6 +1633,113 @@ mod tests { .contains(&NativeGlyphRunReplayProofReason::FontBlobBytesMissing)); } + #[test] + fn native_skia_glyph_run_proof_reports_portability_font_blob_digest_mismatch() { + let mut resources = portable_font_resources(); + let wrong_digest = FontDigest { + algorithm: "blake3".to_string(), + value: resource_digest_hex([9_u8, 9, 9, 9]), + }; + if let FontPortability::PortableBlob { digest, .. } = + &mut resources.font_resources_mut().blobs[0].portability + { + *digest = wrong_digest.clone(); + } + let run = portable_glyph_run(GlyphRunOrientation::Horizontal); + let proof = native_skia_glyph_run_replay_proof(&run, &resources); + + assert!(!proof.contract_replayable); + assert!(proof + .reasons + .contains(&NativeGlyphRunReplayProofReason::FontBlobDigestMismatch)); + assert_eq!( + NativeGlyphRunReplayProofReason::FontBlobDigestMismatch.as_str(), + "fontBlobDigestMismatch" + ); + } + + #[test] + fn native_skia_glyph_run_proof_reports_blob_font_digest_mismatch() { + let mut resources = portable_font_resources(); + resources.font_resources_mut().blobs[0].digest = Some(FontDigest { + algorithm: "blake3".to_string(), + value: resource_digest_hex([9_u8, 9, 9, 9]), + }); + let run = portable_glyph_run(GlyphRunOrientation::Horizontal); + let proof = native_skia_glyph_run_replay_proof(&run, &resources); + + assert!(!proof.contract_replayable); + assert!(proof + .reasons + .contains(&NativeGlyphRunReplayProofReason::FontBlobDigestMismatch)); + } + + #[test] + fn native_skia_glyph_run_proof_rejects_unsupported_font_digest_algorithm() { + { + let mut resources = portable_font_resources(); + if let FontPortability::PortableBlob { digest, .. } = + &mut resources.font_resources_mut().blobs[0].portability + { + digest.algorithm = "sha256".to_string(); + } + let run = portable_glyph_run(GlyphRunOrientation::Horizontal); + let proof = native_skia_glyph_run_replay_proof(&run, &resources); + + assert!(!proof.contract_replayable); + assert!(proof + .reasons + .contains(&NativeGlyphRunReplayProofReason::FontBlobDigestMismatch)); + } + + { + let mut resources = portable_font_resources(); + if let Some(digest) = &mut resources.font_resources_mut().blobs[0].digest { + digest.algorithm = "sha256".to_string(); + } + let run = portable_glyph_run(GlyphRunOrientation::Horizontal); + let proof = native_skia_glyph_run_replay_proof(&run, &resources); + + assert!(!proof.contract_replayable); + assert!(proof + .reasons + .contains(&NativeGlyphRunReplayProofReason::FontBlobDigestMismatch)); + } + } + + #[test] + fn native_skia_glyph_run_proof_reports_missing_blob_data_ref_metadata() { + let mut resources = portable_font_resources(); + resources.font_resources_mut().blobs[0].data_ref = None; + let run = portable_glyph_run(GlyphRunOrientation::Horizontal); + let proof = native_skia_glyph_run_replay_proof(&run, &resources); + + assert!(!proof.contract_replayable); + assert!(proof + .reasons + .contains(&NativeGlyphRunReplayProofReason::FontBlobDataRefMismatch)); + assert_eq!( + NativeGlyphRunReplayProofReason::FontBlobDataRefMismatch.as_str(), + "fontBlobDataRefMismatch" + ); + } + + #[test] + fn native_skia_glyph_run_proof_reports_mismatched_blob_data_ref_metadata() { + let mut resources = portable_font_resources(); + resources.font_resources_mut().blobs[0].data_ref = Some(BinaryResourceRef { + kind: BinaryResourceKind::FontBlob, + id: "font:blake3:4:wrong".to_string(), + }); + let run = portable_glyph_run(GlyphRunOrientation::Horizontal); + let proof = native_skia_glyph_run_replay_proof(&run, &resources); + + assert!(!proof.contract_replayable); + assert!(proof + .reasons + .contains(&NativeGlyphRunReplayProofReason::FontBlobDataRefMismatch)); + } + #[test] fn native_skia_glyph_run_proof_separates_replay_eligibility_from_blob_portability() { let resources = portable_font_resources(); @@ -1635,6 +1777,19 @@ mod tests { .contains(&NativeGlyphRunReplayProofReason::FontVariationUnsupported)); } + #[test] + fn native_skia_glyph_run_proof_keeps_glyph_id_range_guard() { + let resources = portable_font_resources(); + let mut run = portable_glyph_run(GlyphRunOrientation::Horizontal); + run.glyph_ids[0] = u16::MAX as u32 + 1; + let proof = native_skia_glyph_run_replay_proof(&run, &resources); + + assert!(!proof.contract_replayable); + assert!(proof + .reasons + .contains(&NativeGlyphRunReplayProofReason::GlyphIdOutOfRange)); + } + #[test] fn native_skia_glyph_run_proof_reports_missing_face() { let resources = ResourceArena::default();