diff --git a/plan/remaining-standards-test-roadmap.md b/plan/remaining-standards-test-roadmap.md new file mode 100644 index 0000000000..1689d2a41d --- /dev/null +++ b/plan/remaining-standards-test-roadmap.md @@ -0,0 +1,414 @@ +# Remaining Standards Test Roadmap + +Status date: 2026-05-29 + +This roadmap replaces the older broad skipped-test plan for active planning. It lists only the remaining work needed to remove current standards-suite skips or to broaden the currently sliced resvg coverage without hiding gaps behind thresholds or manufactured baselines. + +## Current Test Surface + +### W3C + +Focused W3C command: + +```sh +dotnet test tests/Svg.Skia.UnitTests/Svg.Skia.UnitTests.csproj -f net10.0 -c Release --no-restore --filter "FullyQualifiedName~W3CTestSuiteTests.Tests" +``` + +Last verified full W3C result on this branch after the seven lane edits: + +- 523 passed +- 3 skipped +- 526 total + +Current source-level W3C skip inventory on this branch: + +- `struct-image-12-b`: explicit browser broken-image UI policy skip with semantic renderer coverage. +- `struct-use-08-b`: explicit Chrome recursive capture policy skip with semantic renderer coverage. +- `text-fonts-06-t`: fixture is missing from the bundled W3C checkout. + +Rows promoted by lanes 1-4 and validated by the full W3C run: + +- `text-align-08-b` +- `pservers-grad-08-b` +- `render-elems-06-t` +- `render-elems-07-t` +- `render-elems-08-t` +- `render-groups-01-b` +- `render-groups-03-t` +- `animate-elem-23-t` +- `animate-elem-84-t` +- `animate-elem-85-t` +- `color-prof-01-f` +- `color-prop-04-t` + +### resvg + +Fixture inventory in `externals/resvg/crates/resvg/tests`: + +- 1730 total SVG fixtures +- 379 text fixtures +- 1351 non-text fixtures + +Current resvg harness state: + +- `text_fixtures` is enabled. +- `css_styling_fixtures` is enabled for the explicit CSS styling allow-list. +- `resource_rendering_fixtures` is enabled for the explicit resource rendering allow-list. +- The broad `non_text_fixtures` umbrella has been removed. +- Remaining non-text rows are explicit skipped inventory theories by feature area. + +Non-text fixture groups by top-level area: + +- enabled non-text rows: 466 +- remaining explicit inventory rows: 885 +- remaining `extra`: 15 +- remaining `tests/filters`: 281 +- remaining `tests/masking`: 92 +- remaining `tests/paint-servers`: 148 +- remaining `tests/painting`: 115 +- remaining `tests/shapes`: 69 +- remaining `tests/structure`: 165 + +## Lane 1: Mixed-Script Baseline Tables + +Promoted row under Lane 1 validation: + +- `text-align-08-b` + +Current source status: + +- The row has been promoted by the Lane 1 worker and is no longer marked skipped in `W3CTestSuiteTests`. +- The full W3C run confirms the promoted row and adjacent text rows pass on this branch. + +Problem: + +The renderer still lacks browser-grade mixed-script dominant-baseline behavior across Latin, ideographic, and Indic/Devanagari text. The row is close in glyph selection, but alignment is wrong because baseline tables are not modeled with enough script/font specificity. + +Implementation plan: + +1. Add an internal baseline table service in `Svg.SceneGraph` or `Svg.Skia` that resolves SVG dominant-baseline/alignment-baseline keywords against measured font metrics. +2. Classify text runs by script and writing mode before baseline resolution. +3. Use Skia font metrics for alphabetic/central/middle baselines and add explicit synthesized offsets for ideographic, hanging, and mathematical baselines when the font does not expose the baseline directly. +4. Keep baseline resolution in the shared text layout path so retained rendering, DOM metrics, and selection extents agree. +5. Add focused unit tests for Latin plus ideographic plus Devanagari baseline combinations, including horizontal and vertical writing-mode probes. +6. Enable `text-align-08-b` only after the W3C row visually aligns without a broad threshold. + +Acceptance: + +- `text-align-08-b` is enabled. +- Existing W3C text rows remain green. +- `SvgTextRegressionValidationBenchmarks` shows no material regression. + +Lane status: + +- Implementation complete for the current skipped-test roadmap. +- Follow-up benchmark validation remains part of PR readiness, not additional implementation debt for this lane. + +## Lane 2: Webfont And Legacy SVG Font Raster Parity + +Promoted rows: + +- `pservers-grad-08-b` +- `render-elems-06-t` +- `render-elems-07-t` +- `render-elems-08-t` +- `render-groups-01-b` +- `render-groups-03-t` + +Remaining related row: + +- `text-fonts-06-t`, still missing from the bundled W3C checkout. + +Problem: + +These rows need exact SVG/WOFF webfont loading and Chrome-compatible glyph outlines/composition. Current fallback text rendering is not enough for W3C rows that compare precise rendered glyph shapes or text inside paint-server/group composition. + +Implementation plan: + +1. Audit the W3C fixtures and referenced font resources for these rows. +2. Add a font resource loader path that can resolve local SVG fixture font references through `ISvgAssetLoader`. +3. Support `@font-face` and SVG font resources consistently between document loading, nested image documents, and paint-server/group rendering paths. +4. Route loaded font bytes into Skia typeface creation where supported. +5. Preserve existing SVG font/altGlyph handling and avoid duplicating glyph substitution logic. +6. Add deterministic fallback policy when a font format cannot be loaded by SkiaSharp. +7. Update or refresh the bundled W3C fixture checkout if `text-fonts-06-t` exists upstream and is still missing locally. +8. Add focused unit tests for font resource resolution, text inside gradients, and grouped text composition. + +Acceptance: + +- The six webfont-dependent W3C rows are enabled and pass against Chrome overrides generated through HTTP. +- `text-fonts-06-t` is documented as a local-fixture absence until the bundled W3C checkout is refreshed or repaired. +- No broad image thresholds or Chrome override swaps are added to hide font loading failures. + +Lane status: + +- Implementation complete for the available W3C rows. +- Remaining work is fixture-management only: restore `text-fonts-06-t` from a trusted W3C source if it exists in the intended suite version, then classify or enable it through the same font-loading path. + +## Lane 3: Color Policy And Color Management + +Promoted rows under Lane 3 validation: + +- `color-prof-01-f` +- `color-prop-04-t` + +Current source status: + +- The rows have been promoted by the Lane 3 worker and are no longer marked skipped in `W3CTestSuiteTests`. +- `color-prof-01-f` is covered as an explicit optional-ICC unsupported policy assertion. +- `color-prop-04-t` is covered by deterministic system color resolution. +- The full W3C run and color-provider unit tests pass on this branch. + +Problem: + +`color-prof-01-f` depends on optional ICC color profile behavior that is not a stable Chrome-backed baseline today. `color-prop-04-t` depends on CSS system colors that vary by viewer/platform theme. + +Implementation plan: + +1. Decide whether Svg.Skia should implement ICC profile conversion in the static renderer, or keep ICC profile rows as explicitly unsupported optional behavior. +2. If implementing ICC, add profile resource loading, color-space conversion, and focused unit tests before enabling the row. +3. Add a host/system color provider abstraction for CSS system color keywords. +4. Provide a deterministic test provider for W3C comparisons without hardcoding platform UI colors into the renderer. +5. Keep default behavior stable across headless CI, macOS, Windows, and Linux. + +Acceptance: + +- `color-prof-01-f` is either enabled through real color-profile support or remains a documented optional-spec policy skip. +- `color-prop-04-t` is enabled only with a deterministic system-color provider and tests. +- Resource/color regression tests cover CSS Color 4 paths that already pass today. + +Lane status: + +- Implementation complete for current skipped-test debt. +- ICC conversion remains an optional future feature, not a current W3C skip after this branch because the row is semantically asserted as unsupported optional behavior. + +## Lane 4: Deprecated Animation Reference Policy + +Promoted rows under Lane 4 validation: + +- `animate-elem-23-t` +- `animate-elem-84-t` +- `animate-elem-85-t` + +Current source status: + +- The rows have been promoted by the Lane 4 worker and are no longer marked skipped in `W3CTestSuiteTests`. +- Semantic assertions cover legacy `animateColor` interpolation and currentColor behavior. +- The full W3C run and animation unit tests pass on this branch. + +Problem: + +Modern Chrome captures deprecated `animateColor` as a no-op for these rows, so Chrome is not a useful static raster reference. The renderer should not enable these rows by copying a stale or contradictory baseline without a clear policy. + +Implementation plan: + +1. Inspect each fixture and determine the expected SVG 1.1 behavior independent of modern Chrome. +2. Add semantic tests for `animateColor` interpolation or no-op behavior, whichever policy is chosen. +3. If implementing legacy `animateColor`, route it through the same color interpolation path used by `animate` where possible. +4. If keeping Chrome-compatible no-op behavior, document the decision and replace row skips with semantic assertions that prove the intended behavior. +5. Avoid adding a fake PNG baseline for deprecated behavior. + +Acceptance: + +- The three rows are no longer simple raster skips. +- Each row has either enabled rendering or a semantic test that asserts the chosen policy. + +Lane status: + +- Implementation complete for current skipped-test debt. +- The remaining no-op guard is intentionally limited to inherited paint-server color state in `defs`, where browser snapshots keep referenced gradients stable. + +## Lane 5: Browser UI And Recursive Capture Policy + +Blocked skipped rows: + +- `struct-image-12-b` +- `struct-use-08-b` + +Problem: + +These are not normal renderer gaps. `struct-image-12-b` compares Chrome native broken-image UI chrome, while Svg.Skia has a deterministic retained placeholder policy. `struct-use-08-b` cannot get a stable Chrome capture because of recursive loading behavior. + +Implementation plan: + +1. Keep deterministic renderer behavior covered by unit tests for broken, invalid, cyclic, and recursive image references. +2. Add optional host-facing hooks only if consumers need browser-like broken-image chrome; keep the renderer default independent of native browser UI. +3. For recursive `use`, keep recursion guards and add semantic tests proving stable output and bounded traversal. +4. Do not create a fake browser UI baseline. + +Acceptance: + +- Both rows remain explicit policy skips unless a real host/runtime visual policy is introduced. +- The corresponding renderer behavior is covered by semantic/unit tests. + +Lane status: + +- Implementation complete for current policy coverage. +- Remaining work is policy/product work only: introduce browser-like broken-image chrome or a recursive capture visualization only if Svg.Skia decides to expose that as a host-facing option. + +## Lane 6: resvg Non-Text Expansion + +Former broad skipped harness: + +- `non_text_fixtures` in `tests/Svg.Skia.UnitTests/resvgTests.cs` + +Problem: + +The broad non-text theory covered 1351 fixture rows. Text, CSS styling, and the current resource subset are already enabled through targeted theories. The umbrella skip has now been replaced by explicit feature-area inventory theories so each future renderer slice has a concrete fixture pool. + +Current source status: + +- `non_text_fixtures` has been removed. +- `resvg_remaining_non_text_fixture_inventory` accounts for all 1730 resvg fixtures. +- `resvg_remaining_non_text_theories_are_explicit_feature_area_inventory` prevents a broad hardening or umbrella bucket from being reintroduced. +- The remaining explicit skipped row groups are `remaining_extra_fixtures`, `remaining_filter_fixtures`, `remaining_masking_fixtures`, `remaining_paint_server_fixtures`, `remaining_painting_fixtures`, `remaining_shape_fixtures`, and `remaining_structure_fixtures`. + +Implementation plan: + +1. Replace the broad umbrella skip with generated feature-area theories that can be enabled independently. +2. Keep the existing `css_styling_fixtures` and `resource_rendering_fixtures` theories as the first green slices. +3. Add explicit fixture inventories for the remaining groups: + - filters not already in the resource allow-list + - masking not already covered by clip/mask resource tests + - paint servers not already covered by gradient/pattern resource tests + - painting operations not already covered by stroke/fill/marker/color subsets + - shapes edge cases not already covered by W3C and resource fixtures + - structure/use/image cases not already covered by W3C semantic tests + - `extra` fixtures +4. For each group, run probe mode, classify failures as implementation, raster-threshold, reference-policy, or unsupported browser/runtime behavior. +5. Enable only rows backed by renderer fixes or row-specific reference review. +6. Move unavoidable browser/runtime rows into explicit named skip lists with reasons rather than relying on the umbrella skip. + +Acceptance: + +- `non_text_fixtures` is deleted or converted into a non-skipped inventory assertion. +- All remaining skipped resvg rows are explicit by fixture name and reason. +- No new broad thresholds are added. + +Lane status: + +- Implementation complete for the roadmap split. +- Future resvg expansion should proceed one feature-area theory at a time, with failing fixtures promoted only when backed by a renderer fix, reference review, or explicit unsupported policy. + +## Lane 7: Promotion Gate For Deeper Hardening + +Lane 7 is not an implementation bucket. It is the guardrail that prevents broad browser-parity work from being counted as remaining skipped-test work unless there is concrete evidence. + +Current source review: + +- No current W3C skipped row maps directly to generic text/resource hardening. +- Remaining W3C skips are owned by Lane 2 fixture management (`text-fonts-06-t`) or Lane 5 policy coverage (`struct-image-12-b`, `struct-use-08-b`). +- Remaining resvg skips are owned by Lane 6 feature-area inventory theories. +- No deeper text/resource hardening item is promoted by this lane in the current checkout. + +Promotion criteria: + +An item may move from hardening risk to implementation only when one of these is attached to the task: + +1. A named failing W3C row, resvg fixture, or newly added upstream fixture path. +2. A consumer-visible bug report with a minimal SVG reproducer and expected browser/reference behavior. +3. A reference-policy update that explains why the fixture should be enabled, skipped, or semantically asserted. + +The promotion packet must include: + +- exact fixture path, test row, or issue identifier +- observed Svg.Skia output and expected output +- owning implementation lane, such as text layout, font loading, filters, paint servers, masks, markers, or DOM/runtime +- focused test command to reproduce the failure +- acceptance command and benchmark requirements +- decision on whether the row is raster-enabled, semantically asserted, or explicitly skipped with a row-specific reason + +Not valid as Lane 7 work: + +- "full browser parity" without a failing fixture or consumer reproducer +- broad text/resource rewrites that do not enable or protect a named row +- new umbrella skips, broad thresholds, or undocumented Chrome/reference swaps +- counting the text/resource hardening risk list as current skipped-test debt + +Hardening risk register: + +These are real risks, but they stay outside the remaining skipped-test count until promoted by the criteria above. + +Text risks: + +- Full Unicode Bidi and CSS Text parity for isolates, overrides, plaintext, generated Unicode tables, weak/neutral edge cases, and UAX #9/#14/#29 conformance ingestion. +- Browser-grade line breaking with CSS `line-break`, `word-break`, `overflow-wrap`, and dictionary segmentation for Thai/Lao/Khmer/Myanmar. +- Complete vertical and RTL wrapping, including vertical/RTL wrapped `textLength`, overflow marker placement, DOM metrics, and shape interaction. +- Full textPath-in-wrapping and `method="stretch"` raster parity for multiline, nested, transformed, vertical, fallback-font, emoji, color-font, and complex-script cases. +- Complete CSS Shapes text semantics for shape boxes, image shapes, holes, fill rules, shape margin/padding offsets, floats, and multiple same-line fragments. +- Browser UI selection/focus/caret behavior beyond retained static selection highlights. + +Resource risks: + +- CSS filter expression parity outside the guarded static math subset. +- Unsupported CSS value functions, wide-gamut color spaces, and relative color syntaxes. +- Broader selector/color parity in resource subtrees and nested/external resources. +- Exact browser-raster primitive-region, color-management, turbulence/noise, convolution, and lighting edge cases. +- Pathological pattern tile-edge identity beyond the bounded Skia picture-shader emulation. +- Remote/MIME/scripted SVG-as-image behavior for `feImage`. +- Exact browser-raster pattern, marker, and mask edge cases beyond the current focused static subset. + +Acceptance: + +- No Lane 7 item is implemented without a promotion packet. +- Roadmap entries distinguish current skipped-test debt from unpromoted hardening risk. +- Test harness guardrails prevent broad resvg non-text or hardening buckets from reappearing. +- Performance validation for promoted text/resource work includes both focused-area benchmarks and `SvgAllAreaRegressionValidationBenchmarks`. + +Lane status: + +- Implementation complete for guardrails. +- The hardening risk register remains deliberately unpromoted until a named fixture, upstream addition, or consumer bug supplies a promotion packet. + +## Required Validation Per Lane + +Before opening a PR for any lane: + +```sh +dotnet format Svg.Skia.slnx --no-restore --verify-no-changes +dotnet build Svg.Skia.slnx -c Release --no-restore +dotnet test tests/Svg.Skia.UnitTests/Svg.Skia.UnitTests.csproj -f net10.0 -c Release --no-build --filter "FullyQualifiedName~W3CTestSuiteTests.Tests" +dotnet test Svg.Skia.slnx -c Release --no-build +``` + +For text-affecting changes: + +```sh +dotnet run -c Release --project tests/Svg.Skia.Benchmarks/Svg.Skia.Benchmarks.csproj --filter '*SvgTextRegressionValidationBenchmarks*' +dotnet run -c Release --project tests/Svg.Skia.Benchmarks/Svg.Skia.Benchmarks.csproj --filter '*SvgAllAreaRegressionValidationBenchmarks*' +``` + +For resource-affecting changes: + +```sh +dotnet test tests/Svg.Skia.UnitTests/Svg.Skia.UnitTests.csproj -f net10.0 -c Release --no-build --filter "FullyQualifiedName~SvgResourceRenderingParityTests|FullyQualifiedName~resvgTests.resource_rendering_fixtures" +dotnet run -c Release --project tests/Svg.Skia.Benchmarks/Svg.Skia.Benchmarks.csproj --filter '*SvgAllAreaRegressionValidationBenchmarks*' +``` + +For resvg fixture expansion: + +```sh +dotnet test tests/Svg.Skia.UnitTests/Svg.Skia.UnitTests.csproj -f net10.0 -c Release --no-build --filter "FullyQualifiedName~resvgTests" +``` + +For Lane 7 promotion-gate changes: + +```sh +dotnet test tests/Svg.Skia.UnitTests/Svg.Skia.UnitTests.csproj -f net10.0 -c Release --no-restore --filter "FullyQualifiedName~resvgTests.resvg_remaining_non_text_fixture_inventory|FullyQualifiedName~resvgTests.resvg_remaining_non_text_theories_are_explicit_feature_area_inventory" +``` + +## Implementation Order + +1. Mixed-script baseline tables (`text-align-08-b`) - complete. +2. Webfont/SVG font resource loading for W3C render and paint-server rows - complete for available fixtures. +3. Color policy decisions and deterministic system color provider - complete. +4. Deprecated `animateColor` semantic policy - complete. +5. Browser UI and recursive capture policy cleanup - complete as explicit policy coverage. +6. Split resvg non-text umbrella into explicit feature-area theories - complete. +7. Enforce the Lane 7 promotion gate; do not implement unbacked deeper text/resource hardening - complete. + +Remaining current roadmap work after this branch: + +- Restore or permanently document missing W3C `text-fonts-06-t`. +- Keep `struct-image-12-b` and `struct-use-08-b` as explicit policy skips unless the project chooses browser UI/recursive capture visual emulation. +- Expand resvg non-text coverage by enabling explicit feature-area theories in separate renderer slices. diff --git a/scripts/capture_w3c_chrome_overrides.mjs b/scripts/capture_w3c_chrome_overrides.mjs index 1c94a60a62..4e0876ccec 100644 --- a/scripts/capture_w3c_chrome_overrides.mjs +++ b/scripts/capture_w3c_chrome_overrides.mjs @@ -54,9 +54,12 @@ const mimeTypes = new Map([ ['.jpg', 'image/jpeg'], ['.jpeg', 'image/jpeg'], ['.js', 'application/javascript; charset=utf-8'], + ['.otf', 'font/otf'], ['.png', 'image/png'], ['.svg', 'image/svg+xml; charset=utf-8'], ['.txt', 'text/plain; charset=utf-8'], + ['.ttf', 'font/ttf'], + ['.woff', 'font/woff'], ]); async function readAnimationSeekOverrides() @@ -103,6 +106,29 @@ function getContentType(filePath) return mimeTypes.get(path.extname(filePath).toLowerCase()) ?? 'application/octet-stream'; } +function tryGetW3CFontResourceFallback(normalizedPath) +{ + const svgWoffsDir = path.join( + repoRoot, + 'externals', + 'W3C_SVG_11_TestSuite', + 'W3C_SVG_11_TestSuite', + 'svg', + 'woffs'); + if (!normalizedPath.startsWith(`${svgWoffsDir}${path.sep}`)) + { + return null; + } + + return path.join( + repoRoot, + 'externals', + 'W3C_SVG_11_TestSuite', + 'W3C_SVG_11_TestSuite', + 'resources', + path.basename(normalizedPath)); +} + function createStaticServer(rootPath) { return http.createServer(async (req, res) => @@ -122,10 +148,27 @@ function createStaticServer(rootPath) return; } - const stats = await fs.stat(normalizedPath); + let stats; + let resolvedPath = normalizedPath; + try + { + stats = await fs.stat(resolvedPath); + } + catch + { + const fallbackPath = tryGetW3CFontResourceFallback(resolvedPath); + if (!fallbackPath) + { + throw new Error('Not Found'); + } + + resolvedPath = fallbackPath; + stats = await fs.stat(resolvedPath); + } + const filePath = stats.isDirectory() - ? path.join(normalizedPath, 'index.html') - : normalizedPath; + ? path.join(resolvedPath, 'index.html') + : resolvedPath; const body = await fs.readFile(filePath); res.writeHead(200, { 'Content-Type': getContentType(filePath) }); diff --git a/src/Svg.Animation/Animation/SvgAnimationController.cs b/src/Svg.Animation/Animation/SvgAnimationController.cs index d12346b7e0..3c2008cd20 100644 --- a/src/Svg.Animation/Animation/SvgAnimationController.cs +++ b/src/Svg.Animation/Animation/SvgAnimationController.cs @@ -1289,7 +1289,7 @@ private static List DiscoverBindings(SvgDocument sourceDocumen continue; } - if (ShouldIgnoreBrowserUnsupportedAnimateColorBinding(animation, target, attributeName!)) + if (ShouldIgnorePaintServerDefinitionAnimateColorBinding(animation, target, attributeName!)) { continue; } @@ -1300,13 +1300,15 @@ private static List DiscoverBindings(SvgDocument sourceDocumen return bindings; } - private static bool ShouldIgnoreBrowserUnsupportedAnimateColorBinding( + private static bool ShouldIgnorePaintServerDefinitionAnimateColorBinding( SvgAnimationElement animation, SvgElement target, string attributeName) { - // Current browser snapshots do not apply deprecated animateColor to inherited - // paint-server color state declared in defs, while regular animate still applies. + // Direct SVG 1.1 animateColor interpolation is supported. This guard is + // limited to inherited paint-server color state in defs, where current + // browser snapshots keep referenced gradients stable while regular + // numeric animation on the same subtree still applies. if (animation is not SvgAnimateColor || !IsInheritedPaintServerColorAttribute(attributeName) || !IsInsideDefinitions(target)) diff --git a/src/Svg.Custom/Compatibility/SvgCssCompatibilityProcessor.cs b/src/Svg.Custom/Compatibility/SvgCssCompatibilityProcessor.cs index f0e1534365..edea33b5d4 100644 --- a/src/Svg.Custom/Compatibility/SvgCssCompatibilityProcessor.cs +++ b/src/Svg.Custom/Compatibility/SvgCssCompatibilityProcessor.cs @@ -1498,6 +1498,116 @@ private static string ExpandImportedStyles(IReadOnlyCollection EnumerateExpandedStyleSources( + IReadOnlyCollection sources, + SvgDocument svgDocument, + SvgDocumentLoadOptions? loadOptions) + { + if (sources.Count == 0) + { + yield break; + } + + var mediaContext = ResolveMediaContext(svgDocument); + foreach (var source in sources) + { + foreach (var expanded in EnumerateExpandedStyleSource( + source.Content, + source.BaseUri, + source.BaseUri, + mediaContext, + loadOptions, + CreateImportChain(), + allowLeadingImports: true)) + { + yield return expanded; + } + } + } + + private static IEnumerable EnumerateExpandedStyleSource( + string cssText, + Uri? baseUri, + Uri? policyBaseUri, + CssMediaContext mediaContext, + SvgDocumentLoadOptions? loadOptions, + HashSet importChain, + bool allowLeadingImports) + { + var index = 0; + var isInLeadingImportSection = allowLeadingImports; + + while (TryReadNextTopLevelStatement(cssText, ref index, out var statement)) + { + var atRuleKind = GetAtRuleKind(cssText, statement); + if (isInLeadingImportSection && atRuleKind == CssAtRuleKind.Import) + { + if (TryParseKnownImportRule(cssText, statement, out var href, out var mediaCondition) && + ShouldApplyMediaForCurrentContext(mediaCondition, mediaContext)) + { + var imported = TryLoadImportedStylesheet(href, baseUri, policyBaseUri, loadOptions, importChain); + if (imported is not null) + { + try + { + foreach (var expanded in EnumerateExpandedStyleSource( + imported.Content, + imported.BaseUri, + policyBaseUri, + mediaContext, + loadOptions, + importChain, + allowLeadingImports: true)) + { + yield return expanded; + } + } + finally + { + importChain.Remove(imported.BaseUri!.AbsoluteUri); + } + } + } + + continue; + } + + if (atRuleKind == CssAtRuleKind.Media) + { + isInLeadingImportSection = false; + if (TryGetMediaRuleParts(cssText, statement, out var mediaCondition, out var nestedCssText) && + ShouldApplyMediaForCurrentContext(mediaCondition, mediaContext)) + { + foreach (var expanded in EnumerateExpandedStyleSource( + nestedCssText, + baseUri, + policyBaseUri, + mediaContext, + loadOptions, + importChain, + allowLeadingImports: false)) + { + yield return expanded; + } + } + + continue; + } + + if (atRuleKind != CssAtRuleKind.Charset) + { + isInLeadingImportSection = false; + } + + if (atRuleKind == CssAtRuleKind.Import || statement.Length <= 0) + { + continue; + } + + yield return new SvgCssStyleSource(cssText.Substring(statement.Start, statement.Length), baseUri); + } + } + internal static HashSet CreateImportChain() { // Import cycle detection should compare the fully resolved URI text exactly. Folding case diff --git a/src/Svg.Custom/Compatibility/SvgDocument.DynamicStyles.cs b/src/Svg.Custom/Compatibility/SvgDocument.DynamicStyles.cs index c3e5dfa20e..cc0586e05a 100644 --- a/src/Svg.Custom/Compatibility/SvgDocument.DynamicStyles.cs +++ b/src/Svg.Custom/Compatibility/SvgDocument.DynamicStyles.cs @@ -189,6 +189,8 @@ internal void CopyCompatibilityStyleStateTo(SvgDocument target) internal bool HasCompatibilityStyleSources => _compatibilityStyleSources is { Count: > 0 }; + internal IReadOnlyList? CompatibilityStyleSources => _compatibilityStyleSources; + internal bool UpdateCompatibilityStyleAttribute(SvgElement element, string name, string? value) { if (!SvgStyleAttributeNames.Contains(name)) diff --git a/src/Svg.Custom/Painting/SvgPaintServerFactory.cs b/src/Svg.Custom/Painting/SvgPaintServerFactory.cs index 9acd2ed982..c5d57b9eb5 100644 --- a/src/Svg.Custom/Painting/SvgPaintServerFactory.cs +++ b/src/Svg.Custom/Painting/SvgPaintServerFactory.cs @@ -171,6 +171,11 @@ internal static bool TryParseCssConcreteColor(string value, out Color color) return true; } + if (SvgSystemColorResolver.TryGetColor(value, out color)) + { + return true; + } + if (TryParseHexColorWithAlpha(value, out color) || TryParseCssFunctionalColor(value, out color)) { diff --git a/src/Svg.Custom/Painting/SvgSystemColorProvider.cs b/src/Svg.Custom/Painting/SvgSystemColorProvider.cs new file mode 100644 index 0000000000..8bcf8e8f5d --- /dev/null +++ b/src/Svg.Custom/Painting/SvgSystemColorProvider.cs @@ -0,0 +1,142 @@ +// Copyright (c) Wiesław Šoltés. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for details. +using System; +using System.Collections.Generic; +using System.Drawing; +using System.Threading; + +namespace Svg +{ + public interface ISvgSystemColorProvider + { + bool TryGetColor(string name, out Color color); + } + + public sealed class SvgDictionarySystemColorProvider : ISvgSystemColorProvider + { + private readonly IReadOnlyDictionary _colors; + + public SvgDictionarySystemColorProvider(IDictionary colors) + { + if (colors == null) + { + throw new ArgumentNullException(nameof(colors)); + } + + _colors = new Dictionary(colors, StringComparer.OrdinalIgnoreCase); + } + + public bool TryGetColor(string name, out Color color) + { + if (name == null) + { + color = Color.Empty; + return false; + } + + return _colors.TryGetValue(name.Trim(), out color); + } + } + + public sealed class SvgFixedSystemColorProvider : ISvgSystemColorProvider + { + public static SvgFixedSystemColorProvider Instance { get; } = new SvgFixedSystemColorProvider(); + + private static readonly IReadOnlyDictionary s_colors = new Dictionary(StringComparer.OrdinalIgnoreCase) + { + ["ActiveBorder"] = FromRgb(0xD4, 0xD0, 0xC8), + ["ActiveCaption"] = FromRgb(0x0A, 0x24, 0x6A), + ["AppWorkspace"] = FromRgb(0x80, 0x80, 0x80), + ["Background"] = FromRgb(0x3A, 0x6E, 0xA5), + ["ButtonFace"] = FromRgb(0xD4, 0xD0, 0xC8), + ["ButtonHighlight"] = FromRgb(0xFF, 0xFF, 0xFF), + ["ButtonShadow"] = FromRgb(0x80, 0x80, 0x80), + ["ButtonText"] = FromRgb(0x00, 0x00, 0x00), + ["CaptionText"] = FromRgb(0xFF, 0xFF, 0xFF), + ["GrayText"] = FromRgb(0x80, 0x80, 0x80), + ["Highlight"] = FromRgb(0x0A, 0x24, 0x6A), + ["HighlightText"] = FromRgb(0xFF, 0xFF, 0xFF), + ["InactiveBorder"] = FromRgb(0xD4, 0xD0, 0xC8), + ["InactiveCaption"] = FromRgb(0x80, 0x80, 0x80), + ["InactiveCaptionText"] = FromRgb(0xD4, 0xD0, 0xC8), + ["InfoBackground"] = FromRgb(0xFF, 0xFF, 0xE1), + ["InfoText"] = FromRgb(0x00, 0x00, 0x00), + ["Menu"] = FromRgb(0xD4, 0xD0, 0xC8), + ["MenuText"] = FromRgb(0x00, 0x00, 0x00), + ["Scrollbar"] = FromRgb(0xD4, 0xD0, 0xC8), + ["ThreeDDarkShadow"] = FromRgb(0x40, 0x40, 0x40), + ["ThreeDFace"] = FromRgb(0xD4, 0xD0, 0xC8), + ["ThreeDHighlight"] = FromRgb(0xE3, 0xE3, 0xE3), + ["ThreeDLightShadow"] = FromRgb(0xFF, 0xFF, 0xFF), + ["ThreeDShadow"] = FromRgb(0x80, 0x80, 0x80), + ["Window"] = FromRgb(0xFF, 0xFF, 0xFF), + ["WindowFrame"] = FromRgb(0x00, 0x00, 0x00), + ["WindowText"] = FromRgb(0x00, 0x00, 0x00) + }; + + private SvgFixedSystemColorProvider() + { + } + + public bool TryGetColor(string name, out Color color) + { + if (name == null) + { + color = Color.Empty; + return false; + } + + return s_colors.TryGetValue(name.Trim(), out color); + } + + private static Color FromRgb(int red, int green, int blue) + => Color.FromArgb(255, red, green, blue); + } + + public static class SvgSystemColorResolver + { + private static readonly AsyncLocal s_scopedProvider = new AsyncLocal(); + private static ISvgSystemColorProvider s_defaultProvider = SvgFixedSystemColorProvider.Instance; + + public static ISvgSystemColorProvider DefaultProvider + { + get { return s_defaultProvider; } + set { s_defaultProvider = value ?? throw new ArgumentNullException(nameof(value)); } + } + + public static bool TryGetColor(string name, out Color color) + { + var provider = s_scopedProvider.Value ?? s_defaultProvider; + return provider.TryGetColor(name, out color); + } + + public static IDisposable PushProvider(ISvgSystemColorProvider provider) + { + var previous = s_scopedProvider.Value; + s_scopedProvider.Value = provider; + return new Scope(previous); + } + + private sealed class Scope : IDisposable + { + private readonly ISvgSystemColorProvider _previous; + private bool _disposed; + + public Scope(ISvgSystemColorProvider previous) + { + _previous = previous; + } + + public void Dispose() + { + if (_disposed) + { + return; + } + + s_scopedProvider.Value = _previous; + _disposed = true; + } + } + } +} diff --git a/src/Svg.Model/ISvgAssetLoader.cs b/src/Svg.Model/ISvgAssetLoader.cs index ab8764f355..d9fef0256f 100644 --- a/src/Svg.Model/ISvgAssetLoader.cs +++ b/src/Svg.Model/ISvgAssetLoader.cs @@ -37,6 +37,12 @@ public interface ISvgImageAlphaProvider bool TryGetImageAlpha(SKImage image, out int width, out int height, out byte[] alpha); } +public interface ISvgDocumentFontLoader +{ + void ClearDocumentFonts(); + IDisposable PushDocumentFonts(SvgDocument document); +} + public interface ISvgTextReferenceRenderingOptions { bool EnableTextReferences { get; } diff --git a/src/Svg.SceneGraph/SvgFontTextRenderer.cs b/src/Svg.SceneGraph/SvgFontTextRenderer.cs index 604f8c9431..896c0a70e2 100644 --- a/src/Svg.SceneGraph/SvgFontTextRenderer.cs +++ b/src/Svg.SceneGraph/SvgFontTextRenderer.cs @@ -1343,77 +1343,7 @@ private static bool IsVerticalWritingMode(SvgTextBase styleSource) private static SvgDominantBaseline ResolveScriptBaseline(string text) { - var charIndex = 0; - while (TryReadNextCodepoint(text, ref charIndex, out var scalar)) - { - if (IsCjkBaselineScript(scalar)) - { - return SvgDominantBaseline.Ideographic; - } - - if (IsMathematicalBaselineScript(scalar)) - { - return SvgDominantBaseline.Mathematical; - } - - if (IsHangingBaselineScript(scalar)) - { - return SvgDominantBaseline.Hanging; - } - } - - return SvgDominantBaseline.Alphabetic; - } - - private static bool TryReadNextCodepoint(string text, ref int charIndex, out int scalar) - { - while (charIndex < text.Length) - { - var current = text[charIndex++]; - if (char.IsWhiteSpace(current)) - { - continue; - } - - if (char.IsHighSurrogate(current) && - charIndex < text.Length && - char.IsLowSurrogate(text[charIndex])) - { - scalar = char.ConvertToUtf32(current, text[charIndex]); - charIndex++; - return true; - } - - scalar = current; - return true; - } - - scalar = 0; - return false; - } - - private static bool IsCjkBaselineScript(int scalar) - { - return scalar is >= 0x2E80 and <= 0xA4CF or - >= 0xAC00 and <= 0xD7AF or - >= 0xF900 and <= 0xFAFF or - >= 0xFE30 and <= 0xFE4F or - >= 0x20000 and <= 0x2FA1F; - } - - private static bool IsMathematicalBaselineScript(int scalar) - { - return scalar is >= 0x2200 and <= 0x22FF or - >= 0x27C0 and <= 0x27EF or - >= 0x2980 and <= 0x2AFF or - >= 0x1D400 and <= 0x1D7FF; - } - - private static bool IsHangingBaselineScript(int scalar) - { - return scalar is >= 0x0900 and <= 0x0D7F or - >= 0x0F00 and <= 0x0FFF or - >= 0x1000 and <= 0x109F; + return SvgTextBaselineResolver.ResolveScriptBaseline(text); } private static bool HasBaselineCoordinate(float value) @@ -1438,7 +1368,7 @@ private static float GetBaselineCoordinate(SvgFontFace fontFace, SvgDominantBase return baseline switch { SvgDominantBaseline.Ideographic => ResolveBaselineCoordinate(fontFace.Ideographic, -Math.Abs(descent)), - SvgDominantBaseline.Hanging => ResolveBaselineCoordinate(fontFace.Hanging, ascent * 0.8f), + SvgDominantBaseline.Hanging => ResolveBaselineCoordinate(fontFace.Hanging, ascent), SvgDominantBaseline.Mathematical => ResolveBaselineCoordinate(fontFace.Mathematical, central), SvgDominantBaseline.Middle when HasBaselineCoordinate(fontFace.XHeight) => fontFace.XHeight * 0.5f, SvgDominantBaseline.Middle when HasBaselineCoordinate(fontFace.CapHeight) => fontFace.CapHeight * 0.5f, diff --git a/src/Svg.SceneGraph/SvgSceneCompiler.cs b/src/Svg.SceneGraph/SvgSceneCompiler.cs index 71bfeef5ef..f00619c1c8 100644 --- a/src/Svg.SceneGraph/SvgSceneCompiler.cs +++ b/src/Svg.SceneGraph/SvgSceneCompiler.cs @@ -334,6 +334,7 @@ private static bool TryCompile( out SvgSceneDocument? sceneDocument) { sceneDocument = null; + using var documentFontScope = PushDocumentFonts(sourceDocument, assetLoader); if (!TryCompileNodeTree( sourceDocument, @@ -370,6 +371,7 @@ internal static bool TryCompileNodeTree( out SKRect effectiveCullRect, out SKRect viewport) { + using var documentFontScope = PushDocumentFonts(sourceDocument, assetLoader); return TryCompileNodeTree( sourceDocument, cullRect, @@ -450,6 +452,7 @@ internal static bool TryCompileFragment( DrawAttributes ignoreAttributes, out SvgSceneDocument? sceneDocument) { + using var documentFontScope = PushDocumentFonts(sourceFragment as SvgDocument ?? sourceFragment?.OwnerDocument, assetLoader); return TryCompileFragment( sourceFragment, cullRect, @@ -460,6 +463,17 @@ internal static bool TryCompileFragment( out sceneDocument); } + private static IDisposable? PushDocumentFonts(SvgDocument? document, ISvgAssetLoader assetLoader) + { + if (document is not null && + assetLoader is ISvgDocumentFontLoader fontLoader) + { + return fontLoader.PushDocumentFonts(document); + } + + return null; + } + private static bool TryCompileFragment( SvgFragment? sourceFragment, SKRect cullRect, diff --git a/src/Svg.SceneGraph/SvgSceneRenderer.cs b/src/Svg.SceneGraph/SvgSceneRenderer.cs index 34c8d6818c..2bcf8527b7 100644 --- a/src/Svg.SceneGraph/SvgSceneRenderer.cs +++ b/src/Svg.SceneGraph/SvgSceneRenderer.cs @@ -15,6 +15,17 @@ private enum SvgScenePaintPhase Markers } + private static IDisposable? PushDocumentFonts(SvgSceneDocument sceneDocument) + { + if (sceneDocument.SourceDocument is not null && + sceneDocument.AssetLoader is ISvgDocumentFontLoader fontLoader) + { + return fontLoader.PushDocumentFonts(sceneDocument.SourceDocument); + } + + return null; + } + public static SKPicture? Render(SvgSceneDocument? sceneDocument) { if (sceneDocument is null) @@ -33,9 +44,10 @@ private enum SvgScenePaintPhase return null; } + using var documentFontScope = PushDocumentFonts(sceneDocument); var recorder = new SKPictureRecorder(); var canvas = recorder.BeginRecording(cullRect); - RenderNodeToCanvas(sceneDocument, sceneDocument.Root, canvas); + RenderNodeToCanvasCore(sceneDocument, sceneDocument.Root, canvas); return recorder.EndRecording(); } @@ -56,9 +68,10 @@ private enum SvgScenePaintPhase return null; } + using var documentFontScope = PushDocumentFonts(sceneDocument); var recorder = new SKPictureRecorder(); var canvas = recorder.BeginRecording(cullRect); - RenderNodeToCanvas(sceneDocument, node, canvas, ignoreAttributes, until, enableRootTransform, ignoreRootOpacity, ignoreRootMask, ignoreRootFilter); + RenderNodeToCanvasCore(sceneDocument, node, canvas, ignoreAttributes, until, enableRootTransform, ignoreRootOpacity, ignoreRootMask, ignoreRootFilter); return recorder.EndRecording(); } @@ -72,6 +85,30 @@ internal static bool RenderNodeToCanvas( bool ignoreCurrentOpacity = false, bool ignoreCurrentMask = false, bool ignoreCurrentFilter = false) + { + using var documentFontScope = PushDocumentFonts(sceneDocument); + return RenderNodeToCanvasCore( + sceneDocument, + node, + canvas, + ignoreAttributes, + until, + enableTransform, + ignoreCurrentOpacity, + ignoreCurrentMask, + ignoreCurrentFilter); + } + + private static bool RenderNodeToCanvasCore( + SvgSceneDocument sceneDocument, + SvgSceneNode node, + SKCanvas canvas, + DrawAttributes ignoreAttributes = DrawAttributes.None, + SvgSceneNode? until = null, + bool enableTransform = true, + bool ignoreCurrentOpacity = false, + bool ignoreCurrentMask = false, + bool ignoreCurrentFilter = false) { if (until is not null && ReferenceEquals(node, until)) { @@ -170,7 +207,7 @@ internal static bool RenderNodeToCanvas( if (node.MaskNode is { } maskNode && node.MaskDstIn is { } maskDstIn && enableMask) { canvas.SaveLayer(maskDstIn); - RenderNodeToCanvas(sceneDocument, maskNode, canvas, ignoreAttributes, until: null); + RenderNodeToCanvasCore(sceneDocument, maskNode, canvas, ignoreAttributes, until: null); canvas.Restore(); } @@ -190,6 +227,7 @@ internal static bool RenderBackgroundToCanvas( throw new ArgumentNullException(nameof(until)); } + using var documentFontScope = PushDocumentFonts(sceneDocument); return RenderBackgroundToCanvasCore(sceneDocument, node, canvas, until, enableTransform); } @@ -295,7 +333,7 @@ private static bool RenderBackgroundToCanvasCore( if (enableMask && node.MaskNode is { } maskNode && node.MaskDstIn is { } maskDstIn) { canvas.SaveLayer(maskDstIn); - RenderNodeToCanvas(sceneDocument, maskNode, canvas, until: null); + RenderNodeToCanvasCore(sceneDocument, maskNode, canvas, until: null); canvas.Restore(); } @@ -557,7 +595,7 @@ private static bool RenderChildrenToCanvas( { for (var i = 0; i < node.Children.Count; i++) { - if (!RenderNodeToCanvas(sceneDocument, node.Children[i], canvas, ignoreAttributes, until)) + if (!RenderNodeToCanvasCore(sceneDocument, node.Children[i], canvas, ignoreAttributes, until)) { return false; } @@ -581,7 +619,7 @@ private static bool RenderMarkerChildrenToCanvas( continue; } - if (!RenderNodeToCanvas(sceneDocument, child, canvas, ignoreAttributes, until)) + if (!RenderNodeToCanvasCore(sceneDocument, child, canvas, ignoreAttributes, until)) { return false; } @@ -605,7 +643,7 @@ private static bool RenderNonMarkerChildrenToCanvas( continue; } - if (!RenderNodeToCanvas(sceneDocument, child, canvas, ignoreAttributes, until)) + if (!RenderNodeToCanvasCore(sceneDocument, child, canvas, ignoreAttributes, until)) { return false; } diff --git a/src/Svg.SceneGraph/SvgSceneTextCompiler.cs b/src/Svg.SceneGraph/SvgSceneTextCompiler.cs index 18d69c076c..822f962b3f 100644 --- a/src/Svg.SceneGraph/SvgSceneTextCompiler.cs +++ b/src/Svg.SceneGraph/SvgSceneTextCompiler.cs @@ -2809,6 +2809,14 @@ private static float DrawTextRuns( var fallbackText = GetBrowserCompatibleFallbackText(svgTextBase, text, assetLoader); if (TryCreateBrowserCompatibleFullRunPaint(svgTextBase, fallbackText, paint, assetLoader, out var fullRunPaint, out var shapedText)) { + fullRunPaint.TextAlign = SKTextAlign.Left; + if (TryCreateBrowserBidiShapedGlyphRun(svgTextBase, fallbackText, fullRunPaint, assetLoader, out var bidiShapedRun, out var bidiShapedAdvance)) + { + var shapedRunStartX = GetAlignedStartX(anchorX, bidiShapedAdvance, textAlign); + DrawShapedGlyphRun(bidiShapedRun, shapedRunStartX, anchorY, fullRunPaint, canvas); + return bidiShapedAdvance; + } + var fullRunMeasureBounds = new SKRect(); var fullRunAdvance = EnsureWhitespaceAdvance( fallbackText, @@ -2816,7 +2824,6 @@ private static float DrawTextRuns( assetLoader, assetLoader.MeasureText(shapedText, fullRunPaint, ref fullRunMeasureBounds)); - fullRunPaint.TextAlign = SKTextAlign.Left; if (TryCreateBrowserShapedGlyphRun(svgTextBase, fallbackText, fullRunPaint, assetLoader, out var shapedRun, out var shapedAdvance)) { var shapedRunStartX = GetAlignedStartX(anchorX, shapedAdvance, textAlign); @@ -2829,12 +2836,16 @@ private static float DrawTextRuns( return fullRunAdvance; } - var typefaceSpans = assetLoader.FindTypefaces(fallbackText, paint); + var usesVisualSpanText = TryGetBrowserCompatibleVisualText(svgTextBase, fallbackText, out var visualText); + var spanText = usesVisualSpanText + ? visualText + : fallbackText; + var typefaceSpans = assetLoader.FindTypefaces(spanText, paint); var naturalTotalAdvance = 0f; if (typefaceSpans.Count == 0) { var scratchBounds = new SKRect(); - naturalTotalAdvance = assetLoader.MeasureText(fallbackText, paint, ref scratchBounds); + naturalTotalAdvance = assetLoader.MeasureText(spanText, paint, ref scratchBounds); } else { @@ -2853,7 +2864,7 @@ private static float DrawTextRuns( if (typefaceSpans.Count == 1) { paint.Typeface = typefaceSpans[0].Typeface; - if (TryCreateBrowserShapedGlyphRun(svgTextBase, fallbackText, paint, assetLoader, out var shapedRun, out var shapedAdvance)) + if (TryCreateBrowserShapedGlyphRun(svgTextBase, spanText, paint, assetLoader, out var shapedRun, out var shapedAdvance)) { DrawShapedGlyphRun(shapedRun, GetAlignedStartX(anchorX, shapedAdvance, textAlign), anchorY, paint, canvas); return shapedAdvance; @@ -2864,11 +2875,11 @@ private static float DrawTextRuns( if (typefaceSpans.Count == 0) { - canvas.DrawText(ApplyBrowserCompatibleBidiControls(svgTextBase, fallbackText), currentX, anchorY, paint); + canvas.DrawText(spanText, currentX, anchorY, paint); return naturalTotalAdvance; } - var isRightToLeft = IsRightToLeft(svgTextBase); + var isRightToLeft = !usesVisualSpanText && IsRightToLeft(svgTextBase); var startIndex = isRightToLeft ? typefaceSpans.Count - 1 : 0; var endIndex = isRightToLeft ? -1 : typefaceSpans.Count; var step = isRightToLeft ? -1 : 1; @@ -2876,7 +2887,7 @@ private static float DrawTextRuns( { var typefaceSpan = typefaceSpans[i]; paint.Typeface = typefaceSpan.Typeface; - canvas.DrawText(ApplyBrowserCompatibleBidiControls(svgTextBase, typefaceSpan.Text), currentX, anchorY, paint); + canvas.DrawText(typefaceSpan.Text, currentX, anchorY, paint); currentX += typefaceSpan.Advance; paint = paint.Clone(); } @@ -11592,6 +11603,68 @@ private static bool TryCreateBrowserShapedGlyphRun( return advance > 0f; } + private static bool TryCreateBrowserBidiShapedGlyphRun( + SvgTextBase svgTextBase, + string text, + SKPaint paint, + ISvgAssetLoader assetLoader, + out ShapedGlyphRun shapedRun, + out float advance) + { + shapedRun = default; + advance = 0f; + if (string.IsNullOrEmpty(text) || + SvgTextBidiResolver.ResolveUnicodeBidi(svgTextBase) != SvgUnicodeBidiMode.Normal || + SvgTextBidiResolver.ResolveDirection(svgTextBase) != SvgTextDirection.RightToLeft || + !ContainsMixedStrongDirections(text) || + HasEffectiveSpacingAdjustments(svgTextBase, text) || + assetLoader is not ISvgTextDirectedGlyphRunResolver glyphRunResolver) + { + return false; + } + + var visualRuns = CreateLogicalBidiRuns(svgTextBase, text, SvgTextDirection.RightToLeft); + if (visualRuns.Count <= 1) + { + return false; + } + + var glyphs = new List(); + var points = new List(); + var clusters = new List(); + var currentAdvance = 0f; + for (var i = 0; i < visualRuns.Count; i++) + { + var visualRun = visualRuns[i]; + var runText = text.Substring(visualRun.StartCharIndex, visualRun.Length); + if (!glyphRunResolver.TryShapeGlyphRun(runText, paint, visualRun.Direction == SvgTextDirection.RightToLeft, out var run) || + run.Glyphs.Length == 0 || + run.Points.Length != run.Glyphs.Length || + run.Clusters.Length != run.Glyphs.Length) + { + return false; + } + + for (var glyphIndex = 0; glyphIndex < run.Glyphs.Length; glyphIndex++) + { + glyphs.Add(run.Glyphs[glyphIndex]); + points.Add(new SKPoint(run.Points[glyphIndex].X + currentAdvance, run.Points[glyphIndex].Y)); + clusters.Add(visualRun.StartCharIndex + run.Clusters[glyphIndex]); + } + + currentAdvance += run.Advance; + } + + if (glyphs.Count == 0 || currentAdvance <= 0f) + { + return false; + } + + advance = EnsureWhitespaceAdvance(text, paint, assetLoader, currentAdvance); + shapedRun = new ShapedGlyphRun(glyphs.ToArray(), points.ToArray(), clusters.ToArray(), advance); + return true; + } + private static bool TryCreateMixedScriptSpacingRunLayout( SvgTextBase svgTextBase, string text, @@ -17493,6 +17566,12 @@ private static float DrawTextRunsAlignedLeft( var fallbackText = GetBrowserCompatibleFallbackText(svgTextBase, text, assetLoader); if (TryCreateBrowserCompatibleFullRunPaint(svgTextBase, fallbackText, paint, assetLoader, out var fullRunPaint, out var shapedText)) { + if (TryCreateBrowserBidiShapedGlyphRun(svgTextBase, fallbackText, fullRunPaint, assetLoader, out var bidiShapedRun, out var bidiShapedAdvance)) + { + DrawShapedGlyphRun(bidiShapedRun, anchorX, anchorY, fullRunPaint, canvas); + return bidiShapedAdvance; + } + var fullRunMeasureBounds = new SKRect(); var measuredAdvance = EnsureWhitespaceAdvance( fallbackText, @@ -17510,19 +17589,22 @@ private static float DrawTextRunsAlignedLeft( return measuredAdvance; } - var typefaceSpans = assetLoader.FindTypefaces(fallbackText, paint); + var spanText = TryGetBrowserCompatibleVisualText(svgTextBase, fallbackText, out var visualText) + ? visualText + : fallbackText; + var typefaceSpans = assetLoader.FindTypefaces(spanText, paint); if (typefaceSpans.Count == 0) { var scratchBounds = new SKRect(); - var measuredAdvance = EnsureWhitespaceAdvance(fallbackText, paint, assetLoader, assetLoader.MeasureText(fallbackText, paint, ref scratchBounds)); - canvas.DrawText(ApplyBrowserCompatibleBidiControls(svgTextBase, fallbackText), anchorX, anchorY, paint); + var measuredAdvance = EnsureWhitespaceAdvance(spanText, paint, assetLoader, assetLoader.MeasureText(spanText, paint, ref scratchBounds)); + canvas.DrawText(spanText, anchorX, anchorY, paint); return measuredAdvance; } if (typefaceSpans.Count == 1) { paint.Typeface = typefaceSpans[0].Typeface; - if (TryCreateBrowserShapedGlyphRun(svgTextBase, fallbackText, paint, assetLoader, out var shapedRun, out var shapedAdvance)) + if (TryCreateBrowserShapedGlyphRun(svgTextBase, spanText, paint, assetLoader, out var shapedRun, out var shapedAdvance)) { DrawShapedGlyphRun(shapedRun, anchorX, anchorY, paint, canvas); return shapedAdvance; @@ -17536,13 +17618,13 @@ private static float DrawTextRunsAlignedLeft( foreach (var typefaceSpan in typefaceSpans) { paint.Typeface = typefaceSpan.Typeface; - canvas.DrawText(ApplyBrowserCompatibleBidiControls(svgTextBase, typefaceSpan.Text), currentX, anchorY, paint); + canvas.DrawText(typefaceSpan.Text, currentX, anchorY, paint); currentX += typefaceSpan.Advance; naturalTotalAdvance += typefaceSpan.Advance; paint = paint.Clone(); } - naturalTotalAdvance = EnsureWhitespaceAdvance(fallbackText, paint, assetLoader, naturalTotalAdvance); + naturalTotalAdvance = EnsureWhitespaceAdvance(spanText, paint, assetLoader, naturalTotalAdvance); return naturalTotalAdvance; } @@ -19233,6 +19315,15 @@ private static string ApplyBrowserCompatibleBidiControls(SvgTextBase svgTextBase return SvgTextBidiResolver.ApplyBrowserCompatibleControls(svgTextBase, text); } + private static bool TryGetBrowserCompatibleVisualText(SvgTextBase svgTextBase, string text, out string visualText) + { + return SvgTextBidiResolver.TryGetVisualText( + text, + SvgTextBidiResolver.ResolveDirection(svgTextBase), + SvgTextBidiResolver.ResolveUnicodeBidi(svgTextBase), + out visualText); + } + private static bool TryGetVisualBidiText(string text, string? direction, string? unicodeBidi, out string visualText) { var baseDirection = string.Equals(direction, "rtl", StringComparison.OrdinalIgnoreCase) @@ -19618,18 +19709,7 @@ private static float GetDominantBaselineOffset(SvgTextBase svgTextBase, SKRect v return svgFontBaselineOffset; } - var baselineLineOffset = baseline switch - { - SvgDominantBaseline.Ideographic => metrics.Descent, - SvgDominantBaseline.Hanging => metrics.Ascent * 0.8f, - SvgDominantBaseline.Mathematical or SvgDominantBaseline.Middle => (metrics.Ascent + metrics.Descent) * 0.5f, - SvgDominantBaseline.Central => (metrics.Top + metrics.Bottom) * 0.5f, - SvgDominantBaseline.TextAfterEdge or SvgDominantBaseline.TextBottom => metrics.Bottom, - SvgDominantBaseline.TextBeforeEdge or SvgDominantBaseline.TextTop => metrics.Top, - _ => 0f - }; - - return -baselineLineOffset; + return -SvgTextBaselineResolver.GetNativeBaselineLineOffset(metrics, baseline); } private static SvgDominantBaseline ResolveBaselineIdentifier(SvgTextBase svgTextBase) @@ -19765,15 +19845,15 @@ private static SvgDominantBaseline ResolveScriptDominantBaseline(SvgTextBase svg } var scalar = char.ConvertToUtf32(codepoint, 0); - if (IsCjkBaselineScript(scalar)) + if (SvgTextBaselineResolver.IsCjkBaselineScript(scalar)) { cjkCount++; } - else if (IsMathematicalBaselineScript(scalar)) + else if (SvgTextBaselineResolver.IsMathematicalBaselineScript(scalar)) { mathCount++; } - else if (IsHangingBaselineScript(scalar)) + else if (SvgTextBaselineResolver.IsHangingBaselineScript(scalar)) { hangingCount++; } @@ -19801,30 +19881,6 @@ private static SvgDominantBaseline ResolveScriptDominantBaseline(SvgTextBase svg return SvgDominantBaseline.Alphabetic; } - private static bool IsCjkBaselineScript(int scalar) - { - return scalar is >= 0x2E80 and <= 0xA4CF or - >= 0xAC00 and <= 0xD7AF or - >= 0xF900 and <= 0xFAFF or - >= 0xFE30 and <= 0xFE4F or - >= 0x20000 and <= 0x2FA1F; - } - - private static bool IsMathematicalBaselineScript(int scalar) - { - return scalar is >= 0x2200 and <= 0x22FF or - >= 0x27C0 and <= 0x27EF or - >= 0x2980 and <= 0x2AFF or - >= 0x1D400 and <= 0x1D7FF; - } - - private static bool IsHangingBaselineScript(int scalar) - { - return scalar is >= 0x0900 and <= 0x0D7F or - >= 0x0F00 and <= 0x0FFF or - >= 0x1000 and <= 0x109F; - } - private static string GetRawTextContent(SvgElement element) { var builder = new StringBuilder(); diff --git a/src/Svg.SceneGraph/SvgTextBaselineResolver.cs b/src/Svg.SceneGraph/SvgTextBaselineResolver.cs new file mode 100644 index 0000000000..9c548ab4ac --- /dev/null +++ b/src/Svg.SceneGraph/SvgTextBaselineResolver.cs @@ -0,0 +1,113 @@ +// Copyright (c) Wiesław Šoltés. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for details. +using ShimSkiaSharp; + +namespace Svg.Skia; + +internal static class SvgTextBaselineResolver +{ + public static SvgDominantBaseline ResolveScriptBaseline(string? text) + { + if (string.IsNullOrEmpty(text)) + { + return SvgDominantBaseline.Alphabetic; + } + + var charIndex = 0; + while (TryReadNextCodepoint(text!, ref charIndex, out var scalar)) + { + var baseline = ResolveScriptBaseline(scalar); + if (baseline != SvgDominantBaseline.Alphabetic) + { + return baseline; + } + } + + return SvgDominantBaseline.Alphabetic; + } + + public static SvgDominantBaseline ResolveScriptBaseline(int scalar) + { + if (IsCjkBaselineScript(scalar)) + { + return SvgDominantBaseline.Ideographic; + } + + if (IsMathematicalBaselineScript(scalar)) + { + return SvgDominantBaseline.Mathematical; + } + + if (IsHangingBaselineScript(scalar)) + { + return SvgDominantBaseline.Hanging; + } + + return SvgDominantBaseline.Alphabetic; + } + + public static float GetNativeBaselineLineOffset(SKFontMetrics metrics, SvgDominantBaseline baseline) + { + return baseline switch + { + SvgDominantBaseline.Ideographic => metrics.Descent, + SvgDominantBaseline.Hanging => metrics.Ascent * 0.8f, + SvgDominantBaseline.Mathematical or SvgDominantBaseline.Middle => (metrics.Ascent + metrics.Descent) * 0.5f, + SvgDominantBaseline.Central => (metrics.Top + metrics.Bottom) * 0.5f, + SvgDominantBaseline.TextAfterEdge or SvgDominantBaseline.TextBottom => metrics.Bottom, + SvgDominantBaseline.TextBeforeEdge or SvgDominantBaseline.TextTop => metrics.Top, + _ => 0f + }; + } + + public static bool TryReadNextCodepoint(string text, ref int charIndex, out int scalar) + { + while (charIndex < text.Length) + { + var current = text[charIndex++]; + if (char.IsWhiteSpace(current)) + { + continue; + } + + if (char.IsHighSurrogate(current) && + charIndex < text.Length && + char.IsLowSurrogate(text[charIndex])) + { + scalar = char.ConvertToUtf32(current, text[charIndex]); + charIndex++; + return true; + } + + scalar = current; + return true; + } + + scalar = 0; + return false; + } + + public static bool IsCjkBaselineScript(int scalar) + { + return scalar is >= 0x2E80 and <= 0xA4CF or + >= 0xAC00 and <= 0xD7AF or + >= 0xF900 and <= 0xFAFF or + >= 0xFE30 and <= 0xFE4F or + >= 0x20000 and <= 0x2FA1F; + } + + public static bool IsMathematicalBaselineScript(int scalar) + { + return scalar is >= 0x2200 and <= 0x22FF or + >= 0x27C0 and <= 0x27EF or + >= 0x2980 and <= 0x2AFF or + >= 0x1D400 and <= 0x1D7FF; + } + + public static bool IsHangingBaselineScript(int scalar) + { + return scalar is >= 0x0900 and <= 0x0D7F or + >= 0x0F00 and <= 0x0FFF or + >= 0x1000 and <= 0x109F; + } +} diff --git a/src/Svg.Skia/SKSvg.AnimationLayers.cs b/src/Svg.Skia/SKSvg.AnimationLayers.cs index ff93f77272..a85490d677 100644 --- a/src/Svg.Skia/SKSvg.AnimationLayers.cs +++ b/src/Svg.Skia/SKSvg.AnimationLayers.cs @@ -75,6 +75,7 @@ public void Rebuild( return; } + using var documentFontScope = PushDocumentFonts(sceneDocument.SourceDocument, sceneDocument.AssetLoader); var newModel = RecordOverlayNodeModel(sceneDocument, RootNode, ignoreAttributes); var newPicture = newModel is null ? null : owner.SkiaModel.ToSKPicture(newModel); @@ -259,6 +260,7 @@ private bool TryRenderAnimationLayerFrame( } var cullRect = _animationLayerBounds ?? sceneDocument.CullRect; + using var documentFontScope = PushDocumentFonts(sceneDocument.SourceDocument, sceneDocument.AssetLoader); var dynamicLayerModel = RecordDynamicLayerModel(layerEntries, cullRect); var dynamicLayerPicture = SkiaModel.ToSKPicture(dynamicLayerModel); var compositeModel = ComposeAnimationLayerModel(_staticAnimationLayerModel, dynamicLayerModel, cullRect); @@ -421,6 +423,7 @@ _animationLayerEntries is { } existingEntries && SkiaSharp.SKPicture? newStaticLayerPicture = null; if (topologyChanged || _staticAnimationLayerModel is null || _staticAnimationLayerPicture is null) { + using var documentFontScope = PushDocumentFonts(sceneDocument.SourceDocument, sceneDocument.AssetLoader); newStaticLayerModel = RecordStaticLayerModel(sceneDocument, rootInfos, cullRect, IgnoreAttributes); newStaticLayerPicture = SkiaModel.ToSKPicture(newStaticLayerModel); if (newStaticLayerPicture is null) diff --git a/src/Svg.Skia/SKSvg.Model.cs b/src/Svg.Skia/SKSvg.Model.cs index 15a15b1a1b..0aa11f95d6 100644 --- a/src/Svg.Skia/SKSvg.Model.cs +++ b/src/Svg.Skia/SKSvg.Model.cs @@ -84,6 +84,7 @@ public static SKSvg CreateFromSvgDocument(SvgDocument svgDocument) public static SkiaSharp.SKPicture? ToPicture(SvgFragment svgFragment, SkiaModel skiaModel, ISvgAssetLoader assetLoader) { + using var documentFontScope = PushDocumentFonts(svgFragment as SvgDocument ?? svgFragment.OwnerDocument, assetLoader); var picture = SvgSceneRuntime.CreateModel( svgFragment, assetLoader, @@ -94,6 +95,7 @@ public static SKSvg CreateFromSvgDocument(SvgDocument svgDocument) public static void Draw(SkiaSharp.SKCanvas skCanvas, SvgFragment svgFragment, SkiaModel skiaModel, ISvgAssetLoader assetLoader) { + using var documentFontScope = PushDocumentFonts(svgFragment as SvgDocument ?? svgFragment.OwnerDocument, assetLoader); var picture = SvgSceneRuntime.CreateModel( svgFragment, assetLoader, @@ -475,6 +477,7 @@ public virtual SkiaSharp.SKPicture? Picture } } + using var documentFontScope = PushDocumentFonts(SourceDocument, AssetLoader); var newPicture = SkiaModel.ToSKPicture(model); if (newPicture is null) { @@ -575,6 +578,7 @@ public SKSvg() return null; } + using var documentFontScope = PushDocumentFonts(SourceDocument, AssetLoader); var rebuilt = SkiaModel.ToSKPicture(model); lock (Sync) { @@ -718,6 +722,7 @@ public SKSvg Clone() Func loader) { SvgDocument? svgDocument; + using var systemColorScope = CreateSystemColorProviderScope(); if (CacheOriginalStream) { @@ -753,6 +758,7 @@ public SKSvg Clone() private SkiaSharp.SKPicture? LoadSvgInternal(System.IO.Stream stream, SvgParameters? parameters, Uri? baseUri) { SvgDocument? svgDocument; + using var systemColorScope = CreateSystemColorProviderScope(); if (CacheOriginalStream) { @@ -792,6 +798,7 @@ public SKSvg Clone() return null; } + using var systemColorScope = CreateSystemColorProviderScope(); _originalPath = path; _originalParameters = parameters; _originalBaseUri = null; @@ -804,6 +811,7 @@ public SKSvg Clone() private SkiaSharp.SKPicture? LoadSvgReader(XmlReader reader) { + using var systemColorScope = CreateSystemColorProviderScope(); _originalPath = null; _originalParameters = null; _originalBaseUri = null; @@ -814,6 +822,24 @@ public SKSvg Clone() return LoadSvgDocument(SvgService.Open(reader, Settings.EnableJavaScript)); } + private IDisposable? CreateSystemColorProviderScope() + { + return Settings.SystemColorProvider is { } provider + ? SvgSystemColorResolver.PushProvider(provider) + : null; + } + + private static IDisposable? PushDocumentFonts(SvgDocument? document, ISvgAssetLoader assetLoader) + { + if (document is not null && + assetLoader is ISvgDocumentFontLoader fontLoader) + { + return fontLoader.PushDocumentFonts(document); + } + + return null; + } + private SkiaSharp.SKPicture? LoadPath( string? path, SvgParameters? parameters, @@ -857,6 +883,11 @@ public SKSvg Clone() return null; } + if (AssetLoader is ISvgDocumentFontLoader fontLoader) + { + fontLoader.ClearDocumentFonts(); + } + if (baseUri is { }) { svgDocument.BaseUri = baseUri; @@ -930,6 +961,7 @@ public SKSvg Clone() public SkiaSharp.SKPicture? FromSvg(string svg) { + using var systemColorScope = CreateSystemColorProviderScope(); var svgDocument = SvgService.FromSvg(svg, Settings.EnableJavaScript); return LoadSvgDocument(svgDocument); } @@ -1289,6 +1321,11 @@ public void Draw(SkiaSharp.SKCanvas canvas) private void Reset() { ReplaceAnimationController(null); + if (AssetLoader is ISvgDocumentFontLoader fontLoader) + { + fontLoader.ClearDocumentFonts(); + } + SourceDocument = null; _javaScriptRuntime = null; ClearAnimationRenderState(); @@ -1366,6 +1403,7 @@ private void WaitForDrawsLocked() private bool RenderRetainedSceneDocument(SvgSceneDocument sceneDocument) { + using var documentFontScope = PushDocumentFonts(sceneDocument.SourceDocument, sceneDocument.AssetLoader); var model = sceneDocument.CreateModel(); if (model is null) { @@ -2851,6 +2889,7 @@ private bool RenderAnimationFrame(TimeSpan time, bool raiseInvalidation, bool by return false; } + using var systemColorScope = CreateSystemColorProviderScope(); return RenderAnimationFrame(AnimationController.EvaluateFrameState(time), raiseInvalidation, bypassThrottle); } @@ -2861,6 +2900,7 @@ private bool RenderAnimationFrame(SvgAnimationFrameState frameState, bool raiseI return false; } + using var systemColorScope = CreateSystemColorProviderScope(); var forceRender = DispatchAnimationTimelineCallbacks(ref frameState); if (!forceRender && diff --git a/src/Svg.Skia/SKSvg.NativeComposition.cs b/src/Svg.Skia/SKSvg.NativeComposition.cs index 0c67aa5821..22694ecb0f 100644 --- a/src/Svg.Skia/SKSvg.NativeComposition.cs +++ b/src/Svg.Skia/SKSvg.NativeComposition.cs @@ -334,6 +334,8 @@ private static bool TryGetNativeCompositionRootNode( private SvgDocument GetNativeCompositionDocument(SvgAnimationController animationController) { + using var systemColorScope = CreateSystemColorProviderScope(); + if (_animatedDocument is { } animatedDocument) { return animatedDocument; diff --git a/src/Svg.Skia/SKSvg.SceneGraph.cs b/src/Svg.Skia/SKSvg.SceneGraph.cs index cb9b26bed3..d3a5fa70ea 100644 --- a/src/Svg.Skia/SKSvg.SceneGraph.cs +++ b/src/Svg.Skia/SKSvg.SceneGraph.cs @@ -80,6 +80,7 @@ public bool TryEnsureRetainedSceneGraph(out SvgSceneDocument? sceneDocument) public SkiaSharp.SKPicture? CreateRetainedSceneGraphPicture() { + using var documentFontScope = PushDocumentFonts(SourceDocument, AssetLoader); var model = CreateRetainedSceneGraphModel(); return model is null ? null : SkiaModel.ToSKPicture(model); } @@ -255,6 +256,7 @@ public bool TryApplyRetainedSceneMutationByIdAndRender( public SkiaSharp.SKPicture? CreateRetainedSceneNodePicture(SvgSceneNode node, SKRect? clip = null) { + using var documentFontScope = PushDocumentFonts(SourceDocument, AssetLoader); var model = CreateRetainedSceneNodeModel(node, clip); return model is null ? null : SkiaModel.ToSKPicture(model); } @@ -273,6 +275,7 @@ public bool TryApplyRetainedSceneMutationByIdAndRender( public SkiaSharp.SKPicture? CreateRetainedScenePicture(SvgElement element, SKRect? clip = null) { + using var documentFontScope = PushDocumentFonts(SourceDocument, AssetLoader); var model = CreateRetainedSceneModel(element, clip); return model is null ? null : SkiaModel.ToSKPicture(model); } diff --git a/src/Svg.Skia/SKSvgSettings.cs b/src/Svg.Skia/SKSvgSettings.cs index 3c3413c16b..819065a9b9 100644 --- a/src/Svg.Skia/SKSvgSettings.cs +++ b/src/Svg.Skia/SKSvgSettings.cs @@ -19,6 +19,8 @@ public class SKSvgSettings public IList? TypefaceProviders { get; set; } + internal IList? DocumentTypefaceProviders { get; set; } + public SkiaSharp.SKRect? StandaloneViewport { get; set; } public bool EnableSvgFonts { get; set; } @@ -29,6 +31,8 @@ public class SKSvgSettings public bool EnableBrokenImagePlaceholders { get; set; } + public Svg.ISvgSystemColorProvider? SystemColorProvider { get; set; } + public bool EnableJavaScript { get; set; } public bool EnableTextSelectionRendering { get; set; } @@ -68,11 +72,13 @@ public void CopyTo(SKSvgSettings target) target.TypefaceProviders = TypefaceProviders is null ? null : new List(TypefaceProviders); + target.DocumentTypefaceProviders = null; target.StandaloneViewport = StandaloneViewport; target.EnableSvgFonts = EnableSvgFonts; target.EnableTextReferences = EnableTextReferences; target.EnableFilterBackgroundInputs = EnableFilterBackgroundInputs; target.EnableBrokenImagePlaceholders = EnableBrokenImagePlaceholders; + target.SystemColorProvider = SystemColorProvider; target.EnableJavaScript = EnableJavaScript; target.EnableTextSelectionRendering = EnableTextSelectionRendering; target.TextSelectionColor = TextSelectionColor; @@ -99,12 +105,14 @@ public SKSvgSettings() new FontManagerTypefaceProvider(), new DefaultTypefaceProvider() }; + DocumentTypefaceProviders = null; StandaloneViewport = null; EnableSvgFonts = true; EnableTextReferences = true; EnableFilterBackgroundInputs = true; EnableBrokenImagePlaceholders = true; + SystemColorProvider = null; EnableJavaScript = false; EnableTextSelectionRendering = true; TextSelectionColor = new SkiaSharp.SKColor(0x00, 0x80, 0x00, 0xFF); diff --git a/src/Svg.Skia/SkiaModel.cs b/src/Svg.Skia/SkiaModel.cs index 8af78f13aa..351e2d3821 100644 --- a/src/Svg.Skia/SkiaModel.cs +++ b/src/Svg.Skia/SkiaModel.cs @@ -276,7 +276,8 @@ internal static IEnumerable EnumerateFontFamilyCandidates(string? fontFa private void EnsureTypefaceProviderCaches() { var providers = Settings.TypefaceProviders; - var hash = ComputeTypefaceProviderHash(providers); + var documentProviders = Settings.DocumentTypefaceProviders; + var hash = ComputeTypefaceProviderHash(documentProviders, providers); if (!ReferenceEquals(providers, _providerStateList) || hash != _providerStateHash) { _providerStateList = providers; @@ -288,35 +289,40 @@ private void EnsureTypefaceProviderCaches() } } - private static int ComputeTypefaceProviderHash(IList? providers) + private static int ComputeTypefaceProviderHash(params IList?[] providerLists) { unchecked { var hash = 17; - if (providers is null) + for (var listIndex = 0; listIndex < providerLists.Length; listIndex++) { - return hash; - } - - hash = (hash * 397) ^ providers.Count; - for (var i = 0; i < providers.Count; i++) - { - var provider = providers[i]; - if (provider is null) + var providers = providerLists[listIndex]; + if (providers is null) { + hash = (hash * 397) ^ -1; continue; } - hash = (hash * 397) ^ RuntimeHelpers.GetHashCode(provider); - hash = (hash * 397) ^ provider.GetHashCode(); - if (provider is CustomTypefaceProvider custom) - { - hash = (hash * 397) ^ (custom.Typeface?.Handle.GetHashCode() ?? 0); - } - else if (provider is FontManagerTypefaceProvider fontManagerProvider && - fontManagerProvider.TryGetFontManagerHandle(out var handle)) + hash = (hash * 397) ^ providers.Count; + for (var i = 0; i < providers.Count; i++) { - hash = (hash * 397) ^ handle.GetHashCode(); + var provider = providers[i]; + if (provider is null) + { + continue; + } + + hash = (hash * 397) ^ RuntimeHelpers.GetHashCode(provider); + hash = (hash * 397) ^ provider.GetHashCode(); + if (provider is CustomTypefaceProvider custom) + { + hash = (hash * 397) ^ (custom.Typeface?.Handle.GetHashCode() ?? 0); + } + else if (provider is FontManagerTypefaceProvider fontManagerProvider && + fontManagerProvider.TryGetFontManagerHandle(out var handle)) + { + hash = (hash * 397) ^ handle.GetHashCode(); + } } } @@ -324,6 +330,25 @@ private static int ComputeTypefaceProviderHash(IList? provide } } + internal IEnumerable EnumerateEffectiveTypefaceProviders() + { + if (Settings.DocumentTypefaceProviders is { } documentProviders) + { + for (var i = 0; i < documentProviders.Count; i++) + { + yield return documentProviders[i]; + } + } + + if (Settings.TypefaceProviders is { } providers) + { + for (var i = 0; i < providers.Count; i++) + { + yield return providers[i]; + } + } + } + private void ClearPositionedTextCache() { lock (_positionedTextCacheLock) @@ -500,20 +525,19 @@ private TypefaceResolution ResolveSKTypeface(SKTypeface? typeface) const bool browserCompatibleFontFallback = true; foreach (var candidate in EnumerateFontFamilyCandidates(fontFamily, browserCompatibleFontFallback)) { - if (Settings.TypefaceProviders is { } && Settings.TypefaceProviders.Count > 0) + foreach (var typefaceProvider in EnumerateEffectiveTypefaceProviders()) { - foreach (var typefaceProvider in Settings.TypefaceProviders) + var providerTypeface = ResolveProviderTypeface(typefaceProvider, candidate, fontWeight, fontWidth, fontStyle); + if (providerTypeface is { } && providerTypeface.Handle != IntPtr.Zero) { - var providerTypeface = ResolveProviderTypeface(typefaceProvider, candidate, fontWeight, fontWidth, fontStyle); - if (providerTypeface is { } && providerTypeface.Handle != IntPtr.Zero) - { - return CacheTypefaceResolution(cacheKey, providerTypeface, ShouldSuppressSyntheticBold(typefaceProvider, candidate, providerTypeface)); - } + return CacheTypefaceResolution(cacheKey, providerTypeface, ShouldSuppressSyntheticBold(typefaceProvider, candidate, providerTypeface)); } } var resolved = ResolveTypeface(candidate, style); - if (resolved is { } && resolved.Handle != IntPtr.Zero) + if (resolved is { } && + resolved.Handle != IntPtr.Zero && + IsAcceptableResolvedFamily(candidate, resolved)) { return CacheTypefaceResolution(cacheKey, resolved, suppressSyntheticBold: false); } @@ -523,35 +547,31 @@ private TypefaceResolution ResolveSKTypeface(SKTypeface? typeface) { foreach (var candidate in EnumerateFontFamilyCandidates("serif", browserCompatibleFontFallback)) { - if (Settings.TypefaceProviders is { } && Settings.TypefaceProviders.Count > 0) + foreach (var typefaceProvider in EnumerateEffectiveTypefaceProviders()) { - foreach (var typefaceProvider in Settings.TypefaceProviders) + var providerTypeface = ResolveProviderTypeface(typefaceProvider, candidate, fontWeight, fontWidth, fontStyle); + if (providerTypeface is { } && providerTypeface.Handle != IntPtr.Zero) { - var providerTypeface = ResolveProviderTypeface(typefaceProvider, candidate, fontWeight, fontWidth, fontStyle); - if (providerTypeface is { } && providerTypeface.Handle != IntPtr.Zero) - { - return CacheTypefaceResolution(cacheKey, providerTypeface, ShouldSuppressSyntheticBold(typefaceProvider, candidate, providerTypeface)); - } + return CacheTypefaceResolution(cacheKey, providerTypeface, ShouldSuppressSyntheticBold(typefaceProvider, candidate, providerTypeface)); } } var resolved = ResolveTypeface(candidate, style); - if (resolved is { } && resolved.Handle != IntPtr.Zero) + if (resolved is { } && + resolved.Handle != IntPtr.Zero && + IsAcceptableResolvedFamily(candidate, resolved)) { return CacheTypefaceResolution(cacheKey, resolved, suppressSyntheticBold: false); } } } - if (Settings.TypefaceProviders is { } && Settings.TypefaceProviders.Count > 0) + foreach (var typefaceProvider in EnumerateEffectiveTypefaceProviders()) { - foreach (var typefaceProvider in Settings.TypefaceProviders) + var providerTypeface = ResolveProviderTypeface(typefaceProvider, SkiaSharp.SKTypeface.Default.FamilyName, fontWeight, fontWidth, fontStyle); + if (providerTypeface is { } && providerTypeface.Handle != IntPtr.Zero) { - var providerTypeface = ResolveProviderTypeface(typefaceProvider, SkiaSharp.SKTypeface.Default.FamilyName, fontWeight, fontWidth, fontStyle); - if (providerTypeface is { } && providerTypeface.Handle != IntPtr.Zero) - { - return CacheTypefaceResolution(cacheKey, providerTypeface, ShouldSuppressSyntheticBold(typefaceProvider, SkiaSharp.SKTypeface.Default.FamilyName, providerTypeface)); - } + return CacheTypefaceResolution(cacheKey, providerTypeface, ShouldSuppressSyntheticBold(typefaceProvider, SkiaSharp.SKTypeface.Default.FamilyName, providerTypeface)); } } @@ -565,6 +585,13 @@ private TypefaceResolution ResolveSKTypeface(SKTypeface? typeface) return CacheTypefaceResolution(cacheKey, fallback, suppressSyntheticBold: false); } + private static bool IsAcceptableResolvedFamily(string candidate, SkiaSharp.SKTypeface resolved) + { + return s_genericFontFamilyMap.ContainsKey(candidate) || + s_browserCompatibleGenericFontFamilyMap.ContainsKey(candidate) || + string.Equals(resolved.FamilyName, candidate, StringComparison.OrdinalIgnoreCase); + } + internal static bool HasExplicitTypeface(SKTypeface? typeface) { return !string.IsNullOrWhiteSpace(typeface?.FamilyName); @@ -605,7 +632,7 @@ private TypefaceResolution CacheTypefaceResolution(TypefaceKey cacheKey, SkiaSha private static bool ShouldSuppressSyntheticBold(ITypefaceProvider typefaceProvider, string candidate, SkiaSharp.SKTypeface providerTypeface) { - if (typefaceProvider is FontManagerTypefaceProvider or DefaultTypefaceProvider) + if (typefaceProvider is FontManagerTypefaceProvider or DefaultTypefaceProvider or DocumentFontTypefaceProvider) { return false; } diff --git a/src/Svg.Skia/SkiaSvgAssetLoader.DocumentFonts.cs b/src/Svg.Skia/SkiaSvgAssetLoader.DocumentFonts.cs new file mode 100644 index 0000000000..0b8ce72d17 --- /dev/null +++ b/src/Svg.Skia/SkiaSvgAssetLoader.DocumentFonts.cs @@ -0,0 +1,952 @@ +// Copyright (c) Wiesław Šoltés. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for details. +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.IO; +using System.IO.Compression; +using System.Linq; +using System.Net; +using System.Text; +using System.Text.RegularExpressions; +using Svg; +using Svg.Model; +using Svg.Skia.TypefaceProviders; + +namespace Svg.Skia; + +public partial class SkiaSvgAssetLoader : ISvgDocumentFontLoader +{ + private const uint WoffSignature = 0x774F4646; + + private sealed class CssFontFace + { + public CssFontFace( + string family, + Uri sourceUri, + string? format, + SkiaSharp.SKFontStyleWeight weight, + SkiaSharp.SKFontStyleWidth width, + SkiaSharp.SKFontStyleSlant slant) + { + Family = family; + SourceUri = sourceUri; + Format = format; + Weight = weight; + Width = width; + Slant = slant; + } + + public string Family { get; } + + public Uri SourceUri { get; } + + public string? Format { get; } + + public SkiaSharp.SKFontStyleWeight Weight { get; } + + public SkiaSharp.SKFontStyleWidth Width { get; } + + public SkiaSharp.SKFontStyleSlant Slant { get; } + } + + private sealed class WoffTable + { + public WoffTable( + uint tag, + uint checksum, + int offset, + int compressedLength, + int originalLength) + { + Tag = tag; + Checksum = checksum; + Offset = offset; + CompressedLength = compressedLength; + OriginalLength = originalLength; + } + + public uint Tag { get; } + + public uint Checksum { get; } + + public int Offset { get; } + + public int CompressedLength { get; } + + public int OriginalLength { get; } + + public int OutputOffset { get; set; } + + public byte[] Data { get; set; } = Array.Empty(); + } + + private static readonly Regex s_cssUrlRegex = new(@"url\((?[^\)]+)\)", RegexOptions.Compiled | RegexOptions.IgnoreCase); + private static readonly Regex s_cssFormatRegex = new(@"format\((?[^\)]+)\)", RegexOptions.Compiled | RegexOptions.IgnoreCase); + private readonly object _documentFontsLock = new(); + + public void ClearDocumentFonts() + { + lock (_documentFontsLock) + { + var activeProviders = _skiaModel.Settings.DocumentTypefaceProviders; + _skiaModel.Settings.DocumentTypefaceProviders = null; + ClearPaintCache(); + DisposeDocumentProviders(activeProviders); + } + } + + public IDisposable PushDocumentFonts(SvgDocument document) + { + if (document is null) + { + throw new ArgumentNullException(nameof(document)); + } + + var provider = CreateDocumentFontProvider(document); + lock (_documentFontsLock) + { + var previousProviders = _skiaModel.Settings.DocumentTypefaceProviders; + _skiaModel.Settings.DocumentTypefaceProviders = provider.IsEmpty + ? null + : new List { provider }; + ClearPaintCache(); + return new DocumentFontScope(this, provider, previousProviders); + } + } + + private sealed class DocumentFontScope : IDisposable + { + private readonly SkiaSvgAssetLoader _assetLoader; + private readonly DocumentFontTypefaceProvider _activeProvider; + private readonly IList? _previousProviders; + private bool _disposed; + + public DocumentFontScope( + SkiaSvgAssetLoader assetLoader, + DocumentFontTypefaceProvider activeProvider, + IList? previousProviders) + { + _assetLoader = assetLoader; + _activeProvider = activeProvider; + _previousProviders = previousProviders; + } + + public void Dispose() + { + if (_disposed) + { + return; + } + + lock (_assetLoader._documentFontsLock) + { + _assetLoader._skiaModel.Settings.DocumentTypefaceProviders = _previousProviders; + _assetLoader.ClearPaintCache(); + } + + _activeProvider.Dispose(); + _disposed = true; + } + } + + private static void DisposeDocumentProviders(IList? providers) + { + if (providers is null) + { + return; + } + + for (var i = 0; i < providers.Count; i++) + { + if (providers[i] is IDisposable disposable) + { + disposable.Dispose(); + } + } + } + + private DocumentFontTypefaceProvider CreateDocumentFontProvider(SvgDocument document) + { + var provider = new DocumentFontTypefaceProvider(); + if (document.CompatibilityStyleSources is { Count: > 0 } compatibilityStyleSources) + { + foreach (var styleSource in SvgCssCompatibilityProcessor.EnumerateExpandedStyleSources( + compatibilityStyleSources, + document, + document.LoadOptions)) + { + AddCssFontFaces(provider, document, styleSource.Content, styleSource.BaseUri); + } + + return provider; + } + + foreach (var styleElement in document.Descendants().OfType()) + { + if (string.IsNullOrWhiteSpace(styleElement.Content) || + styleElement.Content.IndexOf("@font-face", StringComparison.OrdinalIgnoreCase) < 0) + { + continue; + } + + AddCssFontFaces(provider, document, styleElement.Content, styleElement); + } + + return provider; + } + + private static void AddCssFontFaces( + DocumentFontTypefaceProvider provider, + SvgDocument document, + string css, + SvgElement ownerElement) + { + foreach (var fontFace in ParseCssFontFaces(css)) + { + var fontUri = SvgExternalResourceResolver.ResolveResourceUri(ownerElement, fontFace.SourceUri); + AddCssFontFace(provider, document, ownerElement, fontUri, fontFace); + } + } + + private static void AddCssFontFaces( + DocumentFontTypefaceProvider provider, + SvgDocument document, + string css, + Uri? baseUri) + { + foreach (var fontFace in ParseCssFontFaces(css)) + { + var fontUri = ResolveCssResourceUri(baseUri, fontFace.SourceUri); + AddCssFontFace(provider, document, document, fontUri, fontFace); + } + } + + private static void AddCssFontFace( + DocumentFontTypefaceProvider provider, + SvgDocument document, + SvgElement ownerElement, + Uri fontUri, + CssFontFace fontFace) + { + if (!SvgExternalResourceResolver.AllowsExternalResource(ownerElement, fontUri)) + { + return; + } + + if (!TryCreateTypeface(fontUri, document, fontFace.Format, out var typeface) || + typeface is null) + { + return; + } + + provider.Add(fontFace.Family, fontFace.Weight, fontFace.Width, fontFace.Slant, typeface); + } + + private static Uri ResolveCssResourceUri(Uri? baseUri, Uri uri) + { + if (uri.IsAbsoluteUri) + { + return uri; + } + + var uriString = uri.OriginalString; + if (string.IsNullOrEmpty(uriString) || uriString[0] == '#') + { + return uri; + } + + return baseUri is { IsAbsoluteUri: true } + ? new Uri(baseUri, uriString) + : uri; + } + + private static IEnumerable ParseCssFontFaces(string css) + { + var searchIndex = 0; + while (searchIndex < css.Length) + { + var atIndex = css.IndexOf("@font-face", searchIndex, StringComparison.OrdinalIgnoreCase); + if (atIndex < 0) + { + yield break; + } + + var blockStart = css.IndexOf('{', atIndex); + if (blockStart < 0) + { + yield break; + } + + var blockEnd = css.IndexOf('}', blockStart + 1); + var bodyEnd = blockEnd < 0 ? css.Length : blockEnd; + var body = css.Substring(blockStart + 1, bodyEnd - blockStart - 1); + searchIndex = blockEnd < 0 ? css.Length : blockEnd + 1; + + var values = ParseCssDeclarations(body); + + if (!values.TryGetValue("font-family", out var family) || + string.IsNullOrWhiteSpace(family) || + !values.TryGetValue("src", out var src)) + { + continue; + } + + var normalizedFamily = family.Trim().Trim('"', '\''); + var weightValue = ParseFontWeight(values.TryGetValue("font-weight", out var weight) ? weight : null); + var widthValue = ParseFontWidth(values.TryGetValue("font-stretch", out var stretch) ? stretch : null); + var slantValue = ParseFontSlant(values.TryGetValue("font-style", out var style) ? style : null); + foreach (var source in ParseCssFontFaceSources(src)) + { + yield return new CssFontFace( + normalizedFamily, + source.SourceUri, + source.Format, + weightValue, + widthValue, + slantValue); + } + } + } + + private static IEnumerable<(Uri SourceUri, string? Format)> ParseCssFontFaceSources(string src) + { + foreach (var source in SplitCssCommaList(src)) + { + var urlMatch = s_cssUrlRegex.Match(source); + if (!urlMatch.Success) + { + continue; + } + + var uriValue = urlMatch.Groups["url"].Value.Trim().Trim('"', '\''); + if (!Uri.TryCreate(uriValue, UriKind.RelativeOrAbsolute, out var sourceUri)) + { + continue; + } + + var format = default(string); + var formatMatch = s_cssFormatRegex.Match(source); + if (formatMatch.Success) + { + format = formatMatch.Groups["format"].Value.Trim().Trim('"', '\'').ToLowerInvariant(); + } + + yield return (sourceUri, format); + } + } + + private static IEnumerable SplitCssCommaList(string value) + { + var itemStart = 0; + var depth = 0; + var quote = '\0'; + var escaped = false; + + for (var i = 0; i <= value.Length; i++) + { + var ch = i < value.Length ? value[i] : ','; + if (escaped) + { + escaped = false; + continue; + } + + if (ch == '\\') + { + escaped = true; + continue; + } + + if (quote != '\0') + { + if (ch == quote) + { + quote = '\0'; + } + + continue; + } + + if (ch is '"' or '\'') + { + quote = ch; + continue; + } + + if (ch == '(') + { + depth++; + continue; + } + + if (ch == ')' && depth > 0) + { + depth--; + continue; + } + + if (ch != ',' || depth != 0) + { + continue; + } + + var item = value.Substring(itemStart, i - itemStart).Trim(); + if (item.Length > 0) + { + yield return item; + } + + itemStart = i + 1; + } + } + + private static Dictionary ParseCssDeclarations(string body) + { + var values = new Dictionary(StringComparer.OrdinalIgnoreCase); + var propertyStart = 0; + var valueStart = -1; + var depth = 0; + var quote = '\0'; + var escaped = false; + + for (var i = 0; i <= body.Length; i++) + { + var ch = i < body.Length ? body[i] : ';'; + if (escaped) + { + escaped = false; + continue; + } + + if (ch == '\\') + { + escaped = true; + continue; + } + + if (quote != '\0') + { + if (ch == quote) + { + quote = '\0'; + } + + continue; + } + + if (ch is '"' or '\'') + { + quote = ch; + continue; + } + + if (ch == '(') + { + depth++; + continue; + } + + if (ch == ')' && depth > 0) + { + depth--; + continue; + } + + if (ch == ':' && depth == 0 && valueStart < 0) + { + valueStart = i + 1; + continue; + } + + if (ch != ';' || depth != 0) + { + continue; + } + + if (valueStart > propertyStart) + { + var name = body.Substring(propertyStart, valueStart - propertyStart - 1).Trim(); + var value = body.Substring(valueStart, i - valueStart).Trim(); + if (name.Length > 0 && value.Length > 0) + { + values[name] = value; + } + } + + propertyStart = i + 1; + valueStart = -1; + } + + return values; + } + + private static bool TryCreateTypeface(Uri fontUri, SvgDocument document, string? format, out SkiaSharp.SKTypeface? typeface) + { + typeface = null; + if (!IsSupportedFontFormat(fontUri, format)) + { + return false; + } + + try + { + using var stream = OpenFontResourceStream(fontUri, document); + if (stream is null) + { + return false; + } + + if (!TryReadTypefaceBytes(stream, fontUri, format, out var fontBytes)) + { + return false; + } + + using var typefaceStream = new MemoryStream(fontBytes); + typeface = SkiaSharp.SKTypeface.FromStream(typefaceStream); + if (typeface is null || typeface.Handle == IntPtr.Zero) + { + typeface?.Dispose(); + typeface = null; + return false; + } + + return true; + } + catch (Exception ex) + { + Debug.WriteLine(ex.Message); + Debug.WriteLine(ex.StackTrace); + typeface = null; + return false; + } + } + + private static bool TryReadTypefaceBytes(Stream stream, Uri fontUri, string? format, out byte[] fontBytes) + { + using var memoryStream = new MemoryStream(); + stream.CopyTo(memoryStream); + var sourceBytes = memoryStream.ToArray(); + if (IsWoffFont(fontUri, format, sourceBytes)) + { + return TryConvertWoffToSfnt(sourceBytes, out fontBytes); + } + + fontBytes = sourceBytes; + return fontBytes.Length > 0; + } + + private static bool IsWoffFont(Uri fontUri, string? format, byte[] bytes) + { + if (bytes.Length >= 4 && ReadUInt32BigEndian(bytes, 0) == WoffSignature) + { + return true; + } + + if (format is { } fontFormat && + !string.IsNullOrWhiteSpace(fontFormat) && + fontFormat.Trim().Equals("woff", StringComparison.OrdinalIgnoreCase)) + { + return true; + } + + var extension = Path.GetExtension(fontUri.IsAbsoluteUri ? fontUri.LocalPath : fontUri.OriginalString); + return extension.Equals(".woff", StringComparison.OrdinalIgnoreCase); + } + + private static bool TryConvertWoffToSfnt(byte[] woffBytes, out byte[] sfntBytes) + { + sfntBytes = Array.Empty(); + if (woffBytes.Length < 44 || + ReadUInt32BigEndian(woffBytes, 0) != WoffSignature) + { + return false; + } + + var flavor = ReadUInt32BigEndian(woffBytes, 4); + var declaredLength = ReadUInt32BigEndian(woffBytes, 8); + var numTables = ReadUInt16BigEndian(woffBytes, 12); + if (numTables == 0 || + declaredLength > woffBytes.Length || + !CanReadRange(woffBytes.Length, 44, numTables * 20)) + { + return false; + } + + var tables = new List(numTables); + for (var i = 0; i < numTables; i++) + { + var recordOffset = 44 + i * 20; + var tag = ReadUInt32BigEndian(woffBytes, recordOffset); + var tableOffset = CheckedUInt32ToInt(ReadUInt32BigEndian(woffBytes, recordOffset + 4)); + var compressedLength = CheckedUInt32ToInt(ReadUInt32BigEndian(woffBytes, recordOffset + 8)); + var originalLength = CheckedUInt32ToInt(ReadUInt32BigEndian(woffBytes, recordOffset + 12)); + var checksum = ReadUInt32BigEndian(woffBytes, recordOffset + 16); + if (tableOffset < 0 || + compressedLength < 0 || + originalLength < 0 || + compressedLength > originalLength || + !CanReadRange(woffBytes.Length, tableOffset, compressedLength)) + { + return false; + } + + var table = new WoffTable(tag, checksum, tableOffset, compressedLength, originalLength); + if (!TryReadWoffTableData(woffBytes, table, out var tableData)) + { + return false; + } + + table.Data = tableData; + tables.Add(table); + } + + tables.Sort(static (left, right) => left.Tag.CompareTo(right.Tag)); + + var tableDirectoryLength = 12 + tables.Count * 16; + var tableDataOffset = Align4(tableDirectoryLength); + var outputLength = tableDataOffset; + for (var i = 0; i < tables.Count; i++) + { + tables[i].OutputOffset = outputLength; + outputLength = Align4(outputLength + tables[i].OriginalLength); + } + + sfntBytes = new byte[outputLength]; + WriteUInt32BigEndian(sfntBytes, 0, flavor); + WriteUInt16BigEndian(sfntBytes, 4, (ushort)tables.Count); + var entrySelector = GetEntrySelector(tables.Count); + var searchRange = (ushort)((1 << entrySelector) * 16); + var rangeShift = (ushort)(tables.Count * 16 - searchRange); + WriteUInt16BigEndian(sfntBytes, 6, searchRange); + WriteUInt16BigEndian(sfntBytes, 8, (ushort)entrySelector); + WriteUInt16BigEndian(sfntBytes, 10, rangeShift); + + for (var i = 0; i < tables.Count; i++) + { + var table = tables[i]; + var recordOffset = 12 + i * 16; + WriteUInt32BigEndian(sfntBytes, recordOffset, table.Tag); + WriteUInt32BigEndian(sfntBytes, recordOffset + 4, table.Checksum); + WriteUInt32BigEndian(sfntBytes, recordOffset + 8, (uint)table.OutputOffset); + WriteUInt32BigEndian(sfntBytes, recordOffset + 12, (uint)table.OriginalLength); + Array.Copy(table.Data, 0, sfntBytes, table.OutputOffset, table.OriginalLength); + } + + return true; + } + + private static bool TryReadWoffTableData(byte[] woffBytes, WoffTable table, out byte[] tableData) + { + tableData = Array.Empty(); + if (table.CompressedLength == table.OriginalLength) + { + tableData = new byte[table.OriginalLength]; + Array.Copy(woffBytes, table.Offset, tableData, 0, table.OriginalLength); + return true; + } + + if (table.CompressedLength < 6) + { + return false; + } + + try + { + using var compressedStream = new MemoryStream(woffBytes, table.Offset + 2, table.CompressedLength - 6); + using var deflateStream = new DeflateStream(compressedStream, CompressionMode.Decompress); + using var outputStream = new MemoryStream(table.OriginalLength); + deflateStream.CopyTo(outputStream); + tableData = outputStream.ToArray(); + return tableData.Length == table.OriginalLength; + } + catch (Exception ex) + { + Debug.WriteLine(ex.Message); + Debug.WriteLine(ex.StackTrace); + tableData = Array.Empty(); + return false; + } + } + + private static Stream? OpenFontResourceStream(Uri fontUri, SvgDocument document) + { + if (fontUri.IsAbsoluteUri && string.Equals(fontUri.Scheme, "data", StringComparison.OrdinalIgnoreCase)) + { + return OpenDataUriStream(fontUri.OriginalString); + } + + if (fontUri.IsFile) + { + var localPath = fontUri.LocalPath; + if (File.Exists(localPath)) + { + return File.OpenRead(localPath); + } + + if (TryResolveW3CPackagedFontResource(localPath, document, out var fallbackPath)) + { + return File.OpenRead(fallbackPath); + } + + return null; + } + +#pragma warning disable 618, SYSLIB0014 + var request = WebRequest.Create(fontUri); +#pragma warning restore 618, SYSLIB0014 + using var response = request.GetResponse(); + using var responseStream = response.GetResponseStream(); + if (responseStream is null) + { + return null; + } + + var memoryStream = new MemoryStream(); + responseStream.CopyTo(memoryStream); + memoryStream.Position = 0; + return memoryStream; + } + + private static Stream? OpenDataUriStream(string uriString) + { + var commaIndex = uriString.IndexOf(','); + if (commaIndex < 0 || commaIndex + 1 >= uriString.Length) + { + return null; + } + + var header = uriString.Substring(5, commaIndex - 5); + var data = uriString.Substring(commaIndex + 1); + var bytes = header.IndexOf(";base64", StringComparison.OrdinalIgnoreCase) >= 0 + ? Convert.FromBase64String(Uri.UnescapeDataString(data)) + : PercentDecodeDataUriPayload(data); + return new MemoryStream(bytes); + } + + private static byte[] PercentDecodeDataUriPayload(string data) + { + var output = new MemoryStream(data.Length); + var textStart = 0; + + for (var i = 0; i < data.Length; i++) + { + if (data[i] != '%' || + i + 2 >= data.Length || + !TryReadHexByte(data[i + 1], data[i + 2], out var value)) + { + continue; + } + + WriteDataUriTextBytes(data, textStart, i - textStart, output); + output.WriteByte(value); + i += 2; + textStart = i + 1; + } + + WriteDataUriTextBytes(data, textStart, data.Length - textStart, output); + return output.ToArray(); + } + + private static void WriteDataUriTextBytes(string data, int start, int length, Stream output) + { + if (length <= 0) + { + return; + } + + var bytes = Encoding.UTF8.GetBytes(data.Substring(start, length)); + output.Write(bytes, 0, bytes.Length); + } + + private static bool TryReadHexByte(char high, char low, out byte value) + { + var highValue = ReadHexValue(high); + var lowValue = ReadHexValue(low); + if (highValue < 0 || lowValue < 0) + { + value = 0; + return false; + } + + value = (byte)((highValue << 4) | lowValue); + return true; + } + + private static int ReadHexValue(char ch) + { + if (ch is >= '0' and <= '9') + { + return ch - '0'; + } + + if (ch is >= 'A' and <= 'F') + { + return ch - 'A' + 10; + } + + if (ch is >= 'a' and <= 'f') + { + return ch - 'a' + 10; + } + + return -1; + } + + private static bool TryResolveW3CPackagedFontResource(string missingLocalPath, SvgDocument document, out string fallbackPath) + { + fallbackPath = string.Empty; + var fileName = Path.GetFileName(missingLocalPath); + if (string.IsNullOrEmpty(fileName) || + document.BaseUri is not { IsFile: true }) + { + return false; + } + + var documentDirectory = Path.GetDirectoryName(document.BaseUri.LocalPath); + var suiteRoot = Path.GetDirectoryName(documentDirectory ?? string.Empty); + if (string.IsNullOrEmpty(suiteRoot)) + { + return false; + } + + var candidate = Path.Combine(suiteRoot, "resources", fileName); + if (!File.Exists(candidate)) + { + return false; + } + + fallbackPath = candidate; + return true; + } + + private static bool CanReadRange(int length, int offset, int count) + { + return offset >= 0 && + count >= 0 && + offset <= length && + count <= length - offset; + } + + private static int Align4(int value) + { + return (value + 3) & ~3; + } + + private static int CheckedUInt32ToInt(uint value) + { + return value <= int.MaxValue ? (int)value : -1; + } + + private static int GetEntrySelector(int numTables) + { + var entrySelector = 0; + var power = 1; + while (power * 2 <= numTables) + { + power *= 2; + entrySelector++; + } + + return entrySelector; + } + + private static ushort ReadUInt16BigEndian(byte[] bytes, int offset) + { + return (ushort)((bytes[offset] << 8) | bytes[offset + 1]); + } + + private static uint ReadUInt32BigEndian(byte[] bytes, int offset) + { + return ((uint)bytes[offset] << 24) | + ((uint)bytes[offset + 1] << 16) | + ((uint)bytes[offset + 2] << 8) | + bytes[offset + 3]; + } + + private static void WriteUInt16BigEndian(byte[] bytes, int offset, ushort value) + { + bytes[offset] = (byte)(value >> 8); + bytes[offset + 1] = (byte)value; + } + + private static void WriteUInt32BigEndian(byte[] bytes, int offset, uint value) + { + bytes[offset] = (byte)(value >> 24); + bytes[offset + 1] = (byte)(value >> 16); + bytes[offset + 2] = (byte)(value >> 8); + bytes[offset + 3] = (byte)value; + } + + private static bool IsSupportedFontFormat(Uri fontUri, string? format) + { + var normalizedFormat = format?.Trim(); + if (!string.IsNullOrEmpty(normalizedFormat)) + { + var knownFormat = normalizedFormat!; + return knownFormat.Equals("woff", StringComparison.OrdinalIgnoreCase) || + knownFormat.Equals("truetype", StringComparison.OrdinalIgnoreCase) || + knownFormat.Equals("opentype", StringComparison.OrdinalIgnoreCase) || + knownFormat.Equals("ttf", StringComparison.OrdinalIgnoreCase) || + knownFormat.Equals("otf", StringComparison.OrdinalIgnoreCase); + } + + if (fontUri.IsAbsoluteUri && + string.Equals(fontUri.Scheme, "data", StringComparison.OrdinalIgnoreCase)) + { + return true; + } + + var extension = Path.GetExtension(fontUri.IsAbsoluteUri ? fontUri.LocalPath : fontUri.OriginalString); + return extension.Equals(".woff", StringComparison.OrdinalIgnoreCase) || + extension.Equals(".ttf", StringComparison.OrdinalIgnoreCase) || + extension.Equals(".otf", StringComparison.OrdinalIgnoreCase); + } + + private static SkiaSharp.SKFontStyleWeight ParseFontWeight(string? value) + { + var trimmed = value?.Trim(); + if (string.IsNullOrEmpty(trimmed)) + { + return SkiaSharp.SKFontStyleWeight.Normal; + } + + var knownWeight = trimmed!; + if (int.TryParse(knownWeight, out var numericWeight)) + { + return (SkiaSharp.SKFontStyleWeight)Math.Max(1, Math.Min(1000, numericWeight)); + } + + return knownWeight.Equals("bold", StringComparison.OrdinalIgnoreCase) || + knownWeight.Equals("bolder", StringComparison.OrdinalIgnoreCase) + ? SkiaSharp.SKFontStyleWeight.Bold + : SkiaSharp.SKFontStyleWeight.Normal; + } + + private static SkiaSharp.SKFontStyleWidth ParseFontWidth(string? value) + { + return value?.Trim().ToLowerInvariant() switch + { + "ultra-condensed" => SkiaSharp.SKFontStyleWidth.UltraCondensed, + "extra-condensed" => SkiaSharp.SKFontStyleWidth.ExtraCondensed, + "condensed" => SkiaSharp.SKFontStyleWidth.Condensed, + "semi-condensed" => SkiaSharp.SKFontStyleWidth.SemiCondensed, + "semi-expanded" => SkiaSharp.SKFontStyleWidth.SemiExpanded, + "expanded" => SkiaSharp.SKFontStyleWidth.Expanded, + "extra-expanded" => SkiaSharp.SKFontStyleWidth.ExtraExpanded, + "ultra-expanded" => SkiaSharp.SKFontStyleWidth.UltraExpanded, + _ => SkiaSharp.SKFontStyleWidth.Normal + }; + } + + private static SkiaSharp.SKFontStyleSlant ParseFontSlant(string? value) + { + return value?.Trim().ToLowerInvariant() switch + { + "italic" => SkiaSharp.SKFontStyleSlant.Italic, + "oblique" => SkiaSharp.SKFontStyleSlant.Oblique, + _ => SkiaSharp.SKFontStyleSlant.Upright + }; + } + +} diff --git a/src/Svg.Skia/SkiaSvgAssetLoader.cs b/src/Svg.Skia/SkiaSvgAssetLoader.cs index 84dcd6d422..645419bc68 100644 --- a/src/Svg.Skia/SkiaSvgAssetLoader.cs +++ b/src/Svg.Skia/SkiaSvgAssetLoader.cs @@ -106,10 +106,13 @@ public bool TryGetImageAlpha(ShimSkiaSharp.SKImage image, out int width, out int var slant = _skiaModel.ToSKFontStyleSlant(preferredTypeface?.FontSlant ?? ShimSkiaSharp.SKFontStyleSlant.Upright); var preferredFamily = GetExplicitFamilyName(preferredTypeface) ?? (preferredTypeface is null ? null : runningPaint.Typeface?.FamilyName); - System.Func matchCharacter = codepoint => - MatchCharacter(preferredFamily, weight, width, slant, codepoint); + SkiaSharp.SKTypeface? MatchCharacterForSpan(int codepoint, out string? familyOverride) + { + return MatchCharacterForTypefaceSpan(preferredFamily, weight, width, slant, codepoint, out familyOverride); + } var currentTypefaceStartIndex = 0; + var currentShimTypeface = default(ShimSkiaSharp.SKTypeface); var i = 0; void YieldCurrentTypefaceText() @@ -124,7 +127,7 @@ void YieldCurrentTypefaceText() paintPreferredTypeface.FontVariantLigatures), runningPaint.Typeface is null ? null - : ToShimTypeface(runningPaint.Typeface, requestedWeight) + : currentShimTypeface ?? ToShimTypeface(runningPaint.Typeface, requestedWeight) )); } @@ -132,8 +135,10 @@ runningPaint.Typeface is null { var ch = text[i]; SkiaSharp.SKTypeface? typeface; + var matchedShimTypeface = currentShimTypeface; if (runningPaint.Typeface is { } currentTypeface && - (ch <= ' ' || ch is '\u0085' or '\u00A0' || ch >= '\u0300' && IsNonAsciiTypefaceSpanGlue(text, i, ch))) + (ch <= ' ' || ch is '\u0085' or '\u00A0' || ch >= '\u0300' && IsNonAsciiTypefaceSpanGlue(text, i, ch)) && + CanKeepGlueInCurrentTypeface(currentTypeface, text, i, ch)) { // Keep marks and whitespace in the active span so bidi/shaping stays attached to // the surrounding script run instead of splitting on font fallback for nonspacing @@ -142,12 +147,14 @@ runningPaint.Typeface is null } else { - typeface = matchCharacter(GetCodepoint(text, i, ch)); + typeface = MatchCharacterForSpan(GetCodepoint(text, i, ch), out var familyOverride); + matchedShimTypeface = ToShimTypeface(typeface, requestedWeight, familyOverride); } if (i == 0) { runningPaint.Typeface = typeface; + currentShimTypeface = matchedShimTypeface; } else if (runningPaint.Typeface is null && typeface is { } || runningPaint.Typeface is { } @@ -159,6 +166,7 @@ runningPaint.Typeface is null currentTypefaceStartIndex = i; runningPaint.Typeface = typeface; + currentShimTypeface = matchedShimTypeface; } if (char.IsHighSurrogate(text[i])) @@ -197,8 +205,8 @@ runningPaint.Typeface is null var preferredFamily = GetExplicitFamilyName(preferredTypeface) ?? (preferredTypeface is null ? null : preferredPaint?.Typeface?.FamilyName); - var candidates = new List(); - void AddCandidate(SkiaSharp.SKTypeface? candidate) + var candidates = new List<(SkiaSharp.SKTypeface Typeface, ShimSkiaSharp.SKTypeface? ReturnTypeface)>(); + void AddCandidate(SkiaSharp.SKTypeface? candidate, ShimSkiaSharp.SKTypeface? returnTypeface = null) { if (candidate is null || candidate.Handle == IntPtr.Zero) { @@ -207,29 +215,32 @@ void AddCandidate(SkiaSharp.SKTypeface? candidate) for (var i = 0; i < candidates.Count; i++) { - var existing = candidates[i]; - if (existing is not null && - (existing.FamilyName, existing.FontWeight, existing.FontWidth, existing.FontSlant) == - (candidate.FamilyName, candidate.FontWeight, candidate.FontWidth, candidate.FontSlant)) + var existing = candidates[i].Typeface; + var existingReturn = candidates[i].ReturnTypeface; + if ((existing.FamilyName, existing.FontWeight, existing.FontWidth, existing.FontSlant) == + (candidate.FamilyName, candidate.FontWeight, candidate.FontWidth, candidate.FontSlant) && + (existingReturn?.FamilyName, existingReturn?.FontWeight, existingReturn?.FontWidth, existingReturn?.FontSlant) == + (returnTypeface?.FamilyName, returnTypeface?.FontWeight, returnTypeface?.FontWidth, returnTypeface?.FontSlant)) { return; } } - candidates.Add(candidate); + candidates.Add((candidate, returnTypeface)); } - AddCandidate(preferredPaint?.Typeface); - var spans = FindTypefaces(text, paintPreferredTypeface); for (var i = 0; i < spans.Count; i++) { if (spans[i].Typeface is { } spanTypeface) { - AddCandidate(_skiaModel.ToSKTypeface(spanTypeface)); + var spanNativeTypeface = _skiaModel.ToSKTypeface(spanTypeface); + AddCandidate(spanNativeTypeface, GetRunReturnTypeface(spanTypeface, spanNativeTypeface)); } } + AddCandidate(preferredPaint?.Typeface); + for (var i = 0; i < codepoints.Count; i++) { AddCandidate(MatchCharacter(preferredFamily, preferredWeight, preferredWidth, preferredSlant, codepoints[i])); @@ -238,14 +249,48 @@ void AddCandidate(SkiaSharp.SKTypeface? candidate) for (var i = 0; i < candidates.Count; i++) { - var candidate = candidates[i]; + var candidate = candidates[i].Typeface; if (CanRenderAllCodepoints(candidate, codepoints)) { - return ToShimTypeface(candidate, requestedWeight); + return candidates[i].ReturnTypeface ?? ToShimTypeface(candidate, requestedWeight); } } return null; + + ShimSkiaSharp.SKTypeface? GetRunReturnTypeface( + ShimSkiaSharp.SKTypeface spanTypeface, + SkiaSharp.SKTypeface? nativeTypeface) + { + var spanFamilyName = spanTypeface.FamilyName; + if (nativeTypeface is null || + nativeTypeface.Handle == IntPtr.Zero || + spanFamilyName is null || + string.IsNullOrWhiteSpace(spanFamilyName) || + spanFamilyName.IndexOf(',') < 0) + { + return spanTypeface; + } + + foreach (var candidate in SkiaModel.EnumerateFontFamilyCandidates(spanFamilyName, browserCompatible: true)) + { + var candidateTypeface = ShimSkiaSharp.SKTypeface.FromFamilyName( + candidate, + spanTypeface.FontWeight, + spanTypeface.FontWidth, + spanTypeface.FontSlant); + var candidateNativeTypeface = _skiaModel.ToSKTypeface(candidateTypeface); + if (candidateNativeTypeface is not null && + candidateNativeTypeface.Handle != IntPtr.Zero && + (candidateNativeTypeface.FamilyName, candidateNativeTypeface.FontWeight, candidateNativeTypeface.FontWidth, candidateNativeTypeface.FontSlant) == + (nativeTypeface.FamilyName, nativeTypeface.FontWeight, nativeTypeface.FontWidth, nativeTypeface.FontSlant)) + { + return candidateTypeface; + } + } + + return spanTypeface; + } } /// @@ -329,7 +374,8 @@ public bool TryGetGlyphRunPath(Model.ShapedGlyphRun shapedRun, ShimSkiaSharp.SKP private void EnsureTypefaceProviderCaches() { var providers = _skiaModel.Settings.TypefaceProviders; - var hash = ComputeTypefaceProviderHash(providers); + var documentProviders = _skiaModel.Settings.DocumentTypefaceProviders; + var hash = ComputeTypefaceProviderHash(documentProviders, providers); if (!ReferenceEquals(providers, _providerStateList) || hash != _providerStateHash) { _providerStateList = providers; @@ -340,35 +386,40 @@ private void EnsureTypefaceProviderCaches() } } - private static int ComputeTypefaceProviderHash(IList? providers) + private static int ComputeTypefaceProviderHash(params IList?[] providerLists) { unchecked { var hash = 17; - if (providers is null) - { - return hash; - } - - hash = (hash * 397) ^ providers.Count; - for (var i = 0; i < providers.Count; i++) + for (var listIndex = 0; listIndex < providerLists.Length; listIndex++) { - var provider = providers[i]; - if (provider is null) + var providers = providerLists[listIndex]; + if (providers is null) { + hash = (hash * 397) ^ -1; continue; } - hash = (hash * 397) ^ RuntimeHelpers.GetHashCode(provider); - hash = (hash * 397) ^ provider.GetHashCode(); - if (provider is CustomTypefaceProvider custom) - { - hash = (hash * 397) ^ (custom.Typeface?.Handle.GetHashCode() ?? 0); - } - else if (provider is FontManagerTypefaceProvider fontManagerProvider && - fontManagerProvider.TryGetFontManagerHandle(out var handle)) + hash = (hash * 397) ^ providers.Count; + for (var i = 0; i < providers.Count; i++) { - hash = (hash * 397) ^ handle.GetHashCode(); + var provider = providers[i]; + if (provider is null) + { + continue; + } + + hash = (hash * 397) ^ RuntimeHelpers.GetHashCode(provider); + hash = (hash * 397) ^ provider.GetHashCode(); + if (provider is CustomTypefaceProvider custom) + { + hash = (hash * 397) ^ (custom.Typeface?.Handle.GetHashCode() ?? 0); + } + else if (provider is FontManagerTypefaceProvider fontManagerProvider && + fontManagerProvider.TryGetFontManagerHandle(out var handle)) + { + hash = (hash * 397) ^ handle.GetHashCode(); + } } } @@ -493,6 +544,25 @@ private void TrimCachesIfNeeded() return typeface; } + private SkiaSharp.SKTypeface? MatchCharacterForTypefaceSpan( + string? familyName, + SkiaSharp.SKFontStyleWeight weight, + SkiaSharp.SKFontStyleWidth width, + SkiaSharp.SKFontStyleSlant slant, + int codepoint, + out string? familyOverride) + { + familyOverride = null; + var typeface = TryMatchCharacterFromCustomProviders(familyName, weight, width, slant, codepoint, out var matchedFamily); + if (typeface is { }) + { + familyOverride = matchedFamily; + return typeface; + } + + return MatchCharacter(familyName, weight, width, slant, codepoint); + } + private static string? GetExplicitFamilyName(ShimSkiaSharp.SKTypeface? typeface) { return SkiaModel.HasExplicitTypeface(typeface) ? typeface!.FamilyName : null; @@ -622,7 +692,9 @@ private static List CollectDistinctRenderableCodepoints(string text) for (var i = 0; i < text.Length; i++) { var ch = text[i]; - if (ch <= ' ' || ch is '\u0085' or '\u00A0' || ch >= '\u0300' && IsNonAsciiTypefaceSpanGlue(text, i, ch)) + if ((ch <= ' ' || ch is '\u0085' or '\u00A0') && + ch is not ' ' and not '\u00A0' || + ch >= '\u0300' && IsNonAsciiTypefaceSpanGlue(text, i, ch)) { if (char.IsHighSurrogate(text[i])) { @@ -652,6 +724,12 @@ private static int GetCodepoint(string text, int index, char ch) return char.IsSurrogate(ch) ? char.ConvertToUtf32(text, index) : ch; } + private static bool CanKeepGlueInCurrentTypeface(SkiaSharp.SKTypeface typeface, string text, int index, char ch) + { + return ch is not ' ' and not '\u00A0' || + typeface.ContainsGlyph(GetCodepoint(text, index, ch)); + } + private static bool IsNonAsciiTypefaceSpanGlue(string text, int index, char ch) { if (ch is >= '\u0300' and <= '\u036F' or >= '\uFE00' and <= '\uFE0F') @@ -702,7 +780,8 @@ private static bool CanRenderAllCodepoints(SkiaSharp.SKTypeface? typeface, IRead private static ShimSkiaSharp.SKTypeface? ToShimTypeface( SkiaSharp.SKTypeface? typeface, - SkiaSharp.SKFontStyleWeight? requestedWeight) + SkiaSharp.SKFontStyleWeight? requestedWeight, + string? familyNameOverride = null) { if (typeface is null || typeface.Handle == IntPtr.Zero) { @@ -715,7 +794,7 @@ private static bool CanRenderAllCodepoints(SkiaSharp.SKTypeface? typeface, IRead : (ShimSkiaSharp.SKFontStyleWeight)resolvedWeight; return ShimSkiaSharp.SKTypeface.FromFamilyName( - typeface.FamilyName, + familyNameOverride ?? typeface.FamilyName, shimWeight, (ShimSkiaSharp.SKFontStyleWidth)typeface.FontWidth, (ShimSkiaSharp.SKFontStyleSlant)typeface.FontSlant); @@ -762,13 +841,14 @@ private static bool CanRenderAllCodepoints(SkiaSharp.SKTypeface? typeface, IRead /// A matching typeface from custom providers, or null if none found. private SkiaSharp.SKTypeface? TryMatchCharacterFromCustomProviders(string? familyName, SkiaSharp.SKFontStyleWeight weight, SkiaSharp.SKFontStyleWidth width, SkiaSharp.SKFontStyleSlant slant, int codepoint) { - if (_skiaModel.Settings.TypefaceProviders is null || _skiaModel.Settings.TypefaceProviders.Count == 0) - { - return null; - } + return TryMatchCharacterFromCustomProviders(familyName, weight, width, slant, codepoint, out _); + } + private SkiaSharp.SKTypeface? TryMatchCharacterFromCustomProviders(string? familyName, SkiaSharp.SKFontStyleWeight weight, SkiaSharp.SKFontStyleWidth width, SkiaSharp.SKFontStyleSlant slant, int codepoint, out string? matchedFamily) + { + matchedFamily = null; var familyKey = familyName ?? "Default"; - foreach (var provider in _skiaModel.Settings.TypefaceProviders) + foreach (var provider in _skiaModel.EnumerateEffectiveTypefaceProviders()) { if (familyName is null && provider is FontManagerTypefaceProvider or DefaultTypefaceProvider) @@ -779,6 +859,7 @@ private static bool CanRenderAllCodepoints(SkiaSharp.SKTypeface? typeface, IRead var typeface = GetProviderTypeface(provider, familyKey, weight, width, slant); if (typeface is { } && typeface.ContainsGlyph(codepoint)) { + matchedFamily = familyName; return typeface; } } diff --git a/src/Svg.Skia/TypefaceProviders/DocumentFontTypefaceProvider.cs b/src/Svg.Skia/TypefaceProviders/DocumentFontTypefaceProvider.cs new file mode 100644 index 0000000000..3c992eb1b4 --- /dev/null +++ b/src/Svg.Skia/TypefaceProviders/DocumentFontTypefaceProvider.cs @@ -0,0 +1,138 @@ +// Copyright (c) Wiesław Šoltés. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for details. +using System; +using System.Collections.Generic; +using System.Linq; + +namespace Svg.Skia.TypefaceProviders; + +internal sealed class DocumentFontTypefaceProvider : ITypefaceProvider, IDisposable +{ + private sealed class Entry + { + public Entry( + string familyName, + SkiaSharp.SKFontStyleWeight weight, + SkiaSharp.SKFontStyleWidth width, + SkiaSharp.SKFontStyleSlant slant, + SkiaSharp.SKTypeface typeface) + { + FamilyName = familyName; + Weight = weight; + Width = width; + Slant = slant; + Typeface = typeface; + } + + public string FamilyName { get; } + + public SkiaSharp.SKFontStyleWeight Weight { get; } + + public SkiaSharp.SKFontStyleWidth Width { get; } + + public SkiaSharp.SKFontStyleSlant Slant { get; } + + public SkiaSharp.SKTypeface Typeface { get; } + } + + private static readonly char[] s_fontFamilyTrim = { '\'', '"' }; + private readonly List _entries = new(); + private bool _disposed; + + public bool IsEmpty => _entries.Count == 0; + + public void Add( + string familyName, + SkiaSharp.SKFontStyleWeight weight, + SkiaSharp.SKFontStyleWidth width, + SkiaSharp.SKFontStyleSlant slant, + SkiaSharp.SKTypeface typeface) + { + if (_disposed) + { + throw new ObjectDisposedException(nameof(DocumentFontTypefaceProvider)); + } + + if (string.IsNullOrWhiteSpace(familyName) || + typeface.Handle == IntPtr.Zero) + { + return; + } + + _entries.Add(new Entry( + familyName.Trim().Trim(s_fontFamilyTrim), + weight, + width, + slant, + typeface)); + } + + public SkiaSharp.SKTypeface? FromFamilyName( + string fontFamily, + SkiaSharp.SKFontStyleWeight fontWeight, + SkiaSharp.SKFontStyleWidth fontWidth, + SkiaSharp.SKFontStyleSlant fontStyle) + { + if (_disposed) + { + return null; + } + + if (_entries.Count == 0 || string.IsNullOrWhiteSpace(fontFamily)) + { + return null; + } + + var requestedFamilies = fontFamily + .Split(',') + .Select(static family => family.Trim().Trim(s_fontFamilyTrim)) + .Where(static family => family.Length > 0); + + foreach (var requestedFamily in requestedFamilies) + { + Entry? best = null; + var bestScore = int.MaxValue; + for (var i = 0; i < _entries.Count; i++) + { + var entry = _entries[i]; + if (!string.Equals(entry.FamilyName, requestedFamily, StringComparison.OrdinalIgnoreCase)) + { + continue; + } + + var score = + Math.Abs((int)entry.Weight - (int)fontWeight) + + (entry.Width == fontWidth ? 0 : Math.Abs((int)entry.Width - (int)fontWidth) * 10) + + (entry.Slant == fontStyle ? 0 : 1000); + if (score < bestScore) + { + best = entry; + bestScore = score; + } + } + + if (best is { }) + { + return best.Typeface; + } + } + + return null; + } + + public void Dispose() + { + if (_disposed) + { + return; + } + + _disposed = true; + for (var i = 0; i < _entries.Count; i++) + { + _entries[i].Typeface.Dispose(); + } + + _entries.Clear(); + } +} diff --git a/tests/Svg.Model.UnitTests/SvgSystemColorProviderTests.cs b/tests/Svg.Model.UnitTests/SvgSystemColorProviderTests.cs new file mode 100644 index 0000000000..feb8d99ecd --- /dev/null +++ b/tests/Svg.Model.UnitTests/SvgSystemColorProviderTests.cs @@ -0,0 +1,108 @@ +using System.Collections.Generic; +using System.Drawing; +using Svg.Model.Services; +using Xunit; + +namespace Svg.Model.UnitTests; + +public class SvgSystemColorProviderTests +{ + [Fact] + public void DefaultProvider_ResolvesSvg11SystemColorsDeterministically() + { + Assert.True(SvgSystemColorResolver.TryGetColor("Window", out var window)); + Assert.True(SvgSystemColorResolver.TryGetColor("windowtext", out var windowText)); + Assert.True(SvgSystemColorResolver.TryGetColor("Highlight", out var highlight)); + Assert.True(SvgSystemColorResolver.TryGetColor("ThreeDShadow", out var threeDShadow)); + + Assert.Equal(Color.FromArgb(255, 255, 255, 255).ToArgb(), window.ToArgb()); + Assert.Equal(Color.FromArgb(255, 0, 0, 0).ToArgb(), windowText.ToArgb()); + Assert.Equal(Color.FromArgb(255, 10, 36, 106).ToArgb(), highlight.ToArgb()); + Assert.Equal(Color.FromArgb(255, 128, 128, 128).ToArgb(), threeDShadow.ToArgb()); + } + + [Fact] + public void SvgService_UsesDeterministicSystemColorsForPaintServers() + { + var document = SvgService.FromSvg( + """ + + + + + """, + null); + + var presentation = Assert.IsType(document!.GetElementById("presentation")); + var styled = Assert.IsType(document.GetElementById("styled")); + styled.FlushStyles(); + + AssertColor(presentation.Fill, Color.FromArgb(255, 255, 255, 255)); + AssertColor(presentation.Stroke, Color.FromArgb(255, 10, 36, 106)); + AssertColor(styled.Fill, Color.FromArgb(255, 212, 208, 200)); + AssertColor(styled.Stroke, Color.FromArgb(255, 64, 64, 64)); + AssertColor(document.Color, Color.Black); + } + + [Fact] + public void ScopedProvider_OverridesDefaultSystemColorsForCurrentLoad() + { + var provider = new SvgDictionarySystemColorProvider(new Dictionary + { + ["Window"] = Color.FromArgb(255, 1, 2, 3), + ["Highlight"] = Color.FromArgb(255, 4, 5, 6) + }); + + SvgDocument document; + using (SvgSystemColorResolver.PushProvider(provider)) + { + document = SvgService.FromSvg( + """ + + + + """, + null)!; + } + + var shape = Assert.IsType(document.GetElementById("shape")); + AssertColor(shape.Fill, Color.FromArgb(255, 1, 2, 3)); + AssertColor(shape.Stroke, Color.FromArgb(255, 4, 5, 6)); + Assert.True(SvgSystemColorResolver.TryGetColor("Window", out var window)); + Assert.Equal(Color.White.ToArgb(), window.ToArgb()); + } + + [Fact] + public void ColorProfileElement_IsPreservedAsUnsupportedOptionalPolicy() + { + var document = SvgService.FromSvg( + """ + + + + + + + """, + null); + + var profile = document!.GetElementById("changeColor"); + Assert.NotNull(profile); + Assert.IsType(profile); + Assert.True(profile.TryGetAttribute("name", out var profileName)); + Assert.Equal("changeColor", profileName?.ToString()); + + var image = Assert.IsType(document.GetElementById("profiled")); + Assert.True(image.TryGetAttribute("color-profile", out var colorProfile)); + Assert.Equal("changeColor", colorProfile?.ToString()); + Assert.Equal("../images/colorprof.png", image.Href); + } + + private static void AssertColor(SvgPaintServer server, Color expected) + { + var color = Assert.IsType(server); + Assert.Equal(expected.ToArgb(), color.Colour.ToArgb()); + } +} diff --git a/tests/Svg.Skia.UnitTests/ChromeReference/W3C/pservers-grad-08-b.png b/tests/Svg.Skia.UnitTests/ChromeReference/W3C/pservers-grad-08-b.png index f70167f711..991c617b17 100644 Binary files a/tests/Svg.Skia.UnitTests/ChromeReference/W3C/pservers-grad-08-b.png and b/tests/Svg.Skia.UnitTests/ChromeReference/W3C/pservers-grad-08-b.png differ diff --git a/tests/Svg.Skia.UnitTests/ChromeReference/W3C/render-elems-06-t.png b/tests/Svg.Skia.UnitTests/ChromeReference/W3C/render-elems-06-t.png index 83072c9751..ecfffe8d92 100644 Binary files a/tests/Svg.Skia.UnitTests/ChromeReference/W3C/render-elems-06-t.png and b/tests/Svg.Skia.UnitTests/ChromeReference/W3C/render-elems-06-t.png differ diff --git a/tests/Svg.Skia.UnitTests/ChromeReference/W3C/render-elems-07-t.png b/tests/Svg.Skia.UnitTests/ChromeReference/W3C/render-elems-07-t.png index 9e1dd2166a..e10e7a5ff6 100644 Binary files a/tests/Svg.Skia.UnitTests/ChromeReference/W3C/render-elems-07-t.png and b/tests/Svg.Skia.UnitTests/ChromeReference/W3C/render-elems-07-t.png differ diff --git a/tests/Svg.Skia.UnitTests/ChromeReference/W3C/render-elems-08-t.png b/tests/Svg.Skia.UnitTests/ChromeReference/W3C/render-elems-08-t.png index 48733d1863..7abaca6169 100644 Binary files a/tests/Svg.Skia.UnitTests/ChromeReference/W3C/render-elems-08-t.png and b/tests/Svg.Skia.UnitTests/ChromeReference/W3C/render-elems-08-t.png differ diff --git a/tests/Svg.Skia.UnitTests/ChromeReference/W3C/render-groups-01-b.png b/tests/Svg.Skia.UnitTests/ChromeReference/W3C/render-groups-01-b.png index 0789473f58..13e73bc61f 100644 Binary files a/tests/Svg.Skia.UnitTests/ChromeReference/W3C/render-groups-01-b.png and b/tests/Svg.Skia.UnitTests/ChromeReference/W3C/render-groups-01-b.png differ diff --git a/tests/Svg.Skia.UnitTests/ChromeReference/W3C/render-groups-03-t.png b/tests/Svg.Skia.UnitTests/ChromeReference/W3C/render-groups-03-t.png index c5278cfb8e..8e6cadbede 100644 Binary files a/tests/Svg.Skia.UnitTests/ChromeReference/W3C/render-groups-03-t.png and b/tests/Svg.Skia.UnitTests/ChromeReference/W3C/render-groups-03-t.png differ diff --git a/tests/Svg.Skia.UnitTests/ChromeReference/W3C/text-intro-09-b.png b/tests/Svg.Skia.UnitTests/ChromeReference/W3C/text-intro-09-b.png index 5ea134838f..7ffe59797c 100644 Binary files a/tests/Svg.Skia.UnitTests/ChromeReference/W3C/text-intro-09-b.png and b/tests/Svg.Skia.UnitTests/ChromeReference/W3C/text-intro-09-b.png differ diff --git a/tests/Svg.Skia.UnitTests/SKSvgSettingsTests.cs b/tests/Svg.Skia.UnitTests/SKSvgSettingsTests.cs index 1c802da868..fd3f530877 100644 --- a/tests/Svg.Skia.UnitTests/SKSvgSettingsTests.cs +++ b/tests/Svg.Skia.UnitTests/SKSvgSettingsTests.cs @@ -1,4 +1,7 @@ +using System; using System.Collections.Generic; +using System.Drawing; +using System.IO; using System.Reflection; using SkiaSharp; using Svg; @@ -64,6 +67,7 @@ public void Defaults_EnableTextSelectionRendering() public void CopyTo_CopiesRenderingAndJavaScriptSettings() { var provider = new DefaultTypefaceProvider(); + var systemColorProvider = new SvgDictionarySystemColorProvider(new Dictionary()); var factory = new TestJavaScriptRuntimeFactory(); var source = new SKSvgSettings { @@ -75,6 +79,7 @@ public void CopyTo_CopiesRenderingAndJavaScriptSettings() EnableTextReferences = false, EnableFilterBackgroundInputs = false, EnableBrokenImagePlaceholders = false, + SystemColorProvider = systemColorProvider, EnableJavaScript = true, EnableTextSelectionRendering = false, TextSelectionColor = new SKColor(1, 2, 3, 4), @@ -97,6 +102,7 @@ public void CopyTo_CopiesRenderingAndJavaScriptSettings() Assert.False(target.EnableTextReferences); Assert.False(target.EnableFilterBackgroundInputs); Assert.False(target.EnableBrokenImagePlaceholders); + Assert.Same(systemColorProvider, target.SystemColorProvider); Assert.True(target.EnableJavaScript); Assert.False(target.EnableTextSelectionRendering); Assert.Equal(new SKColor(1, 2, 3, 4), target.TextSelectionColor); @@ -109,6 +115,55 @@ public void CopyTo_CopiesRenderingAndJavaScriptSettings() Assert.Same(provider, Assert.Single(target.TypefaceProviders!)); } + [Fact] + public void FromSvg_UsesSettingsSystemColorProviderForCurrentLoad() + { + using var svg = new SKSvg(); + svg.Settings.SystemColorProvider = new SvgDictionarySystemColorProvider(new Dictionary + { + ["Window"] = Color.FromArgb(255, 11, 22, 33) + }); + + svg.FromSvg( + """ + + + + """); + + var shape = Assert.IsType(svg.SourceDocument!.GetElementById("shape")); + var fill = Assert.IsType(shape.Fill); + Assert.Equal(Color.FromArgb(255, 11, 22, 33).ToArgb(), fill.Colour.ToArgb()); + } + + [Fact] + public void SetAnimationTime_UsesSettingsSystemColorProviderForAnimatedColors() + { + using var svg = new SKSvg(); + svg.Settings.SystemColorProvider = new SvgDictionarySystemColorProvider(new Dictionary + { + ["Window"] = Color.FromArgb(255, 11, 22, 33), + ["Highlight"] = Color.FromArgb(255, 44, 55, 66) + }); + + svg.FromSvg( + """ + + + + + + """); + + svg.SetAnimationTime(TimeSpan.FromSeconds(2)); + + using var stream = new MemoryStream(); + Assert.True(svg.Save(stream, SKColors.Transparent)); + stream.Position = 0; + using var bitmap = SKBitmap.Decode(stream); + Assert.Equal(new SKColor(44, 55, 66, 255), bitmap.GetPixel(5, 5)); + } + [Fact] public void Clone_CopiesJavaScriptSettings() { diff --git a/tests/Svg.Skia.UnitTests/SkiaSvgAssetLoaderCachingTests.cs b/tests/Svg.Skia.UnitTests/SkiaSvgAssetLoaderCachingTests.cs index 419ad71c3b..7019c6d513 100644 --- a/tests/Svg.Skia.UnitTests/SkiaSvgAssetLoaderCachingTests.cs +++ b/tests/Svg.Skia.UnitTests/SkiaSvgAssetLoaderCachingTests.cs @@ -1,9 +1,12 @@ #pragma warning disable CS0618 // Shim paint keeps deprecated SKPaint text/typeface surface for compatibility +using System; using System.Collections.Generic; using System.IO; using System.Linq; +using System.Text; using ShimSkiaSharp; +using Svg.Model.Services; using Svg.Skia; using Svg.Skia.TypefaceProviders; using Xunit; @@ -110,6 +113,388 @@ public void SharedCaches_DoNotBypassCustomTypefaceProvidersAcrossModels() Assert.True(secondProvider.CallCount > 0); } + [Fact] + public void Load_W3CWoffFontFaceRegistersDocumentTypeface() + { + var expectedFamily = GetDocumentFontFamilyName("Blocky", GetW3CResourcePath("Blocky.woff"), "G"); + using var svg = new SKSvg(); + svg.Settings.EnableSvgFonts = false; + svg.Settings.StandaloneViewport = SkiaSharp.SKRect.Create(0f, 0f, 480f, 360f); + + using var _ = svg.Load(GetW3CSvgPath("pservers-grad-08-b")); + var assetLoader = Assert.IsType(svg.AssetLoader); + using var fontScope = assetLoader.PushDocumentFonts(svg.SourceDocument!); + + AssertDocumentTypefaceFamily(svg, "Blocky", "Gradient", expectedFamily); + } + + [Fact] + public void Load_W3CRenderWoffFontFaceRegistersFallbackFamily() + { + var expectedFamily = GetDocumentFontFamilyName("BlockyWoff", GetW3CResourcePath("Blocky.woff"), "G"); + using var svg = new SKSvg(); + svg.Settings.EnableSvgFonts = false; + svg.Settings.StandaloneViewport = SkiaSharp.SKRect.Create(0f, 0f, 480f, 360f); + + using var _ = svg.Load(GetW3CSvgPath("render-elems-06-t")); + var assetLoader = Assert.IsType(svg.AssetLoader); + using var fontScope = assetLoader.PushDocumentFonts(svg.SourceDocument!); + + AssertDocumentTypefaceFamily(svg, "BlockyWoff", "G", expectedFamily); + AssertDocumentTypefaceFamily(svg, "Blocky, BlockyWoff", "G", expectedFamily); + AssertRunTypefaceFamily(svg, "Blocky, BlockyWoff", "G", expectedFamily); + } + + [Fact] + public void Load_W3CGroupWoffFontFaceRegistersDocumentTypeface() + { + var expectedFamily = GetDocumentFontFamilyName("anglepoise", GetW3CResourcePath("anglepoi.woff"), "S"); + using var svg = new SKSvg(); + svg.Settings.EnableSvgFonts = false; + svg.Settings.StandaloneViewport = SkiaSharp.SKRect.Create(0f, 0f, 480f, 360f); + + using var _ = svg.Load(GetW3CSvgPath("render-groups-01-b")); + var assetLoader = Assert.IsType(svg.AssetLoader); + using var fontScope = assetLoader.PushDocumentFonts(svg.SourceDocument!); + + AssertDocumentTypefaceFamily(svg, "anglepoise", "SVG", expectedFamily); + } + + [Fact] + public void Load_ClearsDocumentFontFaceProvidersBetweenDocuments() + { + const string transientFamily = "SvgSkiaTransientBlocky"; + var blockyFamily = GetDocumentFontFamilyName(transientFamily, GetW3CResourcePath("Blocky.woff"), "G"); + var blockyUri = new Uri(Path.GetFullPath(GetW3CResourcePath("Blocky.woff"))).AbsoluteUri; + using var svg = new SKSvg(); + svg.Settings.EnableSvgFonts = false; + svg.Settings.StandaloneViewport = SkiaSharp.SKRect.Create(0f, 0f, 480f, 360f); + + using (svg.FromSvg($$""" + + + G + + """)) + { + var assetLoader = Assert.IsType(svg.AssetLoader); + using var fontScope = assetLoader.PushDocumentFonts(svg.SourceDocument!); + AssertDocumentTypefaceFamily(svg, transientFamily, "G", blockyFamily); + } + + using var _ = svg.FromSvg(""" + + G + + """); + + var spans = FindTypefaces(svg, transientFamily, "G"); + Assert.DoesNotContain( + spans, + span => string.Equals(span.Typeface?.FamilyName, blockyFamily, StringComparison.OrdinalIgnoreCase)); + } + + [Fact] + public void FromSvg_DataUriWoffFontFaceRegistersDocumentTypeface() + { + const string family = "SvgSkiaDataUriBlocky"; + var blockyPath = GetW3CResourcePath("Blocky.woff"); + var expectedFamily = GetDocumentFontFamilyName(family, blockyPath, "G"); + var fontData = Convert.ToBase64String(File.ReadAllBytes(blockyPath)); + using var svg = new SKSvg(); + svg.Settings.EnableSvgFonts = false; + svg.Settings.StandaloneViewport = SkiaSharp.SKRect.Create(0f, 0f, 40f, 40f); + + using var _ = svg.FromSvg($$""" + + + G + + """); + var assetLoader = Assert.IsType(svg.AssetLoader); + using var fontScope = assetLoader.PushDocumentFonts(svg.SourceDocument!); + + AssertDocumentTypefaceFamily(svg, family, "G", expectedFamily); + } + + [Fact] + public void FromSvg_DataUriWoffFontFaceWithoutFormatRegistersDocumentTypeface() + { + const string family = "SvgSkiaDataUriNoFormatBlocky"; + var blockyPath = GetW3CResourcePath("Blocky.woff"); + var expectedFamily = GetDocumentFontFamilyName(family, blockyPath, "G"); + var fontData = Convert.ToBase64String(File.ReadAllBytes(blockyPath)); + using var svg = new SKSvg(); + svg.Settings.EnableSvgFonts = false; + svg.Settings.StandaloneViewport = SkiaSharp.SKRect.Create(0f, 0f, 40f, 40f); + + using var _ = svg.FromSvg($$""" + + + G + + """); + var assetLoader = Assert.IsType(svg.AssetLoader); + using var fontScope = assetLoader.PushDocumentFonts(svg.SourceDocument!); + + AssertDocumentTypefaceFamily(svg, family, "G", expectedFamily); + } + + [Fact] + public void FromSvg_PercentEncodedDataUriWoffFontFaceRegistersDocumentTypeface() + { + const string family = "SvgSkiaDataUriPercentBlocky"; + var blockyPath = GetW3CResourcePath("Blocky.woff"); + var expectedFamily = GetDocumentFontFamilyName(family, blockyPath, "G"); + var fontData = PercentEncodeDataBytes(File.ReadAllBytes(blockyPath)); + using var svg = new SKSvg(); + svg.Settings.EnableSvgFonts = false; + svg.Settings.StandaloneViewport = SkiaSharp.SKRect.Create(0f, 0f, 40f, 40f); + + using var _ = svg.FromSvg($$""" + + + G + + """); + var assetLoader = Assert.IsType(svg.AssetLoader); + using var fontScope = assetLoader.PushDocumentFonts(svg.SourceDocument!); + + AssertDocumentTypefaceFamily(svg, family, "G", expectedFamily); + } + + [Fact] + public void FindRunTypeface_PreservesDocumentFontFamilyOverride() + { + const string family = "SvgSkiaAliasBlocky"; + var document = CreateFontFaceDocument(family, GetW3CResourcePath("Blocky.woff"), "G"); + var assetLoader = new SkiaSvgAssetLoader(new SkiaModel(new SKSvgSettings())); + using var fontScope = assetLoader.PushDocumentFonts(document); + + var spans = FindTypefaces(assetLoader, family, "G"); + Assert.Contains( + spans, + span => string.Equals(span.Typeface?.FamilyName, family, StringComparison.OrdinalIgnoreCase)); + + var runTypeface = FindRunTypeface(assetLoader, family, "G"); + + Assert.NotNull(runTypeface); + Assert.Equal(family, runTypeface!.FamilyName, ignoreCase: true); + } + + [Fact] + public void FromSvg_FontFaceSrcFallbackUsesLaterSupportedSource() + { + const string family = "SvgSkiaFallbackSrcBlocky"; + var blockyPath = GetW3CResourcePath("Blocky.woff"); + var expectedFamily = GetDocumentFontFamilyName(family, blockyPath, "G"); + var blockyUri = new Uri(Path.GetFullPath(blockyPath)).AbsoluteUri; + var unsupportedFontData = Convert.ToBase64String(new byte[] { 0, 1, 2, 3 }); + using var svg = new SKSvg(); + svg.Settings.EnableSvgFonts = false; + svg.Settings.StandaloneViewport = SkiaSharp.SKRect.Create(0f, 0f, 40f, 40f); + + using var _ = svg.FromSvg($$""" + + + G + + """); + var assetLoader = Assert.IsType(svg.AssetLoader); + using var fontScope = assetLoader.PushDocumentFonts(svg.SourceDocument!); + + AssertDocumentTypefaceFamily(svg, family, "G", expectedFamily); + } + + [Fact] + public void OpenPath_ImportedStylesheetFontFaceRegistersDocumentTypeface() + { + const string family = "SvgSkiaImportedCssBlocky"; + var tempDirectory = Directory.CreateTempSubdirectory("SvgSkiaFontImport"); + + try + { + var blockyPath = WriteTempBlockyFont(tempDirectory.FullName); + var expectedFamily = GetDocumentFontFamilyName(family, blockyPath, "G"); + var cssPath = Path.Combine(tempDirectory.FullName, "fonts.css"); + var svgPath = Path.Combine(tempDirectory.FullName, "source.svg"); + File.WriteAllText(cssPath, $$""" + @font-face { + font-family: {{family}}; + src: url("Blocky.woff") format("woff"); + } + """); + File.WriteAllText(svgPath, $$""" + + + G + + """); + + using var svg = new SKSvg(); + svg.Settings.EnableSvgFonts = false; + svg.Settings.StandaloneViewport = SkiaSharp.SKRect.Create(0f, 0f, 40f, 40f); + using var _ = svg.Load(svgPath); + var assetLoader = Assert.IsType(svg.AssetLoader); + using var fontScope = assetLoader.PushDocumentFonts(svg.SourceDocument!); + + AssertDocumentTypefaceFamily(svg, family, "G", expectedFamily); + } + finally + { + tempDirectory.Delete(recursive: true); + } + } + + [Fact] + public void OpenPath_LinkedStylesheetFontFaceRegistersDocumentTypeface() + { + const string family = "SvgSkiaLinkedCssBlocky"; + var tempDirectory = Directory.CreateTempSubdirectory("SvgSkiaFontLink"); + + try + { + var blockyPath = WriteTempBlockyFont(tempDirectory.FullName); + var expectedFamily = GetDocumentFontFamilyName(family, blockyPath, "G"); + var cssPath = Path.Combine(tempDirectory.FullName, "fonts.css"); + var svgPath = Path.Combine(tempDirectory.FullName, "source.svg"); + File.WriteAllText(cssPath, $$""" + @font-face { + font-family: {{family}}; + src: url("Blocky.woff") format("woff"); + } + """); + File.WriteAllText(svgPath, $$""" + + + G + + """); + + using var svg = new SKSvg(); + svg.Settings.EnableSvgFonts = false; + svg.Settings.StandaloneViewport = SkiaSharp.SKRect.Create(0f, 0f, 40f, 40f); + using var _ = svg.Load(svgPath); + var assetLoader = Assert.IsType(svg.AssetLoader); + using var fontScope = assetLoader.PushDocumentFonts(svg.SourceDocument!); + + AssertDocumentTypefaceFamily(svg, family, "G", expectedFamily); + } + finally + { + tempDirectory.Delete(recursive: true); + } + } + + [Theory] + [InlineData("screen", true)] + [InlineData("print", false)] + public void OpenPath_LinkedStylesheetFontFaceRespectsMediaContext(string media, bool shouldApply) + { + var family = $"SvgSkiaMediaCssBlocky{media}"; + var tempDirectory = Directory.CreateTempSubdirectory("SvgSkiaFontMedia"); + + try + { + var blockyPath = WriteTempBlockyFont(tempDirectory.FullName); + var expectedFamily = GetDocumentFontFamilyName(family, blockyPath, "G"); + var cssPath = Path.Combine(tempDirectory.FullName, "fonts.css"); + var svgPath = Path.Combine(tempDirectory.FullName, "source.svg"); + File.WriteAllText(cssPath, $$""" + @media {{media}} { + @font-face { + font-family: {{family}}; + src: url("Blocky.woff") format("woff"); + } + } + """); + File.WriteAllText(svgPath, $$""" + + + G + + """); + + using var svg = new SKSvg(); + svg.Settings.EnableSvgFonts = false; + svg.Settings.StandaloneViewport = SkiaSharp.SKRect.Create(0f, 0f, 40f, 40f); + using var _ = svg.Load(svgPath); + var assetLoader = Assert.IsType(svg.AssetLoader); + using var fontScope = assetLoader.PushDocumentFonts(svg.SourceDocument!); + + if (shouldApply) + { + AssertDocumentTypefaceFamily(svg, family, "G", expectedFamily); + } + else + { + AssertDocumentTypefaceNotFamily(assetLoader, family, "G", expectedFamily); + } + } + finally + { + tempDirectory.Delete(recursive: true); + } + } + + [Fact] + public void PushDocumentFonts_RestoresParentScopeAfterNestedDocument() + { + const string parentFamily = "SvgSkiaParentBlocky"; + const string childFamily = "SvgSkiaChildAnglepoise"; + var parentFontPath = GetW3CResourcePath("Blocky.woff"); + var childFontPath = GetW3CResourcePath("anglepoi.woff"); + var parentExpectedFamily = GetDocumentFontFamilyName(parentFamily, parentFontPath, "G"); + var childExpectedFamily = GetDocumentFontFamilyName(childFamily, childFontPath, "S"); + var parentDocument = CreateFontFaceDocument(parentFamily, parentFontPath, "G"); + var childDocument = CreateFontFaceDocument(childFamily, childFontPath, "S"); + var assetLoader = new SkiaSvgAssetLoader(new SkiaModel(new SKSvgSettings())); + + using (assetLoader.PushDocumentFonts(parentDocument)) + { + AssertDocumentTypefaceFamily(assetLoader, parentFamily, "G", parentExpectedFamily); + AssertDocumentTypefaceNotFamily(assetLoader, childFamily, "S", childExpectedFamily); + + using (assetLoader.PushDocumentFonts(childDocument)) + { + AssertDocumentTypefaceFamily(assetLoader, childFamily, "S", childExpectedFamily); + AssertDocumentTypefaceNotFamily(assetLoader, parentFamily, "G", parentExpectedFamily); + } + + AssertDocumentTypefaceFamily(assetLoader, parentFamily, "G", parentExpectedFamily); + AssertDocumentTypefaceNotFamily(assetLoader, childFamily, "S", childExpectedFamily); + } + + AssertDocumentTypefaceNotFamily(assetLoader, parentFamily, "G", parentExpectedFamily); + AssertDocumentTypefaceNotFamily(assetLoader, childFamily, "S", childExpectedFamily); + } + private static SKPaint CreateTextPaint(float textSize) { return new SKPaint @@ -123,6 +508,150 @@ private static SKPaint CreateTextPaint(float textSize) }; } + private static List FindTypefaces(SKSvg svg, string familyName, string text) + { + var assetLoader = Assert.IsType(svg.AssetLoader); + return FindTypefaces(assetLoader, familyName, text); + } + + private static List FindTypefaces(SkiaSvgAssetLoader assetLoader, string familyName, string text) + { + var paint = new SKPaint + { + TextSize = 48f, + Typeface = SKTypeface.FromFamilyName( + familyName, + SKFontStyleWeight.Normal, + SKFontStyleWidth.Normal, + SKFontStyleSlant.Upright) + }; + + return assetLoader.FindTypefaces(text, paint); + } + + private static void AssertDocumentTypefaceFamily(SKSvg svg, string familyName, string text, string expectedFamily) + { + AssertDocumentTypefaceFamily(Assert.IsType(svg.AssetLoader), familyName, text, expectedFamily); + } + + private static void AssertDocumentTypefaceFamily(SkiaSvgAssetLoader assetLoader, string familyName, string text, string expectedFamily) + { + var spans = FindTypefaces(assetLoader, familyName, text); + Assert.NotEmpty(spans); + Assert.Contains( + spans, + span => string.Equals(span.Typeface?.FamilyName, expectedFamily, StringComparison.OrdinalIgnoreCase) || + string.Equals(span.Typeface?.FamilyName, familyName, StringComparison.OrdinalIgnoreCase)); + } + + private static void AssertDocumentTypefaceNotFamily(SkiaSvgAssetLoader assetLoader, string familyName, string text, string expectedFamily) + { + var spans = FindTypefaces(assetLoader, familyName, text); + Assert.DoesNotContain( + spans, + span => string.Equals(span.Typeface?.FamilyName, expectedFamily, StringComparison.OrdinalIgnoreCase)); + } + + private static void AssertRunTypefaceFamily(SKSvg svg, string familyName, string text, string expectedFamily) + { + var assetLoader = Assert.IsType(svg.AssetLoader); + var runTypeface = FindRunTypeface(assetLoader, familyName, text); + Assert.NotNull(runTypeface); + Assert.Equal(expectedFamily, runTypeface!.FamilyName, ignoreCase: true); + } + + private static SKTypeface? FindRunTypeface(SkiaSvgAssetLoader assetLoader, string familyName, string text) + { + var paint = new SKPaint + { + TextSize = 48f, + Typeface = SKTypeface.FromFamilyName( + familyName, + SKFontStyleWeight.Normal, + SKFontStyleWidth.Normal, + SKFontStyleSlant.Upright) + }; + + return assetLoader.FindRunTypeface(text, paint); + } + + private static string GetDocumentFontFamilyName(string familyName, string fontPath, string text) + { + var document = CreateFontFaceDocument(familyName, fontPath, text); + var assetLoader = new SkiaSvgAssetLoader(new SkiaModel(new SKSvgSettings())); + using var fontScope = assetLoader.PushDocumentFonts(document); + var typeface = FindRunTypeface(assetLoader, familyName, text); + + Assert.NotNull(typeface); + Assert.False(string.IsNullOrWhiteSpace(typeface.FamilyName)); + return typeface.FamilyName; + } + + private static string PercentEncodeDataBytes(byte[] bytes) + { + const string Hex = "0123456789ABCDEF"; + var builder = new StringBuilder(bytes.Length * 3); + foreach (var value in bytes) + { + builder.Append('%'); + builder.Append(Hex[value >> 4]); + builder.Append(Hex[value & 0x0F]); + } + + return builder.ToString(); + } + + private static SvgDocument CreateFontFaceDocument(string familyName, string fontPath, string text) + { + var fontUri = new Uri(Path.GetFullPath(fontPath)).AbsoluteUri; + return SvgService.FromSvg( + $$""" + + + {{text}} + + """, + null)!; + } + + private static string WriteTempBlockyFont(string directory) + { + var fontPath = Path.Combine(directory, "Blocky.woff"); + File.Copy(GetW3CResourcePath("Blocky.woff"), fontPath, overwrite: true); + return fontPath; + } + + private static string GetW3CSvgPath(string name) + => Path.Combine( + "..", + "..", + "..", + "..", + "..", + "externals", + "W3C_SVG_11_TestSuite", + "W3C_SVG_11_TestSuite", + "svg", + $"{name}.svg"); + + private static string GetW3CResourcePath(string name) + => Path.Combine( + "..", + "..", + "..", + "..", + "..", + "externals", + "W3C_SVG_11_TestSuite", + "W3C_SVG_11_TestSuite", + "resources", + name); + private sealed class CountingTypefaceProvider : ITypefaceProvider { public int CallCount { get; private set; } diff --git a/tests/Svg.Skia.UnitTests/SvgAnimationControllerTests.cs b/tests/Svg.Skia.UnitTests/SvgAnimationControllerTests.cs index ecbaa08d32..f51ab5ec3d 100644 --- a/tests/Svg.Skia.UnitTests/SvgAnimationControllerTests.cs +++ b/tests/Svg.Skia.UnitTests/SvgAnimationControllerTests.cs @@ -1445,6 +1445,61 @@ public void CreateAnimatedDocument_UsesSameFrameAnimatedColorForCurrentColor() AssertAnimatedFill(animated, "fill-first", Color.Cyan); } + [Fact] + public void CreateAnimatedDocument_InterpolatesW3CAnimateColorBasicFixture() + { + var start = CreateAnimatedW3CDocument("animate-elem-23-t.svg", TimeSpan.FromSeconds(3)); + AssertVisualFill(GetLargeAnimatedCircle(start), Color.Blue); + + var midpoint = CreateAnimatedW3CDocument("animate-elem-23-t.svg", TimeSpan.FromSeconds(6)); + AssertVisualFill(GetLargeAnimatedCircle(midpoint), Color.FromArgb(0, 128, 128)); + + var end = CreateAnimatedW3CDocument("animate-elem-23-t.svg", TimeSpan.FromSeconds(9)); + AssertVisualFill(GetLargeAnimatedCircle(end), Color.Lime); + } + + [Fact] + public void CreateAnimatedDocument_InterpolatesW3CAnimateColorKeywordFixture() + { + var animated = CreateAnimatedW3CDocument("animate-elem-84-t.svg", TimeSpan.FromSeconds(7)); + var animatedRects = animated.Descendants() + .OfType() + .Where(rectangle => NearlyEqual(rectangle.Width.Value, 100f) && NearlyEqual(rectangle.Height.Value, 100f)) + .ToArray(); + + Assert.Equal(5, animatedRects.Length); + foreach (var rectangle in animatedRects) + { + var expected = NearlyEqual(rectangle.X.Value, 240f) && NearlyEqual(rectangle.Y.Value, 0f) + ? Color.FromArgb(0, 119, 0) + : Color.Green; + AssertVisualFill(rectangle, expected); + } + } + + [Fact] + public void CreateAnimatedDocument_UsesSameFrameCurrentColorForW3CAnimateColorFixture() + { + var firstEnd = CreateAnimatedW3CDocument("animate-elem-85-t.svg", TimeSpan.FromSeconds(5)); + var topRects = GetW3CAnimateElem85TopRectangles(firstEnd); + Assert.Equal(4, topRects.Length); + Assert.All(topRects, rectangle => AssertVisualFill(rectangle, Color.Green)); + + var firstEndBottomRects = GetW3CAnimateElem85BottomRectangles(firstEnd); + Assert.Equal(2, firstEndBottomRects.Length); + Assert.All(firstEndBottomRects, rectangle => AssertVisualFill(rectangle, Color.Green)); + + var midpoint = CreateAnimatedW3CDocument("animate-elem-85-t.svg", TimeSpan.FromSeconds(7.5)); + var midpointBottomRects = GetW3CAnimateElem85BottomRectangles(midpoint); + Assert.Equal(2, midpointBottomRects.Length); + Assert.All(midpointBottomRects, rectangle => AssertVisualFill(rectangle, Color.FromArgb(0, 128, 128))); + + var end = CreateAnimatedW3CDocument("animate-elem-85-t.svg", TimeSpan.FromSeconds(10)); + var endBottomRects = GetW3CAnimateElem85BottomRectangles(end); + Assert.Equal(2, endBottomRects.Length); + Assert.All(endBottomRects, rectangle => AssertVisualFill(rectangle, Color.Cyan)); + } + [Fact] public void CreateAnimatedDocument_InterpolatesFeCompositeArithmeticCoefficients() { @@ -1697,6 +1752,54 @@ private static string GetW3CTestSvgPath(string name) name)); } + private static SvgDocument CreateAnimatedW3CDocument(string name, TimeSpan time) + { + var path = GetW3CTestSvgPath(name); + Assert.True(File.Exists(path), $"Expected W3C SVG fixture at '{path}'."); + + var document = SvgService.Open(path); + Assert.NotNull(document); + + using var controller = new SvgAnimationController(document!); + return controller.CreateAnimatedDocument(time); + } + + private static SvgCircle GetLargeAnimatedCircle(SvgDocument document) + { + return Assert.Single(document.Descendants().OfType(), circle => circle.Radius.Value > 100f); + } + + private static SvgRectangle[] GetW3CAnimateElem85TopRectangles(SvgDocument document) + { + return GetW3CAnimateElem85Rectangles(document, y: 50f); + } + + private static SvgRectangle[] GetW3CAnimateElem85BottomRectangles(SvgDocument document) + { + return document.Descendants() + .OfType() + .Where(rectangle => NearlyEqual(rectangle.X.Value, 100f) && + (NearlyEqual(rectangle.Y.Value, 180f) || NearlyEqual(rectangle.Y.Value, 245f)) && + NearlyEqual(rectangle.Width.Value, 280f) && + NearlyEqual(rectangle.Height.Value, 60f)) + .ToArray(); + } + + private static SvgRectangle[] GetW3CAnimateElem85Rectangles(SvgDocument document, float y) + { + return document.Descendants() + .OfType() + .Where(rectangle => NearlyEqual(rectangle.Y.Value, y) && + NearlyEqual(rectangle.Width.Value, 90f) && + NearlyEqual(rectangle.Height.Value, 100f)) + .ToArray(); + } + + private static bool NearlyEqual(float actual, float expected) + { + return Math.Abs(actual - expected) < 0.001f; + } + private const string AnimationRuntimeSvg = """ (elementId); Assert.NotNull(target); - var fill = Assert.IsType(target!.Fill); + AssertVisualFill(target, expectedColor); + } + + private static void AssertVisualFill(SvgElement? element, Color expectedColor) + { + var visualElement = Assert.IsAssignableFrom(element); + AssertPaintColor(visualElement.Fill, expectedColor); + } + + private static void AssertPaintColor(SvgPaintServer? paintServer, Color expectedColor) + { + var fill = Assert.IsType(paintServer); Assert.Equal(expectedColor.A, fill.Colour.A); Assert.Equal(expectedColor.R, fill.Colour.R); Assert.Equal(expectedColor.G, fill.Colour.G); diff --git a/tests/Svg.Skia.UnitTests/SvgFontBaselineTextTests.cs b/tests/Svg.Skia.UnitTests/SvgFontBaselineTextTests.cs index 23df0457b1..652b6f7732 100644 --- a/tests/Svg.Skia.UnitTests/SvgFontBaselineTextTests.cs +++ b/tests/Svg.Skia.UnitTests/SvgFontBaselineTextTests.cs @@ -7,6 +7,18 @@ namespace Svg.Skia.UnitTests; public class SvgFontBaselineTextTests { + [Theory] + [InlineData("A\u6F22", SvgDominantBaseline.Ideographic)] + [InlineData(".\u0923", SvgDominantBaseline.Hanging)] + [InlineData("x\u2211", SvgDominantBaseline.Mathematical)] + public void ResolveScriptBaseline_ScansPastAlphabeticPrefixes(string text, SvgDominantBaseline expected) + { + var resolver = typeof(SvgSceneRenderer).Assembly.GetType("Svg.Skia.SvgTextBaselineResolver", throwOnError: true)!; + var method = resolver.GetMethod("ResolveScriptBaseline", new[] { typeof(string) }); + + Assert.Equal(expected, method!.Invoke(null, new object?[] { text })); + } + [Fact] public void SvgFontUseScriptBaseline_UsesMixedScriptFontFaceCoordinates() { @@ -39,6 +51,46 @@ public void SvgFontUseScriptBaseline_UsesMixedScriptFontFaceCoordinates() $"Expected hanging baseline to sit below alphabetic fallback. latin={latin}, hanging={hanging}"); } + [Fact] + public void SvgFontMixedScriptBaselineTable_AlignsDescendantFontSizesToRootTable() + { + const string svgMarkup = """ + + + + + + + + + + + + a + a + a + + + """; + + var latinLarge = GetOnlyPathBounds(svgMarkup, "latin-large"); + var cjkLarge = GetOnlyPathBounds(svgMarkup, "cjk-large"); + var hangingLarge = GetOnlyPathBounds(svgMarkup, "hanging-large"); + var latinSmall = GetOnlyPathBounds(svgMarkup, "latin-small"); + var cjkSmall = GetOnlyPathBounds(svgMarkup, "cjk-small"); + var hangingSmall = GetOnlyPathBounds(svgMarkup, "hanging-small"); + var latinTiny = GetOnlyPathBounds(svgMarkup, "latin-tiny"); + var cjkTiny = GetOnlyPathBounds(svgMarkup, "cjk-tiny"); + var hangingTiny = GetOnlyPathBounds(svgMarkup, "hanging-tiny"); + + Assert.Equal(latinLarge.Bottom, latinSmall.Bottom, 2); + Assert.Equal(latinLarge.Bottom, latinTiny.Bottom, 2); + Assert.Equal(cjkLarge.Bottom, cjkSmall.Bottom, 2); + Assert.Equal(cjkLarge.Bottom, cjkTiny.Bottom, 2); + Assert.Equal(hangingLarge.Top, hangingSmall.Top, 2); + Assert.Equal(hangingLarge.Top, hangingTiny.Top, 2); + } + [Fact] public void SvgFontBaselineFallback_UsesBrowserLikeFontCoordinatesWhenTableEntriesAreMissing() { diff --git a/tests/Svg.Skia.UnitTests/SvgSceneTextCompilerTests.cs b/tests/Svg.Skia.UnitTests/SvgSceneTextCompilerTests.cs index 3162a41e24..50aed9ed52 100644 --- a/tests/Svg.Skia.UnitTests/SvgSceneTextCompilerTests.cs +++ b/tests/Svg.Skia.UnitTests/SvgSceneTextCompilerTests.cs @@ -140,6 +140,12 @@ public class SvgSceneTextCompilerTests binder: null, [typeof(SvgTextBase), typeof(IReadOnlyList), typeof(SKRect), typeof(ISvgAssetLoader)], modifiers: null)!; + private static readonly MethodInfo s_drawTextRunsMethod = s_svgSceneTextCompilerType.GetMethod( + "DrawTextRuns", + BindingFlags.NonPublic | BindingFlags.Static, + binder: null, + [typeof(SvgTextBase), typeof(string), typeof(float), typeof(float), typeof(SKRect), typeof(SKPaint), typeof(SKCanvas), typeof(ISvgAssetLoader), typeof(float[])], + modifiers: null)!; private static readonly MethodInfo s_tryPrepareFlatTextRunMethod = s_svgSceneTextCompilerType .GetMethods(BindingFlags.NonPublic | BindingFlags.Static) .Single(method => @@ -4610,6 +4616,26 @@ public void SvgTextBidiResolver_PreservesSameDirectionExplicitEmbeddingSpan() run => GetPlanProperty(run, "Level") >= 2); } + [Fact] + public void DrawTextRuns_RtlVisualTypefaceSpansDrawOnceInVisualOrder() + { + const string text = "ABC \u05D0\u05D1\u05D2 DEF"; + var document = SvgDocumentCompatibilityLoader.FromSvg( + """ + + ABC אבג DEF + + """); + var svgText = document.Descendants().OfType().Single(static element => element.ID == "label"); + var loader = new VisualOrderTypefaceSpanAssetLoader(); + + var draws = InvokeDrawTextRuns(svgText, text, SKRect.Create(0f, 0f, 240f, 80f), loader) + .Select(static command => command.Text) + .ToArray(); + + Assert.Equal(["DEF", "\u05D0\u05D1\u05D2", "ABC"], draws); + } + [Fact] public void SvgTextLayoutPlanner_NestedIsolateSpanKeepsOuterNeutralPunctuation() { @@ -5236,6 +5262,33 @@ private static bool InvokeTryCompileSequentialText( return Assert.IsType(s_tryCompileSequentialTextMethod.Invoke(null, args)); } + private static IReadOnlyList InvokeDrawTextRuns( + SvgTextBase svgTextBase, + string text, + SKRect viewport, + ISvgAssetLoader assetLoader) + { + var recorder = new SKPictureRecorder(); + var canvas = recorder.BeginRecording(viewport); + var paint = new SKPaint(); + _ = s_drawTextRunsMethod.Invoke( + null, + [ + svgTextBase, + text, + 10f, + 40f, + viewport, + paint, + canvas, + assetLoader, + null + ]); + + return recorder.EndRecording().Commands?.OfType().ToArray() + ?? Array.Empty(); + } + private static SKRect InvokeSvgFontLayoutBounds( SvgTextBase svgTextBase, string text, @@ -5765,6 +5818,91 @@ private float GetAdvance(string text) } } + private sealed class VisualOrderTypefaceSpanAssetLoader : ISvgAssetLoader + { + private readonly SKTypeface _typeface = SKTypeface.FromFamilyName( + "sans-serif", + SKFontStyleWeight.Normal, + SKFontStyleWidth.Normal, + SKFontStyleSlant.Upright); + + public bool EnableSvgFonts => false; + + public SKImage LoadImage(Stream stream) => LoadTestImage(stream); + + public List FindTypefaces(string? text, SKPaint paintPreferredTypeface) + { + var spans = new List(); + foreach (var run in SplitStrongRuns(text ?? string.Empty)) + { + spans.Add(new TypefaceSpan(run, run.Length * 10f, _typeface)); + } + + return spans; + } + + public SKFontMetrics GetFontMetrics(SKPaint paint) => default; + + public float MeasureText(string? text, SKPaint paint, ref SKRect bounds) + { + bounds = default; + return SplitStrongRuns(text ?? string.Empty).Sum(static run => run.Length * 10f); + } + + public SKPath? GetTextPath(string? text, SKPaint paint, float x, float y) => null; + + private static IEnumerable SplitStrongRuns(string text) + { + var builder = new StringBuilder(); + StrongRunDirection? currentDirection = null; + + foreach (var character in text) + { + var direction = GetStrongRunDirection(character); + if (direction is null) + { + continue; + } + + if (currentDirection is { } && + currentDirection != direction) + { + yield return builder.ToString(); + builder.Clear(); + } + + currentDirection = direction; + builder.Append(character); + } + + if (builder.Length > 0) + { + yield return builder.ToString(); + } + } + + private static StrongRunDirection? GetStrongRunDirection(char character) + { + if (character is >= 'A' and <= 'Z' or >= 'a' and <= 'z') + { + return StrongRunDirection.LeftToRight; + } + + if (character is >= '\u0590' and <= '\u05FF') + { + return StrongRunDirection.RightToLeft; + } + + return null; + } + + private enum StrongRunDirection + { + LeftToRight, + RightToLeft + } + } + private sealed class SplitComplexTextElementClusterAssetLoader : ISvgAssetLoader, ISvgTextRunTypefaceResolver, ISvgTextGlyphRunResolver { private readonly SKTypeface _typeface = SKTypeface.FromFamilyName( diff --git a/tests/Svg.Skia.UnitTests/SvgViewerResourceTests.cs b/tests/Svg.Skia.UnitTests/SvgViewerResourceTests.cs index b3be643786..53f9522c54 100644 --- a/tests/Svg.Skia.UnitTests/SvgViewerResourceTests.cs +++ b/tests/Svg.Skia.UnitTests/SvgViewerResourceTests.cs @@ -1,6 +1,7 @@ using System; using System.IO; using System.IO.Compression; +using System.Linq; using System.Text; using Xunit; using SkiaAlphaType = SkiaSharp.SKAlphaType; @@ -87,6 +88,7 @@ public void Load_CyclicNestedSvgImageUsesPlaceholderAndPreservesSiblingContent() using var svg = new SKSvg(); using var _ = svg.Load(parentPath); using var bitmap = RenderBitmap(svg); + using var secondBitmap = RenderBitmap(svg); AssertMostlyRed( bitmap.GetPixel(10, 5), @@ -97,6 +99,7 @@ public void Load_CyclicNestedSvgImageUsesPlaceholderAndPreservesSiblingContent() AssertNeutralPlaceholder( bitmap.GetPixel(10, 15), "Expected the recursive nested SVG image edge to render the deterministic broken-image placeholder."); + AssertSameBitmap(bitmap, secondBitmap); } finally { @@ -108,9 +111,29 @@ public void Load_CyclicNestedSvgImageUsesPlaceholderAndPreservesSiblingContent() public void Load_W3CBrokenAndCyclicImageFixtureUsesPlaceholdersAndPreservesSurroundingContent() { using var svg = new SKSvg(); + SetStandaloneW3CViewport(svg); svg.Load(GetW3CSvgPath("struct-image-12-b")); + var scene = svg.RetainedSceneGraph; + Assert.NotNull(scene); + var fixtureImageNodes = scene!.Traverse() + .Where(static node => + node.Kind == SvgSceneNodeKind.Image && + IsClose(node.GeometryBounds.Left, 60f) && + IsClose(node.GeometryBounds.Top, 50f) && + IsClose(node.GeometryBounds.Width, 240f) && + IsClose(node.GeometryBounds.Height, 240f)) + .ToArray(); + Assert.True(fixtureImageNodes.Length >= 3); + Assert.All(fixtureImageNodes, static node => + { + Assert.True(node.IsRenderable); + Assert.True(node.LocalModel is not null || node.Children.Count > 0); + }); + Assert.True(fixtureImageNodes.Count(static node => node.LocalModel is not null) >= 2); + using var bitmap = RenderBitmap(svg); + using var secondBitmap = RenderBitmap(svg); AssertNeutralPlaceholder( bitmap.GetPixel(120, 120), @@ -118,6 +141,51 @@ public void Load_W3CBrokenAndCyclicImageFixtureUsesPlaceholdersAndPreservesSurro AssertMostlyBlue( bitmap.GetPixel(360, 230), "Expected content surrounding the invalid/cyclic W3C image references to remain visible."); + AssertSameBitmap(bitmap, secondBitmap); + } + + [Fact] + public void Load_W3CRecursiveUseFixtureSuppressesRecursiveReferencesAndKeepsOutputStable() + { + using var svg = new SKSvg(); + SetStandaloneW3CViewport(svg); + svg.Load(GetW3CSvgPath("struct-use-08-b")); + + var scene = svg.RetainedSceneGraph; + Assert.NotNull(scene); + var recursiveUseNodes = scene!.Traverse() + .Where(static node => node.ElementId is "use-elm-1" or "use-elm-2") + .OrderBy(static node => node.ElementId, StringComparer.Ordinal) + .ToArray(); + + Assert.Equal(2, recursiveUseNodes.Length); + var rootReferenceUse = recursiveUseNodes[0]; + Assert.False(rootReferenceUse.IsRenderable); + Assert.Empty(rootReferenceUse.Children); + + var imageReferenceUse = recursiveUseNodes[1]; + Assert.True(imageReferenceUse.IsRenderable); + Assert.NotEmpty(imageReferenceUse.Children); + Assert.True(imageReferenceUse.Children.Count <= 1); + Assert.True(scene.Traverse().Count() < 80); + + using var bitmap = RenderBitmap(svg); + using var secondBitmap = RenderBitmap(svg); + + AssertTransparent( + bitmap.GetPixel(120, 110), + "Expected recursive content referencing the external root to be suppressed."); + AssertNotMostlyRed( + bitmap.GetPixel(360, 110), + "Expected bounded recursive image expansion not to paint recursive red content."); + AssertContainsMostlyGreenPixel( + bitmap, + 170, + 245, + 140, + 35, + "Expected non-recursive pass text in struct-use-08-b to remain visible."); + AssertSameBitmap(bitmap, secondBitmap); } private static string GetW3CSvgPath(string name) @@ -135,6 +203,11 @@ private static string GetW3CSvgPath(string name) $"{name}.svg"); } + private static void SetStandaloneW3CViewport(SKSvg svg) + { + svg.Settings.StandaloneViewport = SkiaSharp.SKRect.Create(0f, 0f, 480f, 360f); + } + private static byte[] CompressUtf8(string text) { using var memoryStream = new MemoryStream(); @@ -182,6 +255,18 @@ private static void AssertMostlyBlue(SkiaColor pixel, string message) $"{message} Pixel was {pixel}."); } + private static void AssertTransparent(SkiaColor pixel, string message) + { + Assert.True(pixel.Alpha <= 8, $"{message} Pixel was {pixel}."); + } + + private static void AssertNotMostlyRed(SkiaColor pixel, string message) + { + Assert.False( + pixel.Red > 160 && pixel.Green < 80 && pixel.Blue < 80 && pixel.Alpha > 120, + $"{message} Pixel was {pixel}."); + } + private static void AssertNeutralPlaceholder(SkiaColor pixel, string message) { Assert.True( @@ -193,4 +278,46 @@ private static void AssertNeutralPlaceholder(SkiaColor pixel, string message) Math.Abs(pixel.Red - pixel.Blue) <= 24, $"{message} Pixel was {pixel}."); } + + private static void AssertContainsMostlyGreenPixel( + SkiaBitmap bitmap, + int startX, + int startY, + int width, + int height, + string message) + { + for (var y = startY; y < startY + height; y++) + { + for (var x = startX; x < startX + width; x++) + { + var pixel = bitmap.GetPixel(x, y); + if (pixel.Green > 100 && pixel.Red < 80 && pixel.Blue < 80 && pixel.Alpha > 120) + { + return; + } + } + } + + Assert.Fail(message); + } + + private static void AssertSameBitmap(SkiaBitmap expected, SkiaBitmap actual) + { + Assert.Equal(expected.Width, actual.Width); + Assert.Equal(expected.Height, actual.Height); + + for (var y = 0; y < expected.Height; y++) + { + for (var x = 0; x < expected.Width; x++) + { + Assert.Equal(expected.GetPixel(x, y), actual.GetPixel(x, y)); + } + } + } + + private static bool IsClose(float actual, float expected) + { + return Math.Abs(actual - expected) <= 0.01f; + } } diff --git a/tests/Svg.Skia.UnitTests/W3CTestSuiteTests.cs b/tests/Svg.Skia.UnitTests/W3CTestSuiteTests.cs index 31b55a4485..157eb17d6c 100644 --- a/tests/Svg.Skia.UnitTests/W3CTestSuiteTests.cs +++ b/tests/Svg.Skia.UnitTests/W3CTestSuiteTests.cs @@ -5,6 +5,7 @@ using System.Linq; using System.Reflection; using ShimSkiaSharp; +using ShimSkiaSharp.Editing; using SixLabors.ImageSharp; using SixLabors.ImageSharp.PixelFormats; using Svg; @@ -719,6 +720,9 @@ private static bool TryRunSemanticAssertion(string name, SKSvg svg) case "animate-elem-21-t": AssertChainedIndefiniteHyperlinkFixture(svg); return true; + case "animate-elem-23-t": + AssertBasicAnimateColorFixture(svg); + return true; case "animate-elem-29-b": AssertIndefiniteOpacityHyperlinkFixture(svg); return true; @@ -734,6 +738,12 @@ private static bool TryRunSemanticAssertion(string name, SKSvg svg) case "animate-elem-63-t": AssertMultipleEndUserEventFixture(svg); return true; + case "animate-elem-84-t": + AssertAnimateColorKeywordFixture(svg); + return true; + case "animate-elem-85-t": + AssertAnimateColorCurrentColorFixture(svg); + return true; case "animate-interact-pevents-01-t": AssertTextPointerEventsRows(svg, animated: true); return true; @@ -749,6 +759,12 @@ private static bool TryRunSemanticAssertion(string name, SKSvg svg) case "animate-interact-events-01-t": AssertAnimatedUseInstanceMouseEventsAndBubbling(svg); return true; + case "color-prof-01-f": + AssertOptionalIccProfileFixtureUsesUnsupportedPolicy(svg.SourceDocument!); + return true; + case "color-prop-04-t": + AssertSystemColorFixtureUsesDeterministicPalette(svg.SourceDocument!); + return true; case "conform-viewers-02-f": AssertGzippedSvgDataImageFixture(svg); return true; @@ -852,9 +868,15 @@ private static bool TryRunSemanticAssertion(string name, SKSvg svg) case "struct-image-12-b": AssertBrokenImageAndCycleFixtureUsesPlaceholders(svg); return true; + case "struct-use-08-b": + AssertRecursiveUseFixtureSuppressesRecursiveReferences(svg); + return true; case "struct-image-17-b": AssertEmbeddedSvgImageRemainsStatic(svg); return true; + case "text-align-08-b": + AssertTextAlign08MixedScriptBaselineFixture(svg); + return true; case "text-tselect-01-b": case "text-tselect-02-f": case "text-tselect-03-f": @@ -877,6 +899,47 @@ private static bool TryRunSemanticAssertion(string name, SKSvg svg) } } + private static void AssertTextAlign08MixedScriptBaselineFixture(SKSvg svg) + { + var sourceDocument = svg.SourceDocument; + Assert.NotNull(sourceDocument); + + var baselineText = Assert.Single( + sourceDocument!.Descendants().OfType(), + static text => text.FontSize.Value == 120f); + Assert.Contains("a犜ण", baselineText.Text); + Assert.Equal(new[] { 75f, 30f }, baselineText.Children.OfType().Select(static tspan => tspan.FontSize.Value).ToArray()); + + Assert.True(sourceDocument.BaseUri is { IsFile: true }, "Expected the W3C fixture to have a local file BaseUri."); + using var svgFontRender = new SKSvg(); + svgFontRender.Settings.EnableSvgFonts = true; + svgFontRender.Settings.EnableTextReferences = true; + svgFontRender.Settings.StandaloneViewport = SkiaSharp.SKRect.Create(0f, 0f, 480f, 360f); + using var _ = svgFontRender.Load(sourceDocument.BaseUri!.LocalPath); + Assert.NotNull(svgFontRender.Model); + + var bodyGlyphBounds = svgFontRender.Model! + .FindCommands() + .Select(static command => command.Path?.Bounds ?? SKRect.Empty) + .Where(static bounds => !bounds.IsEmpty && + bounds.Left >= 45f && + bounds.Right <= 440f && + bounds.Top >= 80f && + bounds.Bottom <= 240f && + bounds.Width > 20f && + bounds.Height > 20f) + .OrderBy(static bounds => bounds.Left) + .ToArray(); + + Assert.Equal(3, bodyGlyphBounds.Length); + Assert.InRange(bodyGlyphBounds[0].Top, 88f, 98f); + Assert.InRange(bodyGlyphBounds[0].Bottom, 198f, 202f); + Assert.InRange(bodyGlyphBounds[1].Top, 92f, 101f); + Assert.InRange(bodyGlyphBounds[1].Bottom, 207f, 211f); + Assert.InRange(bodyGlyphBounds[2].Top, 97f, 105f); + Assert.InRange(bodyGlyphBounds[2].Bottom, 216f, 220f); + } + private static void AssertDynamicImageNamespaceFixtureUsesOnlyRealXLinkHref(SvgDocument document) { var images = document.Descendants().OfType().ToArray(); @@ -890,6 +953,44 @@ private static void AssertDynamicImageNamespaceFixtureUsesOnlyRealXLinkHref(SvgD Assert.Equal("No exceptions.", status.Content); } + private static void AssertOptionalIccProfileFixtureUsesUnsupportedPolicy(SvgDocument document) + { + var profile = document.GetElementById("changeColor"); + Assert.NotNull(profile); + Assert.IsType(profile); + Assert.True(profile.TryGetAttribute("name", out var profileName)); + Assert.Equal("changeColor", profileName?.ToString()); + var images = document.Descendants().OfType().ToArray(); + Assert.Equal(2, images.Length); + Assert.Contains(images, static image => string.Equals(image.ID, "image1PNG", StringComparison.Ordinal)); + + var profiledImage = Assert.IsType(document.GetElementById("image2")); + Assert.Equal("../images/colorprof.png", profiledImage.Href); + Assert.True(profiledImage.TryGetAttribute("color-profile", out var colorProfile)); + Assert.Equal("changeColor", colorProfile?.ToString()); + } + + private static void AssertSystemColorFixtureUsesDeterministicPalette(SvgDocument document) + { + AssertColorFill(document.GetElementById("drop-bg"), GetSvgSystemColor("ThreeDFace")); + AssertColorFill(document.GetElementById("windowbar"), GetSvgSystemColor("ActiveCaption")); + AssertColorStroke(document.GetElementById("windowbar"), GetSvgSystemColor("ActiveBorder")); + AssertColorFill(document.GetElementById("contents"), GetSvgSystemColor("WindowText")); + + var menuTexts = document.Descendants() + .OfType() + .Where(static text => text.Content is "Load" or "Save" or "Edit") + .ToArray(); + Assert.Equal(3, menuTexts.Length); + Assert.All(menuTexts, text => AssertColorFill(text, GetSvgSystemColor("MenuText"))); + } + + private static System.Drawing.Color GetSvgSystemColor(string name) + { + Assert.True(SvgSystemColorResolver.TryGetColor(name, out var color), $"System color '{name}' was not registered."); + return color; + } + private static void AssertIndefiniteFillHyperlinkFixture(SKSvg svg) { DispatchPointerClick(svg, new SKPoint(350f, 80f)); @@ -934,6 +1035,89 @@ private static void AssertIndefiniteOpacityHyperlinkFixture(SKSvg svg) Assert.Equal(0f, hiddenRect.FillOpacity, 3); } + private static void AssertBasicAnimateColorFixture(SKSvg svg) + { + var start = CreateAnimatedDocument(svg, TimeSpan.FromSeconds(3)); + AssertColorFill(GetLargeAnimatedCircle(start), System.Drawing.Color.Blue); + + var midpoint = CreateAnimatedDocument(svg, TimeSpan.FromSeconds(6)); + AssertColorFill(GetLargeAnimatedCircle(midpoint), System.Drawing.Color.FromArgb(0, 128, 128)); + + var end = CreateAnimatedDocument(svg, TimeSpan.FromSeconds(9)); + AssertColorFill(GetLargeAnimatedCircle(end), System.Drawing.Color.Lime); + } + + private static void AssertAnimateColorKeywordFixture(SKSvg svg) + { + var animated = CreateAnimatedDocument(svg, TimeSpan.FromSeconds(7)); + var animatedRects = animated.Descendants() + .OfType() + .Where(rectangle => IsClose(rectangle.Width.Value, 100f) && IsClose(rectangle.Height.Value, 100f)) + .ToArray(); + + Assert.Equal(5, animatedRects.Length); + foreach (var rectangle in animatedRects) + { + var expected = IsClose(rectangle.X.Value, 240f) && IsClose(rectangle.Y.Value, 0f) + ? System.Drawing.Color.FromArgb(0, 119, 0) + : System.Drawing.Color.Green; + AssertColorFill(rectangle, expected); + } + } + + private static void AssertAnimateColorCurrentColorFixture(SKSvg svg) + { + var firstEnd = CreateAnimatedDocument(svg, TimeSpan.FromSeconds(5)); + var topRects = GetAnimateElem85TopRectangles(firstEnd); + Assert.Equal(4, topRects.Length); + Assert.All(topRects, rectangle => AssertColorFill(rectangle, System.Drawing.Color.Green)); + + var firstEndBottomRects = GetAnimateElem85BottomRectangles(firstEnd); + Assert.Equal(2, firstEndBottomRects.Length); + Assert.All(firstEndBottomRects, rectangle => AssertColorFill(rectangle, System.Drawing.Color.Green)); + + var midpoint = CreateAnimatedDocument(svg, TimeSpan.FromSeconds(7.5)); + var midpointBottomRects = GetAnimateElem85BottomRectangles(midpoint); + Assert.Equal(2, midpointBottomRects.Length); + Assert.All(midpointBottomRects, rectangle => AssertColorFill(rectangle, System.Drawing.Color.FromArgb(0, 128, 128))); + + var end = CreateAnimatedDocument(svg, TimeSpan.FromSeconds(10)); + var endBottomRects = GetAnimateElem85BottomRectangles(end); + Assert.Equal(2, endBottomRects.Length); + Assert.All(endBottomRects, rectangle => AssertColorFill(rectangle, System.Drawing.Color.Cyan)); + } + + private static SvgCircle GetLargeAnimatedCircle(SvgDocument document) + { + return Assert.Single(document.Descendants().OfType(), circle => circle.Radius.Value > 100f); + } + + private static SvgRectangle[] GetAnimateElem85TopRectangles(SvgDocument document) + { + return document.Descendants() + .OfType() + .Where(rectangle => IsClose(rectangle.Y.Value, 50f) && + IsClose(rectangle.Width.Value, 90f) && + IsClose(rectangle.Height.Value, 100f)) + .ToArray(); + } + + private static SvgRectangle[] GetAnimateElem85BottomRectangles(SvgDocument document) + { + return document.Descendants() + .OfType() + .Where(rectangle => IsClose(rectangle.X.Value, 100f) && + (IsClose(rectangle.Y.Value, 180f) || IsClose(rectangle.Y.Value, 245f)) && + IsClose(rectangle.Width.Value, 280f) && + IsClose(rectangle.Height.Value, 60f)) + .ToArray(); + } + + private static bool IsClose(float actual, float expected) + { + return Math.Abs(actual - expected) < 0.001f; + } + private static void AssertAccessKeyAndPastWallclockBeginFixture(SKSvg svg) { var eventTarget = svg.SourceDocument!.GetElementById("setThreeTarget"); @@ -1702,6 +1886,32 @@ private static void AssertBrokenImageAndCycleFixtureUsesPlaceholders(SKSvg svg) Assert.Contains(imageNodes, static node => node.IsRenderable && node.LocalModel is not null); } + private static void AssertRecursiveUseFixtureSuppressesRecursiveReferences(SKSvg svg) + { + var scene = svg.RetainedSceneGraph; + Assert.NotNull(scene); + var recursiveUseNodes = scene!.Traverse() + .Where(static node => node.ElementId is "use-elm-1" or "use-elm-2") + .OrderBy(static node => node.ElementId, StringComparer.Ordinal) + .ToArray(); + + Assert.Equal(2, recursiveUseNodes.Length); + var rootReferenceUse = recursiveUseNodes[0]; + Assert.False(rootReferenceUse.IsRenderable); + Assert.Empty(rootReferenceUse.Children); + + var imageReferenceUse = recursiveUseNodes[1]; + Assert.True(imageReferenceUse.IsRenderable); + Assert.NotEmpty(imageReferenceUse.Children); + Assert.True(imageReferenceUse.Children.Count <= 1); + Assert.True(scene.Traverse().Count() < 80); + + using var bitmap = RenderBitmap(svg); + AssertTransparent(bitmap.GetPixel(120, 110)); + AssertNotMostlyRed(bitmap.GetPixel(360, 110)); + AssertContainsMostlyGreenPixel(bitmap, 170, 245, 140, 35); + } + private static void AssertEmbeddedSvgImageRemainsStatic(SKSvg svg) { using var bitmap = RenderBitmap(svg); @@ -1747,6 +1957,35 @@ private static SkiaBitmap RenderBitmap(SKSvg svg) return Assert.IsType(bitmap); } + private static void AssertTransparent(SkiaSharp.SKColor pixel) + { + Assert.True(pixel.Alpha <= 8, $"Expected recursive referenced content to stay transparent, but pixel was {pixel}."); + } + + private static void AssertNotMostlyRed(SkiaSharp.SKColor pixel) + { + Assert.False( + pixel.Red > 160 && pixel.Green < 80 && pixel.Blue < 80 && pixel.Alpha > 120, + $"Expected bounded recursive image expansion not to paint recursive red content, but pixel was {pixel}."); + } + + private static void AssertContainsMostlyGreenPixel(SkiaBitmap bitmap, int startX, int startY, int width, int height) + { + for (var y = startY; y < startY + height; y++) + { + for (var x = startX; x < startX + width; x++) + { + var pixel = bitmap.GetPixel(x, y); + if (pixel.Green > 100 && pixel.Red < 80 && pixel.Blue < 80 && pixel.Alpha > 120) + { + return; + } + } + } + + Assert.Fail("Expected recursive-use fixture pass text to remain visible."); + } + private static void AssertRuntimeAttribute(SvgDocument document, string elementId, string attributeName, string expected) { var runtime = new SvgJavaScriptRuntime(document, new SvgJavaScriptSettings { ThrowOnError = true }); @@ -2157,7 +2396,7 @@ public void Dispose() [InlineData("animate-elem-20-t", 0.022)] [InlineData("animate-elem-21-t", 0.022)] [InlineData("animate-elem-22-b", 0.022)] - [InlineData("animate-elem-23-t", 0.022, Skip = "Modern Chrome captures deprecated animateColor as no-op; keep skipped until the W3C animateColor row has a non-Chrome static reference policy.")] + [InlineData("animate-elem-23-t", 0.022)] [InlineData("animate-elem-24-t", 0.022)] [InlineData("animate-elem-25-t", 0.022)] [InlineData("animate-elem-26-t", 0.022)] @@ -2197,8 +2436,8 @@ public void Dispose() [InlineData("animate-elem-81-t", 0.022)] [InlineData("animate-elem-82-t", 0.022)] [InlineData("animate-elem-83-t", 0.022)] - [InlineData("animate-elem-84-t", 0.022, Skip = "Modern Chrome captures deprecated animateColor as no-op; keep skipped until the W3C animateColor row has a non-Chrome static reference policy.")] - [InlineData("animate-elem-85-t", 0.022, Skip = "Modern Chrome captures deprecated animateColor as no-op; keep skipped until the W3C animateColor row has a non-Chrome static reference policy.")] + [InlineData("animate-elem-84-t", 0.022)] + [InlineData("animate-elem-85-t", 0.022)] [InlineData("animate-elem-86-t", 0.022)] [InlineData("animate-elem-87-t", 0.022)] [InlineData("animate-elem-88-t", 0.022)] @@ -2214,11 +2453,11 @@ public void Dispose() [InlineData("animate-pservers-grad-01-b", 0.022)] [InlineData("animate-script-elem-01-b", 0.022)] [InlineData("animate-struct-dom-01-b", 0.022)] - [InlineData("color-prof-01-f", 0.022, Skip = "Optional ICC color profile support is not a stable Chrome-backed baseline.")] + [InlineData("color-prof-01-f", 0.022)] [InlineData("color-prop-01-b", 0.022)] [InlineData("color-prop-02-f", 0.022)] [InlineData("color-prop-03-t", 0.022)] - [InlineData("color-prop-04-t", 0.022, Skip = "System color keywords depend on viewer platform colors and are not a stable pixel baseline.")] + [InlineData("color-prop-04-t", 0.022)] [InlineData("color-prop-05-t", 0.022)] [InlineData("conform-viewers-02-f", 0.022)] [InlineData("conform-viewers-03-f", 0.022)] @@ -2431,7 +2670,7 @@ public void Dispose() [InlineData("pservers-grad-05-b", 0.022)] [InlineData("pservers-grad-06-b", 0.022)] [InlineData("pservers-grad-07-b", 0.022)] - [InlineData("pservers-grad-08-b", 0.022, Skip = "Requires SVG/WOFF webfont loading for exact Chrome text gradients.")] + [InlineData("pservers-grad-08-b", 0.022)] [InlineData("pservers-grad-09-b", 0.022)] [InlineData("pservers-grad-10-b", 0.022)] [InlineData("pservers-grad-11-b", 0.022)] @@ -2460,11 +2699,11 @@ public void Dispose() [InlineData("render-elems-01-t", 0.022)] [InlineData("render-elems-02-t", 0.022)] [InlineData("render-elems-03-t", 0.022)] - [InlineData("render-elems-06-t", 0.022, Skip = "Requires SVG/WOFF webfont loading for exact Chrome glyph outlines.")] - [InlineData("render-elems-07-t", 0.022, Skip = "Requires SVG/WOFF webfont loading for exact Chrome glyph outlines.")] - [InlineData("render-elems-08-t", 0.022, Skip = "Requires SVG/WOFF webfont loading for exact Chrome glyph outlines.")] - [InlineData("render-groups-01-b", 0.022, Skip = "Requires SVG/WOFF webfont loading for exact Chrome group text composition.")] - [InlineData("render-groups-03-t", 0.022, Skip = "Requires SVG/WOFF webfont loading for exact Chrome group text composition.")] + [InlineData("render-elems-06-t", 0.022)] + [InlineData("render-elems-07-t", 0.022)] + [InlineData("render-elems-08-t", 0.022)] + [InlineData("render-groups-01-b", 0.022)] + [InlineData("render-groups-03-t", 0.022)] [InlineData("script-handle-01-b", 0.022)] [InlineData("script-handle-02-b", 0.022)] [InlineData("script-handle-03-b", 0.022)] @@ -2591,7 +2830,7 @@ public void Dispose() [InlineData("text-align-05-b", 0.022)] [InlineData("text-align-06-b", 0.022)] [InlineData("text-align-07-t", 0.022)] - [InlineData("text-align-08-b", 0.022, Skip = "Mixed-script dominant baseline tables are not implemented")] + [InlineData("text-align-08-b", 0.022)] [InlineData("text-altglyph-01-b", 0.17)] [InlineData("text-altglyph-02-b", 0.05)] [InlineData("text-altglyph-03-b", 0.05)] @@ -2663,4 +2902,9 @@ public void Dispose() [InlineData("types-dom-svgstringlist-01-f", 0.022)] [InlineData("types-dom-svgtransformable-01-f", 0.022)] public void Tests(string name, double errorThreshold) => TestImpl(name, errorThreshold); + + [Theory] + [InlineData("struct-image-12-b")] + [InlineData("struct-use-08-b")] + public void BrowserUiAndRecursiveCapturePolicyRowsHaveSemanticCoverage(string name) => TestImpl(name, 0.022); } diff --git a/tests/Svg.Skia.UnitTests/resvgTests.cs b/tests/Svg.Skia.UnitTests/resvgTests.cs index c44996adeb..ea4bbaee2a 100644 --- a/tests/Svg.Skia.UnitTests/resvgTests.cs +++ b/tests/Svg.Skia.UnitTests/resvgTests.cs @@ -2,6 +2,7 @@ using System.Collections.Generic; using System.IO; using System.Linq; +using System.Reflection; using SixLabors.ImageSharp; using SixLabors.ImageSharp.PixelFormats; using SkiaSharp; @@ -14,13 +15,31 @@ namespace Svg.Skia.UnitTests; public class resvgTests : SvgUnitTest { private const double DefaultThreshold = 0.12; + private const int ExpectedTotalFixtureCount = 1730; + private const int ExpectedTextFixtureCount = 379; + private const int ExpectedNonTextFixtureCount = 1351; + private const int ExpectedResourceRenderingFixtureCount = 447; + private const int ExpectedCssStylingFixtureCount = 19; + private const int ExpectedEnabledNonTextFixtureCount = 466; + private const int ExpectedRemainingNonTextFixtureCount = 885; + private const string RemainingExtraFixtureSkipReason = + "Remaining resvg extra fixtures are explicit inventory rows (15); enable individual rows when backed by a tracked renderer bug or parity lane."; + private const string RemainingFilterFixtureSkipReason = + "Remaining resvg filter fixtures are explicit inventory rows (281); filter primitive parity is tracked by feature-specific renderer lanes."; + private const string RemainingMaskingFixtureSkipReason = + "Remaining resvg masking fixtures are explicit inventory rows (92); clip/mask parity is tracked by feature-specific renderer lanes."; + private const string RemainingPaintServerFixtureSkipReason = + "Remaining resvg paint-server fixtures are explicit inventory rows (148); gradient/pattern parity is tracked by feature-specific renderer lanes."; + private const string RemainingPaintingFixtureSkipReason = + "Remaining resvg painting fixtures are explicit inventory rows (115); paint operation parity is tracked by feature-specific renderer lanes."; + private const string RemainingShapeFixtureSkipReason = + "Remaining resvg shape fixtures are explicit inventory rows (69); shape/path geometry parity is tracked by feature-specific renderer lanes."; + private const string RemainingStructureFixtureSkipReason = + "Remaining resvg structure fixtures are explicit inventory rows (165); structure/use/image parity is tracked by feature-specific renderer lanes."; public static IEnumerable TextFixtureRows() => EnumerateFixtureRows("tests/text/"); - public static IEnumerable NonTextFixtureRows() - => EnumerateFixtureRows(excludePrefix: "tests/text/"); - public static IEnumerable ResourceRenderingFixtureRows() => EnumerateFixtureRows() .Where(static row => IsResourceRenderingFixture((string)row[0])); @@ -29,16 +48,32 @@ public static IEnumerable CssStylingFixtureRows() => EnumerateFixtureRows() .Where(static row => IsCssStylingFixture((string)row[0])); + public static IEnumerable RemainingExtraFixtureRows() + => EnumerateRemainingNonTextRows(ResvgFixtureArea.Extra, RemainingExtraFixtureSkipReason); + + public static IEnumerable RemainingFilterFixtureRows() + => EnumerateRemainingNonTextRows(ResvgFixtureArea.Filters, RemainingFilterFixtureSkipReason); + + public static IEnumerable RemainingMaskingFixtureRows() + => EnumerateRemainingNonTextRows(ResvgFixtureArea.Masking, RemainingMaskingFixtureSkipReason); + + public static IEnumerable RemainingPaintServerFixtureRows() + => EnumerateRemainingNonTextRows(ResvgFixtureArea.PaintServers, RemainingPaintServerFixtureSkipReason); + + public static IEnumerable RemainingPaintingFixtureRows() + => EnumerateRemainingNonTextRows(ResvgFixtureArea.Painting, RemainingPaintingFixtureSkipReason); + + public static IEnumerable RemainingShapeFixtureRows() + => EnumerateRemainingNonTextRows(ResvgFixtureArea.Shapes, RemainingShapeFixtureSkipReason); + + public static IEnumerable RemainingStructureFixtureRows() + => EnumerateRemainingNonTextRows(ResvgFixtureArea.Structure, RemainingStructureFixtureSkipReason); + [OSXTheory] [MemberData(nameof(TextFixtureRows))] public void text_fixtures(string relativeName, double errorThreshold) => TestImpl(relativeName, errorThreshold); - [OSXTheory(Skip = "Non-text resvg fixtures are tracked by resvg_fixture_inventory and enabled by feature area.")] - [MemberData(nameof(NonTextFixtureRows))] - public void non_text_fixtures(string relativeName, double errorThreshold) - => TestImpl(relativeName, errorThreshold); - [OSXTheory] [MemberData(nameof(ResourceRenderingFixtureRows))] public void resource_rendering_fixtures(string relativeName, double errorThreshold) @@ -49,6 +84,41 @@ public void resource_rendering_fixtures(string relativeName, double errorThresho public void css_styling_fixtures(string relativeName, double errorThreshold) => TestImpl(relativeName, errorThreshold); + [OSXTheory(Skip = RemainingExtraFixtureSkipReason)] + [MemberData(nameof(RemainingExtraFixtureRows))] + public void remaining_extra_fixtures(string relativeName, double errorThreshold, string skipReason) + => TestSkippedFixtureImpl(relativeName, errorThreshold, skipReason); + + [OSXTheory(Skip = RemainingFilterFixtureSkipReason)] + [MemberData(nameof(RemainingFilterFixtureRows))] + public void remaining_filter_fixtures(string relativeName, double errorThreshold, string skipReason) + => TestSkippedFixtureImpl(relativeName, errorThreshold, skipReason); + + [OSXTheory(Skip = RemainingMaskingFixtureSkipReason)] + [MemberData(nameof(RemainingMaskingFixtureRows))] + public void remaining_masking_fixtures(string relativeName, double errorThreshold, string skipReason) + => TestSkippedFixtureImpl(relativeName, errorThreshold, skipReason); + + [OSXTheory(Skip = RemainingPaintServerFixtureSkipReason)] + [MemberData(nameof(RemainingPaintServerFixtureRows))] + public void remaining_paint_server_fixtures(string relativeName, double errorThreshold, string skipReason) + => TestSkippedFixtureImpl(relativeName, errorThreshold, skipReason); + + [OSXTheory(Skip = RemainingPaintingFixtureSkipReason)] + [MemberData(nameof(RemainingPaintingFixtureRows))] + public void remaining_painting_fixtures(string relativeName, double errorThreshold, string skipReason) + => TestSkippedFixtureImpl(relativeName, errorThreshold, skipReason); + + [OSXTheory(Skip = RemainingShapeFixtureSkipReason)] + [MemberData(nameof(RemainingShapeFixtureRows))] + public void remaining_shape_fixtures(string relativeName, double errorThreshold, string skipReason) + => TestSkippedFixtureImpl(relativeName, errorThreshold, skipReason); + + [OSXTheory(Skip = RemainingStructureFixtureSkipReason)] + [MemberData(nameof(RemainingStructureFixtureRows))] + public void remaining_structure_fixtures(string relativeName, double errorThreshold, string skipReason) + => TestSkippedFixtureImpl(relativeName, errorThreshold, skipReason); + [Fact] public void resvg_fixture_inventory() { @@ -66,6 +136,97 @@ public void resvg_fixture_inventory() } } + [Fact] + public void resvg_remaining_non_text_fixture_inventory() + { + var fixtures = EnumerateFixtureNames().ToArray(); + var textFixtures = fixtures + .Where(static fixture => fixture.StartsWith("tests/text/", StringComparison.Ordinal)) + .ToArray(); + var nonTextFixtures = fixtures + .Where(static fixture => !fixture.StartsWith("tests/text/", StringComparison.Ordinal)) + .ToArray(); + var resourceRenderingFixtures = fixtures + .Where(static fixture => IsResourceRenderingFixture(fixture)) + .ToArray(); + var cssStylingFixtures = fixtures + .Where(static fixture => IsCssStylingFixture(fixture)) + .ToArray(); + var enabledNonTextFixtures = resourceRenderingFixtures + .Concat(cssStylingFixtures) + .Distinct(StringComparer.Ordinal) + .OrderBy(static fixture => fixture, StringComparer.Ordinal) + .ToArray(); + var remainingNonTextFixtures = EnumerateRemainingNonTextFixtureNames().ToArray(); + var accountedFixtures = textFixtures + .Concat(enabledNonTextFixtures) + .Concat(remainingNonTextFixtures) + .OrderBy(static fixture => fixture, StringComparer.Ordinal) + .ToArray(); + + Assert.Equal(ExpectedTotalFixtureCount, fixtures.Length); + Assert.Equal(ExpectedTextFixtureCount, textFixtures.Length); + Assert.Equal(ExpectedNonTextFixtureCount, nonTextFixtures.Length); + Assert.Equal(ExpectedResourceRenderingFixtureCount, resourceRenderingFixtures.Length); + Assert.Equal(ExpectedCssStylingFixtureCount, cssStylingFixtures.Length); + Assert.Equal(ExpectedEnabledNonTextFixtureCount, enabledNonTextFixtures.Length); + Assert.Equal(ExpectedRemainingNonTextFixtureCount, remainingNonTextFixtures.Length); + Assert.Equal(fixtures, accountedFixtures); + + foreach (var (area, expectedCount) in ExpectedRemainingFixtureAreaCounts) + { + var actualCount = remainingNonTextFixtures.Count(fixture => GetNonTextFixtureArea(fixture) == area); + Assert.Equal(expectedCount, actualCount); + } + } + + [Fact] + public void resvg_remaining_non_text_theories_are_explicit_feature_area_inventory() + { + var remainingTheoryNames = typeof(resvgTests) + .GetMethods(BindingFlags.Instance | BindingFlags.Public) + .Where(static method => method.GetCustomAttributes(typeof(OSXTheory), inherit: false).Length > 0) + .Where(static method => method.Name.StartsWith("remaining_", StringComparison.Ordinal)) + .Select(static method => method.Name) + .OrderBy(static name => name, StringComparer.Ordinal) + .ToArray(); + + Assert.Equal( + new[] + { + "remaining_extra_fixtures", + "remaining_filter_fixtures", + "remaining_masking_fixtures", + "remaining_paint_server_fixtures", + "remaining_painting_fixtures", + "remaining_shape_fixtures", + "remaining_structure_fixtures" + }, + remainingTheoryNames); + Assert.DoesNotContain( + typeof(resvgTests).GetMethods(BindingFlags.Instance | BindingFlags.Public), + static method => string.Equals(method.Name, "non_text_fixtures", StringComparison.Ordinal)); + + var skipReasons = new[] + { + RemainingExtraFixtureSkipReason, + RemainingFilterFixtureSkipReason, + RemainingMaskingFixtureSkipReason, + RemainingPaintServerFixtureSkipReason, + RemainingPaintingFixtureSkipReason, + RemainingShapeFixtureSkipReason, + RemainingStructureFixtureSkipReason + }; + + Assert.All(skipReasons, static reason => + { + Assert.Contains("explicit inventory rows", reason, StringComparison.Ordinal); + Assert.DoesNotContain("hardening", reason, StringComparison.OrdinalIgnoreCase); + Assert.DoesNotContain("browser-parity", reason, StringComparison.OrdinalIgnoreCase); + Assert.DoesNotContain("umbrella", reason, StringComparison.OrdinalIgnoreCase); + }); + } + private static IEnumerable EnumerateFixtureRows(string? includePrefix = null, string? excludePrefix = null) { foreach (var fixture in EnumerateFixtureNames()) @@ -84,6 +245,19 @@ private static IEnumerable EnumerateFixtureRows(string? includePrefix } } + private static IEnumerable EnumerateRemainingNonTextRows(ResvgFixtureArea area, string skipReason) + { + foreach (var fixture in EnumerateRemainingNonTextFixtureNames()) + { + if (GetNonTextFixtureArea(fixture) != area) + { + continue; + } + + yield return new object[] { fixture, GetEffectiveThreshold(fixture, DefaultThreshold), skipReason }; + } + } + private static IEnumerable EnumerateFixtureNames() { return EnumerateFixtureNames(GetResvgTestsRoot(), "tests") @@ -91,6 +265,36 @@ private static IEnumerable EnumerateFixtureNames() .OrderBy(x => x, StringComparer.Ordinal); } + private static IEnumerable EnumerateRemainingNonTextFixtureNames() + { + foreach (var fixture in EnumerateFixtureNames()) + { + if (fixture.StartsWith("tests/text/", StringComparison.Ordinal) || + IsResourceRenderingFixture(fixture) || + IsCssStylingFixture(fixture)) + { + continue; + } + + yield return fixture; + } + } + + private static ResvgFixtureArea GetNonTextFixtureArea(string relativeName) + { + return relativeName switch + { + var fixture when fixture.StartsWith("extra/", StringComparison.Ordinal) => ResvgFixtureArea.Extra, + var fixture when fixture.StartsWith("tests/filters/", StringComparison.Ordinal) => ResvgFixtureArea.Filters, + var fixture when fixture.StartsWith("tests/masking/", StringComparison.Ordinal) => ResvgFixtureArea.Masking, + var fixture when fixture.StartsWith("tests/paint-servers/", StringComparison.Ordinal) => ResvgFixtureArea.PaintServers, + var fixture when fixture.StartsWith("tests/painting/", StringComparison.Ordinal) => ResvgFixtureArea.Painting, + var fixture when fixture.StartsWith("tests/shapes/", StringComparison.Ordinal) => ResvgFixtureArea.Shapes, + var fixture when fixture.StartsWith("tests/structure/", StringComparison.Ordinal) => ResvgFixtureArea.Structure, + _ => throw new InvalidOperationException($"Unclassified resvg fixture: {relativeName}") + }; + } + private static IEnumerable EnumerateFixtureNames(string root, string directoryName) { var directory = Path.Combine(root, directoryName); @@ -160,6 +364,12 @@ private void TestImpl(string relativeName, double errorThreshold) } } + private void TestSkippedFixtureImpl(string relativeName, double errorThreshold, string skipReason) + { + Assert.NotEmpty(skipReason); + TestImpl(relativeName, errorThreshold); + } + private static SKColor ToSkColor(Rgba32 color) => new(color.R, color.G, color.B, color.A); @@ -188,6 +398,17 @@ private static bool IsResourceRenderingFixture(string relativeName) private static bool IsCssStylingFixture(string relativeName) => CssStylingFixtureNames.Contains(relativeName, StringComparer.Ordinal); + private static readonly (ResvgFixtureArea Area, int Count)[] ExpectedRemainingFixtureAreaCounts = + { + (ResvgFixtureArea.Extra, 15), + (ResvgFixtureArea.Filters, 281), + (ResvgFixtureArea.Masking, 92), + (ResvgFixtureArea.PaintServers, 148), + (ResvgFixtureArea.Painting, 115), + (ResvgFixtureArea.Shapes, 69), + (ResvgFixtureArea.Structure, 165) + }; + private static readonly string[] ResourceRenderingFixturePrefixes = { "tests/filters/feComponentTransfer/", @@ -357,4 +578,15 @@ private static string GetSafeName(string relativeName) return string.Join("_", safeName.Split(Path.GetInvalidFileNameChars(), StringSplitOptions.RemoveEmptyEntries)); } + + private enum ResvgFixtureArea + { + Extra, + Filters, + Masking, + PaintServers, + Painting, + Shapes, + Structure + } }