diff --git a/docs/text-ir-v2.md b/docs/text-ir-v2.md index 5f0668a7..aecf4907 100644 --- a/docs/text-ir-v2.md +++ b/docs/text-ir-v2.md @@ -19,6 +19,13 @@ paint style is fill-only. Native Skia deliberately keeps using the `TextRun` fallback in P12 because exact blob-backed typeface construction is not wired yet. +P13 closes the first diagnostics layer for this contract. The export is still +schema v1 and still keeps `TextRun` fallback as the replay baseline, but it now +also reports `textV2` compatibility diagnostics: slot-level variant state, +structured validation issues, the v1 downgrade path, fallback-free profile +guards, and line-break risk telemetry for text runs whose shaped replay could +affect layout-sensitive behavior. + ## Export Contract Layer JSON now provides additive text metadata: @@ -41,6 +48,13 @@ Layer JSON now provides additive text metadata: - `fontResources`, an additive table for font blob/face identity. - Optional `GlyphRun` sidecar ops with `variant`, `shapeKey`, glyph ids, glyph positions, shaped clusters, and replay diagnostics. +- `textV2`, an additive diagnostics object with: + - `compatibilityProfile`, currently `v1Compat` for normal exports. + - `fallbackRequired`, which stays true for the v1 compatibility writer. + - `downgradePath=schemaV1FlattenedTextRunAndGlyphRun`. + - `slotDiagnostics`, one entry per v1 text variant group. + - `validationIssues`, using stable issue codes and severity. + - `lineBreakRisks`, report-only telemetry for complex text runs. The explicit visual ops are additive. Existing renderers skip them and keep drawing the paired `TextRun` mirror, so visual output does not double-paint. @@ -70,6 +84,18 @@ fallback instead. but native Skia selection remains disabled until it can instantiate the exact referenced font blob/face. Normal layer lowering still emits `TextRun` only unless a shaping pass explicitly inserts glyph alternatives. +- P13 `textV2` diagnostics are additive and report-only for normal exports. + They must not change renderer output or make `GlyphRun` the canonical path. +- A fallback-free text profile is only valid when every text variant slot has a + strict visual variant. In schema v1 the default writer still exports the + fallback, and the fallback-free profile is only exposed as a guard/validator. +- `slotDiagnostics.strictVariantAvailable` requires exact or position-adjusted + quality, strict visual eligibility, replayable font eligibility, no missing + glyphs, no cluster mismatch, and no unsplit fallback-font use. +- `lineBreakRisks` is explanatory telemetry. It marks cases such as char + overlap, vertical/rotated text, ratio/spacing changes, tab leaders, visible + text effects, field markers, and explicit line/paragraph-end markers. It is + not a layout decision source. - Canvas2D/layered SVG keep using the `TextRun` fallback and ignore glyph sidecars. - Glyph ids require portable font identity. Consumers must not replay glyph ids @@ -81,4 +107,5 @@ fallback instead. - Add CanvasKit glyph replay behind the same variant gate. - Add native glyph outline replay behind a separate strict visual variant. - Add resource table entries for font blobs and face identity. -- Promote renderer diagnostics once glyph alternatives exist. +- Promote renderer diagnostics from report-only to backend selection telemetry + once CanvasKit/native glyph alternatives are actually consumed. diff --git a/src/paint/json.rs b/src/paint/json.rs index af6acb94..b62dd866 100644 --- a/src/paint/json.rs +++ b/src/paint/json.rs @@ -11,7 +11,7 @@ use crate::paint::{ GroupKind, LayerAffineTransform, LayerNode, LayerNodeKind, LayerPoint, LayerVector, PageLayerTree, PaintOp, PaintTextStyle, PaintVariantMeta, RenderProfile, ShapeKey, TextDecorationKind, TextSourceAnnotation, TextSourceEntry, TextSourceId, TextSourceRange, - TextSourceSpan, TextSourceTable, LAYER_TREE_SCHEMA, + TextSourceSpan, TextSourceTable, TextV2Diagnostics, LAYER_TREE_SCHEMA, }; use crate::renderer::layout::compute_char_positions; use crate::renderer::render_tree::{BoundingBox, FieldMarkerType, ShapeTransform, TextRunNode}; @@ -53,6 +53,8 @@ impl PageLayerTree { buf.push_str(",\"fontResources\":"); write_font_resources(&mut buf, self.resources.font_resources()); write_text_export_metadata(&mut buf, &self.root); + buf.push_str(",\"textV2\":"); + TextV2Diagnostics::from_layer_tree(self).write_json(&mut buf); buf.push('}'); buf } @@ -62,7 +64,7 @@ fn write_text_export_metadata(buf: &mut String, root: &LayerNode) { let externalized_visuals = externalized_text_visuals(root); let has_variant_groups = has_text_variant_groups(root); let has_glyph_runs = has_glyph_runs(root); - buf.push_str(",\"usedFeatures\":[\"text.paintStyle\",\"text.sourceTable\",\"text.sourceSpan\",\"text.v2.placement\",\"text.v2.clusters\",\"text.projectionKind\",\"text.legacyVisuals\""); + buf.push_str(",\"usedFeatures\":[\"text.paintStyle\",\"text.sourceTable\",\"text.sourceSpan\",\"text.v2.placement\",\"text.v2.clusters\",\"text.v2.diagnostics\",\"text.projectionKind\",\"text.legacyVisuals\""); if has_glyph_runs { buf.push_str(",\"fontResources\",\"text.glyphRun\""); } @@ -85,7 +87,7 @@ fn write_text_export_metadata(buf: &mut String, root: &LayerNode) { if has_glyph_runs { buf.push_str("\"fontResources\",\"text.glyphRun\""); } - buf.push_str("],\"knownFeatures\":[\"fontResources\",\"fontResources.blobFaceSplit\",\"text.variantGroups\",\"text.shapeDiagnostics\",\"text.glyphRun\",\"text.outlineGlyph\",\"text.specialVisualOps\",\"text.charOverlapOp\",\"text.controlMarkOp\",\"text.tabLeaderOp\",\"text.decorationOp\",\"text.vertical.mixedPerGlyph\"],\"requiredFeatures\":[],\"text\":{\"defaultVariant\":\"textRun\",\"variants\":[\"textRun\""); + buf.push_str("],\"knownFeatures\":[\"fontResources\",\"fontResources.blobFaceSplit\",\"text.variantGroups\",\"text.shapeDiagnostics\",\"text.v2.diagnostics\",\"text.v2.slotDiagnostics\",\"text.v2.validationIssues\",\"text.lineBreakRiskTelemetry\",\"text.fallbackFreeStrictProfile\",\"text.glyphRun\",\"text.outlineGlyph\",\"text.specialVisualOps\",\"text.charOverlapOp\",\"text.controlMarkOp\",\"text.tabLeaderOp\",\"text.decorationOp\",\"text.vertical.mixedPerGlyph\"],\"requiredFeatures\":[],\"text\":{\"defaultVariant\":\"textRun\",\"variants\":[\"textRun\""); if has_glyph_runs { buf.push_str(",\"glyphRun\""); } @@ -1878,8 +1880,8 @@ mod tests { assert!(json.contains("\"kind\":\"leaf\"")); assert!(json.contains("\"schemaVersion\":1")); - assert!(json.contains("\"schemaMinorVersion\":9")); - assert!(json.contains("\"schema\":{\"major\":1,\"minor\":9}")); + assert!(json.contains("\"schemaMinorVersion\":10")); + assert!(json.contains("\"schema\":{\"major\":1,\"minor\":10}")); assert!(json.contains("\"resourceTableVersion\":1")); assert!(json.contains("\"resourceTableMinorVersion\":3")); assert!(json.contains("\"resourceTable\":{\"major\":1,\"minor\":3}")); @@ -1902,8 +1904,11 @@ mod tests { assert!(json.contains("\"fieldMarker\":{\"kind\":\"fieldBegin\"}")); assert!(json.contains("\"charOverlap\":{\"borderType\":1,\"innerCharSize\":90}")); assert!(json.contains("\"usedFeatures\":[\"text.paintStyle\"")); + assert!(json.contains("\"text.v2.diagnostics\"")); assert!(json.contains("\"knownFeatures\":[\"fontResources\"")); assert!(json.contains("\"fontResources\":{\"blobs\":[],\"faces\":[]}")); + assert!(json.contains("\"textV2\":{\"compatibilityProfile\":\"v1Compat\"")); + assert!(json.contains("\"downgradePath\":\"schemaV1FlattenedTextRunAndGlyphRun\"")); assert!(json.contains("\"text\":{\"defaultVariant\":\"textRun\"")); assert!(json.contains("\"fontFamily\":\"Noto Sans KR\"")); assert!(json.contains("\"italic\":true")); @@ -2142,6 +2147,8 @@ mod tests { assert!(json.contains("\"glyphIds\":[42]")); assert!(json.contains("\"replayEligibility\":\"portable\"")); assert!(json.contains("\"strictVisualEligible\":true")); + assert!(json.contains("\"slotDiagnostics\":[{\"paintOrderSlotId\":\"text-0\"")); + assert!(json.contains("\"strictVariantAvailable\":true")); } #[test] diff --git a/src/paint/mod.rs b/src/paint/mod.rs index b67f9b31..c25dcb18 100644 --- a/src/paint/mod.rs +++ b/src/paint/mod.rs @@ -11,6 +11,7 @@ pub mod profile; pub mod resources; pub mod schema; pub mod text_shape; +pub mod text_v2; pub mod text_variants; pub use builder::LayerBuilder; @@ -46,4 +47,8 @@ pub use text_shape::{ FontRequest, FontResolver, GlyphRunQuality, NoopFontResolver, ResolvedFontFace, ResolvedGlyphRun, TextShapeDiagnostic, TextShapeLowerer, TextShapeReport, }; +pub use text_v2::{ + TextV2CompatibilityProfile, TextV2Diagnostics, TextV2LineBreakRisk, TextV2LineBreakRiskLevel, + TextV2SlotDiagnostic, TextV2ValidationIssue, TextV2ValidationSeverity, TextV2VariantDiagnostic, +}; pub use text_variants::{validate_text_variant_scope, TextVariantScopeError}; diff --git a/src/paint/schema.rs b/src/paint/schema.rs index 6e3447f1..389915c5 100644 --- a/src/paint/schema.rs +++ b/src/paint/schema.rs @@ -15,7 +15,7 @@ pub struct LayerTreeSchema { pub const LAYER_TREE_SCHEMA: LayerTreeSchema = LayerTreeSchema { schema_version: 1, - schema_minor_version: 9, + schema_minor_version: 10, resource_table_version: 1, resource_table_minor_version: 3, unit: "px", @@ -37,7 +37,7 @@ mod tests { #[test] fn layer_tree_schema_contract_is_stable() { assert_eq!(LAYER_TREE_SCHEMA.schema_version, 1); - assert_eq!(LAYER_TREE_SCHEMA.schema_minor_version, 9); + assert_eq!(LAYER_TREE_SCHEMA.schema_minor_version, 10); assert_eq!(LAYER_TREE_SCHEMA.resource_table_version, 1); assert_eq!(LAYER_TREE_SCHEMA.resource_table_minor_version, 3); assert_eq!(LAYER_TREE_SCHEMA.unit, "px"); diff --git a/src/paint/text_v2.rs b/src/paint/text_v2.rs new file mode 100644 index 00000000..60a63cbd --- /dev/null +++ b/src/paint/text_v2.rs @@ -0,0 +1,838 @@ +//! Text IR v2 diagnostics and compatibility profile guards. +//! +//! P13 keeps the schema-v1 flattened `TextRun`/`GlyphRun` export as the +//! compatibility writer. This module adds structured diagnostics that explain +//! whether a future schema-v2 text slot can be promoted without silently +//! dropping the required `TextRun` fallback. + +use std::collections::{BTreeMap, BTreeSet}; +use std::fmt::Write as _; + +use serde::Serialize; + +use crate::document_core::helpers::json_escape as raw_json_escape; +use crate::model::style::UnderlineType; +use crate::paint::{ + GlyphRunDiagnostics, GlyphRunReplayEligibility, LayerNode, LayerNodeKind, PageLayerTree, + PaintOp, TextVariantKind, TextVariantQuality, +}; +use crate::renderer::render_tree::{FieldMarkerType, TextRunNode}; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] +#[serde(rename_all = "camelCase")] +pub enum TextV2CompatibilityProfile { + V1Compat, + V2Compat, + FallbackFreeStrict, +} + +impl TextV2CompatibilityProfile { + pub fn as_str(self) -> &'static str { + match self { + Self::V1Compat => "v1Compat", + Self::V2Compat => "v2Compat", + Self::FallbackFreeStrict => "fallbackFreeStrict", + } + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] +#[serde(rename_all = "camelCase")] +pub enum TextV2ValidationSeverity { + Info, + Warning, + Error, +} + +impl TextV2ValidationSeverity { + fn as_str(self) -> &'static str { + match self { + Self::Info => "info", + Self::Warning => "warning", + Self::Error => "error", + } + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] +#[serde(rename_all = "camelCase")] +pub enum TextV2LineBreakRiskLevel { + NoChangeLikely, + ChangePossible, + ChangeLikely, +} + +impl TextV2LineBreakRiskLevel { + fn as_str(self) -> &'static str { + match self { + Self::NoChangeLikely => "noChangeLikely", + Self::ChangePossible => "changePossible", + Self::ChangeLikely => "changeLikely", + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct TextV2ValidationIssue { + pub severity: TextV2ValidationSeverity, + pub code: &'static str, + #[serde(skip_serializing_if = "Option::is_none")] + pub slot_id: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub leaf_path: Option, + pub message: String, +} + +#[derive(Debug, Clone, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct TextV2VariantDiagnostic { + pub variant_id: String, + pub variant_kind: &'static str, + pub required_features: Vec, + pub part_count: u32, + pub present_part_count: u32, + #[serde(skip_serializing_if = "Option::is_none")] + pub quality: Option<&'static str>, + pub strict_visual_eligible: bool, + #[serde(skip_serializing_if = "Option::is_none")] + pub fallback_reason: Option, +} + +#[derive(Debug, Clone, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct TextV2SlotDiagnostic { + pub paint_order_slot_id: String, + pub equivalence_group: String, + pub leaf_path: String, + pub fallback_present: bool, + pub strict_variant_available: bool, + pub variants: Vec, + #[serde(skip_serializing_if = "Option::is_none")] + pub fallback_reason: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct TextV2LineBreakRisk { + pub leaf_path: String, + pub text_preview: String, + pub risk: TextV2LineBreakRiskLevel, + pub reasons: Vec<&'static str>, +} + +#[derive(Debug, Clone, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct TextV2Diagnostics { + pub compatibility_profile: TextV2CompatibilityProfile, + pub fallback_required: bool, + pub downgrade_path: &'static str, + pub slot_diagnostics: Vec, + pub validation_issues: Vec, + pub line_break_risks: Vec, +} + +impl TextV2Diagnostics { + pub fn from_layer_tree(tree: &PageLayerTree) -> Self { + Self::from_layer_tree_with_profile(tree, TextV2CompatibilityProfile::V1Compat) + } + + pub fn from_layer_tree_with_profile( + tree: &PageLayerTree, + profile: TextV2CompatibilityProfile, + ) -> Self { + let mut slots = Vec::new(); + let mut line_break_risks = Vec::new(); + collect_node( + &tree.root, + "root".to_string(), + &mut slots, + &mut line_break_risks, + ); + let mut validation_issues = Vec::new(); + if let Err(error) = crate::paint::validate_text_variant_scope(tree) { + validation_issues.push(TextV2ValidationIssue { + severity: TextV2ValidationSeverity::Error, + code: "schemaV1VariantScopeInvalid", + slot_id: None, + leaf_path: None, + message: error.to_string(), + }); + } + for slot in &slots { + if !slot.fallback_present { + validation_issues.push(TextV2ValidationIssue { + severity: TextV2ValidationSeverity::Error, + code: "schemaV1TextRunFallbackMissing", + slot_id: Some(slot.paint_order_slot_id.clone()), + leaf_path: Some(slot.leaf_path.clone()), + message: format!( + "text slot `{}` has no schema-v1 TextRun fallback", + slot.paint_order_slot_id + ), + }); + } + if matches!(profile, TextV2CompatibilityProfile::FallbackFreeStrict) + && !slot.strict_variant_available + { + validation_issues.push(TextV2ValidationIssue { + severity: TextV2ValidationSeverity::Error, + code: "fallbackFreeStrictVariantMissing", + slot_id: Some(slot.paint_order_slot_id.clone()), + leaf_path: Some(slot.leaf_path.clone()), + message: format!( + "fallback-free text profile requires a strict visual variant for `{}`", + slot.equivalence_group + ), + }); + } + } + if matches!(profile, TextV2CompatibilityProfile::FallbackFreeStrict) && slots.is_empty() { + validation_issues.push(TextV2ValidationIssue { + severity: TextV2ValidationSeverity::Error, + code: "fallbackFreeTextVariantMissing", + slot_id: None, + leaf_path: None, + message: + "fallback-free text profile requires at least one strict text variant slot" + .to_string(), + }); + } + Self { + compatibility_profile: profile, + fallback_required: !matches!(profile, TextV2CompatibilityProfile::FallbackFreeStrict), + downgrade_path: "schemaV1FlattenedTextRunAndGlyphRun", + slot_diagnostics: slots, + validation_issues, + line_break_risks, + } + } + + pub fn has_errors(&self) -> bool { + self.validation_issues + .iter() + .any(|issue| issue.severity == TextV2ValidationSeverity::Error) + } + + pub fn to_json(&self) -> String { + let mut buf = String::with_capacity(1024); + self.write_json(&mut buf); + buf + } + + pub fn write_json(&self, buf: &mut String) { + buf.push('{'); + let _ = write!( + buf, + "\"compatibilityProfile\":{},\"fallbackRequired\":{},\"downgradePath\":{},\"slotDiagnostics\":[", + json_string(self.compatibility_profile.as_str()), + self.fallback_required, + json_string(self.downgrade_path) + ); + for (index, slot) in self.slot_diagnostics.iter().enumerate() { + if index > 0 { + buf.push(','); + } + write_slot_diagnostic(buf, slot); + } + buf.push_str("],\"validationIssues\":["); + for (index, issue) in self.validation_issues.iter().enumerate() { + if index > 0 { + buf.push(','); + } + write_validation_issue(buf, issue); + } + buf.push_str("],\"lineBreakRisks\":["); + for (index, risk) in self.line_break_risks.iter().enumerate() { + if index > 0 { + buf.push(','); + } + write_line_break_risk(buf, risk); + } + buf.push_str("]}"); + } +} + +fn write_slot_diagnostic(buf: &mut String, slot: &TextV2SlotDiagnostic) { + let _ = write!( + buf, + "{{\"paintOrderSlotId\":{},\"equivalenceGroup\":{},\"leafPath\":{},\"fallbackPresent\":{},\"strictVariantAvailable\":{},\"variants\":[", + json_string(&slot.paint_order_slot_id), + json_string(&slot.equivalence_group), + json_string(&slot.leaf_path), + slot.fallback_present, + slot.strict_variant_available + ); + for (index, variant) in slot.variants.iter().enumerate() { + if index > 0 { + buf.push(','); + } + write_variant_diagnostic(buf, variant); + } + buf.push(']'); + if let Some(reason) = &slot.fallback_reason { + let _ = write!(buf, ",\"fallbackReason\":{}", json_string(reason)); + } + buf.push('}'); +} + +fn write_variant_diagnostic(buf: &mut String, variant: &TextV2VariantDiagnostic) { + let _ = write!( + buf, + "{{\"variantId\":{},\"variantKind\":{},\"requiredFeatures\":[", + json_string(&variant.variant_id), + json_string(variant.variant_kind) + ); + for (index, feature) in variant.required_features.iter().enumerate() { + if index > 0 { + buf.push(','); + } + buf.push_str(&json_string(feature)); + } + let _ = write!( + buf, + "],\"partCount\":{},\"presentPartCount\":{}", + variant.part_count, variant.present_part_count + ); + if let Some(quality) = variant.quality { + let _ = write!(buf, ",\"quality\":{}", json_string(quality)); + } + let _ = write!( + buf, + ",\"strictVisualEligible\":{}", + variant.strict_visual_eligible + ); + if let Some(reason) = &variant.fallback_reason { + let _ = write!(buf, ",\"fallbackReason\":{}", json_string(reason)); + } + buf.push('}'); +} + +fn write_validation_issue(buf: &mut String, issue: &TextV2ValidationIssue) { + let _ = write!( + buf, + "{{\"severity\":{},\"code\":{}", + json_string(issue.severity.as_str()), + json_string(issue.code) + ); + if let Some(slot_id) = &issue.slot_id { + let _ = write!(buf, ",\"slotId\":{}", json_string(slot_id)); + } + if let Some(leaf_path) = &issue.leaf_path { + let _ = write!(buf, ",\"leafPath\":{}", json_string(leaf_path)); + } + let _ = write!(buf, ",\"message\":{}}}", json_string(&issue.message)); +} + +fn write_line_break_risk(buf: &mut String, risk: &TextV2LineBreakRisk) { + let _ = write!( + buf, + "{{\"leafPath\":{},\"textPreview\":{},\"risk\":{},\"reasons\":[", + json_string(&risk.leaf_path), + json_string(&risk.text_preview), + json_string(risk.risk.as_str()) + ); + for (index, reason) in risk.reasons.iter().enumerate() { + if index > 0 { + buf.push(','); + } + buf.push_str(&json_string(reason)); + } + buf.push_str("]}"); +} + +fn json_string(value: &str) -> String { + format!("\"{}\"", raw_json_escape(value)) +} + +fn collect_node( + node: &LayerNode, + path: String, + slots: &mut Vec, + line_break_risks: &mut Vec, +) { + match &node.kind { + LayerNodeKind::Group { children, .. } => { + for (index, child) in children.iter().enumerate() { + collect_node( + child, + format!("{path}/group[{index}]"), + slots, + line_break_risks, + ); + } + } + LayerNodeKind::ClipRect { child, .. } => { + collect_node(child, format!("{path}/clip"), slots, line_break_risks); + } + LayerNodeKind::Leaf { ops } => { + let text_fallback_present = ops.iter().any(|op| matches!(op, PaintOp::TextRun { .. })); + let mut groups = BTreeMap::>::new(); + for op in ops { + match op { + PaintOp::TextRun { run, .. } => { + if let Some(risk) = line_break_risk_for_run(&path, run) { + line_break_risks.push(risk); + } + } + PaintOp::GlyphRun { run, .. } => { + let variant = &run.variant; + let group = groups.entry(variant.equivalence_group.clone()).or_default(); + let entry = group.entry(variant.variant_id.clone()).or_insert_with(|| { + VariantAccumulator { + variant_id: variant.variant_id.clone(), + variant_kind: variant.variant_kind, + required_features: variant + .requires + .iter() + .cloned() + .collect::>(), + part_count: variant.part_count, + present_parts: BTreeSet::new(), + strict_parts: BTreeSet::new(), + quality: variant.quality, + fallback_reason: None, + } + }); + entry + .required_features + .extend(variant.requires.iter().cloned()); + entry.part_count = entry.part_count.max(variant.part_count); + entry.present_parts.insert(variant.part_index); + entry.quality = entry.quality.or(variant.quality); + if glyph_run_is_strict(&run.diagnostics) { + entry.strict_parts.insert(variant.part_index); + } + entry.fallback_reason = entry + .fallback_reason + .take() + .or_else(|| glyph_run_fallback_reason(&run.diagnostics)); + } + _ => {} + } + } + for (equivalence_group, variants) in groups { + let variant_diagnostics = variants + .into_values() + .map(|variant| variant.finish()) + .collect::>(); + let strict_variant_available = variant_diagnostics + .iter() + .any(|variant| variant.strict_visual_eligible); + let fallback_reason = if !text_fallback_present { + Some("missingTextRunFallback".to_string()) + } else if !strict_variant_available { + variant_diagnostics + .iter() + .find_map(|variant| variant.fallback_reason.clone()) + .or_else(|| Some("strictVariantUnavailable".to_string())) + } else { + None + }; + slots.push(TextV2SlotDiagnostic { + paint_order_slot_id: equivalence_group.clone(), + equivalence_group, + leaf_path: path.clone(), + fallback_present: text_fallback_present, + strict_variant_available, + variants: variant_diagnostics, + fallback_reason, + }); + } + } + } +} + +#[derive(Debug)] +struct VariantAccumulator { + variant_id: String, + variant_kind: TextVariantKind, + required_features: BTreeSet, + part_count: u32, + present_parts: BTreeSet, + strict_parts: BTreeSet, + quality: Option, + fallback_reason: Option, +} + +impl VariantAccumulator { + fn finish(self) -> TextV2VariantDiagnostic { + let present_part_count = self.present_parts.len() as u32; + let strict_visual_eligible = self.part_count > 0 + && present_part_count == self.part_count + && (0..self.part_count).all(|index| self.strict_parts.contains(&index)); + TextV2VariantDiagnostic { + variant_id: self.variant_id, + variant_kind: self.variant_kind.as_str(), + required_features: self.required_features.into_iter().collect(), + part_count: self.part_count, + present_part_count, + quality: self.quality.map(TextVariantQuality::as_str), + strict_visual_eligible, + fallback_reason: self.fallback_reason, + } + } +} + +fn glyph_run_is_strict(diagnostics: &GlyphRunDiagnostics) -> bool { + diagnostics.strict_visual_eligible + && matches!( + diagnostics.quality, + TextVariantQuality::Exact | TextVariantQuality::PositionAdjusted + ) + && matches!( + diagnostics.replay_eligibility, + GlyphRunReplayEligibility::Portable + | GlyphRunReplayEligibility::ConditionalExternalFont + ) + && diagnostics.missing_glyph_count == 0 + && diagnostics.cluster_mismatch_count == 0 + && diagnostics.used_fallback_font_count == 0 +} + +fn glyph_run_fallback_reason(diagnostics: &GlyphRunDiagnostics) -> Option { + if glyph_run_is_strict(diagnostics) { + return None; + } + if let Some(reason) = &diagnostics.reason { + return Some(reason.clone()); + } + if diagnostics.missing_glyph_count > 0 { + return Some("missingGlyph".to_string()); + } + if diagnostics.cluster_mismatch_count > 0 { + return Some("clusterMismatch".to_string()); + } + if diagnostics.used_fallback_font_count > 0 { + return Some("unsplitFallbackFont".to_string()); + } + if !diagnostics.strict_visual_eligible { + return Some("strictVisualIneligible".to_string()); + } + Some("strictVariantUnavailable".to_string()) +} + +fn line_break_risk_for_run(leaf_path: &str, run: &TextRunNode) -> Option { + let mut reasons = Vec::new(); + if run.char_overlap.is_some() { + reasons.push("charOverlap"); + } + if run.is_vertical { + reasons.push("verticalText"); + } + if run.rotation.abs() > f64::EPSILON { + reasons.push("rotatedText"); + } + if (run.style.ratio - 1.0).abs() > f64::EPSILON { + reasons.push("widthRatio"); + } + if run.style.letter_spacing.abs() > f64::EPSILON + || run.style.extra_word_spacing.abs() > f64::EPSILON + || run.style.extra_char_spacing.abs() > f64::EPSILON + || run.style.extra_dash_advance.abs() > f64::EPSILON + { + reasons.push("distributedSpacing"); + } + if !run.style.tab_leaders.is_empty() || !run.style.inline_tabs.is_empty() { + reasons.push("tabLeaderOrInlineTab"); + } + if run.style.underline != UnderlineType::None + || run.style.strikethrough + || run.style.outline_type != 0 + || run.style.shadow_type != 0 + || run.style.emboss + || run.style.engrave + || run.style.emphasis_dot != 0 + || run.style.shade_color != 0x00FF_FFFF + { + reasons.push("textVisualEffect"); + } + if run.field_marker != FieldMarkerType::None { + reasons.push("fieldMarker"); + } + if run.is_line_break_end { + reasons.push("explicitLineBreakEnd"); + } + if run.is_para_end { + reasons.push("paragraphEndMarker"); + } + if reasons.is_empty() { + return None; + } + let risk = if reasons + .iter() + .any(|reason| matches!(*reason, "charOverlap" | "tabLeaderOrInlineTab")) + { + TextV2LineBreakRiskLevel::ChangeLikely + } else { + TextV2LineBreakRiskLevel::ChangePossible + }; + Some(TextV2LineBreakRisk { + leaf_path: leaf_path.to_string(), + text_preview: run.text.chars().take(32).collect(), + risk, + reasons, + }) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::paint::{ + FontFaceKey, FontFallbackPolicyId, FontInstanceKey, GlyphCluster, GlyphRange, + GlyphRunOrientation, LayerAffineTransform, LayerGlyphRunPaint, LayerNode, LayerPoint, + PaintTextStyle, ScriptTag, ShapeKey, ShapingEngineId, TextDirection, TextSourceId, + TextSourceRange, TextSourceSpan, WritingMode, + }; + use crate::renderer::render_tree::BoundingBox; + use crate::renderer::TextStyle; + + fn text_run(text: &str) -> TextRunNode { + TextRunNode { + text: text.to_string(), + style: TextStyle { + font_family: "Test".to_string(), + font_size: 12.0, + shade_color: 0x00FF_FFFF, + ..Default::default() + }, + char_shape_id: None, + para_shape_id: None, + section_index: None, + para_index: None, + char_start: None, + cell_context: None, + is_para_end: false, + is_line_break_end: false, + rotation: 0.0, + is_vertical: false, + char_overlap: None, + border_fill_id: 0, + baseline: 12.0, + field_marker: FieldMarkerType::None, + } + } + + fn text_op(text: &str) -> PaintOp { + PaintOp::TextRun { + bbox: BoundingBox::new(0.0, 0.0, 20.0, 20.0), + run: text_run(text), + } + } + + fn glyph_op(reason: Option<&str>, missing_glyph_count: u32) -> PaintOp { + glyph_op_part(reason, missing_glyph_count, 0, 1) + } + + fn glyph_op_part( + reason: Option<&str>, + missing_glyph_count: u32, + part_index: u32, + part_count: u32, + ) -> PaintOp { + PaintOp::GlyphRun { + bbox: BoundingBox::new(0.0, 0.0, 20.0, 20.0), + run: Box::new(LayerGlyphRunPaint { + source: TextSourceSpan { + id: TextSourceId(0), + utf8_range: TextSourceRange::new(0, 1), + utf16_range: TextSourceRange::new(0, 1), + stable_source_key: None, + }, + variant: { + let mut variant = crate::paint::PaintVariantMeta::text_run_default("text-0"); + variant.variant_id = "glyphRun".to_string(); + variant.variant_kind = TextVariantKind::GlyphRun; + variant.is_default_fallback = false; + variant.part_index = part_index; + variant.part_count = part_count; + variant.requires = + vec!["fontResources".to_string(), "text.glyphRun".to_string()]; + variant.quality = Some(TextVariantQuality::Exact); + variant + }, + paint_style: PaintTextStyle::from(&TextStyle { + font_family: "Test".to_string(), + font_size: 12.0, + shade_color: 0x00FF_FFFF, + ..Default::default() + }), + shape_key: ShapeKey { + font_instance: FontInstanceKey { + face_key: FontFaceKey("face-0".to_string()), + size_px: 12.0, + variations: Vec::new(), + synthetic_bold: false, + synthetic_italic: false, + }, + direction: TextDirection::Ltr, + writing_mode: WritingMode::HorizontalTb, + script: Some(ScriptTag("DFLT".to_string())), + language: None, + features: Vec::new(), + shaping_engine: ShapingEngineId("test".to_string()), + fallback_policy: FontFallbackPolicyId("none".to_string()), + }, + placement: crate::paint::TextRunPlacement { + run_to_page: LayerAffineTransform { + a: 1.0, + b: 0.0, + c: 0.0, + d: 1.0, + e: 0.0, + f: 12.0, + }, + baseline_y: 0.0, + }, + glyph_ids: vec![42], + positions: vec![LayerPoint { x: 0.0, y: 0.0 }], + advances: None, + clusters: vec![GlyphCluster { + source_range_utf8: TextSourceRange::new(0, 1), + source_range_utf16: Some(TextSourceRange::new(0, 1)), + text_range_utf8: Some(TextSourceRange::new(0, 1)), + glyph_range: GlyphRange::new(0, 1), + flags: Vec::new(), + }], + direction: TextDirection::Ltr, + bidi_level: None, + writing_mode: WritingMode::HorizontalTb, + orientation: GlyphRunOrientation::Horizontal, + glyph_transforms: None, + diagnostics: GlyphRunDiagnostics { + quality: TextVariantQuality::Exact, + replay_eligibility: GlyphRunReplayEligibility::Portable, + strict_visual_eligible: missing_glyph_count == 0, + max_origin_delta_px: 0.0, + max_advance_delta_px: 0.0, + max_residual_after_adjustment_px: 0.0, + cluster_mismatch_count: 0, + missing_glyph_count, + used_fallback_font_count: 0, + reason: reason.map(str::to_string), + }, + }), + } + } + + #[test] + fn reports_strict_glyph_slot_without_validation_errors() { + let tree = PageLayerTree::new( + 100.0, + 100.0, + LayerNode::leaf( + BoundingBox::new(0.0, 0.0, 100.0, 100.0), + None, + vec![text_op("A"), glyph_op(None, 0)], + ), + ); + let diagnostics = TextV2Diagnostics::from_layer_tree_with_profile( + &tree, + TextV2CompatibilityProfile::FallbackFreeStrict, + ); + assert_eq!(diagnostics.slot_diagnostics.len(), 1); + assert!(diagnostics.slot_diagnostics[0].strict_variant_available); + assert!(!diagnostics.has_errors()); + } + + #[test] + fn serializes_requested_profile_without_default_profile_fallback() { + let tree = PageLayerTree::new( + 100.0, + 100.0, + LayerNode::leaf( + BoundingBox::new(0.0, 0.0, 100.0, 100.0), + None, + vec![text_op("A")], + ), + ); + let json = TextV2Diagnostics::from_layer_tree_with_profile( + &tree, + TextV2CompatibilityProfile::FallbackFreeStrict, + ) + .to_json(); + assert!(json.contains("\"compatibilityProfile\":\"fallbackFreeStrict\"")); + assert!(json.contains("\"fallbackRequired\":false")); + } + + #[test] + fn rejects_fallback_free_profile_without_strict_variant() { + let tree = PageLayerTree::new( + 100.0, + 100.0, + LayerNode::leaf( + BoundingBox::new(0.0, 0.0, 100.0, 100.0), + None, + vec![text_op("A"), glyph_op(Some("missingGlyph"), 1)], + ), + ); + let diagnostics = TextV2Diagnostics::from_layer_tree_with_profile( + &tree, + TextV2CompatibilityProfile::FallbackFreeStrict, + ); + assert!(diagnostics.has_errors()); + assert_eq!( + diagnostics.validation_issues[0].code, + "fallbackFreeStrictVariantMissing" + ); + assert_eq!( + diagnostics.slot_diagnostics[0].fallback_reason.as_deref(), + Some("missingGlyph") + ); + } + + #[test] + fn rejects_fallback_free_profile_when_only_some_parts_are_strict() { + let tree = PageLayerTree::new( + 100.0, + 100.0, + LayerNode::leaf( + BoundingBox::new(0.0, 0.0, 100.0, 100.0), + None, + vec![ + text_op("A"), + glyph_op_part(None, 0, 0, 2), + glyph_op_part(Some("missingGlyph"), 1, 1, 2), + ], + ), + ); + let diagnostics = TextV2Diagnostics::from_layer_tree_with_profile( + &tree, + TextV2CompatibilityProfile::FallbackFreeStrict, + ); + assert!(diagnostics.has_errors()); + assert!(!diagnostics.slot_diagnostics[0].strict_variant_available); + assert_eq!( + diagnostics.slot_diagnostics[0].fallback_reason.as_deref(), + Some("missingGlyph") + ); + } + + #[test] + fn reports_line_break_risk_context_for_complex_text_runs() { + let mut run = text_run("A\tB"); + run.style.inline_tabs.push([0, 0, 0, 0, 0, 0, 0]); + run.is_line_break_end = true; + let tree = PageLayerTree::new( + 100.0, + 100.0, + LayerNode::leaf( + BoundingBox::new(0.0, 0.0, 100.0, 100.0), + None, + vec![PaintOp::TextRun { + bbox: BoundingBox::new(0.0, 0.0, 20.0, 20.0), + run, + }], + ), + ); + let diagnostics = TextV2Diagnostics::from_layer_tree(&tree); + assert_eq!(diagnostics.line_break_risks.len(), 1); + assert_eq!( + diagnostics.line_break_risks[0].risk, + TextV2LineBreakRiskLevel::ChangeLikely + ); + assert!(diagnostics.line_break_risks[0] + .reasons + .contains(&"tabLeaderOrInlineTab")); + } +}