From 35d2cd6a08fbc2fe78fe9d669976f891a4f4a323 Mon Sep 17 00:00:00 2001 From: James Jackson-South Date: Wed, 22 Jul 2026 09:37:44 +1000 Subject: [PATCH 1/2] Add baseline anchoring and visible-bounds culling Introduces configurable text/glyph baseline anchoring via new `TextBaseline` options and wires it through layout, rendering, and metrics. Adds OpenType `BASE` table loading plus OS/2 x-height/cap-height support so hanging/ideographic and related baselines can use font-provided coordinates with metric fallbacks. Rendering now supports visible-region culling for text blocks, one-shot text rendering, and glyph runs (including early line-break stop when safe). Measurement behavior was aligned with rendering semantics (zero-based logical advance, positioned renderable bounds), and comprehensive tests/reference outputs were added, including new baseline browser comparison fixtures and a Noto Sans SC baseline subset font. --- src/SixLabors.Fonts/FileFontMetrics.cs | 10 + src/SixLabors.Fonts/FontMetrics.cs | 26 + src/SixLabors.Fonts/GlyphOptions.cs | 19 + src/SixLabors.Fonts/MemoryFontMetrics.cs | 10 + src/SixLabors.Fonts/Rendering/TextRenderer.cs | 169 ++++++- src/SixLabors.Fonts/StreamFontMetrics.Cff.cs | 2 + .../StreamFontMetrics.TrueType.cs | 2 + src/SixLabors.Fonts/StreamFontMetrics.cs | 26 + .../Tables/AdvancedTypographic/BaseTable.cs | 458 +++++++++++++++++ .../Tables/Cff/CompactFontTables.cs | 3 + .../Tables/General/OS2Table.cs | 22 +- src/SixLabors.Fonts/Tables/IFontTables.cs | 2 + src/SixLabors.Fonts/Tables/TableLoader.cs | 1 + .../Tables/TrueType/TrueTypeFontTables.cs | 3 + src/SixLabors.Fonts/TextBaseline.cs | 59 +++ src/SixLabors.Fonts/TextBlock.Visitors.cs | 4 +- src/SixLabors.Fonts/TextBlock.cs | 68 ++- src/SixLabors.Fonts/TextLayout.cs | 474 ++++++++++++++---- src/SixLabors.Fonts/TextMeasurer.cs | 145 +++--- src/SixLabors.Fonts/TextOptions.cs | 25 + tests/Browser/TextBaseline.html | 379 ++++++++++++++ .../NotoSansSC-BaselineSubset.ttf | 3 + tests/Fonts/Noto_Sans_SC/OFL.txt | 93 ++++ ...phemeSelections_VerticalMixedLeftRight.png | 4 +- ...phemeSelections_VerticalMixedRightLeft.png | 4 +- ..._DrawsLineBoxes_VerticalMixedLeftRight.png | 4 +- ..._DrawsLineBoxes_VerticalMixedRightLeft.png | 4 +- ...ight_350-_height_279.125-width_11.438_.png | 4 +- ...LeftRight_350-_height_87.125-width_10_.png | 4 +- .../RendersAnchoredToReference_Alphabetic.png | 3 + .../RendersAnchoredToReference_Central.png | 3 + ...erence_CjkVerticalLeftRight_Alphabetic.png | 3 + ...Reference_CjkVerticalLeftRight_Central.png | 3 + ...Reference_CjkVerticalLeftRight_Hanging.png | 3 + ...rence_CjkVerticalLeftRight_Ideographic.png | 3 + ...Reference_CjkVerticalLeftRight_LineBox.png | 3 + ...oReference_CjkVerticalLeftRight_Middle.png | 3 + ...erence_CjkVerticalLeftRight_TextBottom.png | 3 + ...Reference_CjkVerticalLeftRight_TextTop.png | 3 + ...e_CjkVerticalMixedLeftRight_Alphabetic.png | 3 + ...ence_CjkVerticalMixedLeftRight_Central.png | 3 + ...ence_CjkVerticalMixedLeftRight_Hanging.png | 3 + ..._CjkVerticalMixedLeftRight_Ideographic.png | 3 + ...ence_CjkVerticalMixedLeftRight_LineBox.png | 3 + ...rence_CjkVerticalMixedLeftRight_Middle.png | 3 + ...e_CjkVerticalMixedLeftRight_TextBottom.png | 3 + ...ence_CjkVerticalMixedLeftRight_TextTop.png | 3 + ...dersAnchoredToReference_Cjk_Alphabetic.png | 3 + ...RendersAnchoredToReference_Cjk_Central.png | 3 + ...RendersAnchoredToReference_Cjk_Hanging.png | 3 + ...ersAnchoredToReference_Cjk_Ideographic.png | 3 + ...RendersAnchoredToReference_Cjk_LineBox.png | 3 + .../RendersAnchoredToReference_Cjk_Middle.png | 3 + ...dersAnchoredToReference_Cjk_TextBottom.png | 3 + ...RendersAnchoredToReference_Cjk_TextTop.png | 3 + .../RendersAnchoredToReference_Hanging.png | 3 + ...RendersAnchoredToReference_Ideographic.png | 3 + .../RendersAnchoredToReference_LineBox.png | 3 + .../RendersAnchoredToReference_Middle.png | 3 + .../RendersAnchoredToReference_TextBottom.png | 3 + .../RendersAnchoredToReference_TextTop.png | 3 + ...Reference_VerticalLeftRight_Alphabetic.png | 3 + ...dToReference_VerticalLeftRight_Central.png | 3 + ...dToReference_VerticalLeftRight_Hanging.png | 3 + ...eference_VerticalLeftRight_Ideographic.png | 3 + ...dToReference_VerticalLeftRight_LineBox.png | 3 + ...edToReference_VerticalLeftRight_Middle.png | 3 + ...Reference_VerticalLeftRight_TextBottom.png | 3 + ...dToReference_VerticalLeftRight_TextTop.png | 3 + ...ence_VerticalMixedLeftRight_Alphabetic.png | 3 + ...ference_VerticalMixedLeftRight_Central.png | 3 + ...ference_VerticalMixedLeftRight_Hanging.png | 3 + ...nce_VerticalMixedLeftRight_Ideographic.png | 3 + ...ference_VerticalMixedLeftRight_LineBox.png | 3 + ...eference_VerticalMixedLeftRight_Middle.png | 3 + ...ence_VerticalMixedLeftRight_TextBottom.png | 3 + ...ference_VerticalMixedLeftRight_TextTop.png | 3 + .../SixLabors.Fonts.Tests/Issues/Issues_33.cs | 2 +- .../AdvancedTypographic/BaseTableTests.cs | 392 +++++++++++++++ tests/SixLabors.Fonts.Tests/TestFonts.cs | 4 + .../TextBaselineTests.cs | 401 +++++++++++++++ tests/SixLabors.Fonts.Tests/TextBlockTests.cs | 95 ++++ .../TextLayoutTestUtilities.cs | 2 + .../TextMeasurerGlyphIdTests.cs | 154 +++++- .../TextRendererGlyphIdTests.cs | 35 ++ .../TextRendererTests.cs | 117 +++++ 86 files changed, 3169 insertions(+), 204 deletions(-) create mode 100644 src/SixLabors.Fonts/Tables/AdvancedTypographic/BaseTable.cs create mode 100644 src/SixLabors.Fonts/TextBaseline.cs create mode 100644 tests/Browser/TextBaseline.html create mode 100644 tests/Fonts/Noto_Sans_SC/NotoSansSC-BaselineSubset.ttf create mode 100644 tests/Fonts/Noto_Sans_SC/OFL.txt create mode 100644 tests/Images/ReferenceOutput/RendersAnchoredToReference_Alphabetic.png create mode 100644 tests/Images/ReferenceOutput/RendersAnchoredToReference_Central.png create mode 100644 tests/Images/ReferenceOutput/RendersAnchoredToReference_CjkVerticalLeftRight_Alphabetic.png create mode 100644 tests/Images/ReferenceOutput/RendersAnchoredToReference_CjkVerticalLeftRight_Central.png create mode 100644 tests/Images/ReferenceOutput/RendersAnchoredToReference_CjkVerticalLeftRight_Hanging.png create mode 100644 tests/Images/ReferenceOutput/RendersAnchoredToReference_CjkVerticalLeftRight_Ideographic.png create mode 100644 tests/Images/ReferenceOutput/RendersAnchoredToReference_CjkVerticalLeftRight_LineBox.png create mode 100644 tests/Images/ReferenceOutput/RendersAnchoredToReference_CjkVerticalLeftRight_Middle.png create mode 100644 tests/Images/ReferenceOutput/RendersAnchoredToReference_CjkVerticalLeftRight_TextBottom.png create mode 100644 tests/Images/ReferenceOutput/RendersAnchoredToReference_CjkVerticalLeftRight_TextTop.png create mode 100644 tests/Images/ReferenceOutput/RendersAnchoredToReference_CjkVerticalMixedLeftRight_Alphabetic.png create mode 100644 tests/Images/ReferenceOutput/RendersAnchoredToReference_CjkVerticalMixedLeftRight_Central.png create mode 100644 tests/Images/ReferenceOutput/RendersAnchoredToReference_CjkVerticalMixedLeftRight_Hanging.png create mode 100644 tests/Images/ReferenceOutput/RendersAnchoredToReference_CjkVerticalMixedLeftRight_Ideographic.png create mode 100644 tests/Images/ReferenceOutput/RendersAnchoredToReference_CjkVerticalMixedLeftRight_LineBox.png create mode 100644 tests/Images/ReferenceOutput/RendersAnchoredToReference_CjkVerticalMixedLeftRight_Middle.png create mode 100644 tests/Images/ReferenceOutput/RendersAnchoredToReference_CjkVerticalMixedLeftRight_TextBottom.png create mode 100644 tests/Images/ReferenceOutput/RendersAnchoredToReference_CjkVerticalMixedLeftRight_TextTop.png create mode 100644 tests/Images/ReferenceOutput/RendersAnchoredToReference_Cjk_Alphabetic.png create mode 100644 tests/Images/ReferenceOutput/RendersAnchoredToReference_Cjk_Central.png create mode 100644 tests/Images/ReferenceOutput/RendersAnchoredToReference_Cjk_Hanging.png create mode 100644 tests/Images/ReferenceOutput/RendersAnchoredToReference_Cjk_Ideographic.png create mode 100644 tests/Images/ReferenceOutput/RendersAnchoredToReference_Cjk_LineBox.png create mode 100644 tests/Images/ReferenceOutput/RendersAnchoredToReference_Cjk_Middle.png create mode 100644 tests/Images/ReferenceOutput/RendersAnchoredToReference_Cjk_TextBottom.png create mode 100644 tests/Images/ReferenceOutput/RendersAnchoredToReference_Cjk_TextTop.png create mode 100644 tests/Images/ReferenceOutput/RendersAnchoredToReference_Hanging.png create mode 100644 tests/Images/ReferenceOutput/RendersAnchoredToReference_Ideographic.png create mode 100644 tests/Images/ReferenceOutput/RendersAnchoredToReference_LineBox.png create mode 100644 tests/Images/ReferenceOutput/RendersAnchoredToReference_Middle.png create mode 100644 tests/Images/ReferenceOutput/RendersAnchoredToReference_TextBottom.png create mode 100644 tests/Images/ReferenceOutput/RendersAnchoredToReference_TextTop.png create mode 100644 tests/Images/ReferenceOutput/RendersAnchoredToReference_VerticalLeftRight_Alphabetic.png create mode 100644 tests/Images/ReferenceOutput/RendersAnchoredToReference_VerticalLeftRight_Central.png create mode 100644 tests/Images/ReferenceOutput/RendersAnchoredToReference_VerticalLeftRight_Hanging.png create mode 100644 tests/Images/ReferenceOutput/RendersAnchoredToReference_VerticalLeftRight_Ideographic.png create mode 100644 tests/Images/ReferenceOutput/RendersAnchoredToReference_VerticalLeftRight_LineBox.png create mode 100644 tests/Images/ReferenceOutput/RendersAnchoredToReference_VerticalLeftRight_Middle.png create mode 100644 tests/Images/ReferenceOutput/RendersAnchoredToReference_VerticalLeftRight_TextBottom.png create mode 100644 tests/Images/ReferenceOutput/RendersAnchoredToReference_VerticalLeftRight_TextTop.png create mode 100644 tests/Images/ReferenceOutput/RendersAnchoredToReference_VerticalMixedLeftRight_Alphabetic.png create mode 100644 tests/Images/ReferenceOutput/RendersAnchoredToReference_VerticalMixedLeftRight_Central.png create mode 100644 tests/Images/ReferenceOutput/RendersAnchoredToReference_VerticalMixedLeftRight_Hanging.png create mode 100644 tests/Images/ReferenceOutput/RendersAnchoredToReference_VerticalMixedLeftRight_Ideographic.png create mode 100644 tests/Images/ReferenceOutput/RendersAnchoredToReference_VerticalMixedLeftRight_LineBox.png create mode 100644 tests/Images/ReferenceOutput/RendersAnchoredToReference_VerticalMixedLeftRight_Middle.png create mode 100644 tests/Images/ReferenceOutput/RendersAnchoredToReference_VerticalMixedLeftRight_TextBottom.png create mode 100644 tests/Images/ReferenceOutput/RendersAnchoredToReference_VerticalMixedLeftRight_TextTop.png create mode 100644 tests/SixLabors.Fonts.Tests/Tables/AdvancedTypographic/BaseTableTests.cs create mode 100644 tests/SixLabors.Fonts.Tests/TextBaselineTests.cs create mode 100644 tests/SixLabors.Fonts.Tests/TextRendererTests.cs diff --git a/src/SixLabors.Fonts/FileFontMetrics.cs b/src/SixLabors.Fonts/FileFontMetrics.cs index 80208c7c8..b0f9a1993 100644 --- a/src/SixLabors.Fonts/FileFontMetrics.cs +++ b/src/SixLabors.Fonts/FileFontMetrics.cs @@ -106,6 +106,12 @@ private FileFontMetrics(FontDescription description, string path, long offset) /// public override short StrikeoutSize => this.fontMetrics.Value.StrikeoutSize; + /// + public override short XHeight => this.fontMetrics.Value.XHeight; + + /// + public override short CapHeight => this.fontMetrics.Value.CapHeight; + /// public override short StrikeoutPosition => this.fontMetrics.Value.StrikeoutPosition; @@ -196,6 +202,10 @@ public override ReadOnlyMemory GetAvailableCodePoints() internal override bool TryGetGSubTable([NotNullWhen(true)] out GSubTable? gSubTable) => this.fontMetrics.Value.TryGetGSubTable(out gSubTable); + /// + internal override bool TryGetBaselineCoordinate(Tag baselineTag, bool isVerticalLayout, out short coordinate) + => this.fontMetrics.Value.TryGetBaselineCoordinate(baselineTag, isVerticalLayout, out coordinate); + /// internal override void ApplySubstitution(GlyphSubstitutionCollection collection) => this.fontMetrics.Value.ApplySubstitution(collection); diff --git a/src/SixLabors.Fonts/FontMetrics.cs b/src/SixLabors.Fonts/FontMetrics.cs index 7ec278a86..999d400c5 100644 --- a/src/SixLabors.Fonts/FontMetrics.cs +++ b/src/SixLabors.Fonts/FontMetrics.cs @@ -89,6 +89,16 @@ internal FontMetrics() /// public abstract short StrikeoutSize { get; } + /// + /// Gets the x-height in font design units, or 0 when the font does not provide it. + /// + public abstract short XHeight { get; } + + /// + /// Gets the cap height in font design units, or 0 when the font does not provide it. + /// + public abstract short CapHeight { get; } + /// /// Gets the position of the top of the strikeout stroke relative to the baseline in font design units. /// @@ -289,6 +299,22 @@ internal abstract FontGlyphMetrics GetGlyphMetrics( /// true, if the glyph class could be retrieved. internal abstract bool TryGetGSubTable([NotNullWhen(true)] out GSubTable? gSubTable); + /// + /// Tries to get the coordinate of the named baseline from the font's baseline table for + /// the given layout direction, read from the default script record of the matching axis. + /// + /// The baseline identification tag, for example 'hang' or 'ideo'. + /// + /// Whether to read the vertical axis, whose coordinates are X values, rather than the + /// horizontal axis, whose coordinates are Y values. + /// + /// + /// The baseline coordinate in design units, measured from the zero position on the + /// relevant axis. + /// + /// when the font defines the named baseline; otherwise . + internal abstract bool TryGetBaselineCoordinate(Tag baselineTag, bool isVerticalLayout, out short coordinate); + /// /// Applies any available substitutions to the collection of glyphs. /// diff --git a/src/SixLabors.Fonts/GlyphOptions.cs b/src/SixLabors.Fonts/GlyphOptions.cs index 89bfa4921..94ade76c8 100644 --- a/src/SixLabors.Fonts/GlyphOptions.cs +++ b/src/SixLabors.Fonts/GlyphOptions.cs @@ -50,6 +50,25 @@ public float Dpi /// public Vector2 Origin { get; set; } = Vector2.Zero; + /// + /// Gets or sets the visible region in pixel units (px) used when rendering the glyph. + /// A glyph that falls outside the region is skipped. + /// + /// + /// If value is then culling is disabled. + /// + public FontRectangle? VisibleBounds { get; set; } + + /// + /// Gets or sets which reference line of the glyph's em box is placed at + /// along the block flow axis. + /// + /// + /// Baseline positions derive from the metrics of . For a single glyph + /// anchors the top of the em box, matching text layout. + /// + public TextBaseline TextBaseline { get; set; } + /// /// Gets or sets the zero-based grapheme cluster index represented by the glyph. /// diff --git a/src/SixLabors.Fonts/MemoryFontMetrics.cs b/src/SixLabors.Fonts/MemoryFontMetrics.cs index 1e89ab498..dc83badc3 100644 --- a/src/SixLabors.Fonts/MemoryFontMetrics.cs +++ b/src/SixLabors.Fonts/MemoryFontMetrics.cs @@ -100,6 +100,12 @@ private MemoryFontMetrics(FontDescription description, byte[] data, long offset) /// public override short StrikeoutSize => this.fontMetrics.Value.StrikeoutSize; + /// + public override short XHeight => this.fontMetrics.Value.XHeight; + + /// + public override short CapHeight => this.fontMetrics.Value.CapHeight; + /// public override short StrikeoutPosition => this.fontMetrics.Value.StrikeoutPosition; @@ -190,6 +196,10 @@ public override ReadOnlyMemory GetAvailableCodePoints() internal override bool TryGetGSubTable([NotNullWhen(true)] out GSubTable? gSubTable) => this.fontMetrics.Value.TryGetGSubTable(out gSubTable); + /// + internal override bool TryGetBaselineCoordinate(Tag baselineTag, bool isVerticalLayout, out short coordinate) + => this.fontMetrics.Value.TryGetBaselineCoordinate(baselineTag, isVerticalLayout, out coordinate); + /// internal override void ApplySubstitution(GlyphSubstitutionCollection collection) => this.fontMetrics.Value.ApplySubstitution(collection); diff --git a/src/SixLabors.Fonts/Rendering/TextRenderer.cs b/src/SixLabors.Fonts/Rendering/TextRenderer.cs index f50879fc1..4515623d3 100644 --- a/src/SixLabors.Fonts/Rendering/TextRenderer.cs +++ b/src/SixLabors.Fonts/Rendering/TextRenderer.cs @@ -76,8 +76,16 @@ public void Render(string text, TextOptions options) /// The text options. controls wrapping; use -1 to disable wrapping. public void Render(ReadOnlySpan text, TextOptions options) { - TextBlock block = new(text, options); - block.RenderTo(this.renderer, options.WrappingLength); + if (text.IsEmpty) + { + this.renderer.BeginText(FontRectangle.Empty); + this.renderer.EndText(); + return; + } + + ShapedText shaped = TextLayout.ShapeText(text, options); + LogicalTextLine logicalLine = TextLayout.ComposeLogicalLine(shaped, text, options); + this.RenderText(logicalLine, options); } /// @@ -99,11 +107,49 @@ public void Render(ushort glyphId, GlyphOptions options) return; } - TextRun textRun = options.CreateTextRun(); - FontGlyphMetrics renderMetrics = metrics.CloneForRendering(textRun); Vector2 origin = options.Origin / options.Dpi; GlyphLayoutMode glyphLayoutMode = options.GetGlyphLayoutMode(metrics.CodePoint); + if (glyphLayoutMode == GlyphLayoutMode.Horizontal) + { + // The renderer positions glyphs by their alphabetic baseline; shifting the origin + // by the selected reference line's offset from that baseline puts the reference on + // the caller's origin. + origin.Y -= TextLayout.GetBaselineOffset(options.TextBaseline, options.Font, false); + } + else + { + // Vertical rendering positions glyphs about the column's central axis; the same + // shift applies along X from that axis. + origin.X -= TextLayout.GetBaselineOffset(options.TextBaseline, options.Font, true); + } + + if (options.VisibleBounds is FontRectangle visibleBounds) + { + // The origin is the baseline-anchored render position in layout units (pixels + // divided by DPI), so the ink box computed against it compares directly with the + // scaled region. Inflating by the scaled line height for the glyph's orientation + // gives em-box anchored decorations the same tolerance culled text receives, and + // rejecting here also skips the per-glyph metrics clone below. + FontRectangle box = metrics.GetBoundingBox(glyphLayoutMode, origin, options.Font.Size); + IMetricsHeader metricsHeader = glyphLayoutMode == GlyphLayoutMode.Vertical + ? fontMetrics.VerticalMetrics + : fontMetrics.HorizontalMetrics; + + float tolerance = metricsHeader.LineHeight * (options.Font.Size / metrics.ScaleFactor.Y); + float dpi = options.Dpi; + if (box.Right + tolerance < visibleBounds.Left / dpi || + box.Left - tolerance > visibleBounds.Right / dpi || + box.Bottom + tolerance < visibleBounds.Top / dpi || + box.Top - tolerance > visibleBounds.Bottom / dpi) + { + return; + } + } + + TextRun textRun = options.CreateTextRun(); + FontGlyphMetrics renderMetrics = metrics.CloneForRendering(textRun); + renderMetrics.RenderTo( this.renderer, options.GraphemeIndex, @@ -151,4 +197,119 @@ public void Render(GlyphRun glyphRun, GlyphOptions options) options.GraphemeIndex = originalGraphemeIndex; } } + + /// + /// Line-breaks and renders prepared text without retaining any layout state. When + /// is set, whole lines outside the region are + /// culled and breaking stops at the region when line order and alignment permit. + /// + /// + /// A full render reports the rendered ink bounds to , + /// matching . A culled render + /// reports the logical advance bounds of the broken lines instead: ink bounds would cost + /// an extra pass over the visible glyphs and are unknowable once breaking stops early. + /// + /// The prepared logical line and line break opportunities. + /// The text options used for layout and rendering. + private void RenderText(in LogicalTextLine logicalLine, TextOptions options) + { + float wrappingLength = options.WrappingLength; + float dpi = options.Dpi; + bool isHorizontal = options.LayoutMode.IsHorizontal(); + + float visibleFlowMin = float.NegativeInfinity; + float visibleFlowMax = float.PositiveInfinity; + if (options.VisibleBounds is FontRectangle visibleBounds) + { + visibleFlowMin = (isHorizontal ? visibleBounds.Top : visibleBounds.Left) / dpi; + visibleFlowMax = (isHorizontal ? visibleBounds.Bottom : visibleBounds.Right) / dpi; + } + + TextBox textBox = BreakVisibleLines(logicalLine, options, wrappingLength, isHorizontal, visibleFlowMax); + if (textBox.TextLines.Count == 0) + { + this.renderer.BeginText(FontRectangle.Empty); + this.renderer.EndText(); + return; + } + + FontRectangle bounds; + if (options.VisibleBounds is null) + { + // The full render keeps the shipped BeginText contract: the rendered ink bounds, + // accumulated in the same measuring pass TextBlock uses. + TextBlock.RenderedRectangleAccumulator accumulator = new(dpi); + TextLayout.LayoutText(textBox, options, wrappingLength, ref accumulator); + bounds = accumulator.Result(); + } + else + { + // The logical advance box comes straight from the line aggregates in one scan + // over the broken lines. + FontRectangle advance = TextBlock.GetAdvance(textBox, dpi, isHorizontal); + bounds = new(options.Origin.X, options.Origin.Y, advance.Width, advance.Height); + } + + this.renderer.BeginText(in bounds); + + TextBlock.GlyphRendererVisitor visitor = new(this.renderer, options, -1); + TextLayout.LayoutText(textBox, options, wrappingLength, visibleFlowMin, visibleFlowMax, ref visitor); + + this.renderer.EndText(); + } + + /// + /// Line-breaks prepared text, stopping once the flow position passes the visible band + /// when line order and alignment permit. An infinite band breaks every line. + /// + /// The prepared logical line and line break opportunities. + /// The text options used for layout. + /// The wrapping length in pixels. Use -1 to disable wrapping. + /// Whether the layout direction is horizontal. + /// The upper visible-band edge along the block flow axis in layout units. + /// The line-broken text box, possibly truncated after the visible band. + private static TextBox BreakVisibleLines( + in LogicalTextLine logicalLine, + TextOptions options, + float wrappingLength, + bool isHorizontal, + float visibleFlowMax) + { + TextDirection textDirection = TextLayout.GetTextDirection(logicalLine, options); + if (TextLayout.LayoutRequiresFullLineSet(options, textDirection)) + { + return TextLayout.BreakLines(logicalLine, options, wrappingLength); + } + + // A line can never be taller than the tallest entry in the prepared text, and + // TextLine.Add keeps that maximum current while the logical line is composed. Once the + // running flow position is more than two of those heights past the end of the visible + // band, no later line can still be visible: one height covers the line itself, the + // other covers the walk's one-line-height visibility tolerance. Line spacing below one + // shifts each line upward when centering it, so that shift widens the margin too. + float maxLineExtent = logicalLine.TextLine.ScaledMaxLineHeight; + float stopSlack = maxLineExtent * 2F; + if (options.LineSpacing < 1F) + { + stopSlack += maxLineExtent * (1F - options.LineSpacing) / (2F * options.LineSpacing); + } + + List textLines = []; + TextLineBreakEnumerator lineEnumerator = new(logicalLine, options); + float flowPosition = (isHorizontal ? options.Origin.Y : options.Origin.X) / options.Dpi; + + while (lineEnumerator.MoveNext(wrappingLength)) + { + TextLine line = lineEnumerator.Current; + textLines.Add(line); + flowPosition += line.ScaledMaxLineHeight; + + if (flowPosition - visibleFlowMax > stopSlack) + { + break; + } + } + + return new TextBox(textLines, textDirection); + } } diff --git a/src/SixLabors.Fonts/StreamFontMetrics.Cff.cs b/src/SixLabors.Fonts/StreamFontMetrics.Cff.cs index 61a71c45f..d09fb89d2 100644 --- a/src/SixLabors.Fonts/StreamFontMetrics.Cff.cs +++ b/src/SixLabors.Fonts/StreamFontMetrics.Cff.cs @@ -48,6 +48,7 @@ private static StreamFontMetrics LoadCompactFont(FontReader reader, FontSource s KerningTable? kern = reader.TryGetTable(); + BaseTable? baseTable = reader.TryGetTable(); GlyphDefinitionTable? gdef = reader.TryGetTable(); GSubTable? gSub = reader.TryGetTable(); GPosTable? gPos = reader.TryGetTable(); @@ -80,6 +81,7 @@ private static StreamFontMetrics LoadCompactFont(FontReader reader, FontSource s Kern = kern, Vhea = vhea, Vmtx = vmtx, + Base = baseTable, Gdef = gdef, GSub = gSub, GPos = gPos, diff --git a/src/SixLabors.Fonts/StreamFontMetrics.TrueType.cs b/src/SixLabors.Fonts/StreamFontMetrics.TrueType.cs index a5ec9e737..0c3fe0efc 100644 --- a/src/SixLabors.Fonts/StreamFontMetrics.TrueType.cs +++ b/src/SixLabors.Fonts/StreamFontMetrics.TrueType.cs @@ -123,6 +123,7 @@ private static StreamFontMetrics LoadTrueTypeFont(FontReader reader, FontSource vmtx = reader.TryGetTable(); } + BaseTable? baseTable = reader.TryGetTable(); GlyphDefinitionTable? gdef = reader.TryGetTable(); GSubTable? gSub = reader.TryGetTable(); GPosTable? gPos = reader.TryGetTable(); @@ -154,6 +155,7 @@ private static StreamFontMetrics LoadTrueTypeFont(FontReader reader, FontSource Kern = kern, Vhea = vhea, Vmtx = vmtx, + Base = baseTable, Gdef = gdef, GSub = gSub, GPos = gPos, diff --git a/src/SixLabors.Fonts/StreamFontMetrics.cs b/src/SixLabors.Fonts/StreamFontMetrics.cs index 35dd5b554..bcd197f41 100644 --- a/src/SixLabors.Fonts/StreamFontMetrics.cs +++ b/src/SixLabors.Fonts/StreamFontMetrics.cs @@ -52,6 +52,8 @@ internal partial class StreamFontMetrics : FontMetrics private short superscriptYOffset; private short strikeoutSize; private short strikeoutPosition; + private short xHeight; + private short capHeight; private short underlinePosition; private short underlineThickness; private float italicAngle; @@ -206,6 +208,12 @@ private StreamFontMetrics( /// public override short StrikeoutSize => this.strikeoutSize; + /// + public override short XHeight => this.xHeight; + + /// + public override short CapHeight => this.capHeight; + /// public override short StrikeoutPosition => this.strikeoutPosition; @@ -426,6 +434,22 @@ internal override bool TryGetGSubTable([NotNullWhen(true)] out GSubTable? gSubTa return gSubTable is not null; } + /// + internal override bool TryGetBaselineCoordinate(Tag baselineTag, bool isVerticalLayout, out short coordinate) + { + BaseTable? baseTable = this.outlineType == OutlineType.TrueType + ? this.trueTypeFontTables!.Base + : this.compactFontTables!.Base; + + if (baseTable is null) + { + coordinate = 0; + return false; + } + + return baseTable.TryGetBaselineCoordinate(baselineTag, isVerticalLayout, out coordinate); + } + /// internal override void ApplySubstitution(GlyphSubstitutionCollection collection) { @@ -640,6 +664,8 @@ private static StreamFontMetrics LoadFont(FontReader reader, FontSource source) this.superscriptYOffset = os2.SuperscriptYOffset; this.strikeoutSize = os2.StrikeoutSize; this.strikeoutPosition = os2.StrikeoutPosition; + this.xHeight = os2.XHeight; + this.capHeight = os2.CapHeight; this.underlinePosition = post.UnderlinePosition; this.underlineThickness = post.UnderlineThickness; this.italicAngle = post.ItalicAngle; diff --git a/src/SixLabors.Fonts/Tables/AdvancedTypographic/BaseTable.cs b/src/SixLabors.Fonts/Tables/AdvancedTypographic/BaseTable.cs new file mode 100644 index 000000000..3a41ba6f0 --- /dev/null +++ b/src/SixLabors.Fonts/Tables/AdvancedTypographic/BaseTable.cs @@ -0,0 +1,458 @@ +// Copyright (c) Six Labors. +// Licensed under the Six Labors Split License. + +namespace SixLabors.Fonts.Tables.AdvancedTypographic; + +/// +/// The Baseline table (BASE) provides information used to align glyphs of different scripts +/// and sizes in a line of text, whether the glyphs are in the same font or in different fonts. +/// +/// +internal sealed class BaseTable : Table +{ + /// + /// The OpenType table tag for the BASE table. + /// + internal const string TableName = "BASE"; + + /// + /// Initializes a new instance of the class. + /// + /// The horizontal axis table, or if not present. + /// The vertical axis table, or if not present. + public BaseTable(BaseAxisTable? horizontalAxis, BaseAxisTable? verticalAxis) + { + this.HorizontalAxis = horizontalAxis; + this.VerticalAxis = verticalAxis; + } + + /// + /// Gets the axis table holding baseline data for horizontal text layout, where baseline + /// coordinates are Y values, or if the font provides none. + /// + public BaseAxisTable? HorizontalAxis { get; } + + /// + /// Gets the axis table holding baseline data for vertical text layout, where baseline + /// coordinates are X values, or if the font provides none. + /// + public BaseAxisTable? VerticalAxis { get; } + + /// + /// Tries to get the coordinate of the named baseline for the given layout direction from + /// the default script record of the matching axis. + /// + /// The baseline identification tag, for example 'hang' or 'ideo'. + /// + /// Whether to read the vertical axis, whose coordinates are X values, rather than the + /// horizontal axis, whose coordinates are Y values. + /// + /// + /// The baseline coordinate in design units, measured from the zero position on the + /// relevant axis. + /// + /// when the axis defines the named baseline; otherwise . + public bool TryGetBaselineCoordinate(Tag baselineTag, bool isVerticalLayout, out short coordinate) + { + BaseAxisTable? axis = isVerticalLayout + ? this.VerticalAxis + : this.HorizontalAxis; + + if (axis is null) + { + coordinate = 0; + return false; + } + + return axis.TryGetBaselineCoordinate(baselineTag, out coordinate); + } + + /// + /// Loads the from the font reader. + /// + /// The font reader. + /// The , or if not present. + public static BaseTable? Load(FontReader fontReader) + { + if (!fontReader.TryGetReaderAtTablePosition(TableName, out BigEndianBinaryReader? binaryReader)) + { + return null; + } + + using (binaryReader) + { + return Load(binaryReader); + } + } + + /// + /// Loads the from a big endian binary reader. + /// + /// The big endian binary reader. + /// The . + internal static BaseTable Load(BigEndianBinaryReader reader) + { + // BASE Header, Version 1.0 + // +----------+-----------------+--------------------------------------------------------------------------+ + // | Type | Name | Description | + // +==========+=================+==========================================================================+ + // | uint16 | majorVersion | Major version of the BASE table, = 1 | + // +----------+-----------------+--------------------------------------------------------------------------+ + // | uint16 | minorVersion | Minor version of the BASE table, = 0 | + // +----------+-----------------+--------------------------------------------------------------------------+ + // | Offset16 | horizAxisOffset | Offset to horizontal Axis table, from beginning of BASE table (may be NULL) | + // +----------+-----------------+--------------------------------------------------------------------------+ + // | Offset16 | vertAxisOffset | Offset to vertical Axis table, from beginning of BASE table (may be NULL) | + // +----------+-----------------+--------------------------------------------------------------------------+ + + // BASE Header, Version 1.1 + // +----------+--------------------+-------------------------------------------------------------------------------+ + // | Type | Name | Description | + // +==========+====================+===============================================================================+ + // | uint16 | majorVersion | Major version of the BASE table, = 1 | + // +----------+--------------------+-------------------------------------------------------------------------------+ + // | uint16 | minorVersion | Minor version of the BASE table, = 1 | + // +----------+--------------------+-------------------------------------------------------------------------------+ + // | Offset16 | horizAxisOffset | Offset to horizontal Axis table, from beginning of BASE table (may be NULL) | + // +----------+--------------------+-------------------------------------------------------------------------------+ + // | Offset16 | vertAxisOffset | Offset to vertical Axis table, from beginning of BASE table (may be NULL) | + // +----------+--------------------+-------------------------------------------------------------------------------+ + // | Offset32 | itemVarStoreOffset | Offset to ItemVariationStore table, from beginning of BASE table (may be NULL) | + // +----------+--------------------+-------------------------------------------------------------------------------+ + ushort majorVersion = reader.ReadUInt16(); + ushort minorVersion = reader.ReadUInt16(); + + ushort horizAxisOffset = reader.ReadOffset16(); + ushort vertAxisOffset = reader.ReadOffset16(); + + BaseAxisTable? horizontalAxis = horizAxisOffset != 0 + ? BaseAxisTable.Load(reader, horizAxisOffset) + : null; + + BaseAxisTable? verticalAxis = vertAxisOffset != 0 + ? BaseAxisTable.Load(reader, vertAxisOffset) + : null; + + return new BaseTable(horizontalAxis, verticalAxis); + } +} + +/// +/// An Axis table of the BASE table stores all baseline information for one text layout +/// direction: baseline identification tags and the per-script baseline coordinates. +/// +/// +internal sealed class BaseAxisTable +{ + /// + /// The 'DFLT' script identification tag, preferred when selecting the script record that + /// supplies baseline coordinates for the whole font. + /// + private static readonly Tag DefaultScriptTag = Tag.Parse("DFLT"); + + /// + /// Initializes a new instance of the class. + /// + /// The baseline identification tags in this text direction. + /// The per-script baseline entries in this text direction. + public BaseAxisTable(Tag[] baselineTags, BaseScriptEntry[] scripts) + { + this.BaselineTags = baselineTags; + this.Scripts = scripts; + } + + /// + /// Gets the baseline identification tags in this text direction, in alphabetical order. + /// Baseline coordinates in each script's values are stored in matching order. + /// + public Tag[] BaselineTags { get; } + + /// + /// Gets the per-script baseline entries in this text direction. + /// + public BaseScriptEntry[] Scripts { get; } + + /// + /// Tries to get the coordinate of the named baseline from the 'DFLT' script entry, falling + /// back to the first script entry that carries baseline values. + /// + /// The baseline identification tag, for example 'hang' or 'ideo'. + /// + /// The baseline coordinate in design units, measured from the zero position on the + /// relevant axis. + /// + /// when the axis defines the named baseline; otherwise . + public bool TryGetBaselineCoordinate(Tag baselineTag, out short coordinate) + { + coordinate = 0; + + int index = -1; + for (int i = 0; i < this.BaselineTags.Length; i++) + { + if (this.BaselineTags[i] == baselineTag) + { + index = i; + break; + } + } + + if (index < 0) + { + return false; + } + + BaseValuesTable? values = null; + for (int i = 0; i < this.Scripts.Length; i++) + { + BaseScriptEntry entry = this.Scripts[i]; + if (entry.Values is null) + { + continue; + } + + if (entry.ScriptTag == DefaultScriptTag) + { + values = entry.Values; + break; + } + + values ??= entry.Values; + } + + if (values is null || index >= values.Coordinates.Length) + { + return false; + } + + coordinate = values.Coordinates[index]; + return true; + } + + /// + /// Loads the from the binary reader at the specified offset. + /// + /// The big endian binary reader. + /// Offset from the beginning of the BASE table to the Axis table. + /// The . + public static BaseAxisTable Load(BigEndianBinaryReader reader, long offset) + { + // Axis Table + // +----------+----------------------+------------------------------------------------------------------------+ + // | Type | Name | Description | + // +==========+======================+========================================================================+ + // | Offset16 | baseTagListOffset | Offset to BaseTagList table, from beginning of Axis table (may be NULL) | + // +----------+----------------------+------------------------------------------------------------------------+ + // | Offset16 | baseScriptListOffset | Offset to BaseScriptList table, from beginning of Axis table | + // +----------+----------------------+------------------------------------------------------------------------+ + reader.Seek(offset, SeekOrigin.Begin); + + ushort baseTagListOffset = reader.ReadOffset16(); + ushort baseScriptListOffset = reader.ReadOffset16(); + + // BaseTagList Table + // +--------+----------------------------+--------------------------------------------------------------------+ + // | Type | Name | Description | + // +========+============================+====================================================================+ + // | uint16 | baseTagCount | Number of baseline identification tags in this text direction | + // +--------+----------------------------+--------------------------------------------------------------------+ + // | Tag | baselineTags[baseTagCount] | Array of 4-byte baseline identification tags, in alphabetical order | + // +--------+----------------------------+--------------------------------------------------------------------+ + Tag[] baselineTags = Array.Empty(); + if (baseTagListOffset != 0) + { + reader.Seek(offset + baseTagListOffset, SeekOrigin.Begin); + + ushort baseTagCount = reader.ReadUInt16(); + baselineTags = new Tag[baseTagCount]; + for (int i = 0; i < baselineTags.Length; i++) + { + baselineTags[i] = reader.ReadUInt32(); + } + } + + // BaseScriptList Table + // +------------------+-------------------------------------+-------------------------------------------------+ + // | Type | Name | Description | + // +==================+=====================================+=================================================+ + // | uint16 | baseScriptCount | Number of BaseScriptRecords defined | + // +------------------+-------------------------------------+-------------------------------------------------+ + // | BaseScriptRecord | baseScriptRecords[baseScriptCount] | Array of BaseScriptRecords, in alphabetical | + // | | | order by baseScriptTag | + // +------------------+-------------------------------------+-------------------------------------------------+ + + // BaseScriptRecord + // +----------+------------------+-----------------------------------------------------------------------+ + // | Type | Name | Description | + // +==========+==================+=======================================================================+ + // | Tag | baseScriptTag | 4-byte script identification tag | + // +----------+------------------+-----------------------------------------------------------------------+ + // | Offset16 | baseScriptOffset | Offset to BaseScript table, from beginning of BaseScriptList | + // +----------+------------------+-----------------------------------------------------------------------+ + long scriptListStart = offset + baseScriptListOffset; + reader.Seek(scriptListStart, SeekOrigin.Begin); + + ushort baseScriptCount = reader.ReadUInt16(); + var scriptTags = new Tag[baseScriptCount]; + ushort[] scriptOffsets = new ushort[baseScriptCount]; + for (int i = 0; i < scriptTags.Length; i++) + { + scriptTags[i] = reader.ReadUInt32(); + scriptOffsets[i] = reader.ReadOffset16(); + } + + var scripts = new BaseScriptEntry[baseScriptCount]; + for (int i = 0; i < scripts.Length; i++) + { + scripts[i] = BaseScriptEntry.Load(scriptTags[i], reader, scriptListStart + scriptOffsets[i]); + } + + return new BaseAxisTable(baselineTags, scripts); + } +} + +/// +/// A BaseScript entry pairs a script identification tag with the baseline values the BASE +/// table defines for that script in one text direction. +/// +/// +internal sealed class BaseScriptEntry +{ + /// + /// Initializes a new instance of the class. + /// + /// The script identification tag. + /// The baseline values for the script, or if none are defined. + public BaseScriptEntry(Tag scriptTag, BaseValuesTable? values) + { + this.ScriptTag = scriptTag; + this.Values = values; + } + + /// + /// Gets the script identification tag. + /// + public Tag ScriptTag { get; } + + /// + /// Gets the baseline values for the script, or if none are defined. + /// + public BaseValuesTable? Values { get; } + + /// + /// Loads the from the binary reader at the specified offset. + /// + /// The script identification tag from the owning record. + /// The big endian binary reader. + /// Offset from the beginning of the BASE table to the BaseScript table. + /// The . + public static BaseScriptEntry Load(Tag scriptTag, BigEndianBinaryReader reader, long offset) + { + // BaseScript Table + // +----------------+-------------------------------------+---------------------------------------------------+ + // | Type | Name | Description | + // +================+=====================================+===================================================+ + // | Offset16 | baseValuesOffset | Offset to BaseValues table, from beginning of | + // | | | BaseScript table (may be NULL) | + // +----------------+-------------------------------------+---------------------------------------------------+ + // | Offset16 | defaultMinMaxOffset | Offset to MinMax table, from beginning of | + // | | | BaseScript table (may be NULL) | + // +----------------+-------------------------------------+---------------------------------------------------+ + // | uint16 | baseLangSysCount | Number of BaseLangSys records defined | + // +----------------+-------------------------------------+---------------------------------------------------+ + // | BaseLangSys | baseLangSysRecords[baseLangSysCount] | Array of BaseLangSys records, in alphabetical | + // | | | order by BaseLangSysTag | + // +----------------+-------------------------------------+---------------------------------------------------+ + reader.Seek(offset, SeekOrigin.Begin); + + ushort baseValuesOffset = reader.ReadOffset16(); + + BaseValuesTable? values = baseValuesOffset != 0 + ? BaseValuesTable.Load(reader, offset + baseValuesOffset) + : null; + + return new BaseScriptEntry(scriptTag, values); + } +} + +/// +/// A BaseValues table lists the coordinate positions of all baselines named in the +/// corresponding BaseTagList for one script and identifies the script's default baseline. +/// +/// +internal sealed class BaseValuesTable +{ + /// + /// Initializes a new instance of the class. + /// + /// The index of the script's default baseline in the axis baseline tags. + /// The baseline coordinates in design units, in baseline tag order. + public BaseValuesTable(ushort defaultBaselineIndex, short[] coordinates) + { + this.DefaultBaselineIndex = defaultBaselineIndex; + this.Coordinates = coordinates; + } + + /// + /// Gets the index of the script's default baseline in the axis baseline tags. + /// + public ushort DefaultBaselineIndex { get; } + + /// + /// Gets the baseline coordinates in design units, ordered to match the axis baseline tags. + /// + public short[] Coordinates { get; } + + /// + /// Loads the from the binary reader at the specified offset. + /// + /// The big endian binary reader. + /// Offset from the beginning of the BASE table to the BaseValues table. + /// The . + public static BaseValuesTable Load(BigEndianBinaryReader reader, long offset) + { + // BaseValues Table + // +----------+-----------------------------------+-----------------------------------------------------------+ + // | Type | Name | Description | + // +==========+===================================+===========================================================+ + // | uint16 | defaultBaselineIndex | Index of default baseline for this script, equals index | + // | | | of baseline tag in baselineTags array of the BaseTagList | + // +----------+-----------------------------------+-----------------------------------------------------------+ + // | uint16 | baseCoordCount | Number of BaseCoord tables defined, should equal | + // | | | baseTagCount in the BaseTagList | + // +----------+-----------------------------------+-----------------------------------------------------------+ + // | Offset16 | baseCoordOffsets[baseCoordCount] | Array of offsets to BaseCoord tables, from beginning of | + // | | | BaseValues table, order matches baselineTags array | + // +----------+-----------------------------------+-----------------------------------------------------------+ + reader.Seek(offset, SeekOrigin.Begin); + + ushort defaultBaselineIndex = reader.ReadUInt16(); + ushort baseCoordCount = reader.ReadUInt16(); + ushort[] coordOffsets = new ushort[baseCoordCount]; + for (int i = 0; i < coordOffsets.Length; i++) + { + coordOffsets[i] = reader.ReadOffset16(); + } + + // BaseCoord Tables + // All three formats begin with the format identifier followed by the coordinate in + // design units. Format 2 appends a reference glyph and contour point index used for + // hinting adjustments; format 3 appends an offset to a Device or VariationIndex + // table. The design unit coordinate is authoritative in every format. + // +--------+------------+-------------------------------------+ + // | Type | Name | Description | + // +========+============+=====================================+ + // | uint16 | format | Format identifier, = 1, 2 or 3 | + // +--------+------------+-------------------------------------+ + // | int16 | coordinate | X or Y value, in design units | + // +--------+------------+-------------------------------------+ + short[] coordinates = new short[baseCoordCount]; + for (int i = 0; i < coordinates.Length; i++) + { + reader.Seek(offset + coordOffsets[i], SeekOrigin.Begin); + ushort format = reader.ReadUInt16(); + coordinates[i] = reader.ReadInt16(); + } + + return new BaseValuesTable(defaultBaselineIndex, coordinates); + } +} diff --git a/src/SixLabors.Fonts/Tables/Cff/CompactFontTables.cs b/src/SixLabors.Fonts/Tables/Cff/CompactFontTables.cs index 5cad652c7..39619a232 100644 --- a/src/SixLabors.Fonts/Tables/Cff/CompactFontTables.cs +++ b/src/SixLabors.Fonts/Tables/Cff/CompactFontTables.cs @@ -75,6 +75,9 @@ public CompactFontTables( /// public PostTable Post { get; set; } + /// + public BaseTable? Base { get; set; } + /// public GlyphDefinitionTable? Gdef { get; set; } diff --git a/src/SixLabors.Fonts/Tables/General/OS2Table.cs b/src/SixLabors.Fonts/Tables/General/OS2Table.cs index e27356e62..d785c7c9c 100644 --- a/src/SixLabors.Fonts/Tables/General/OS2Table.cs +++ b/src/SixLabors.Fonts/Tables/General/OS2Table.cs @@ -47,12 +47,12 @@ internal sealed class OS2Table : Table /// /// The code page range bits 0-31. /// - private readonly ushort codePageRange1; + private readonly uint codePageRange1; /// /// The code page range bits 32-63. /// - private readonly ushort codePageRange2; + private readonly uint codePageRange2; /// /// The Unicode range bits 0-31. @@ -231,8 +231,8 @@ public OS2Table( /// The maximum target glyph context length. public OS2Table( OS2Table version0Table, - ushort codePageRange1, - ushort codePageRange2, + uint codePageRange1, + uint codePageRange2, short heightX, short capHeight, ushort defaultChar, @@ -408,6 +408,16 @@ internal enum FontStyleSelection : ushort /// public short StrikeoutSize { get; } + /// + /// Gets the x-height in font design units, or 0 when the font does not provide it. + /// + public short XHeight => this.heightX; + + /// + /// Gets the cap height in font design units, or 0 when the font does not provide it. + /// + public short CapHeight => this.capHeight; + /// /// Gets the horizontal offset for subscript characters. /// @@ -591,8 +601,8 @@ public static OS2Table Load(BigEndianBinaryReader reader) ushort breakChar = 0; ushort maxContext = 0; - ushort codePageRange1 = reader.ReadUInt16(); // Bits 0–31 - ushort codePageRange2 = reader.ReadUInt16(); // Bits 32–63 + uint codePageRange1 = reader.ReadUInt32(); // Bits 0–31 + uint codePageRange2 = reader.ReadUInt32(); // Bits 32–63 // fields exist only in > v1 https://docs.microsoft.com/en-us/typography/opentype/spec/os2 if (version > 1) diff --git a/src/SixLabors.Fonts/Tables/IFontTables.cs b/src/SixLabors.Fonts/Tables/IFontTables.cs index 27599bf57..d1d796af9 100644 --- a/src/SixLabors.Fonts/Tables/IFontTables.cs +++ b/src/SixLabors.Fonts/Tables/IFontTables.cs @@ -69,6 +69,8 @@ internal interface IFontTables // +------+-------------------------+ // | MATH | Math layout data | // +------+-------------------------+ + public BaseTable? Base { get; set; } + public GlyphDefinitionTable? Gdef { get; set; } public GSubTable? GSub { get; set; } diff --git a/src/SixLabors.Fonts/Tables/TableLoader.cs b/src/SixLabors.Fonts/Tables/TableLoader.cs index cf0f26532..6376c7a3f 100644 --- a/src/SixLabors.Fonts/Tables/TableLoader.cs +++ b/src/SixLabors.Fonts/Tables/TableLoader.cs @@ -49,6 +49,7 @@ public TableLoader() this.Register(CpalTable.TableName, CpalTable.Load); this.Register(GPosTable.TableName, GPosTable.Load); this.Register(GSubTable.TableName, GSubTable.Load); + this.Register(BaseTable.TableName, BaseTable.Load); this.Register(CvtTable.TableName, CvtTable.Load); this.Register(FpgmTable.TableName, FpgmTable.Load); this.Register(PrepTable.TableName, PrepTable.Load); diff --git a/src/SixLabors.Fonts/Tables/TrueType/TrueTypeFontTables.cs b/src/SixLabors.Fonts/Tables/TrueType/TrueTypeFontTables.cs index 8d3f89959..91651bd29 100644 --- a/src/SixLabors.Fonts/Tables/TrueType/TrueTypeFontTables.cs +++ b/src/SixLabors.Fonts/Tables/TrueType/TrueTypeFontTables.cs @@ -82,6 +82,9 @@ public TrueTypeFontTables( /// public PostTable Post { get; set; } + /// + public BaseTable? Base { get; set; } + /// public GlyphDefinitionTable? Gdef { get; set; } diff --git a/src/SixLabors.Fonts/TextBaseline.cs b/src/SixLabors.Fonts/TextBaseline.cs new file mode 100644 index 000000000..ca3557f0d --- /dev/null +++ b/src/SixLabors.Fonts/TextBaseline.cs @@ -0,0 +1,59 @@ +// Copyright (c) Six Labors. +// Licensed under the Six Labors Split License. + +namespace SixLabors.Fonts; + +/// +/// Specifies which reference line of laid-out text is placed at +/// along the block flow axis, and equivalently which line rides the path when text follows one. +/// Values match the CSS dominant-baseline and HTML canvas textBaseline vocabulary. +/// +public enum TextBaseline +{ + /// + /// The line box anchors at the origin and block alignment positions it along the flow axis. + /// + LineBox = 0, + + /// + /// The top of the em box is placed at the origin. Matches CSS text-top. + /// + TextTop, + + /// + /// The hanging baseline, from which Tibetan and similar scripts hang, is placed at the + /// origin. Matches CSS and SVG hanging. Positioned by the font's baseline table + /// when the font provides one and derived from the ascender otherwise. + /// + Hanging, + + /// + /// The middle baseline, half the x-height above the alphabetic baseline, is placed at the + /// origin. Matches CSS and SVG middle. + /// + Middle, + + /// + /// The central baseline, the middle of the em box, is placed at the origin. + /// The conventional anchor for vertical CJK layout; matches CSS and SVG central. + /// + Central, + + /// + /// The alphabetic baseline, the line Latin glyphs sit on, is placed at the origin. + /// Matches CSS and SVG alphabetic and the HTML canvas default. + /// + Alphabetic, + + /// + /// The ideographic-under baseline, beneath CJK ideographs, is placed at the origin. + /// Matches CSS and SVG ideographic. Positioned by the font's baseline table + /// when the font provides one and derived from the descender otherwise. + /// + Ideographic, + + /// + /// The bottom of the em box is placed at the origin. Matches CSS text-bottom. + /// + TextBottom, +} diff --git a/src/SixLabors.Fonts/TextBlock.Visitors.cs b/src/SixLabors.Fonts/TextBlock.Visitors.cs index 991e5a158..eafdf603e 100644 --- a/src/SixLabors.Fonts/TextBlock.Visitors.cs +++ b/src/SixLabors.Fonts/TextBlock.Visitors.cs @@ -377,7 +377,7 @@ private void Flush() /// /// Accumulates the rendered rectangle as glyphs stream from layout. /// - private struct RenderedRectangleAccumulator : TextLayout.IGlyphLayoutVisitor + internal struct RenderedRectangleAccumulator : TextLayout.IGlyphLayoutVisitor { private readonly float dpi; private float left; @@ -771,7 +771,7 @@ public readonly void EndLine() /// /// Renders glyphs as they stream from layout. /// - private struct GlyphRendererVisitor : TextLayout.IGlyphLayoutVisitor + internal struct GlyphRendererVisitor : TextLayout.IGlyphLayoutVisitor { private readonly IGlyphRenderer renderer; private readonly TextOptions options; diff --git a/src/SixLabors.Fonts/TextBlock.cs b/src/SixLabors.Fonts/TextBlock.cs index b55a29a00..8903a2e62 100644 --- a/src/SixLabors.Fonts/TextBlock.cs +++ b/src/SixLabors.Fonts/TextBlock.cs @@ -29,8 +29,9 @@ public sealed partial class TextBlock /// The text to prepare. /// The text options used to prepare, measure, and render the block. /// - /// is ignored while preparing the block; pass the wrapping length - /// to the measurement or rendering method. Use -1 there to disable wrapping. + /// and are ignored while + /// preparing the block; pass the wrapping length (use -1 to disable wrapping) and any visible + /// bounds to the measurement or rendering methods. /// public TextBlock(string text, TextOptions options) : this(text.AsSpan(), options) @@ -43,8 +44,9 @@ public TextBlock(string text, TextOptions options) /// The text to prepare. /// The text options used to prepare, measure, and render the block. /// - /// is ignored while preparing the block; pass the wrapping length - /// to the measurement or rendering method. Use -1 there to disable wrapping. + /// and are ignored while + /// preparing the block; pass the wrapping length (use -1 to disable wrapping) and any visible + /// bounds to the measurement or rendering methods. /// public TextBlock(ReadOnlySpan text, TextOptions options) { @@ -362,6 +364,40 @@ public void RenderTo(IGlyphRenderer renderer, float wrappingLength) RenderTo(renderer, layout.TextBox, this.Options, wrappingLength, rect); } + /// + /// Renders the visible portion of this block to the supplied glyph renderer at the supplied + /// wrapping length, culling whole lines that lie outside the supplied visible bounds. + /// + /// + /// Culling is line-granular: every glyph on a line intersecting + /// is rendered, and the comparison inflates each line box by one line height on each side so + /// ink or decorations overhanging a line box never disappear at the band edges. A culled line + /// still advances layout, so visible lines render at positions identical to a full render, and + /// always receives the full text bounds. + /// + /// The target renderer. + /// The wrapping length in pixels. Use -1 to disable wrapping. + /// The visible region in pixels, in the same space as the rendered output. + public void RenderTo(IGlyphRenderer renderer, float wrappingLength, in FontRectangle visibleBounds) + { + CachedTextLayout layout = this.GetOrCreateLayout(wrappingLength); + FontRectangle rect = this.GetOrComputeBounds(layout, wrappingLength); + + // The layout walk runs in layout units (pixels divided by DPI) and culls along the block + // flow axis: Y for horizontal layouts, X for vertical layouts. + float dpi = this.Options.Dpi; + bool isHorizontal = this.Options.LayoutMode.IsHorizontal(); + float visibleFlowMin = (isHorizontal ? visibleBounds.Top : visibleBounds.Left) / dpi; + float visibleFlowMax = (isHorizontal ? visibleBounds.Bottom : visibleBounds.Right) / dpi; + + renderer.BeginText(in rect); + + GlyphRendererVisitor visitor = new(renderer, this.Options, -1); + TextLayout.LayoutText(layout.TextBox, this.Options, wrappingLength, visibleFlowMin, visibleFlowMax, ref visitor); + + renderer.EndText(); + } + /// /// Renders an already line-broken text box to the supplied glyph renderer. /// @@ -440,6 +476,28 @@ private static LineMetrics[] GetLineMetrics(TextBox textBox, TextOptions options or LayoutMode.VerticalRightLeft or LayoutMode.VerticalMixedRightLeft; + if (options.TextBaseline != TextBaseline.LineBox) + { + // Baseline anchoring places the first laid-out line's selected reference line on + // the origin; shift the reported line starts by the matching amount so metrics + // agree with rendering. + TextLine anchorLine = textBox.TextLines[reverseLineOrder ? textBox.TextLines.Count - 1 : 0]; + if (isHorizontalLayout) + { + float anchorDelta = anchorLine.ScaledMaxDelta; + float anchorCore = anchorLine.ScaledMaxAscender + anchorLine.ScaledMaxDescender + (2 * anchorDelta); + float anchorExtra = anchorLine.ScaledMaxLineHeight - anchorCore; + float anchorBaseline = (anchorExtra * .5F) + anchorLine.ScaledMaxAscender + anchorDelta; + lineOffset -= (anchorBaseline + TextLayout.GetBaselineOffset(options.TextBaseline, options.Font, false)) * options.Dpi; + } + else + { + // Columns anchor their central axis, half the unscaled column width from the pen. + float anchorCentral = anchorLine.ScaledMaxLineHeight / options.LineSpacing * .5F; + lineOffset -= (anchorCentral + TextLayout.GetBaselineOffset(options.TextBaseline, options.Font, true)) * options.Dpi; + } + } + int i = reverseLineOrder ? textBox.TextLines.Count - 1 : 0; int step = reverseLineOrder ? -1 : 1; int graphemeOffset = 0; @@ -648,7 +706,7 @@ internal static GlyphMetrics[] GetGlyphMetricsArray( /// The target DPI. /// Whether the layout direction is horizontal. /// The logical advance rectangle. - private static FontRectangle GetAdvance(TextBox textBox, float dpi, bool isHorizontalLayout) + internal static FontRectangle GetAdvance(TextBox textBox, float dpi, bool isHorizontalLayout) { if (textBox.TextLines.Count == 0) { diff --git a/src/SixLabors.Fonts/TextLayout.cs b/src/SixLabors.Fonts/TextLayout.cs index b945746ec..36d300269 100644 --- a/src/SixLabors.Fonts/TextLayout.cs +++ b/src/SixLabors.Fonts/TextLayout.cs @@ -12,6 +12,16 @@ namespace SixLabors.Fonts; /// internal static partial class TextLayout { + /// + /// The tag for the hanging baseline ('hang') in the font's baseline table. + /// + private static readonly Tag HangingBaselineTag = Tag.Parse("hang"); + + /// + /// The tag for the ideographic-under baseline ('ideo') in the font's baseline table. + /// + private static readonly Tag IdeographicBaselineTag = Tag.Parse("ideo"); + /// /// Resolves the ordered sequence of instances that cover . /// @@ -290,6 +300,192 @@ public static void LayoutText( float wrappingLength, ref TVisitor visitor) where TVisitor : struct, IGlyphLayoutVisitor + => LayoutText(textBox, options, wrappingLength, float.NegativeInfinity, float.PositiveInfinity, ref visitor); + + /// + /// Gets a value indicating whether the layout walk must observe every broken line before it + /// can place the first one. This is the authoritative statement of the walk's full-line-set + /// dependencies; update it alongside any change to the line layout methods in this file. + /// + /// + /// The reversed layout orders place the last line first. Non-start text alignment and + /// right-to-left blocks offset each line using the widest line advance, as do centered and + /// right block alignment; centered and bottom block alignment on the cross axis sum every + /// line's extent on the first line. zero short-circuits + /// breaking with its own empty-box direction rule in + /// . + /// + /// The text options used to lay out text. + /// The resolved block-level text direction. + /// when layout needs the full line set. + public static bool LayoutRequiresFullLineSet(TextOptions options, TextDirection textDirection) + => options.MaxLines == 0 || + options.TextAlignment != TextAlignment.Start || + options.HorizontalAlignment != HorizontalAlignment.Left || + options.VerticalAlignment != VerticalAlignment.Top || + options.LayoutMode is not (LayoutMode.HorizontalTopBottom or LayoutMode.VerticalLeftRight or LayoutMode.VerticalMixedLeftRight) || + textDirection != TextDirection.LeftToRight; + + /// + /// Computes the offset from the dominant baseline to the reference line selected by + /// for the supplied font, in layout units. Horizontal layouts + /// measure from the alphabetic baseline along Y, increasing toward the under side. + /// Vertical layouts measure from the central column axis along X, increasing toward the + /// over side: each baseline keeps the distance from the central baseline the horizontal + /// metrics give it, exactly as CSS synthesizes vertical baseline tables, with dedicated + /// vertical baseline table data taking precedence when the font provides it. + /// resolves to the line-box leading edge from the + /// flow axis metrics so single-glyph callers share the text anchor model. + /// + /// The reference line to resolve. + /// The font whose metrics position the reference lines. + /// Whether the layout flows vertically. + /// The offset from the dominant baseline in layout units. + public static float GetBaselineOffset(TextBaseline baseline, Font font, bool isVerticalLayout) + { + FontMetrics metrics = font.FontMetrics; + float scale = font.Size / metrics.ScaleFactor; + + if (baseline == TextBaseline.LineBox) + { + // The line box is column geometry rather than a baseline, so it anchors from the + // metrics of the flow axis itself. The leading edge sits above the baseline, or + // left of the column axis, by the delta-adjusted ascender: the delta centers the + // em box within the font's declared line height, mirroring the layout engine's + // cell model, so a LineBox-anchored glyph cell starts exactly at the origin. + float flowAscender; + float flowLineHeight; + if (isVerticalLayout) + { + VerticalMetrics verticalMetrics = metrics.VerticalMetrics; + flowAscender = verticalMetrics.Ascender; + flowLineHeight = verticalMetrics.LineHeight; + } + else + { + HorizontalMetrics horizontalMetrics = metrics.HorizontalMetrics; + flowAscender = horizontalMetrics.Ascender; + flowLineHeight = horizontalMetrics.LineHeight; + } + + float delta = ((flowLineHeight - metrics.UnitsPerEm) * scale) * .5F; + return -((flowAscender * scale) - delta); + } + + if (isVerticalLayout && + baseline is TextBaseline.Hanging or TextBaseline.Ideographic && + metrics.TryGetBaselineCoordinate( + baseline == TextBaseline.Hanging ? HangingBaselineTag : IdeographicBaselineTag, + true, + out short vertical)) + { + // Dedicated vertical axis data positions the baseline as an X coordinate from + // the em box leading edge; re-centering on the column axis subtracts half the em. + return (vertical - (metrics.UnitsPerEm * .5F)) * scale; + } + + // Every baseline anchor derives from the horizontal metrics: its height above the + // alphabetic baseline in horizontal layout is also its distance from the central + // baseline in vertical layout, which is how CSS synthesizes vertical baselines for + // fonts without dedicated vertical baseline data. + HorizontalMetrics horizontal = metrics.HorizontalMetrics; + float ascender = horizontal.Ascender * scale; + float descender = horizontal.Descender * scale; + float height; + switch (baseline) + { + case TextBaseline.TextTop: + height = ascender; + break; + case TextBaseline.Hanging: + { + // 80% of the ascender approximates the hanging baseline when the font + // carries no baseline table data for it. + height = metrics.TryGetBaselineCoordinate(HangingBaselineTag, false, out short coordinate) + ? coordinate * scale + : 0.8F * ascender; + break; + } + + case TextBaseline.Middle: + { + float xHeight = metrics.XHeight * scale; + if (xHeight <= 0) + { + // Half the ascender approximates the x-height when the font omits it. + xHeight = ascender * .5F; + } + + height = xHeight * .5F; + break; + } + + case TextBaseline.Central: + // The descender is negative, so this lands halfway between em top and bottom. + height = (ascender + descender) * .5F; + break; + case TextBaseline.Ideographic: + { + // The em bottom approximates the ideographic-under baseline when the font + // carries no baseline table data for it. + height = metrics.TryGetBaselineCoordinate(IdeographicBaselineTag, false, out short coordinate) + ? coordinate * scale + : descender; + break; + } + + case TextBaseline.TextBottom: + height = descender; + break; + default: + height = 0; + break; + } + + if (!isVerticalLayout) + { + // Y increases toward the under side, so a reference above the baseline is a + // negative offset. + return -height; + } + + // X increases toward the over side, so the offset is the reference height re-centered + // on the central baseline. + return height - ((ascender + descender) * .5F); + } + + /// + /// Lays out the supplied , streaming each laid-out glyph through the + /// supplied in layout order, culling whole lines whose extent along + /// the block flow axis lies outside the supplied visible band. + /// + /// + /// A culled line advances the pen exactly as a rendered line would but visits no glyphs. The + /// band is compared against each line's box inflated by one line height on each side, so ink + /// or decorations overhanging a line box by up to one line height never disappear. + /// + /// The concrete visitor struct type. + /// The shaped and line-broken text. + /// The text options used to lay out . + /// The wrapping length in pixels. Use -1 to disable wrapping. + /// + /// The lower edge of the visible band along the block flow axis (Y for horizontal layouts, + /// X for vertical layouts) in layout units (pixels divided by DPI). + /// Use to disable culling at this edge. + /// + /// + /// The upper edge of the visible band along the block flow axis in layout units. + /// Use to disable culling at this edge. + /// + /// The visitor that receives each positioned glyph. + internal static void LayoutText( + TextBox textBox, + TextOptions options, + float wrappingLength, + float visibleFlowMin, + float visibleFlowMax, + ref TVisitor visitor) + where TVisitor : struct, IGlyphLayoutVisitor { if (textBox.TextLines.Count == 0) { @@ -323,6 +519,8 @@ public static void LayoutText( maxScaledAdvance, options, i, + visibleFlowMin, + visibleFlowMax, ref boxLocation, ref penLocation, ref visitor); @@ -343,6 +541,8 @@ public static void LayoutText( maxScaledAdvance, options, index++, + visibleFlowMin, + visibleFlowMax, ref boxLocation, ref penLocation, ref visitor); @@ -362,6 +562,8 @@ public static void LayoutText( maxScaledAdvance, options, i, + visibleFlowMin, + visibleFlowMax, ref boxLocation, ref penLocation, ref visitor); @@ -382,6 +584,8 @@ public static void LayoutText( maxScaledAdvance, options, index++, + visibleFlowMin, + visibleFlowMax, ref boxLocation, ref penLocation, ref visitor); @@ -401,6 +605,8 @@ public static void LayoutText( maxScaledAdvance, options, i, + visibleFlowMin, + visibleFlowMax, ref boxLocation, ref penLocation, ref visitor); @@ -421,6 +627,8 @@ public static void LayoutText( maxScaledAdvance, options, index++, + visibleFlowMin, + visibleFlowMax, ref boxLocation, ref penLocation, ref visitor); @@ -442,6 +650,8 @@ public static void LayoutText( /// The widest scaled line advance in the block (or wrapping length). /// The text options used to position the line. /// The zero-based visual index of this line within the block. + /// The lower visible-band edge along Y in layout units. + /// The upper visible-band edge along Y in layout units. /// The running top-left position of the glyph boxes; advanced by this method. /// The running pen position used for glyph placement; advanced by this method. /// The visitor that receives each positioned glyph. @@ -452,6 +662,8 @@ private static void LayoutLineHorizontal( float maxScaledAdvance, TextOptions options, int index, + float visibleFlowMin, + float visibleFlowMax, ref Vector2 boxLocation, ref Vector2 penLocation, ref TVisitor visitor) @@ -475,42 +687,66 @@ private static void LayoutLineHorizontal( // Set the Y origin for the first horizontal line and account for tall stacks. if (isFirstLine) { - // ScaledMinY is the minimum ink Y for this line in Y down (baseline at 0). - // -ScaledMinY is the actual ascent required to contain the ink. - // ScaledMaxAscender is the typographic ascent we already used to build the line box. - float requiredAscent = -textLine.ScaledMinY; - float extraAscent = requiredAscent - textLine.ScaledMaxAscender; - - if (extraAscent > 0) + if (options.TextBaseline != TextBaseline.LineBox) { - // Shift the baseline down only by the extra ascent needed so that - // stacked glyphs (Tibetan, etc) fit inside the bitmap. For Latin, - // requiredAscent ~= ScaledMaxAscender and extraAscent is zero. - offsetY += extraAscent; - advanceY += extraAscent; + // The walk renders this line's baseline at pen + ScaledMaxAscender, so moving + // the pen to origin - ascender - reference places the selected reference line, + // expressed as an offset from that baseline, exactly on the origin. Block + // alignment and tall-stack compensation position the line box and therefore + // do not apply to baseline-anchored text. + offsetY = -textLine.ScaledMaxAscender - GetBaselineOffset(options.TextBaseline, options.Font, false); } - - switch (options.VerticalAlignment) + else { - case VerticalAlignment.Center: - for (int i = 0; i < textBox.TextLines.Count; i++) - { - offsetY -= textBox.TextLines[i].ScaledMaxLineHeight * .5F; - } + // ScaledMinY is the minimum ink Y for this line in Y down (baseline at 0). + // -ScaledMinY is the actual ascent required to contain the ink. + // ScaledMaxAscender is the typographic ascent we already used to build the line box. + float requiredAscent = -textLine.ScaledMinY; + float extraAscent = requiredAscent - textLine.ScaledMaxAscender; - break; - case VerticalAlignment.Bottom: - for (int i = 0; i < textBox.TextLines.Count; i++) - { - offsetY -= textBox.TextLines[i].ScaledMaxLineHeight; - } + if (extraAscent > 0) + { + // Shift the baseline down only by the extra ascent needed so that + // stacked glyphs (Tibetan, etc) fit inside the bitmap. For Latin, + // requiredAscent ~= ScaledMaxAscender and extraAscent is zero. + offsetY += extraAscent; + advanceY += extraAscent; + } - break; + switch (options.VerticalAlignment) + { + case VerticalAlignment.Center: + for (int i = 0; i < textBox.TextLines.Count; i++) + { + offsetY -= textBox.TextLines[i].ScaledMaxLineHeight * .5F; + } + + break; + case VerticalAlignment.Bottom: + for (int i = 0; i < textBox.TextLines.Count; i++) + { + offsetY -= textBox.TextLines[i].ScaledMaxLineHeight; + } + + break; + } } } penLocation.Y += offsetY; + // Line-band culling: a line whose box, inflated by one line height on each side to cover + // ink overshoot and decoration reach, lies fully outside the visible band advances the + // pen and box exactly as a rendered line would without visiting a single glyph. The pen + // X has not moved yet and a completed line always restores it, so only Y advances here. + if (penLocation.Y + advanceY + scaledLineHeight < visibleFlowMin || + penLocation.Y - scaledLineHeight > visibleFlowMax) + { + penLocation.Y += yLineAdvance; + boxLocation.Y += advanceY; + return; + } + // Set the X-Origin for horizontal alignment. switch (options.HorizontalAlignment) { @@ -551,7 +787,6 @@ private static void LayoutLineHorizontal( penLocation.X += offsetX; Vector2 boundsLocation = boxLocation; - bool emitted = false; for (int i = 0; i < textLine.Count; i++) { GlyphLayoutData data = textLine[i]; @@ -611,8 +846,6 @@ private static void LayoutLineHorizontal( i == 0 && j == 0, data.GraphemeIndex, data.StringIndex)); - - emitted = true; } boxLocation.X += layoutAdvance; @@ -622,11 +855,8 @@ private static void LayoutLineHorizontal( boxLocation.X = originX; penLocation.X = originX; - if (emitted) - { - penLocation.Y += yLineAdvance; - boxLocation.Y += advanceY; - } + penLocation.Y += yLineAdvance; + boxLocation.Y += advanceY; } /// @@ -642,6 +872,8 @@ private static void LayoutLineHorizontal( /// The longest scaled line advance in the block (or wrapping length). /// The text options used to position the line. /// The zero-based visual index of this line within the block. + /// The lower visible-band edge along X in layout units. + /// The upper visible-band edge along X in layout units. /// The running top-left position of the glyph boxes; advanced by this method. /// The running pen position used for glyph placement; advanced by this method. /// The visitor that receives each positioned glyph. @@ -652,6 +884,8 @@ private static void LayoutLineVertical( float maxScaledAdvance, TextOptions options, int index, + float visibleFlowMin, + float visibleFlowMax, ref Vector2 boxLocation, ref Vector2 penLocation, ref TVisitor visitor) @@ -714,39 +948,62 @@ private static void LayoutLineVertical( bool isFirstLine = index == 0; if (isFirstLine) { - // In vertical layout, first-line Y ascent compensation introduces unwanted - // leading space before the first glyph. Keep first-line handling limited - // to X-origin block alignment only. - - // Set the X-Origin for horizontal alignment. - switch (options.HorizontalAlignment) + if (options.TextBaseline != TextBaseline.LineBox) { - case HorizontalAlignment.Right: - for (int i = 0; i < textBox.TextLines.Count; i++) - { - offsetX -= textBox.TextLines[i].ScaledMaxLineHeight; - } + // The walk centers glyphs on the column's central axis at pen plus half the + // unscaled line height; moving the pen so that axis sits at the origin minus + // the reference offset anchors the selected line. Block alignment positions + // the column box and therefore does not apply to baseline-anchored text. + offsetX = -(unscaledLineHeight * .5F) - GetBaselineOffset(options.TextBaseline, options.Font, true); + } + else + { + // In vertical layout, first-line Y ascent compensation introduces unwanted + // leading space before the first glyph. Keep first-line handling limited + // to X-origin block alignment only. - break; - case HorizontalAlignment.Center: - for (int i = 0; i < textBox.TextLines.Count; i++) - { - offsetX -= textBox.TextLines[i].ScaledMaxLineHeight * .5F; - } + // Set the X-Origin for horizontal alignment. + switch (options.HorizontalAlignment) + { + case HorizontalAlignment.Right: + for (int i = 0; i < textBox.TextLines.Count; i++) + { + offsetX -= textBox.TextLines[i].ScaledMaxLineHeight; + } - break; + break; + case HorizontalAlignment.Center: + for (int i = 0; i < textBox.TextLines.Count; i++) + { + offsetX -= textBox.TextLines[i].ScaledMaxLineHeight * .5F; + } + + break; + } } } penLocation.Y += offsetY; penLocation.X += offsetX; + // Line-band culling: a column whose box, inflated by one column width on each side to + // cover ink overshoot and decoration reach, lies fully outside the visible band advances + // the pen and box exactly as a rendered column would without visiting a single glyph. + // A completed column keeps its X offset and restores Y to the origin. + if (penLocation.X + advanceX + scaledMaxLineHeight < visibleFlowMin || + penLocation.X - scaledMaxLineHeight > visibleFlowMax) + { + boxLocation.Y = originY; + penLocation.Y = originY; + boxLocation.X += advanceX; + penLocation.X += xLineAdvance; + return; + } + float lineOriginX = penLocation.X; Vector2 boundsLocation = boxLocation; float boundsLineOriginX = boundsLocation.X; - bool emitted = false; - // Grapheme-scoped state for transformed glyph alignment. // // IMPORTANT: GlyphLayoutData is per-codepoint, not per-grapheme. @@ -958,7 +1215,6 @@ private static void LayoutLineVertical( data.GraphemeIndex, data.StringIndex)); - emitted = true; j++; } @@ -986,11 +1242,8 @@ private static void LayoutLineVertical( boxLocation.Y = originY; penLocation.Y = originY; - if (emitted) - { - boxLocation.X += advanceX; - penLocation.X += xLineAdvance; - } + boxLocation.X += advanceX; + penLocation.X += xLineAdvance; } /// @@ -1006,6 +1259,8 @@ private static void LayoutLineVertical( /// The longest scaled line advance in the block (or wrapping length). /// The text options used to position the line. /// The zero-based visual index of this line within the block. + /// The lower visible-band edge along X in layout units. + /// The upper visible-band edge along X in layout units. /// The running top-left position of the glyph boxes; advanced by this method. /// The running pen position used for glyph placement; advanced by this method. /// The visitor that receives each positioned glyph. @@ -1016,6 +1271,8 @@ private static void LayoutLineVerticalMixed( float maxScaledAdvance, TextOptions options, int index, + float visibleFlowMin, + float visibleFlowMax, ref Vector2 boxLocation, ref Vector2 penLocation, ref TVisitor visitor) @@ -1078,35 +1335,60 @@ private static void LayoutLineVerticalMixed( bool isFirstLine = index == 0; if (isFirstLine) { - // In vertical-mixed layout, first-line Y ascent compensation introduces - // unwanted leading space before the first glyph. Keep first-line handling - // limited to X-origin block alignment only. - - // Set the X-Origin for horizontal alignment. - switch (options.HorizontalAlignment) + if (options.TextBaseline != TextBaseline.LineBox) { - case HorizontalAlignment.Right: - for (int i = 0; i < textBox.TextLines.Count; i++) - { - offsetX -= textBox.TextLines[i].ScaledMaxLineHeight; - } + // The walk centers glyphs on the column's central axis at pen plus half the + // unscaled line height; moving the pen so that axis sits at the origin minus + // the reference offset anchors the selected line. Block alignment positions + // the column box and therefore does not apply to baseline-anchored text. + offsetX = -(unscaledLineHeight * .5F) - GetBaselineOffset(options.TextBaseline, options.Font, true); + } + else + { + // In vertical-mixed layout, first-line Y ascent compensation introduces + // unwanted leading space before the first glyph. Keep first-line handling + // limited to X-origin block alignment only. - break; - case HorizontalAlignment.Center: - for (int i = 0; i < textBox.TextLines.Count; i++) - { - offsetX -= textBox.TextLines[i].ScaledMaxLineHeight * .5F; - } + // Set the X-Origin for horizontal alignment. + switch (options.HorizontalAlignment) + { + case HorizontalAlignment.Right: + for (int i = 0; i < textBox.TextLines.Count; i++) + { + offsetX -= textBox.TextLines[i].ScaledMaxLineHeight; + } - break; + break; + case HorizontalAlignment.Center: + for (int i = 0; i < textBox.TextLines.Count; i++) + { + offsetX -= textBox.TextLines[i].ScaledMaxLineHeight * .5F; + } + + break; + } } } penLocation.Y += offsetY; penLocation.X += offsetX; + + // Line-band culling: a column whose box, inflated by one column width on each side to + // cover ink overshoot and decoration reach, lies fully outside the visible band advances + // the pen and box exactly as a rendered column would without visiting a single glyph. + // A completed column keeps its X offset and restores Y to the origin. + if (penLocation.X + advanceX + scaledMaxLineHeight < visibleFlowMin || + penLocation.X - scaledMaxLineHeight > visibleFlowMax) + { + boxLocation.Y = originY; + penLocation.Y = originY; + boxLocation.X += advanceX; + penLocation.X += xLineAdvance; + return; + } + Vector2 boundsLocation = boxLocation; - bool emitted = false; for (int i = 0; i < textLine.Count; i++) { GlyphLayoutData data = textLine[i]; @@ -1159,19 +1441,18 @@ private static void LayoutLineVerticalMixed( // The glyph will be rotated 90 degrees for vertical mixed layout. // We still advance along Y, but the glyphs are laid out sideways in X. - // Calculate the initial horizontal offset to center the glyph baseline: - // - Take half the difference between the max line height (scaledMaxLineHeight) - // and the current glyph's line height (data.ScaledLineHeight). - // - The line height includes both ascender and descender metrics. - float baselineDelta = (unscaledLineHeight - scaledLineHeight) * .5F; + // Rotated glyphs sit on the line's alphabetic baseline, which lies half + // the ascender-plus-descender span toward the under side of the central + // column axis at the middle of the line. Upright glyphs in the same line + // center on that axis, so both orientations share the column lines the + // horizontal metrics synthesize, which is also how browsers position + // mixed-orientation runs. + Vector2 rotatedScale = new Vector2(data.PointSize) / metric.ScaleFactor; + HorizontalMetrics rotatedMetrics = metric.FontMetrics.HorizontalMetrics; + float centralOffset = (rotatedMetrics.Ascender + rotatedMetrics.Descender) * .5F * rotatedScale.Y; - // Adjust the horizontal offset further by considering the descender differences: - // - Subtract the current glyph's descender (data.ScaledDescender) to align it properly. - float descenderAbs = Math.Abs(data.ScaledDescender); - float descenderDelta = (Math.Abs(textLine.ScaledMaxDescender) - descenderAbs) * .5F; - - float centerOffsetX = baselineDelta + descenderAbs + descenderDelta; - Vector2 glyphOrigin = penLocation + new Vector2(centerOffsetX, 0); + float baselineX = (unscaledLineHeight * .5F) - centralOffset; + Vector2 glyphOrigin = penLocation + new Vector2(baselineX, 0); visitor.Visit( new GlyphLayout( @@ -1187,8 +1468,6 @@ private static void LayoutLineVerticalMixed( i == 0 && j == 0, data.GraphemeIndex, data.StringIndex)); - - emitted = true; } } else @@ -1224,8 +1503,6 @@ private static void LayoutLineVerticalMixed( i == 0 && j == 0, data.GraphemeIndex, data.StringIndex)); - - emitted = true; } } @@ -1235,11 +1512,8 @@ private static void LayoutLineVerticalMixed( boxLocation.Y = originY; penLocation.Y = originY; - if (emitted) - { - boxLocation.X += advanceX; - penLocation.X += xLineAdvance; - } + boxLocation.X += advanceX; + penLocation.X += xLineAdvance; } /// diff --git a/src/SixLabors.Fonts/TextMeasurer.cs b/src/SixLabors.Fonts/TextMeasurer.cs index 4e1f292cc..3ea16b496 100644 --- a/src/SixLabors.Fonts/TextMeasurer.cs +++ b/src/SixLabors.Fonts/TextMeasurer.cs @@ -98,13 +98,11 @@ public static FontRectangle MeasureRenderableBounds(ReadOnlySpan text, Tex /// /// /// The advance is computed directly from the font's cached per-glyph metrics without decoding - /// outlines or running the layout engine. is the glyph's - /// baseline pen position. For horizontal layouts the advance rectangle spans the same line - /// box the layout engine builds for a single glyph (the em box, ascender-balanced against the - /// font's declared line height) over the glyph's advance width. For vertical layouts the - /// rectangle is centered on the origin and extends downward by the advance the layout - /// direction consumes; line-level policies such as column centering and line spacing only - /// apply when text is laid out. + /// outlines or running the layout engine. Matching the text-level advance contract, the + /// rectangle is zero-based: the advance width by the em height for horizontal layouts, and + /// the advance the layout direction consumes for vertical layouts, independent of + /// and . Positioned + /// geometry is reported by the bounds overloads. /// /// The glyph identifier within the font face referenced by . /// The glyph options, including the font, origin, and layout mode. @@ -157,37 +155,41 @@ public static FontRectangle MeasureBounds(ushort glyphId, GlyphOptions options) /// The glyph identifier within the font face referenced by . /// The glyph options, including the font, origin, and layout mode. /// - /// The union of the logical advance rectangle and the rendered bounds of the glyph if it was - /// to be rendered, or when the font does not contain the - /// glyph or the glyph never renders. + /// The union of the advance placed at and the rendered + /// bounds of the glyph if it was to be rendered, or when + /// the font does not contain the glyph or the glyph never renders. /// public static FontRectangle MeasureRenderableBounds(ushort glyphId, GlyphOptions options) { Guard.NotNull(options, nameof(options)); return TryGetMeasurableGlyphMetrics(glyphId, options, out FontGlyphMetrics? metrics) - ? FontRectangle.Union(GetGlyphAdvance(metrics, options), GetGlyphBounds(metrics, options)) + ? FontRectangle.Union(GetAbsoluteAdvance(metrics, options), GetGlyphBounds(metrics, options)) : FontRectangle.Empty; } /// - /// Measures the union of logical glyph advances for positioned glyphs in pixel units. + /// Measures the logical advance of positioned glyphs in pixel units. /// /// - /// Each glyph is measured at its own run origin exactly as - /// - /// renders it; is replaced per glyph and restored. Glyph ids - /// the font does not contain and glyphs that never render are skipped, matching renderer - /// behavior. + /// Matching the text-level advance contract, the rectangle is zero-based: the extent the + /// run's advance cells cover at their run origins, reported independent of position and of + /// . Glyph ids the font does not contain and glyphs + /// that never render are skipped, matching renderer behavior. /// /// The positioned glyphs. /// The glyph options, including the font and layout mode. /// - /// The union of the logical advance rectangles of the run if it was to be rendered, or + /// The zero-based logical advance extent of the run if it was to be rendered, or /// when no glyph in the run participates in rendering. /// public static FontRectangle MeasureAdvance(GlyphRun glyphRun, GlyphOptions options) - => MeasureGlyphRun(glyphRun, options, static (metrics, options) => GetGlyphAdvance(metrics, options)); + { + // Match the text-level advance contract: measure the extent the positioned cells + // cover, then report it zero-based. + FontRectangle extent = MeasureGlyphRun(glyphRun, options, static (metrics, options) => GetAbsoluteAdvance(metrics, options)); + return new FontRectangle(0, 0, extent.Width, extent.Height); + } /// /// Measures the union of rendered glyph bounds for positioned glyphs in pixel units. @@ -222,15 +224,15 @@ public static FontRectangle MeasureBounds(GlyphRun glyphRun, GlyphOptions option /// The positioned glyphs. /// The glyph options, including the font and layout mode. /// - /// The union of the logical advance rectangles and the rendered glyph bounds of the run if it - /// was to be rendered, or when no glyph in the run - /// participates in rendering. + /// The union of the advances placed at their run origins and the rendered glyph bounds of + /// the run if it was to be rendered, or when no glyph in + /// the run participates in rendering. /// public static FontRectangle MeasureRenderableBounds(GlyphRun glyphRun, GlyphOptions options) => MeasureGlyphRun( glyphRun, options, - static (metrics, options) => FontRectangle.Union(GetGlyphAdvance(metrics, options), GetGlyphBounds(metrics, options))); + static (metrics, options) => FontRectangle.Union(GetAbsoluteAdvance(metrics, options), GetGlyphBounds(metrics, options))); /// public static ReadOnlyMemory GetGlyphMetrics(string text, TextOptions options) @@ -604,12 +606,36 @@ private static GlyphMetrics CreateGlyphMetrics(ushort glyphId, GlyphOptions opti metrics.CodePoint, advance, bounds, - FontRectangle.Union(advance, bounds), + FontRectangle.Union(GetAbsoluteAdvance(metrics, options), bounds), options.Font, graphemeIndex, index); } + /// + /// Applies the configured baseline anchor to a glyph origin in pixel units, matching the + /// shift the renderer applies before positioning the glyph. + /// + /// The glyph options. + /// The resolved per-glyph layout mode. + /// The anchored origin. + private static Vector2 GetAnchoredOrigin(GlyphOptions options, GlyphLayoutMode layoutMode) + { + // Mirror the renderer's exact operation order (normalize to layout units, anchor, + // convert back to pixels) so measured bounds stay bit-identical to rendered bounds. + Vector2 origin = options.Origin / options.Dpi; + if (layoutMode == GlyphLayoutMode.Horizontal) + { + origin.Y -= TextLayout.GetBaselineOffset(options.TextBaseline, options.Font, false); + } + else + { + origin.X -= TextLayout.GetBaselineOffset(options.TextBaseline, options.Font, true); + } + + return origin * options.Dpi; + } + /// /// Computes one glyph's rendered bounds at : the same /// per-glyph layout mode and scaled size feed the same bounding-box computation the @@ -619,68 +645,51 @@ private static GlyphMetrics CreateGlyphMetrics(ushort glyphId, GlyphOptions opti /// The glyph options, including the font, origin, and layout mode. /// The rendered glyph bounds. private static FontRectangle GetGlyphBounds(FontGlyphMetrics metrics, GlyphOptions options) - => metrics.GetBoundingBox( - options.GetGlyphLayoutMode(metrics.CodePoint), - options.Origin, + { + GlyphLayoutMode layoutMode = options.GetGlyphLayoutMode(metrics.CodePoint); + return metrics.GetBoundingBox( + layoutMode, + GetAnchoredOrigin(options, layoutMode), metrics.GetScaledSize(options.Font.Size, options.Dpi)); + } /// - /// Computes one glyph's logical advance rectangle at , - /// mirroring the line-box construction the layout engine applies to a single glyph: the - /// line height is the em box, and the ascender is balanced by the delta that centers the - /// em box within the font's declared line height. Vertical layouts center the cell on the - /// origin and extend downward by the advance the layout direction consumes. + /// Computes one glyph's logical advance: a zero-based measure of the space the glyph + /// consumes, matching the text-level advance contract. The origin and baseline anchoring + /// never move it; positioned geometry is reported by the bounds overloads. /// /// The glyph metrics. - /// The glyph options, including the font, origin, and layout mode. - /// The logical advance rectangle. + /// The glyph options, including the font and layout mode. + /// The zero-based logical advance rectangle. private static FontRectangle GetGlyphAdvance(FontGlyphMetrics metrics, GlyphOptions options) { float scaledSize = metrics.GetScaledSize(options.Font.Size, options.Dpi); Vector2 scale = new(scaledSize / metrics.ScaleFactor.X, scaledSize / metrics.ScaleFactor.Y); - Vector2 origin = options.Origin; - - // Match the layout engine's CSS-style line box: the em box is the line height, and - // the delta centers it within the font's declared line height. - // Reference: TextLayout.LineBreaking line-height calculation. float emHeight = metrics.UnitsPerEm * scale.Y; switch (options.GetGlyphLayoutMode(metrics.CodePoint)) { case GlyphLayoutMode.Vertical: - { - float advanceWidth = metrics.AdvanceWidth * scale.X; - return new FontRectangle( - origin.X - (advanceWidth * .5F), - origin.Y, - advanceWidth, - metrics.AdvanceHeight * scale.Y); - } - + return new FontRectangle(0, 0, metrics.AdvanceWidth * scale.X, metrics.AdvanceHeight * scale.Y); case GlyphLayoutMode.VerticalRotated: - { // A rotated glyph advances along the column by its horizontal advance and its // line box lies across the column. - return new FontRectangle( - origin.X - (emHeight * .5F), - origin.Y, - emHeight, - metrics.AdvanceWidth * scale.X); - } - + return new FontRectangle(0, 0, emHeight, metrics.AdvanceWidth * scale.X); default: - { - // The origin is the baseline pen position; the delta-adjusted ascender places - // the cell top exactly where layout places the line-box top above the baseline. - HorizontalMetrics horizontalMetrics = options.Font.FontMetrics.HorizontalMetrics; - float delta = ((horizontalMetrics.LineHeight * scale.Y) - emHeight) * .5F; - float ascender = (horizontalMetrics.Ascender * scale.Y) - delta; - return new FontRectangle( - origin.X, - origin.Y - ascender, - metrics.AdvanceWidth * scale.X, - emHeight); - } + return new FontRectangle(0, 0, metrics.AdvanceWidth * scale.X, emHeight); } } + + /// + /// Places a glyph's zero-based advance at , mirroring the + /// absolute-advance composition the text-level renderable bounds use. + /// + /// The glyph metrics. + /// The glyph options, including the font, origin, and layout mode. + /// The advance rectangle placed at the origin. + private static FontRectangle GetAbsoluteAdvance(FontGlyphMetrics metrics, GlyphOptions options) + { + FontRectangle advance = GetGlyphAdvance(metrics, options); + return new FontRectangle(options.Origin.X, options.Origin.Y, advance.Width, advance.Height); + } } diff --git a/src/SixLabors.Fonts/TextOptions.cs b/src/SixLabors.Fonts/TextOptions.cs index 35d5465ff..285a6b218 100644 --- a/src/SixLabors.Fonts/TextOptions.cs +++ b/src/SixLabors.Fonts/TextOptions.cs @@ -38,6 +38,8 @@ public TextOptions(TextOptions options) this.LineSpacing = options.LineSpacing; this.Origin = options.Origin; this.WrappingLength = options.WrappingLength; + this.VisibleBounds = options.VisibleBounds; + this.TextBaseline = options.TextBaseline; this.MaxLines = options.MaxLines; this.WordBreaking = options.WordBreaking; this.TextEllipsis = options.TextEllipsis; @@ -147,6 +149,29 @@ public float LineSpacing /// public float WrappingLength { get; set; } = -1F; + /// + /// Gets or sets the visible region in pixel units (px) used when rendering text. + /// Whole lines that fall outside the region are skipped and the remaining lines are + /// positioned as if all lines had been rendered. + /// + /// + /// If value is then culling is disabled. + /// + public FontRectangle? VisibleBounds { get; set; } + + /// + /// Gets or sets which reference line of the first laid-out line is placed at + /// along the block flow axis. + /// + /// + /// Baseline positions derive from the metrics of : horizontal layouts + /// anchor along Y from the alphabetic baseline, vertical layouts along X from the central + /// column axis. When the value is not the block + /// alignment along the flow axis does not apply; additional wrapped lines stack relative + /// to the anchored first line. + /// + public TextBaseline TextBaseline { get; set; } + /// /// Gets or sets the maximum number of lines to lay out. /// diff --git a/tests/Browser/TextBaseline.html b/tests/Browser/TextBaseline.html new file mode 100644 index 000000000..3b7e60a3d --- /dev/null +++ b/tests/Browser/TextBaseline.html @@ -0,0 +1,379 @@ + + + + + + Text baseline browser comparison + + + +
+

Browser text baseline comparison

+
+ +
+

Open Sans horizontal, baseline anchored at the red rule, 72pt

+
+
+
Browser
+
SixLabors.Fonts actual output
+
+
+ +
+

Open Sans vertical upright, baseline anchored at the red rule, 72pt

+
+
+
Browser
+
SixLabors.Fonts actual output
+
+
+ +
+

Open Sans vertical mixed, baseline anchored at the red rule, 72pt

+
+
+
Browser
+
SixLabors.Fonts actual output
+
+
+ +
+

Noto Sans SC subset horizontal, baseline anchored at the red rule, 72pt

+
+
+
Browser
+
SixLabors.Fonts actual output
+
+
+ +
+

Noto Sans SC subset vertical upright, baseline anchored at the red rule, 72pt

+
+
+
Browser
+
SixLabors.Fonts actual output
+
+
+ +
+

Noto Sans SC subset vertical mixed, baseline anchored at the red rule, 72pt

+
+
+
Browser
+
SixLabors.Fonts actual output
+
+
+ + + + diff --git a/tests/Fonts/Noto_Sans_SC/NotoSansSC-BaselineSubset.ttf b/tests/Fonts/Noto_Sans_SC/NotoSansSC-BaselineSubset.ttf new file mode 100644 index 000000000..b801bbc0d --- /dev/null +++ b/tests/Fonts/Noto_Sans_SC/NotoSansSC-BaselineSubset.ttf @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:6b1baa189859fbebee61a0545e755f8558d1bbdaef1442b77e522117a2951ffd +size 6700 diff --git a/tests/Fonts/Noto_Sans_SC/OFL.txt b/tests/Fonts/Noto_Sans_SC/OFL.txt new file mode 100644 index 000000000..773e5e85a --- /dev/null +++ b/tests/Fonts/Noto_Sans_SC/OFL.txt @@ -0,0 +1,93 @@ +(c) 2014-2021 Adobe (http://www.adobe.com/), with Reserved Font Name 'Source'. + +This Font Software is licensed under the SIL Open Font License, Version 1.1. +This license is copied below, and is also available with a FAQ at: +http://scripts.sil.org/OFL + + +----------------------------------------------------------- +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 +----------------------------------------------------------- + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font creation +efforts of academic and linguistic communities, and to provide a free and +open framework in which fonts may be shared and improved in partnership +with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply +to any document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This may +include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software components as +distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, deleting, +or substituting -- in part or in whole -- any of the components of the +Original Version, by changing formats or by porting the Font Software to a +new environment. + +"Author" refers to any designer, engineer, programmer, technical +writer or other person who contributed to the Font Software. + +PERMISSION & CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Font Software, to use, study, copy, merge, embed, modify, +redistribute, and sell modified and unmodified copies of the Font +Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components, +in Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, +redistributed and/or sold with any software, provided that each copy +contains the above copyright notice and this license. These can be +included either as stand-alone text files, human-readable headers or +in the appropriate machine-readable metadata fields within text or +binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font +Name(s) unless explicit written permission is granted by the corresponding +Copyright Holder. This restriction only applies to the primary font name as +presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font +Software shall not be used to promote, endorse or advertise any +Modified Version, except to acknowledge the contribution(s) of the +Copyright Holder(s) and the Author(s) or with their explicit written +permission. + +5) The Font Software, modified or unmodified, in part or in whole, +must be distributed entirely under this license, and must not be +distributed under any other license. The requirement for fonts to +remain under this license does not apply to any document created +using the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE. diff --git a/tests/Images/ReferenceOutput/GraphemeMetrics_GetSelectionBounds_DrawsGraphemeSelections_VerticalMixedLeftRight.png b/tests/Images/ReferenceOutput/GraphemeMetrics_GetSelectionBounds_DrawsGraphemeSelections_VerticalMixedLeftRight.png index 3e729e3eb..86cb6bebb 100644 --- a/tests/Images/ReferenceOutput/GraphemeMetrics_GetSelectionBounds_DrawsGraphemeSelections_VerticalMixedLeftRight.png +++ b/tests/Images/ReferenceOutput/GraphemeMetrics_GetSelectionBounds_DrawsGraphemeSelections_VerticalMixedLeftRight.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:ef05b7f0f8980bb45f731a490f6d05459f9fc03d78b85dd67d59144ff5e49a48 -size 12101 +oid sha256:f302eba2ed09954772665f0ea5dc6bd1949a9cf348a9d7501e88f8f26e370fc0 +size 12073 diff --git a/tests/Images/ReferenceOutput/GraphemeMetrics_GetSelectionBounds_DrawsGraphemeSelections_VerticalMixedRightLeft.png b/tests/Images/ReferenceOutput/GraphemeMetrics_GetSelectionBounds_DrawsGraphemeSelections_VerticalMixedRightLeft.png index ffa6ad61e..1c9795992 100644 --- a/tests/Images/ReferenceOutput/GraphemeMetrics_GetSelectionBounds_DrawsGraphemeSelections_VerticalMixedRightLeft.png +++ b/tests/Images/ReferenceOutput/GraphemeMetrics_GetSelectionBounds_DrawsGraphemeSelections_VerticalMixedRightLeft.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:e50cea59d264c07217548b532161f90205fe8a938fc2f1ada1e2f65c24850bf8 -size 12020 +oid sha256:5c7c6ea5ba2d35f7e89cccfcda562560314001f5bab2f90331adf5861b521305 +size 12140 diff --git a/tests/Images/ReferenceOutput/LineMetrics_StartAndExtent_DrawsLineBoxes_VerticalMixedLeftRight.png b/tests/Images/ReferenceOutput/LineMetrics_StartAndExtent_DrawsLineBoxes_VerticalMixedLeftRight.png index c20d08f56..a50295ec2 100644 --- a/tests/Images/ReferenceOutput/LineMetrics_StartAndExtent_DrawsLineBoxes_VerticalMixedLeftRight.png +++ b/tests/Images/ReferenceOutput/LineMetrics_StartAndExtent_DrawsLineBoxes_VerticalMixedLeftRight.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:6155b1b17e7e18d29cb4481f83d63893e128204d050312a1779eea7d9682ac66 -size 11881 +oid sha256:ccb8fca7ca36dba1fb1ccc387b91e676a2ecaf7d9955972fd1c9e71e4f9e227c +size 11855 diff --git a/tests/Images/ReferenceOutput/LineMetrics_StartAndExtent_DrawsLineBoxes_VerticalMixedRightLeft.png b/tests/Images/ReferenceOutput/LineMetrics_StartAndExtent_DrawsLineBoxes_VerticalMixedRightLeft.png index d85463d8f..5d31b77b7 100644 --- a/tests/Images/ReferenceOutput/LineMetrics_StartAndExtent_DrawsLineBoxes_VerticalMixedRightLeft.png +++ b/tests/Images/ReferenceOutput/LineMetrics_StartAndExtent_DrawsLineBoxes_VerticalMixedRightLeft.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:35507cf0aa5d6a0e59da2b4351209cbbd3b1338d8b9ea325527d2ac1a4159cde -size 11830 +oid sha256:9636874a5a7b265582d071ea74e27587620bd35909be6ca5a066ede33fa2a13f +size 11821 diff --git a/tests/Images/ReferenceOutput/MeasureTextWordWrappingVerticalMixedLeftRight_350-_height_279.125-width_11.438_.png b/tests/Images/ReferenceOutput/MeasureTextWordWrappingVerticalMixedLeftRight_350-_height_279.125-width_11.438_.png index 701d6c929..fc71a77d2 100644 --- a/tests/Images/ReferenceOutput/MeasureTextWordWrappingVerticalMixedLeftRight_350-_height_279.125-width_11.438_.png +++ b/tests/Images/ReferenceOutput/MeasureTextWordWrappingVerticalMixedLeftRight_350-_height_279.125-width_11.438_.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:77ce2c51367958b9f889459b508585ca99e798a2efdea06ec4d158cf506888a4 -size 922 +oid sha256:40b19ee3b76c27defc525168a1113d3ba35aa9da90c22b3dc4c372f27ab5510a +size 939 diff --git a/tests/Images/ReferenceOutput/MeasureTextWordWrappingVerticalMixedLeftRight_350-_height_87.125-width_10_.png b/tests/Images/ReferenceOutput/MeasureTextWordWrappingVerticalMixedLeftRight_350-_height_87.125-width_10_.png index abdadb05a..8204e5614 100644 --- a/tests/Images/ReferenceOutput/MeasureTextWordWrappingVerticalMixedLeftRight_350-_height_87.125-width_10_.png +++ b/tests/Images/ReferenceOutput/MeasureTextWordWrappingVerticalMixedLeftRight_350-_height_87.125-width_10_.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:b0bb7b04b88e9d8d6ad842702cafaff290febda2feca438d85bc0136c28d3b37 -size 863 +oid sha256:0c6ba452d3de904d7702192611aaf45593d916b317ec67a0e121167b55c8473b +size 866 diff --git a/tests/Images/ReferenceOutput/RendersAnchoredToReference_Alphabetic.png b/tests/Images/ReferenceOutput/RendersAnchoredToReference_Alphabetic.png new file mode 100644 index 000000000..47c99678a --- /dev/null +++ b/tests/Images/ReferenceOutput/RendersAnchoredToReference_Alphabetic.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:df9fc0ebd7a739841625cded10fba1852aabc54de24f0d1a2551be5c997f3e35 +size 2991 diff --git a/tests/Images/ReferenceOutput/RendersAnchoredToReference_Central.png b/tests/Images/ReferenceOutput/RendersAnchoredToReference_Central.png new file mode 100644 index 000000000..ff52d88b1 --- /dev/null +++ b/tests/Images/ReferenceOutput/RendersAnchoredToReference_Central.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:67c46237716bcbb8f294729bb3058897394e05a9386d4a561b340a7f876f6d6f +size 3039 diff --git a/tests/Images/ReferenceOutput/RendersAnchoredToReference_CjkVerticalLeftRight_Alphabetic.png b/tests/Images/ReferenceOutput/RendersAnchoredToReference_CjkVerticalLeftRight_Alphabetic.png new file mode 100644 index 000000000..a592a937e --- /dev/null +++ b/tests/Images/ReferenceOutput/RendersAnchoredToReference_CjkVerticalLeftRight_Alphabetic.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:ae6c589e3af6dfb840a8448f821a0508d78144b0aabf69e2785d5b9f191ccfc8 +size 5022 diff --git a/tests/Images/ReferenceOutput/RendersAnchoredToReference_CjkVerticalLeftRight_Central.png b/tests/Images/ReferenceOutput/RendersAnchoredToReference_CjkVerticalLeftRight_Central.png new file mode 100644 index 000000000..e8db48600 --- /dev/null +++ b/tests/Images/ReferenceOutput/RendersAnchoredToReference_CjkVerticalLeftRight_Central.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:6c59209e9f154e44dccc0227c28a6f432d99662de17ce202faf0d3530fb93cb5 +size 4684 diff --git a/tests/Images/ReferenceOutput/RendersAnchoredToReference_CjkVerticalLeftRight_Hanging.png b/tests/Images/ReferenceOutput/RendersAnchoredToReference_CjkVerticalLeftRight_Hanging.png new file mode 100644 index 000000000..c930d8d6f --- /dev/null +++ b/tests/Images/ReferenceOutput/RendersAnchoredToReference_CjkVerticalLeftRight_Hanging.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:43086c4e8e761b18ba36181008dc2f374ed06dbe268ec7a83676470a2f86eea6 +size 4260 diff --git a/tests/Images/ReferenceOutput/RendersAnchoredToReference_CjkVerticalLeftRight_Ideographic.png b/tests/Images/ReferenceOutput/RendersAnchoredToReference_CjkVerticalLeftRight_Ideographic.png new file mode 100644 index 000000000..1b6a7fd88 --- /dev/null +++ b/tests/Images/ReferenceOutput/RendersAnchoredToReference_CjkVerticalLeftRight_Ideographic.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:dc1e0c13403497ad0ae71d16bd1eda5d86dc9cad45a17f003352357de6f09873 +size 5039 diff --git a/tests/Images/ReferenceOutput/RendersAnchoredToReference_CjkVerticalLeftRight_LineBox.png b/tests/Images/ReferenceOutput/RendersAnchoredToReference_CjkVerticalLeftRight_LineBox.png new file mode 100644 index 000000000..1b6a7fd88 --- /dev/null +++ b/tests/Images/ReferenceOutput/RendersAnchoredToReference_CjkVerticalLeftRight_LineBox.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:dc1e0c13403497ad0ae71d16bd1eda5d86dc9cad45a17f003352357de6f09873 +size 5039 diff --git a/tests/Images/ReferenceOutput/RendersAnchoredToReference_CjkVerticalLeftRight_Middle.png b/tests/Images/ReferenceOutput/RendersAnchoredToReference_CjkVerticalLeftRight_Middle.png new file mode 100644 index 000000000..e69750252 --- /dev/null +++ b/tests/Images/ReferenceOutput/RendersAnchoredToReference_CjkVerticalLeftRight_Middle.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:5fae6886641c4730411366440bb07dc148c400809e7ee43b4ca8dbd6b4ba4001 +size 8578 diff --git a/tests/Images/ReferenceOutput/RendersAnchoredToReference_CjkVerticalLeftRight_TextBottom.png b/tests/Images/ReferenceOutput/RendersAnchoredToReference_CjkVerticalLeftRight_TextBottom.png new file mode 100644 index 000000000..73a77cbf9 --- /dev/null +++ b/tests/Images/ReferenceOutput/RendersAnchoredToReference_CjkVerticalLeftRight_TextBottom.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:26b7d443bfe1d7ed30436d50148315dcb9c442de80465c69f55a25f2d1a4b6be +size 5067 diff --git a/tests/Images/ReferenceOutput/RendersAnchoredToReference_CjkVerticalLeftRight_TextTop.png b/tests/Images/ReferenceOutput/RendersAnchoredToReference_CjkVerticalLeftRight_TextTop.png new file mode 100644 index 000000000..2b02201fd --- /dev/null +++ b/tests/Images/ReferenceOutput/RendersAnchoredToReference_CjkVerticalLeftRight_TextTop.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:6266c464dafa5fc5d3a93246c8909525c2443621cdf0968123a76ea6d4f17c34 +size 4280 diff --git a/tests/Images/ReferenceOutput/RendersAnchoredToReference_CjkVerticalMixedLeftRight_Alphabetic.png b/tests/Images/ReferenceOutput/RendersAnchoredToReference_CjkVerticalMixedLeftRight_Alphabetic.png new file mode 100644 index 000000000..00d4368f6 --- /dev/null +++ b/tests/Images/ReferenceOutput/RendersAnchoredToReference_CjkVerticalMixedLeftRight_Alphabetic.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:aceb8b75a2070bd94fd3e09f241aa4bbc9fe9dc7362fcd71db916e2bfb0cc7a8 +size 4684 diff --git a/tests/Images/ReferenceOutput/RendersAnchoredToReference_CjkVerticalMixedLeftRight_Central.png b/tests/Images/ReferenceOutput/RendersAnchoredToReference_CjkVerticalMixedLeftRight_Central.png new file mode 100644 index 000000000..f3801e606 --- /dev/null +++ b/tests/Images/ReferenceOutput/RendersAnchoredToReference_CjkVerticalMixedLeftRight_Central.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:79b7ab2dfc7dedcc4dd466e486393a0df0fa52c0f64547120334b9b5906c99e1 +size 7614 diff --git a/tests/Images/ReferenceOutput/RendersAnchoredToReference_CjkVerticalMixedLeftRight_Hanging.png b/tests/Images/ReferenceOutput/RendersAnchoredToReference_CjkVerticalMixedLeftRight_Hanging.png new file mode 100644 index 000000000..ff94800ab --- /dev/null +++ b/tests/Images/ReferenceOutput/RendersAnchoredToReference_CjkVerticalMixedLeftRight_Hanging.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:a2ac162b61663c22c51515338e880fed81832ceea687aaac19c939abb688d013 +size 4084 diff --git a/tests/Images/ReferenceOutput/RendersAnchoredToReference_CjkVerticalMixedLeftRight_Ideographic.png b/tests/Images/ReferenceOutput/RendersAnchoredToReference_CjkVerticalMixedLeftRight_Ideographic.png new file mode 100644 index 000000000..c77a0e899 --- /dev/null +++ b/tests/Images/ReferenceOutput/RendersAnchoredToReference_CjkVerticalMixedLeftRight_Ideographic.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:c5b60f2a17c862deefa084417928f86f8449c212c921f090ce6871f4cc5051b9 +size 4680 diff --git a/tests/Images/ReferenceOutput/RendersAnchoredToReference_CjkVerticalMixedLeftRight_LineBox.png b/tests/Images/ReferenceOutput/RendersAnchoredToReference_CjkVerticalMixedLeftRight_LineBox.png new file mode 100644 index 000000000..c77a0e899 --- /dev/null +++ b/tests/Images/ReferenceOutput/RendersAnchoredToReference_CjkVerticalMixedLeftRight_LineBox.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:c5b60f2a17c862deefa084417928f86f8449c212c921f090ce6871f4cc5051b9 +size 4680 diff --git a/tests/Images/ReferenceOutput/RendersAnchoredToReference_CjkVerticalMixedLeftRight_Middle.png b/tests/Images/ReferenceOutput/RendersAnchoredToReference_CjkVerticalMixedLeftRight_Middle.png new file mode 100644 index 000000000..9507e25ac --- /dev/null +++ b/tests/Images/ReferenceOutput/RendersAnchoredToReference_CjkVerticalMixedLeftRight_Middle.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:6d6cac2f2d3cbc038373b3bc2081391b86be5108b7f73dea47cf7f591de29303 +size 4485 diff --git a/tests/Images/ReferenceOutput/RendersAnchoredToReference_CjkVerticalMixedLeftRight_TextBottom.png b/tests/Images/ReferenceOutput/RendersAnchoredToReference_CjkVerticalMixedLeftRight_TextBottom.png new file mode 100644 index 000000000..5cbd23177 --- /dev/null +++ b/tests/Images/ReferenceOutput/RendersAnchoredToReference_CjkVerticalMixedLeftRight_TextBottom.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:afae4230c7512e4dc4b78ff8705994fc69aa79a1024457c8092dced9ae4bfa48 +size 4741 diff --git a/tests/Images/ReferenceOutput/RendersAnchoredToReference_CjkVerticalMixedLeftRight_TextTop.png b/tests/Images/ReferenceOutput/RendersAnchoredToReference_CjkVerticalMixedLeftRight_TextTop.png new file mode 100644 index 000000000..03316c88b --- /dev/null +++ b/tests/Images/ReferenceOutput/RendersAnchoredToReference_CjkVerticalMixedLeftRight_TextTop.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:5a559d0cc5eb131200af3e383b547b8287c46dfa8717ba44548fc822e540e51b +size 4148 diff --git a/tests/Images/ReferenceOutput/RendersAnchoredToReference_Cjk_Alphabetic.png b/tests/Images/ReferenceOutput/RendersAnchoredToReference_Cjk_Alphabetic.png new file mode 100644 index 000000000..1f0e89ada --- /dev/null +++ b/tests/Images/ReferenceOutput/RendersAnchoredToReference_Cjk_Alphabetic.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:eff4cc303678ce1cec089e749ea49da9d39b5d76eb6385d6f13dd2ceda1ce581 +size 6221 diff --git a/tests/Images/ReferenceOutput/RendersAnchoredToReference_Cjk_Central.png b/tests/Images/ReferenceOutput/RendersAnchoredToReference_Cjk_Central.png new file mode 100644 index 000000000..8bf27935a --- /dev/null +++ b/tests/Images/ReferenceOutput/RendersAnchoredToReference_Cjk_Central.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:1eaa9f47739b65d25cd332edd7eceda1f2854dd17f7a2895f736427e1ee7403d +size 4120 diff --git a/tests/Images/ReferenceOutput/RendersAnchoredToReference_Cjk_Hanging.png b/tests/Images/ReferenceOutput/RendersAnchoredToReference_Cjk_Hanging.png new file mode 100644 index 000000000..4f32ab970 --- /dev/null +++ b/tests/Images/ReferenceOutput/RendersAnchoredToReference_Cjk_Hanging.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:30cd50869b311232851b248afea0d34e35bc40efb1129691ac045c4bca91ec54 +size 4090 diff --git a/tests/Images/ReferenceOutput/RendersAnchoredToReference_Cjk_Ideographic.png b/tests/Images/ReferenceOutput/RendersAnchoredToReference_Cjk_Ideographic.png new file mode 100644 index 000000000..ca4805a18 --- /dev/null +++ b/tests/Images/ReferenceOutput/RendersAnchoredToReference_Cjk_Ideographic.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:31730b7fe66fc7a9ee4d39b7b4bda6c1e7b33da64a1a475479aff9a2b41a0a6c +size 3860 diff --git a/tests/Images/ReferenceOutput/RendersAnchoredToReference_Cjk_LineBox.png b/tests/Images/ReferenceOutput/RendersAnchoredToReference_Cjk_LineBox.png new file mode 100644 index 000000000..af0fadb7c --- /dev/null +++ b/tests/Images/ReferenceOutput/RendersAnchoredToReference_Cjk_LineBox.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:2ebdaf10b70d734aa7bb950734078dce8265f28de57ee61093fb21443f4bef76 +size 4096 diff --git a/tests/Images/ReferenceOutput/RendersAnchoredToReference_Cjk_Middle.png b/tests/Images/ReferenceOutput/RendersAnchoredToReference_Cjk_Middle.png new file mode 100644 index 000000000..71796e363 --- /dev/null +++ b/tests/Images/ReferenceOutput/RendersAnchoredToReference_Cjk_Middle.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:2332fc621affa6cc1859915fd07fa9994b180440e1ab7a7fe14ea3f8dee9629d +size 4105 diff --git a/tests/Images/ReferenceOutput/RendersAnchoredToReference_Cjk_TextBottom.png b/tests/Images/ReferenceOutput/RendersAnchoredToReference_Cjk_TextBottom.png new file mode 100644 index 000000000..442ee5a07 --- /dev/null +++ b/tests/Images/ReferenceOutput/RendersAnchoredToReference_Cjk_TextBottom.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:a002e916ee1f385a4dff9714d393d220cbab3038e9560b3816f83803c8400129 +size 3905 diff --git a/tests/Images/ReferenceOutput/RendersAnchoredToReference_Cjk_TextTop.png b/tests/Images/ReferenceOutput/RendersAnchoredToReference_Cjk_TextTop.png new file mode 100644 index 000000000..cbb371a3e --- /dev/null +++ b/tests/Images/ReferenceOutput/RendersAnchoredToReference_Cjk_TextTop.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:77aab06d408b87ca68381cc76867060bee2784fcd915b800cb831d0c55a8493e +size 4111 diff --git a/tests/Images/ReferenceOutput/RendersAnchoredToReference_Hanging.png b/tests/Images/ReferenceOutput/RendersAnchoredToReference_Hanging.png new file mode 100644 index 000000000..ff4ddbb69 --- /dev/null +++ b/tests/Images/ReferenceOutput/RendersAnchoredToReference_Hanging.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:a1b18a559d2024ec58d0185964175641caf4ee6e70600bec15794401de5c8ba9 +size 3011 diff --git a/tests/Images/ReferenceOutput/RendersAnchoredToReference_Ideographic.png b/tests/Images/ReferenceOutput/RendersAnchoredToReference_Ideographic.png new file mode 100644 index 000000000..3a6b507e7 --- /dev/null +++ b/tests/Images/ReferenceOutput/RendersAnchoredToReference_Ideographic.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:763c248af088cdbc77f9f9b2499cdb25a39222fd7f3aaa00fff5bc0d8031f2a0 +size 2953 diff --git a/tests/Images/ReferenceOutput/RendersAnchoredToReference_LineBox.png b/tests/Images/ReferenceOutput/RendersAnchoredToReference_LineBox.png new file mode 100644 index 000000000..1ac7a8c84 --- /dev/null +++ b/tests/Images/ReferenceOutput/RendersAnchoredToReference_LineBox.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:4dbe7d67320d72c78170dbbbd18ef8c15de60ba012857423758d87711058b238 +size 2993 diff --git a/tests/Images/ReferenceOutput/RendersAnchoredToReference_Middle.png b/tests/Images/ReferenceOutput/RendersAnchoredToReference_Middle.png new file mode 100644 index 000000000..827e09a43 --- /dev/null +++ b/tests/Images/ReferenceOutput/RendersAnchoredToReference_Middle.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:2a348060075cd1a7b54006513150321e4dc3ff9245a62d72c153d45f60765b8e +size 3035 diff --git a/tests/Images/ReferenceOutput/RendersAnchoredToReference_TextBottom.png b/tests/Images/ReferenceOutput/RendersAnchoredToReference_TextBottom.png new file mode 100644 index 000000000..3a6b507e7 --- /dev/null +++ b/tests/Images/ReferenceOutput/RendersAnchoredToReference_TextBottom.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:763c248af088cdbc77f9f9b2499cdb25a39222fd7f3aaa00fff5bc0d8031f2a0 +size 2953 diff --git a/tests/Images/ReferenceOutput/RendersAnchoredToReference_TextTop.png b/tests/Images/ReferenceOutput/RendersAnchoredToReference_TextTop.png new file mode 100644 index 000000000..1f49b4f63 --- /dev/null +++ b/tests/Images/ReferenceOutput/RendersAnchoredToReference_TextTop.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:ac44ee8c2d5e7a398b347f56d8a815c1a02ac8882d41b0a9b3fe44855d00bad7 +size 3006 diff --git a/tests/Images/ReferenceOutput/RendersAnchoredToReference_VerticalLeftRight_Alphabetic.png b/tests/Images/ReferenceOutput/RendersAnchoredToReference_VerticalLeftRight_Alphabetic.png new file mode 100644 index 000000000..a30cf035e --- /dev/null +++ b/tests/Images/ReferenceOutput/RendersAnchoredToReference_VerticalLeftRight_Alphabetic.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:d53f1d0b33d9a73aa575f8f8959658bbba376009c4dea82068b91ed1de830f3f +size 4255 diff --git a/tests/Images/ReferenceOutput/RendersAnchoredToReference_VerticalLeftRight_Central.png b/tests/Images/ReferenceOutput/RendersAnchoredToReference_VerticalLeftRight_Central.png new file mode 100644 index 000000000..18760dac9 --- /dev/null +++ b/tests/Images/ReferenceOutput/RendersAnchoredToReference_VerticalLeftRight_Central.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:ae3eca51425bccb05ba3a342d049e71e18ce0234570e9b313717d1fc5cfb9603 +size 3997 diff --git a/tests/Images/ReferenceOutput/RendersAnchoredToReference_VerticalLeftRight_Hanging.png b/tests/Images/ReferenceOutput/RendersAnchoredToReference_VerticalLeftRight_Hanging.png new file mode 100644 index 000000000..486e922d4 --- /dev/null +++ b/tests/Images/ReferenceOutput/RendersAnchoredToReference_VerticalLeftRight_Hanging.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:a192960e8eb4cd7509fb50fed756d3f6d5aa51be9359238c996b3874d06b9d2f +size 3575 diff --git a/tests/Images/ReferenceOutput/RendersAnchoredToReference_VerticalLeftRight_Ideographic.png b/tests/Images/ReferenceOutput/RendersAnchoredToReference_VerticalLeftRight_Ideographic.png new file mode 100644 index 000000000..0742672b6 --- /dev/null +++ b/tests/Images/ReferenceOutput/RendersAnchoredToReference_VerticalLeftRight_Ideographic.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:1d99b3f2c61c07196109ebbeba45c52b6c7659d2c61b7657c50aeb02e053c3d9 +size 4378 diff --git a/tests/Images/ReferenceOutput/RendersAnchoredToReference_VerticalLeftRight_LineBox.png b/tests/Images/ReferenceOutput/RendersAnchoredToReference_VerticalLeftRight_LineBox.png new file mode 100644 index 000000000..bdb69129f --- /dev/null +++ b/tests/Images/ReferenceOutput/RendersAnchoredToReference_VerticalLeftRight_LineBox.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:9866146dae6c5bd4c13bda196aa653c283ca0f4e7d3967e08bc509868d0cfa59 +size 4255 diff --git a/tests/Images/ReferenceOutput/RendersAnchoredToReference_VerticalLeftRight_Middle.png b/tests/Images/ReferenceOutput/RendersAnchoredToReference_VerticalLeftRight_Middle.png new file mode 100644 index 000000000..77bb9dece --- /dev/null +++ b/tests/Images/ReferenceOutput/RendersAnchoredToReference_VerticalLeftRight_Middle.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:4ac2eeb5ebb72162515c5bfe26d5b6771e11c3de825191ae3c0529f9dbeb66e2 +size 4063 diff --git a/tests/Images/ReferenceOutput/RendersAnchoredToReference_VerticalLeftRight_TextBottom.png b/tests/Images/ReferenceOutput/RendersAnchoredToReference_VerticalLeftRight_TextBottom.png new file mode 100644 index 000000000..0742672b6 --- /dev/null +++ b/tests/Images/ReferenceOutput/RendersAnchoredToReference_VerticalLeftRight_TextBottom.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:1d99b3f2c61c07196109ebbeba45c52b6c7659d2c61b7657c50aeb02e053c3d9 +size 4378 diff --git a/tests/Images/ReferenceOutput/RendersAnchoredToReference_VerticalLeftRight_TextTop.png b/tests/Images/ReferenceOutput/RendersAnchoredToReference_VerticalLeftRight_TextTop.png new file mode 100644 index 000000000..6ed0c38b0 --- /dev/null +++ b/tests/Images/ReferenceOutput/RendersAnchoredToReference_VerticalLeftRight_TextTop.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:3108f435df6640e4c085dd1af70f747e69b61d783cfc489ad396de0b63907b88 +size 3538 diff --git a/tests/Images/ReferenceOutput/RendersAnchoredToReference_VerticalMixedLeftRight_Alphabetic.png b/tests/Images/ReferenceOutput/RendersAnchoredToReference_VerticalMixedLeftRight_Alphabetic.png new file mode 100644 index 000000000..3b10a8167 --- /dev/null +++ b/tests/Images/ReferenceOutput/RendersAnchoredToReference_VerticalMixedLeftRight_Alphabetic.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:9aeba6b2cf042b249b846b96772c81a2e9a10a53806a413763f620f57ad8f921 +size 3136 diff --git a/tests/Images/ReferenceOutput/RendersAnchoredToReference_VerticalMixedLeftRight_Central.png b/tests/Images/ReferenceOutput/RendersAnchoredToReference_VerticalMixedLeftRight_Central.png new file mode 100644 index 000000000..6ea297249 --- /dev/null +++ b/tests/Images/ReferenceOutput/RendersAnchoredToReference_VerticalMixedLeftRight_Central.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:7cd2dab9ae5c1a14ea2c11e5d82754f12111324486059ce7f1595e7de251bdc4 +size 3035 diff --git a/tests/Images/ReferenceOutput/RendersAnchoredToReference_VerticalMixedLeftRight_Hanging.png b/tests/Images/ReferenceOutput/RendersAnchoredToReference_VerticalMixedLeftRight_Hanging.png new file mode 100644 index 000000000..153e7f38a --- /dev/null +++ b/tests/Images/ReferenceOutput/RendersAnchoredToReference_VerticalMixedLeftRight_Hanging.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:45707d79106e0c9079539cfdef1488e726f227990723a63e822855ef9bfd447d +size 2878 diff --git a/tests/Images/ReferenceOutput/RendersAnchoredToReference_VerticalMixedLeftRight_Ideographic.png b/tests/Images/ReferenceOutput/RendersAnchoredToReference_VerticalMixedLeftRight_Ideographic.png new file mode 100644 index 000000000..d44b43cb3 --- /dev/null +++ b/tests/Images/ReferenceOutput/RendersAnchoredToReference_VerticalMixedLeftRight_Ideographic.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:b055f0d5c2e9c3c09a6973f5bce5bfdfa8d95b095b271b2e6bd49b94072d269a +size 3195 diff --git a/tests/Images/ReferenceOutput/RendersAnchoredToReference_VerticalMixedLeftRight_LineBox.png b/tests/Images/ReferenceOutput/RendersAnchoredToReference_VerticalMixedLeftRight_LineBox.png new file mode 100644 index 000000000..bc35e13f6 --- /dev/null +++ b/tests/Images/ReferenceOutput/RendersAnchoredToReference_VerticalMixedLeftRight_LineBox.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:9026c48b1a9435f6943c6bbdc80e3d3e09aa817dc05192765e608f22a9c22d95 +size 3159 diff --git a/tests/Images/ReferenceOutput/RendersAnchoredToReference_VerticalMixedLeftRight_Middle.png b/tests/Images/ReferenceOutput/RendersAnchoredToReference_VerticalMixedLeftRight_Middle.png new file mode 100644 index 000000000..5f806740c --- /dev/null +++ b/tests/Images/ReferenceOutput/RendersAnchoredToReference_VerticalMixedLeftRight_Middle.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:25b0c6b0b134264210701889ad3ff61e3847c0d1bd3a0647a88da3f3b8d22f1e +size 3072 diff --git a/tests/Images/ReferenceOutput/RendersAnchoredToReference_VerticalMixedLeftRight_TextBottom.png b/tests/Images/ReferenceOutput/RendersAnchoredToReference_VerticalMixedLeftRight_TextBottom.png new file mode 100644 index 000000000..d44b43cb3 --- /dev/null +++ b/tests/Images/ReferenceOutput/RendersAnchoredToReference_VerticalMixedLeftRight_TextBottom.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:b055f0d5c2e9c3c09a6973f5bce5bfdfa8d95b095b271b2e6bd49b94072d269a +size 3195 diff --git a/tests/Images/ReferenceOutput/RendersAnchoredToReference_VerticalMixedLeftRight_TextTop.png b/tests/Images/ReferenceOutput/RendersAnchoredToReference_VerticalMixedLeftRight_TextTop.png new file mode 100644 index 000000000..3e083cf2f --- /dev/null +++ b/tests/Images/ReferenceOutput/RendersAnchoredToReference_VerticalMixedLeftRight_TextTop.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:c6280f155669118e5fde78e56bef66db7ceae49265a9e1341a0cc86919f4eb68 +size 2900 diff --git a/tests/SixLabors.Fonts.Tests/Issues/Issues_33.cs b/tests/SixLabors.Fonts.Tests/Issues/Issues_33.cs index 0434a451f..d720a4d51 100644 --- a/tests/SixLabors.Fonts.Tests/Issues/Issues_33.cs +++ b/tests/SixLabors.Fonts.Tests/Issues/Issues_33.cs @@ -27,7 +27,7 @@ public void WhiteSpaceAtStartOfLineNotMeasured(string text, float width, float h [Theory] [InlineData(LayoutMode.HorizontalTopBottom, 310, 40)] [InlineData(LayoutMode.VerticalLeftRight, 40, 310)] - [InlineData(LayoutMode.VerticalMixedLeftRight, 50, 310)] + [InlineData(LayoutMode.VerticalMixedLeftRight, 40, 310)] public void LeadingLineBreakContributesToWhitespaceBounds(LayoutMode layoutMode, float width, float height) { const string text = "\n\tHelloworld"; diff --git a/tests/SixLabors.Fonts.Tests/Tables/AdvancedTypographic/BaseTableTests.cs b/tests/SixLabors.Fonts.Tests/Tables/AdvancedTypographic/BaseTableTests.cs new file mode 100644 index 000000000..4fec8acf3 --- /dev/null +++ b/tests/SixLabors.Fonts.Tests/Tables/AdvancedTypographic/BaseTableTests.cs @@ -0,0 +1,392 @@ +// Copyright (c) Six Labors. +// Licensed under the Six Labors Split License. + +using System.Buffers.Binary; +using SixLabors.Fonts.Tables.AdvancedTypographic; + +namespace SixLabors.Fonts.Tests.Tables.AdvancedTypographic; + +public class BaseTableTests +{ + [Fact] + public void LoadBaseTable_ReadsBothAxesAndAllCoordFormats() + { + BaseTable table = BaseTable.Load(CreateFullTableWriter().GetReader()); + + Assert.NotNull(table.HorizontalAxis); + Assert.NotNull(table.VerticalAxis); + Assert.Equal(2, table.HorizontalAxis.BaselineTags.Length); + Assert.Equal(Tag.Parse("hang"), table.HorizontalAxis.BaselineTags[0]); + Assert.Equal(Tag.Parse("ideo"), table.HorizontalAxis.BaselineTags[1]); + Assert.Equal(1, table.HorizontalAxis.Scripts[0].Values!.DefaultBaselineIndex); + + // Horizontal axis coordinates use BaseCoord format 1. + Assert.True(table.TryGetBaselineCoordinate(Tag.Parse("hang"), false, out short coordinate)); + Assert.Equal(1638, coordinate); + Assert.True(table.TryGetBaselineCoordinate(Tag.Parse("ideo"), false, out coordinate)); + Assert.Equal(-288, coordinate); + + // Vertical axis coordinates use BaseCoord formats 2 and 3, whose design unit + // coordinate occupies the same leading position. + Assert.True(table.TryGetBaselineCoordinate(Tag.Parse("hang"), true, out coordinate)); + Assert.Equal(1900, coordinate); + Assert.True(table.TryGetBaselineCoordinate(Tag.Parse("ideo"), true, out coordinate)); + Assert.Equal(100, coordinate); + + // A baseline missing from the tag list reports false. + Assert.False(table.TryGetBaselineCoordinate(Tag.Parse("romn"), false, out _)); + Assert.False(table.TryGetBaselineCoordinate(Tag.Parse("romn"), true, out _)); + } + + [Fact] + public void TryGetBaselineCoordinate_ReturnsFalseWhenAxisMissing() + { + BigEndianBinaryWriter writer = new(); + + // Header referencing only a horizontal axis. + writer.WriteUInt16(1); + writer.WriteUInt16(0); + writer.WriteOffset16(8); + writer.WriteOffset16(0); + WriteSingleScriptAxis(writer, 1638, -288); + + BaseTable table = BaseTable.Load(writer.GetReader()); + + Assert.Null(table.VerticalAxis); + Assert.False(table.TryGetBaselineCoordinate(Tag.Parse("hang"), true, out _)); + Assert.True(table.TryGetBaselineCoordinate(Tag.Parse("hang"), false, out short coordinate)); + Assert.Equal(1638, coordinate); + } + + [Fact] + public void TryGetBaselineCoordinate_ReturnsFalseWhenTagListMissing() + { + BigEndianBinaryWriter writer = new(); + + writer.WriteUInt16(1); + writer.WriteUInt16(0); + writer.WriteOffset16(8); + writer.WriteOffset16(0); + + // Axis at 8 with a NULL BaseTagList. + writer.WriteOffset16(0); + writer.WriteOffset16(4); + + // BaseScriptList at 12 with a single 'DFLT' record at 8 from list start. + writer.WriteUInt16(1); + writer.WriteUInt32("DFLT"); + writer.WriteOffset16(8); + + // BaseScript at 20 with values at 6 from script start. + writer.WriteOffset16(6); + writer.WriteOffset16(0); + writer.WriteUInt16(0); + + // BaseValues at 26 with no coordinates. + writer.WriteUInt16(0); + writer.WriteUInt16(0); + + BaseTable table = BaseTable.Load(writer.GetReader()); + + Assert.False(table.TryGetBaselineCoordinate(Tag.Parse("hang"), false, out _)); + } + + [Fact] + public void TryGetBaselineCoordinate_ReturnsFalseWhenScriptDefinesNoValues() + { + BigEndianBinaryWriter writer = new(); + + writer.WriteUInt16(1); + writer.WriteUInt16(0); + writer.WriteOffset16(8); + writer.WriteOffset16(0); + + // Axis at 8. + writer.WriteOffset16(4); + writer.WriteOffset16(14); + + // BaseTagList at 12. + writer.WriteUInt16(2); + writer.WriteUInt32("hang"); + writer.WriteUInt32("ideo"); + + // BaseScriptList at 22 with a single 'DFLT' record at 8 from list start. + writer.WriteUInt16(1); + writer.WriteUInt32("DFLT"); + writer.WriteOffset16(8); + + // BaseScript at 30 with a NULL BaseValues offset. + writer.WriteOffset16(0); + writer.WriteOffset16(0); + writer.WriteUInt16(0); + + BaseTable table = BaseTable.Load(writer.GetReader()); + + Assert.False(table.TryGetBaselineCoordinate(Tag.Parse("hang"), false, out _)); + } + + [Fact] + public void TryGetBaselineCoordinate_PrefersDefaultScriptRecord() + { + BaseTable table = BaseTable.Load(CreateTwoScriptWriter(true).GetReader()); + + Assert.True(table.TryGetBaselineCoordinate(Tag.Parse("ideo"), false, out short coordinate)); + Assert.Equal(222, coordinate); + } + + [Fact] + public void TryGetBaselineCoordinate_FallsBackToFirstScriptWithValues() + { + BaseTable table = BaseTable.Load(CreateTwoScriptWriter(false).GetReader()); + + Assert.True(table.TryGetBaselineCoordinate(Tag.Parse("ideo"), false, out short coordinate)); + Assert.Equal(222, coordinate); + } + + [Fact] + public void ShouldReturnNullWhenTableCouldNotBeFound() + { + BigEndianBinaryWriter writer = new(); + writer.WriteTrueTypeFileHeader(); + + using MemoryStream stream = writer.GetStream(); + using FontReader reader = new(stream); + + Assert.Null(BaseTable.Load(reader)); + } + + [Fact] + public void GetBaselineOffset_UsesBaselineTableCoordinates() + { + Font font = CreateFontWithBaseTable(); + FontMetrics metrics = font.FontMetrics; + float scale = font.Size / metrics.ScaleFactor; + + // Horizontal coordinates are Y values measured up from the alphabetic baseline. + Assert.Equal(-(1638 * scale), TextLayout.GetBaselineOffset(TextBaseline.Hanging, font, false)); + Assert.Equal(-(-288 * scale), TextLayout.GetBaselineOffset(TextBaseline.Ideographic, font, false)); + + // Vertical coordinates are X values measured from the em box leading edge, re-centered + // on the central column axis; X increases toward the over side. + Assert.Equal((1900 - (metrics.UnitsPerEm * .5F)) * scale, TextLayout.GetBaselineOffset(TextBaseline.Hanging, font, true)); + Assert.Equal((100 - (metrics.UnitsPerEm * .5F)) * scale, TextLayout.GetBaselineOffset(TextBaseline.Ideographic, font, true)); + } + + [Fact] + public void GetBaselineOffset_BaselineTableLeavesMetricBaselinesUntouched() + { + Font tabled = CreateFontWithBaseTable(); + Font plain = TestFonts.GetFont(TestFonts.OpenSansFile, 72); + + Assert.Equal( + TextLayout.GetBaselineOffset(TextBaseline.TextBottom, plain, false), + TextLayout.GetBaselineOffset(TextBaseline.TextBottom, tabled, false)); + Assert.Equal( + TextLayout.GetBaselineOffset(TextBaseline.TextTop, plain, true), + TextLayout.GetBaselineOffset(TextBaseline.TextTop, tabled, true)); + + // The plain font resolves the same baselines from metric fallbacks instead. + Assert.NotEqual( + TextLayout.GetBaselineOffset(TextBaseline.Hanging, plain, false), + TextLayout.GetBaselineOffset(TextBaseline.Hanging, tabled, false)); + } + + private static BigEndianBinaryWriter CreateFullTableWriter() + { + BigEndianBinaryWriter writer = new(); + + // Header with the horizontal axis at 8 and the vertical axis at 52. + writer.WriteUInt16(1); + writer.WriteUInt16(0); + writer.WriteOffset16(8); + writer.WriteOffset16(52); + + WriteSingleScriptAxis(writer, 1638, -288); + + // Vertical axis at 52; layout mirrors the horizontal axis with wider coords. + writer.WriteOffset16(4); + writer.WriteOffset16(14); + + // BaseTagList at axis + 4. + writer.WriteUInt16(2); + writer.WriteUInt32("hang"); + writer.WriteUInt32("ideo"); + + // BaseScriptList at axis + 14 with a single 'DFLT' record at 8 from list start. + writer.WriteUInt16(1); + writer.WriteUInt32("DFLT"); + writer.WriteOffset16(8); + + // BaseScript at axis + 22 with values at 6 from script start. + writer.WriteOffset16(6); + writer.WriteOffset16(0); + writer.WriteUInt16(0); + + // BaseValues at axis + 28; coords at 8 and 16 from values start. + writer.WriteUInt16(1); + writer.WriteUInt16(2); + writer.WriteOffset16(8); + writer.WriteOffset16(16); + + // BaseCoord format 2 with a reference glyph and contour point. + writer.WriteUInt16(2); + writer.Write((short)1900); + writer.WriteUInt16(42); + writer.WriteUInt16(7); + + // BaseCoord format 3 with a NULL device offset. + writer.WriteUInt16(3); + writer.Write((short)100); + writer.WriteOffset16(0); + + return writer; + } + + private static void WriteSingleScriptAxis(BigEndianBinaryWriter writer, short hangCoordinate, short ideoCoordinate) + { + // Axis table. + writer.WriteOffset16(4); + writer.WriteOffset16(14); + + // BaseTagList at axis + 4. + writer.WriteUInt16(2); + writer.WriteUInt32("hang"); + writer.WriteUInt32("ideo"); + + // BaseScriptList at axis + 14 with a single 'DFLT' record at 8 from list start. + writer.WriteUInt16(1); + writer.WriteUInt32("DFLT"); + writer.WriteOffset16(8); + + // BaseScript at axis + 22 with values at 6 from script start. + writer.WriteOffset16(6); + writer.WriteOffset16(0); + writer.WriteUInt16(0); + + // BaseValues at axis + 28; format 1 coords at 8 and 12 from values start. + writer.WriteUInt16(1); + writer.WriteUInt16(2); + writer.WriteOffset16(8); + writer.WriteOffset16(12); + + writer.WriteUInt16(1); + writer.Write(hangCoordinate); + + writer.WriteUInt16(1); + writer.Write(ideoCoordinate); + } + + private static BigEndianBinaryWriter CreateTwoScriptWriter(bool secondScriptIsDefault) + { + BigEndianBinaryWriter writer = new(); + + writer.WriteUInt16(1); + writer.WriteUInt16(0); + writer.WriteOffset16(8); + writer.WriteOffset16(0); + + // Axis at 8. + writer.WriteOffset16(4); + writer.WriteOffset16(10); + + // BaseTagList at 12 with the single 'ideo' tag. + writer.WriteUInt16(1); + writer.WriteUInt32("ideo"); + + // BaseScriptList at 18 with records at 14 and 20 from list start. + writer.WriteUInt16(2); + writer.WriteUInt32("BNG "); + writer.WriteOffset16(14); + writer.WriteUInt32(secondScriptIsDefault ? "DFLT" : "latn"); + writer.WriteOffset16(20); + + // First BaseScript at 32. When testing default script preference it carries a + // decoy value; when testing first-with-values fallback it carries none. + if (secondScriptIsDefault) + { + writer.WriteOffset16(12); + } + else + { + writer.WriteOffset16(0); + } + + writer.WriteOffset16(0); + writer.WriteUInt16(0); + + // Second BaseScript at 38 with values at 16 from script start. + writer.WriteOffset16(16); + writer.WriteOffset16(0); + writer.WriteUInt16(0); + + // First script BaseValues at 44 with its format 1 coord at 6 from values start. + writer.WriteUInt16(0); + writer.WriteUInt16(1); + writer.WriteOffset16(6); + writer.WriteUInt16(1); + writer.Write((short)111); + + // Second script BaseValues at 54 with its format 1 coord at 6 from values start. + writer.WriteUInt16(0); + writer.WriteUInt16(1); + writer.WriteOffset16(6); + writer.WriteUInt16(1); + writer.Write((short)222); + + return writer; + } + + private static Font CreateFontWithBaseTable() + { + using MemoryStream baseStream = CreateFullTableWriter().GetStream(); + byte[] baseTable = baseStream.ToArray(); + byte[] source = File.ReadAllBytes(TestFonts.OpenSansFile); + + // An OpenType file starts with the offset table: + // uint32 sfntVersion, uint16 numTables, uint16 searchRange, + // uint16 entrySelector, uint16 rangeShift + // followed by numTables directory entries of 16 bytes each: + // uint32 tag, uint32 checksum, uint32 offset, uint32 length + // where offset is measured from the start of the file. Table data follows the + // directory, so inserting one directory entry shifts every table by 16 bytes. + ushort numTables = BinaryPrimitives.ReadUInt16BigEndian(source.AsSpan(4)); + int directoryEnd = 12 + (numTables * 16); + + // Rebuild the font with one extra directory entry, shifting every table by the + // inserted entry size and appending the BASE data at the end. + byte[] result = new byte[source.Length + 16 + baseTable.Length]; + + // Copy the 12 byte offset table header and bump the table count. + source.AsSpan(0, 12).CopyTo(result); + BinaryPrimitives.WriteUInt16BigEndian(result.AsSpan(4), (ushort)(numTables + 1)); + + // Copy each original directory entry, adjusting its data offset for the 16 bytes + // the new entry inserts ahead of every table. searchRange, entrySelector, and + // rangeShift in the header describe an optional binary search layout the reader + // never consults, so they can stay stale. + for (int i = 0; i < numTables; i++) + { + int entry = 12 + (i * 16); + source.AsSpan(entry, 16).CopyTo(result.AsSpan(entry)); + uint offset = BinaryPrimitives.ReadUInt32BigEndian(source.AsSpan(entry + 8)); + BinaryPrimitives.WriteUInt32BigEndian(result.AsSpan(entry + 8), offset + 16); + } + + // Append the new directory entry after the originals. 0x42415345 is the table tag + // 'BASE' as big endian ASCII (0x42 'B', 0x41 'A', 0x53 'S', 0x45 'E'). The checksum + // is left zero because the reader never validates checksums, and the table data is + // appended at what was the end of the file, now 16 bytes further along. + int newEntry = directoryEnd; + BinaryPrimitives.WriteUInt32BigEndian(result.AsSpan(newEntry), 0x42415345); + BinaryPrimitives.WriteUInt32BigEndian(result.AsSpan(newEntry + 4), 0); + BinaryPrimitives.WriteUInt32BigEndian(result.AsSpan(newEntry + 8), (uint)(source.Length + 16)); + BinaryPrimitives.WriteUInt32BigEndian(result.AsSpan(newEntry + 12), (uint)baseTable.Length); + + // Copy all original table data verbatim, then the new BASE table at the end. + source.AsSpan(directoryEnd).CopyTo(result.AsSpan(directoryEnd + 16)); + baseTable.CopyTo(result.AsSpan(source.Length + 16)); + + using MemoryStream fontStream = new(result); + return new FontCollection().Add(fontStream).CreateFont(72); + } +} diff --git a/tests/SixLabors.Fonts.Tests/TestFonts.cs b/tests/SixLabors.Fonts.Tests/TestFonts.cs index 9b22323c2..7639b7313 100644 --- a/tests/SixLabors.Fonts.Tests/TestFonts.cs +++ b/tests/SixLabors.Fonts.Tests/TestFonts.cs @@ -15,6 +15,10 @@ public static class TestFonts public static string CarterOneFile => GetFullPath("Carter_One/CarterOne.ttf"); + // Subset of Noto Sans SC pinned to the TextBaselineTests browser comparison strings, + // retaining the BASE table on both axes plus the vertical metrics and layout features. + public static string NotoSansSCBaselineSubsetFile => GetFullPath("Noto_Sans_SC/NotoSansSC-BaselineSubset.ttf"); + public static string WendyOneFile => GetFullPath("Wendy_One/WendyOne-Regular.ttf"); // Font from: https://google-webfonts-helper.herokuapp.com/fonts/open-sans?subsets=cyrillic,cyrillic-ext,greek,greek-ext,hebrew,latin,latin-ext,vietnamese diff --git a/tests/SixLabors.Fonts.Tests/TextBaselineTests.cs b/tests/SixLabors.Fonts.Tests/TextBaselineTests.cs new file mode 100644 index 000000000..0d7a9a847 --- /dev/null +++ b/tests/SixLabors.Fonts.Tests/TextBaselineTests.cs @@ -0,0 +1,401 @@ +// Copyright (c) Six Labors. +// Licensed under the Six Labors Split License. + +using System.Numerics; +using SixLabors.Fonts.Rendering; +using SixLabors.Fonts.Unicode; +using SixLabors.ImageSharp; +using SixLabors.ImageSharp.PixelFormats; + +namespace SixLabors.Fonts.Tests; + +public class TextBaselineTests +{ + // Shared with tests/Browser/TextBaseline.html, which renders the identical text with the + // equivalent browser anchors; the letters cover an ascender, the x-height, and descenders. + private const string BrowserComparisonText = "Hxplq"; + + // CJK companion string for the same page. The Noto Sans SC subset in tests/Fonts carries + // a real BASE table on both axes plus vertical metrics, so these rows exercise the + // table-driven baselines; its glyph set is pinned to exactly these characters plus + // BrowserComparisonText, so extending either string requires regenerating the subset. + private const string BrowserComparisonCjkText = "永国Hxq、"; + + // Rendered large so the differences between reference lines span many pixels; at small + // sizes adjacent anchors differ by well under a pixel. + private const float BrowserComparisonPointSize = 72; + + private static readonly ApproximateFloatComparer Comparer = new(0.001F); + + private static Font Font => TextLayoutTests.CreateRenderingFont(); + + [Theory] + [InlineData(TextBaseline.TextTop)] + [InlineData(TextBaseline.Hanging)] + [InlineData(TextBaseline.Middle)] + [InlineData(TextBaseline.Central)] + [InlineData(TextBaseline.Alphabetic)] + [InlineData(TextBaseline.Ideographic)] + [InlineData(TextBaseline.TextBottom)] + public void RenderAndMetrics_AnchorReferenceLineAtOrigin(TextBaseline baseline) + { + const string text = "Hxp"; + const float originY = 100; + Font font = Font; + float referenceOffset = GetReferenceOffsetPx(font, baseline); + + // Alphabetic places the baseline exactly on the origin; every other value renders the + // same glyphs shifted by the selected reference line's offset from that baseline. + GlyphRenderer alphabetic = new(); + TextRenderer.RenderTo(alphabetic, text, Options(font, TextBaseline.Alphabetic, originY)); + + GlyphRenderer anchored = new(); + TextRenderer.RenderTo(anchored, text, Options(font, baseline, originY)); + + Assert.Equal(alphabetic.GlyphRects.Count, anchored.GlyphRects.Count); + for (int i = 0; i < alphabetic.GlyphRects.Count; i++) + { + Assert.Equal(alphabetic.GlyphRects[i].Y - referenceOffset, anchored.GlyphRects[i].Y, Comparer); + Assert.Equal(alphabetic.GlyphRects[i].X, anchored.GlyphRects[i].X, Comparer); + } + + // Metrics agree with rendering: the absolute baseline of the first line sits at the + // origin minus the reference offset. + TextBlock block = new(text, Options(font, baseline, originY)); + LineMetrics line = block.GetLineMetrics(-1).Span[0]; + Assert.Equal(originY - referenceOffset, line.Start.Y + line.Baseline, Comparer); + } + + [Fact] + public void LineBox_IsTheDefault_AndMatchesLegacyLayout() + { + const string text = "Hxp"; + Font font = Font; + + TextOptions defaultOptions = new(font) { Origin = new Vector2(0, 100) }; + Assert.Equal(TextBaseline.LineBox, defaultOptions.TextBaseline); + + GlyphRenderer legacy = new(); + TextRenderer.RenderTo(legacy, text, defaultOptions); + + GlyphRenderer explicitLineBox = new(); + TextRenderer.RenderTo(explicitLineBox, text, Options(font, TextBaseline.LineBox, 100)); + + Assert.Equal(legacy.GlyphRects.Count, explicitLineBox.GlyphRects.Count); + for (int i = 0; i < legacy.GlyphRects.Count; i++) + { + Assert.Equal(legacy.GlyphRects[i], explicitLineBox.GlyphRects[i], Comparer); + } + } + + [Fact] + public void GlyphId_LineBoxAnchorsEmBoxTop_AlphabeticRestoresBaseline() + { + Font font = Font; + Assert.True(font.TryGetGlyphs(new CodePoint('A'), out Glyph? glyph)); + ushort glyphId = glyph.Value.GlyphMetrics.GlyphId; + + FontMetrics metrics = font.FontMetrics; + float scalePx = font.Size / metrics.ScaleFactor * 72F; + float ascenderPx = metrics.HorizontalMetrics.Ascender * scalePx; + float deltaPx = (metrics.HorizontalMetrics.LineHeight - metrics.UnitsPerEm) * scalePx * .5F; + + GlyphRenderer lineBox = new(); + TextRenderer.RenderTo(lineBox, glyphId, GlyphIdOptions(font, TextBaseline.LineBox)); + + GlyphRenderer alphabetic = new(); + TextRenderer.RenderTo(alphabetic, glyphId, GlyphIdOptions(font, TextBaseline.Alphabetic)); + + // LineBox anchors the line-box top at the origin: the baseline sits one delta-adjusted + // ascender below it, the delta centering the em box within the declared line height. + Assert.Equal(alphabetic.GlyphRects[0].Y + ascenderPx - deltaPx, lineBox.GlyphRects[0].Y, Comparer); + Assert.Equal(alphabetic.GlyphRects[0].X, lineBox.GlyphRects[0].X, Comparer); + } + + [Fact] + public void Text_MeasureAdvance_IsZeroBased_AndUnmovedByBaseline() + { + const string text = "Hxp"; + Font font = Font; + FontRectangle reference = TextMeasurer.MeasureAdvance(text, Options(font, TextBaseline.LineBox, 0)); + + Assert.Equal(0, reference.X); + Assert.Equal(0, reference.Y); + Assert.True(reference.Width > 0); + Assert.True(reference.Height > 0); + + // The advance is a logical measure: no origin and no baseline anchor may move it. + foreach (TextBaseline baseline in Enum.GetValues()) + { + Assert.Equal(reference, TextMeasurer.MeasureAdvance(text, Options(font, baseline, 100))); + } + } + + [Theory] + [InlineData(TextBaseline.LineBox)] + [InlineData(TextBaseline.TextTop)] + [InlineData(TextBaseline.Hanging)] + [InlineData(TextBaseline.Middle)] + [InlineData(TextBaseline.Central)] + [InlineData(TextBaseline.Alphabetic)] + [InlineData(TextBaseline.Ideographic)] + [InlineData(TextBaseline.TextBottom)] + public void Text_MeasureBounds_MatchesRenderedInk(TextBaseline baseline) + { + const string text = "Hxp"; + TextOptions options = Options(Font, baseline, 100); + + GlyphRenderer renderer = new(); + TextRenderer.RenderTo(renderer, text, options); + + // Measured ink bounds must equal the union of the boxes the renderer reports. + FontRectangle expected = default; + bool hasInk = false; + foreach (FontRectangle rect in renderer.GlyphRects) + { + if (rect.Width <= 0 && rect.Height <= 0) + { + continue; + } + + expected = hasInk ? FontRectangle.Union(expected, rect) : rect; + hasInk = true; + } + + Assert.True(hasInk); + Assert.Equal(expected, TextMeasurer.MeasureBounds(text, options), Comparer); + } + + [Theory] + [InlineData(TextBaseline.LineBox)] + [InlineData(TextBaseline.TextTop)] + [InlineData(TextBaseline.Hanging)] + [InlineData(TextBaseline.Middle)] + [InlineData(TextBaseline.Central)] + [InlineData(TextBaseline.Alphabetic)] + [InlineData(TextBaseline.Ideographic)] + [InlineData(TextBaseline.TextBottom)] + public void Text_MeasureRenderableBounds_ComposesAdvanceAtOrigin(TextBaseline baseline) + { + const string text = "Hxp"; + const float originY = 100; + TextOptions options = Options(Font, baseline, originY); + + FontRectangle advance = TextMeasurer.MeasureAdvance(text, options); + FontRectangle bounds = TextMeasurer.MeasureBounds(text, options); + FontRectangle expected = FontRectangle.Union( + new FontRectangle(0, originY, advance.Width, advance.Height), + bounds); + + Assert.Equal(expected, TextMeasurer.MeasureRenderableBounds(text, options), Comparer); + } + + [Fact] + public void GlyphId_Alphabetic_MatchesTextRenderedPosition() + { + Font font = Font; + const float originY = 100; + + GlyphRenderer text = new(); + TextRenderer.RenderTo(text, "A", Options(font, TextBaseline.Alphabetic, originY)); + + Assert.True(font.TryGetGlyphs(new CodePoint('A'), out Glyph? glyph)); + GlyphRenderer glyphId = new(); + TextRenderer.RenderTo(glyphId, glyph.Value.GlyphMetrics.GlyphId, GlyphIdOptions(font, TextBaseline.Alphabetic)); + + // Both APIs anchor the alphabetic baseline at the origin, so a lone glyph must land + // at the same position through either pipeline. + Assert.Equal(text.GlyphRects[0], glyphId.GlyphRects[0], Comparer); + } + + [Theory] + [InlineData(TextBaseline.LineBox)] + [InlineData(TextBaseline.TextTop)] + [InlineData(TextBaseline.Hanging)] + [InlineData(TextBaseline.Middle)] + [InlineData(TextBaseline.Central)] + [InlineData(TextBaseline.Alphabetic)] + [InlineData(TextBaseline.Ideographic)] + [InlineData(TextBaseline.TextBottom)] + public void RendersAnchoredToReference(TextBaseline baseline) + { + const float originY = 120; + Font font = TestFonts.GetFont(TestFonts.OpenSansFile, BrowserComparisonPointSize); + TextOptions options = BrowserComparisonOptions(font, baseline, originY); + + // The red rule marks the origin so each reference image shows the selected reference + // line of the text sitting exactly on it. + TextLayoutTestUtilities.TestLayout( + BrowserComparisonText, + options, + beforeAction: static image => DrawOriginRule(image, (int)originY), + properties: baseline); + } + + [Theory] + [InlineData(TextBaseline.LineBox)] + [InlineData(TextBaseline.TextTop)] + [InlineData(TextBaseline.Hanging)] + [InlineData(TextBaseline.Middle)] + [InlineData(TextBaseline.Central)] + [InlineData(TextBaseline.Alphabetic)] + [InlineData(TextBaseline.Ideographic)] + [InlineData(TextBaseline.TextBottom)] + public void RendersAnchoredToReference_VerticalLeftRight(TextBaseline baseline) + => TestVerticalLayout(LayoutMode.VerticalLeftRight, baseline, TestFonts.OpenSansFile, BrowserComparisonText); + + [Theory] + [InlineData(TextBaseline.LineBox)] + [InlineData(TextBaseline.TextTop)] + [InlineData(TextBaseline.Hanging)] + [InlineData(TextBaseline.Middle)] + [InlineData(TextBaseline.Central)] + [InlineData(TextBaseline.Alphabetic)] + [InlineData(TextBaseline.Ideographic)] + [InlineData(TextBaseline.TextBottom)] + public void RendersAnchoredToReference_VerticalMixedLeftRight(TextBaseline baseline) + => TestVerticalLayout(LayoutMode.VerticalMixedLeftRight, baseline, TestFonts.OpenSansFile, BrowserComparisonText); + + [Theory] + [InlineData(TextBaseline.LineBox)] + [InlineData(TextBaseline.TextTop)] + [InlineData(TextBaseline.Hanging)] + [InlineData(TextBaseline.Middle)] + [InlineData(TextBaseline.Central)] + [InlineData(TextBaseline.Alphabetic)] + [InlineData(TextBaseline.Ideographic)] + [InlineData(TextBaseline.TextBottom)] + public void RendersAnchoredToReference_Cjk(TextBaseline baseline) + { + const float originY = 120; + Font font = TestFonts.GetFont(TestFonts.NotoSansSCBaselineSubsetFile, BrowserComparisonPointSize); + TextOptions options = BrowserComparisonOptions(font, baseline, originY); + + // The red rule marks the origin so each reference image shows the selected reference + // line of the text sitting exactly on it. Hanging exercises the metric fallback: the + // font's baseline table defines the ideographic set but no hanging baseline. + TextLayoutTestUtilities.TestLayout( + BrowserComparisonCjkText, + options, + beforeAction: static image => DrawOriginRule(image, (int)originY), + properties: baseline); + } + + [Theory] + [InlineData(TextBaseline.LineBox)] + [InlineData(TextBaseline.TextTop)] + [InlineData(TextBaseline.Hanging)] + [InlineData(TextBaseline.Middle)] + [InlineData(TextBaseline.Central)] + [InlineData(TextBaseline.Alphabetic)] + [InlineData(TextBaseline.Ideographic)] + [InlineData(TextBaseline.TextBottom)] + public void RendersAnchoredToReference_CjkVerticalLeftRight(TextBaseline baseline) + => TestVerticalLayout(LayoutMode.VerticalLeftRight, baseline, TestFonts.NotoSansSCBaselineSubsetFile, BrowserComparisonCjkText); + + [Theory] + [InlineData(TextBaseline.LineBox)] + [InlineData(TextBaseline.TextTop)] + [InlineData(TextBaseline.Hanging)] + [InlineData(TextBaseline.Middle)] + [InlineData(TextBaseline.Central)] + [InlineData(TextBaseline.Alphabetic)] + [InlineData(TextBaseline.Ideographic)] + [InlineData(TextBaseline.TextBottom)] + public void RendersAnchoredToReference_CjkVerticalMixedLeftRight(TextBaseline baseline) + => TestVerticalLayout(LayoutMode.VerticalMixedLeftRight, baseline, TestFonts.NotoSansSCBaselineSubsetFile, BrowserComparisonCjkText); + + private static void TestVerticalLayout( + LayoutMode layoutMode, + TextBaseline baseline, + string fontFile, + string text, + [System.Runtime.CompilerServices.CallerMemberName] string test = "") + { + const float originX = 120; + TextOptions options = new(TestFonts.GetFont(fontFile, BrowserComparisonPointSize)) + { + Origin = new Vector2(originX, 10), + TextBaseline = baseline, + LayoutMode = layoutMode, + Dpi = 96F + }; + + // Columns anchor along X from the central column axis; the red rule marks the origin + // column so each reference image shows the selected reference line sitting on it. + TextLayoutTestUtilities.TestLayout( + text, + options, + test: test, + beforeAction: static image => DrawOriginColumnRule(image, (int)originX), + properties: baseline); + } + + private static void DrawOriginRule(Image image, int y) + { + Rgba32 red = Color.Red.ToPixel(); + for (int x = 0; x < image.Width; x++) + { + image[x, y] = red; + } + } + + private static void DrawOriginColumnRule(Image image, int x) + { + Rgba32 red = Color.Red.ToPixel(); + for (int y = 0; y < image.Height; y++) + { + image[x, y] = red; + } + } + + private static TextOptions Options(Font font, TextBaseline baseline, float originY) + => new(font) + { + Origin = new Vector2(0, originY), + TextBaseline = baseline + }; + + // 96 dpi maps the font's point size one to one onto CSS pixels so the browser page + // mirrors the rendered geometry exactly, matching the other browser comparison tests. + private static TextOptions BrowserComparisonOptions(Font font, TextBaseline baseline, float originY) + => new(font) + { + Origin = new Vector2(0, originY), + TextBaseline = baseline, + Dpi = 96F + }; + + private static GlyphOptions GlyphIdOptions(Font font, TextBaseline baseline) + => new() + { + Font = font, + Origin = new Vector2(0, 100), + TextBaseline = baseline + }; + + private static float GetReferenceOffsetPx(Font font, TextBaseline baseline) + { + // Expectations derive from the public font metrics at the default 72 DPI, mirroring + // the documented definition of each reference line rather than the implementation. + FontMetrics metrics = font.FontMetrics; + float scale = font.Size / metrics.ScaleFactor * 72F; + float ascender = metrics.HorizontalMetrics.Ascender * scale; + float descender = metrics.HorizontalMetrics.Descender * scale; + float xHeight = metrics.XHeight * scale; + if (xHeight <= 0) + { + xHeight = ascender * .5F; + } + + return baseline switch + { + TextBaseline.TextTop => -ascender, + TextBaseline.Hanging => -0.8F * ascender, + TextBaseline.Middle => -xHeight * .5F, + TextBaseline.Central => -(ascender + descender) * .5F, + TextBaseline.Ideographic or TextBaseline.TextBottom => -descender, + _ => 0F, + }; + } +} diff --git a/tests/SixLabors.Fonts.Tests/TextBlockTests.cs b/tests/SixLabors.Fonts.Tests/TextBlockTests.cs index d0eaedb8f..035f67ef8 100644 --- a/tests/SixLabors.Fonts.Tests/TextBlockTests.cs +++ b/tests/SixLabors.Fonts.Tests/TextBlockTests.cs @@ -332,6 +332,101 @@ public void LineLayout_RenderTo_RendersOnlyThatLine() Assert.True(blockRenderer.GlyphRects.Count > lineRenderer.GlyphRects.Count); } + [Theory] + [InlineData(LayoutMode.HorizontalTopBottom)] + [InlineData(LayoutMode.HorizontalBottomTop)] + [InlineData(LayoutMode.VerticalLeftRight)] + [InlineData(LayoutMode.VerticalRightLeft)] + [InlineData(LayoutMode.VerticalMixedLeftRight)] + [InlineData(LayoutMode.VerticalMixedRightLeft)] + public void RenderTo_VisibleBounds_CoveringBandMatchesFullRender(LayoutMode layoutMode) + { + const string text = "Alpha beta gamma delta epsilon zeta eta theta iota kappa lambda mu."; + const float wrappingLength = 90; + TextOptions options = Options(-1); + options.LayoutMode = layoutMode; + TextBlock block = new(text, options); + + GlyphRenderer full = new(); + block.RenderTo(full, wrappingLength); + + GlyphRenderer culled = new(); + block.RenderTo(culled, wrappingLength, block.MeasureRenderableBounds(wrappingLength)); + + // A band covering the whole block must produce the identical glyph stream: a culled + // line advances the pen through the same arithmetic as a rendered one. + Assert.Equal(full.GlyphRects.Count, culled.GlyphRects.Count); + Assert.Equal(full.ControlPoints.Count, culled.ControlPoints.Count); + for (int i = 0; i < full.GlyphRects.Count; i++) + { + Assert.Equal(full.GlyphRects[i], culled.GlyphRects[i], Comparer); + } + } + + [Theory] + [InlineData(LayoutMode.HorizontalTopBottom)] + [InlineData(LayoutMode.VerticalLeftRight)] + public void RenderTo_VisibleBounds_CullsLinesOutsideBand(LayoutMode layoutMode) + { + const string text = "Alpha beta gamma delta epsilon zeta eta theta iota kappa lambda mu nu xi omicron pi."; + const float wrappingLength = 90; + TextOptions options = Options(-1); + options.LayoutMode = layoutMode; + TextBlock block = new(text, options); + + GlyphRenderer full = new(); + block.RenderTo(full, wrappingLength); + + ReadOnlySpan lines = block.GetLineMetrics(wrappingLength).Span; + Assert.True(lines.Length >= 7); + + // A band covering exactly the middle line. Culling compares along the block flow + // axis: Y for horizontal layouts, X for vertical layouts. + int middle = lines.Length / 2; + LineMetrics middleLine = lines[middle]; + FontRectangle band = layoutMode == LayoutMode.HorizontalTopBottom + ? new(0, middleLine.Start.Y, 10000, middleLine.Extent.Y) + : new(middleLine.Start.X, 0, middleLine.Extent.X, 10000); + + GlyphRenderer culled = new(); + block.RenderTo(culled, wrappingLength, band); + + // With at least three lines on each side of the band and a one-line-height tolerance, + // the outermost lines can never render, so the culled stream must be a strict interior + // slice of the full stream rendered at identical positions. + int offset = AssertRendersContiguousSliceOfFull(full, culled); + Assert.True(offset > 0); + Assert.True(offset + culled.GlyphRects.Count < full.GlyphRects.Count); + + // The slice must contain every glyph of the middle line. + ReadOnlySpan lineLayouts = block.GetLineLayouts(wrappingLength).Span; + int middleStart = 0; + for (int i = 0; i < middle; i++) + { + middleStart += lineLayouts[i].GetGlyphMetrics().Length; + } + + int middleCount = lineLayouts[middle].GetGlyphMetrics().Length; + Assert.True(offset <= middleStart); + Assert.True(offset + culled.GlyphRects.Count >= middleStart + middleCount); + } + + private static int AssertRendersContiguousSliceOfFull(GlyphRenderer full, GlyphRenderer culled) + { + Assert.True(culled.GlyphRects.Count > 0); + Assert.True(culled.GlyphRects.Count < full.GlyphRects.Count); + + int offset = full.GlyphRects.FindIndex(rect => rect.Equals(culled.GlyphRects[0])); + Assert.True(offset >= 0); + Assert.True(offset + culled.GlyphRects.Count <= full.GlyphRects.Count); + for (int i = 0; i < culled.GlyphRects.Count; i++) + { + Assert.Equal(full.GlyphRects[i + offset], culled.GlyphRects[i], Comparer); + } + + return offset; + } + [Fact] public void CharacterMeasurements_MatchTextMeasurer() { diff --git a/tests/SixLabors.Fonts.Tests/TextLayoutTestUtilities.cs b/tests/SixLabors.Fonts.Tests/TextLayoutTestUtilities.cs index e34a9717e..902662809 100644 --- a/tests/SixLabors.Fonts.Tests/TextLayoutTestUtilities.cs +++ b/tests/SixLabors.Fonts.Tests/TextLayoutTestUtilities.cs @@ -144,6 +144,8 @@ private static RichTextOptions FromTextOptions(TextOptions options, bool customD LineSpacing = options.LineSpacing, Origin = options.Origin, WrappingLength = options.WrappingLength, + VisibleBounds = options.VisibleBounds, + TextBaseline = options.TextBaseline, MaxLines = options.MaxLines, WordBreaking = options.WordBreaking, TextHyphenation = options.TextHyphenation, diff --git a/tests/SixLabors.Fonts.Tests/TextMeasurerGlyphIdTests.cs b/tests/SixLabors.Fonts.Tests/TextMeasurerGlyphIdTests.cs index ff138ac28..21597eca2 100644 --- a/tests/SixLabors.Fonts.Tests/TextMeasurerGlyphIdTests.cs +++ b/tests/SixLabors.Fonts.Tests/TextMeasurerGlyphIdTests.cs @@ -85,9 +85,11 @@ public void MeasureRenderableBounds_IsUnionOfAdvanceAndBounds() Origin = new Vector2(20F, 60F) }; - FontRectangle expected = FontRectangle.Union( - TextMeasurer.MeasureAdvance(glyphId, options), - TextMeasurer.MeasureBounds(glyphId, options)); + // The advance is zero-based; renderable bounds union it placed at the origin, exactly + // as the text-level composition does. + FontRectangle advance = TextMeasurer.MeasureAdvance(glyphId, options); + FontRectangle absoluteAdvance = new(options.Origin.X, options.Origin.Y, advance.Width, advance.Height); + FontRectangle expected = FontRectangle.Union(absoluteAdvance, TextMeasurer.MeasureBounds(glyphId, options)); Assert.Equal(expected, TextMeasurer.MeasureRenderableBounds(glyphId, options)); } @@ -128,16 +130,153 @@ public void MeasureGlyphRun_MatchesUnionOfSingleGlyphMeasurements() }; ushort glyphId = glyphRun.GlyphIds.Span[i]; + + // Per-glyph advances are zero-based; the run extent unions them at their origins, + // exactly as the text-level composition does. FontRectangle advance = TextMeasurer.MeasureAdvance(glyphId, positioned); + FontRectangle absoluteAdvance = new(positioned.Origin.X, positioned.Origin.Y, advance.Width, advance.Height); FontRectangle renderable = TextMeasurer.MeasureRenderableBounds(glyphId, positioned); - expectedAdvance = i == 0 ? advance : FontRectangle.Union(expectedAdvance, advance); + expectedAdvance = i == 0 ? absoluteAdvance : FontRectangle.Union(expectedAdvance, absoluteAdvance); expectedRenderable = i == 0 ? renderable : FontRectangle.Union(expectedRenderable, renderable); } - Assert.Equal(expectedAdvance, TextMeasurer.MeasureAdvance(glyphRun, options)); + Assert.Equal(new FontRectangle(0, 0, expectedAdvance.Width, expectedAdvance.Height), TextMeasurer.MeasureAdvance(glyphRun, options)); Assert.Equal(expectedRenderable, TextMeasurer.MeasureRenderableBounds(glyphRun, options)); } + [Fact] + public void MeasureAdvance_IsZeroBased_AndUnmovedByOriginOrBaseline() + { + Font font = TestFonts.GetFont(TestFonts.OpenSansFile, 32); + ushort glyphId = GetGlyphId(font, 'A'); + + FontRectangle reference = TextMeasurer.MeasureAdvance(glyphId, new GlyphOptions { Font = font }); + + Assert.Equal(0, reference.X); + Assert.Equal(0, reference.Y); + Assert.True(reference.Width > 0); + Assert.True(reference.Height > 0); + + // The advance is a logical measure: no origin and no baseline anchor may move it. + foreach (TextBaseline baseline in Enum.GetValues()) + { + GlyphOptions options = new() + { + Font = font, + Origin = new Vector2(13.5F, 27.25F), + TextBaseline = baseline + }; + + Assert.Equal(reference, TextMeasurer.MeasureAdvance(glyphId, options)); + } + + // Bounds are positioned geometry and do move with the anchor. + GlyphOptions lineBox = new() { Font = font, Origin = new Vector2(13.5F, 27.25F) }; + GlyphOptions alphabetic = new() { Font = font, Origin = new Vector2(13.5F, 27.25F), TextBaseline = TextBaseline.Alphabetic }; + Assert.NotEqual(TextMeasurer.MeasureBounds(glyphId, lineBox), TextMeasurer.MeasureBounds(glyphId, alphabetic)); + } + + [Fact] + public void MeasureAdvance_IsZeroBased_ForVerticalLayouts() + { + Font font = TestFonts.GetFont(TestFonts.OpenSansFile, 32); + ushort glyphId = GetGlyphId(font, 'A'); + + foreach (LayoutMode layoutMode in new[] { LayoutMode.VerticalLeftRight, LayoutMode.VerticalMixedLeftRight }) + { + FontRectangle reference = TextMeasurer.MeasureAdvance(glyphId, new GlyphOptions { Font = font, LayoutMode = layoutMode }); + + Assert.Equal(0, reference.X); + Assert.Equal(0, reference.Y); + Assert.True(reference.Width > 0); + Assert.True(reference.Height > 0); + + foreach (TextBaseline baseline in Enum.GetValues()) + { + GlyphOptions options = new() + { + Font = font, + LayoutMode = layoutMode, + Origin = new Vector2(40F, 80F), + TextBaseline = baseline + }; + + Assert.Equal(reference, TextMeasurer.MeasureAdvance(glyphId, options)); + } + } + } + + [Fact] + public void MeasureBounds_MatchesRenderedGlyphBounds_ForEveryBaseline() + { + Font font = TestFonts.GetFont(TestFonts.OpenSansFile, 32); + ushort glyphId = GetGlyphId(font, 'g'); + + foreach (TextBaseline baseline in Enum.GetValues()) + { + GlyphOptions options = new() + { + Font = font, + Dpi = 96F, + Origin = new Vector2(13.5F, 27.25F), + TextBaseline = baseline + }; + + GlyphRenderer renderer = new(); + TextRenderer.RenderTo(renderer, glyphId, options); + + Assert.Single(renderer.GlyphRects); + Assert.Equal(renderer.GlyphRects[0], TextMeasurer.MeasureBounds(glyphId, options)); + } + } + + [Fact] + public void MeasureRenderableBounds_ComposesAdvanceAtOrigin_ForEveryBaseline() + { + Font font = TestFonts.GetFont(TestFonts.OpenSansFile, 32); + ushort glyphId = GetGlyphId(font, 'g'); + + foreach (TextBaseline baseline in Enum.GetValues()) + { + GlyphOptions options = new() + { + Font = font, + Origin = new Vector2(20F, 60F), + TextBaseline = baseline + }; + + FontRectangle advance = TextMeasurer.MeasureAdvance(glyphId, options); + FontRectangle absoluteAdvance = new(options.Origin.X, options.Origin.Y, advance.Width, advance.Height); + FontRectangle expected = FontRectangle.Union(absoluteAdvance, TextMeasurer.MeasureBounds(glyphId, options)); + + Assert.Equal(expected, TextMeasurer.MeasureRenderableBounds(glyphId, options)); + } + } + + [Fact] + public void Run_MeasureBounds_MatchesRenderedUnion_ForEveryBaseline() + { + Font font = TestFonts.GetFont(TestFonts.OpenSansFile, 32); + (GlyphRun glyphRun, GlyphOptions options) = CreateRun(font); + + foreach (TextBaseline baseline in Enum.GetValues()) + { + options.TextBaseline = baseline; + + GlyphRenderer renderer = new(); + TextRenderer.RenderTo(renderer, glyphRun, options); + + Assert.Equal(glyphRun.Count, renderer.GlyphRects.Count); + FontRectangle expected = renderer.GlyphRects[0]; + for (int i = 1; i < renderer.GlyphRects.Count; i++) + { + expected = FontRectangle.Union(expected, renderer.GlyphRects[i]); + } + + Assert.Equal(expected, TextMeasurer.MeasureBounds(glyphRun, options)); + } + } + [Fact] public void MeasureGlyphRun_RestoresOptionsOrigin() { @@ -244,10 +383,13 @@ public void GetIntersections_DescenderBand_IsNarrowerThanInkBounds() Font font = TestFonts.GetFont(TestFonts.OpenSansFile, 64); ushort glyphId = GetGlyphId(font, 'p'); + // The band arithmetic below is baseline-relative, so anchor the origin on the + // alphabetic baseline rather than the default em-box top. GlyphOptions options = new() { Font = font, - Origin = new Vector2(10F, 100F) + Origin = new Vector2(10F, 100F), + TextBaseline = TextBaseline.Alphabetic }; FontRectangle bounds = TextMeasurer.MeasureBounds(glyphId, options); diff --git a/tests/SixLabors.Fonts.Tests/TextRendererGlyphIdTests.cs b/tests/SixLabors.Fonts.Tests/TextRendererGlyphIdTests.cs index c92c833c1..a5d710c66 100644 --- a/tests/SixLabors.Fonts.Tests/TextRendererGlyphIdTests.cs +++ b/tests/SixLabors.Fonts.Tests/TextRendererGlyphIdTests.cs @@ -1,6 +1,7 @@ // Copyright (c) Six Labors. // Licensed under the Six Labors Split License. +using System.Numerics; using SixLabors.Fonts.Rendering; using SixLabors.Fonts.Unicode; @@ -101,6 +102,40 @@ public void RenderGlyph_UsesTextRunCreatedByGlyphOptions() Assert.Equal(TextDecorations.Underline, run.TextDecorations); } + [Fact] + public void RenderGlyphRun_VisibleBounds_SkipsGlyphsOutsideRegion() + { + Font font = TextLayoutTests.CreateRenderingFont(); + CodePoint codePoint = new('A'); + + Assert.True(font.TryGetGlyphs(codePoint, out Glyph? glyph)); + ushort glyphId = glyph.Value.GlyphMetrics.GlyphId; + + ushort[] glyphIds = [glyphId, glyphId, glyphId]; + Vector2[] origins = [new(0, 20), new(100, 20), new(200, 20)]; + GlyphRun run = new(glyphIds, origins); + GlyphOptions options = new() + { + Font = font + }; + + GlyphRenderer full = new(); + TextRenderer.RenderTo(full, run, options); + + Assert.Equal(3, full.GlyphRects.Count); + + // A region around the middle origin only. The outer glyphs sit further from the region + // than the one-line-height tolerance, so just the middle glyph renders, at a position + // identical to the unculled run. + options.VisibleBounds = new FontRectangle(90, 0, 20, 40); + + GlyphRenderer culled = new(); + TextRenderer.RenderTo(culled, run, options); + + Assert.Single(culled.GlyphRects); + Assert.Equal(full.GlyphRects[1], culled.GlyphRects[0]); + } + [Fact] public void RenderGlyph_UnknownGlyphId_DoesNotRender() { diff --git a/tests/SixLabors.Fonts.Tests/TextRendererTests.cs b/tests/SixLabors.Fonts.Tests/TextRendererTests.cs new file mode 100644 index 000000000..f4f682ca8 --- /dev/null +++ b/tests/SixLabors.Fonts.Tests/TextRendererTests.cs @@ -0,0 +1,117 @@ +// Copyright (c) Six Labors. +// Licensed under the Six Labors Split License. + +using SixLabors.Fonts.Rendering; + +namespace SixLabors.Fonts.Tests; + +public class TextRendererTests +{ + private static readonly ApproximateFloatComparer Comparer = new(0.001F); + + private static Font Font => TextLayoutTests.CreateRenderingFont(); + + [Fact] + public void RenderTo_VisibleBounds_CoveringBandMatchesFullRender() + { + const string text = "Alpha beta gamma delta epsilon zeta eta theta iota kappa lambda mu."; + TextOptions options = Options(90); + + GlyphRenderer full = new(); + TextRenderer.RenderTo(full, text, options); + + // The banded one-shot path takes the region from the options, unlike TextBlock which + // takes it per call. + TextOptions bandedOptions = Options(90); + bandedOptions.VisibleBounds = new TextBlock(text, Options(-1)).MeasureRenderableBounds(90); + + GlyphRenderer culled = new(); + TextRenderer.RenderTo(culled, text, bandedOptions); + + Assert.Equal(full.GlyphRects.Count, culled.GlyphRects.Count); + for (int i = 0; i < full.GlyphRects.Count; i++) + { + Assert.Equal(full.GlyphRects[i], culled.GlyphRects[i], Comparer); + } + } + + [Fact] + public void RenderTo_VisibleBounds_CullsLinesAndStopsBreakingOutsideBand() + { + const string text = "Alpha beta gamma delta epsilon zeta eta theta iota kappa lambda mu nu xi omicron pi."; + const float wrappingLength = 90; + TextOptions options = Options(wrappingLength); + + GlyphRenderer full = new(); + TextRenderer.RenderTo(full, text, options); + + TextBlock block = new(text, Options(-1)); + ReadOnlySpan lines = block.GetLineMetrics(wrappingLength).Span; + Assert.True(lines.Length >= 7); + LineMetrics middleLine = lines[lines.Length / 2]; + + // Default options are eligible for early-terminated line breaking, so this exercises + // the truncated-box path end to end. + TextOptions bandedOptions = Options(wrappingLength); + bandedOptions.VisibleBounds = new FontRectangle(0, middleLine.Start.Y, 10000, middleLine.Extent.Y); + + GlyphRenderer culled = new(); + TextRenderer.RenderTo(culled, text, bandedOptions); + + int offset = AssertRendersContiguousSliceOfFull(full, culled); + Assert.True(offset > 0); + Assert.True(offset + culled.GlyphRects.Count < full.GlyphRects.Count); + } + + [Fact] + public void RenderTo_VisibleBounds_AlignmentRequiringFullLineSet_MatchesFullRender() + { + const string text = "Alpha beta gamma delta epsilon zeta eta theta iota kappa lambda mu nu xi omicron pi."; + const float wrappingLength = 90; + TextOptions options = Options(wrappingLength); + options.HorizontalAlignment = HorizontalAlignment.Center; + + GlyphRenderer full = new(); + TextRenderer.RenderTo(full, text, options); + + TextOptions measureOptions = Options(-1); + measureOptions.HorizontalAlignment = HorizontalAlignment.Center; + TextBlock block = new(text, measureOptions); + ReadOnlySpan lines = block.GetLineMetrics(wrappingLength).Span; + Assert.True(lines.Length >= 7); + LineMetrics middleLine = lines[lines.Length / 2]; + + // Centered block alignment depends on the widest line, so breaking must not terminate + // early; the banded walk over the full line set still has to place the visible slice + // exactly where the full render does. + TextOptions bandedOptions = Options(wrappingLength); + bandedOptions.HorizontalAlignment = HorizontalAlignment.Center; + bandedOptions.VisibleBounds = new FontRectangle(-10000, middleLine.Start.Y, 20000, middleLine.Extent.Y); + + GlyphRenderer culled = new(); + TextRenderer.RenderTo(culled, text, bandedOptions); + + int offset = AssertRendersContiguousSliceOfFull(full, culled); + Assert.True(offset > 0); + Assert.True(offset + culled.GlyphRects.Count < full.GlyphRects.Count); + } + + private static TextOptions Options(float wrappingLength) + => new(Font) { WrappingLength = wrappingLength }; + + private static int AssertRendersContiguousSliceOfFull(GlyphRenderer full, GlyphRenderer culled) + { + Assert.True(culled.GlyphRects.Count > 0); + Assert.True(culled.GlyphRects.Count < full.GlyphRects.Count); + + int offset = full.GlyphRects.FindIndex(rect => rect.Equals(culled.GlyphRects[0])); + Assert.True(offset >= 0); + Assert.True(offset + culled.GlyphRects.Count <= full.GlyphRects.Count); + for (int i = 0; i < culled.GlyphRects.Count; i++) + { + Assert.Equal(full.GlyphRects[i + offset], culled.GlyphRects[i], Comparer); + } + + return offset; + } +} From eecad11f9c48243053db4bd723b8c01109d63095 Mon Sep 17 00:00:00 2001 From: James Jackson-South Date: Thu, 23 Jul 2026 09:29:56 +1000 Subject: [PATCH 2/2] Add baseline offset support to text layout Introduces `BaselineOffset` on `TextOptions` (and `GlyphOptions`) and propagates it through layout, rendering, measuring, and line metrics so baseline shift composes with baseline anchoring consistently. The offset is treated as pixel units, applies correctly for horizontal and vertical modes, and keeps logical advance unchanged. Added/updated baseline tests and layout test utilities to cover render/measure parity, vertical behavior, and DPI independence. --- src/SixLabors.Fonts/GlyphOptions.cs | 3 + src/SixLabors.Fonts/Rendering/TextRenderer.cs | 10 +- src/SixLabors.Fonts/TextBlock.cs | 5 + src/SixLabors.Fonts/TextLayout.cs | 49 ++++ src/SixLabors.Fonts/TextMeasurer.cs | 9 +- src/SixLabors.Fonts/TextOptions.cs | 14 + tests/Browser/TextBaselineShift.html | 221 ++++++++++++++++ .../RendersBaselineShiftToReference_-20.png | 3 + .../RendersBaselineShiftToReference_-40.png | 3 + .../RendersBaselineShiftToReference_0.png | 3 + .../RendersBaselineShiftToReference_20.png | 3 + .../RendersBaselineShiftToReference_40.png | 3 + ...rsBaselineShiftToReference_Hanging_-20.png | 3 + ...rsBaselineShiftToReference_Hanging_-40.png | 3 + ...dersBaselineShiftToReference_Hanging_0.png | 3 + ...ersBaselineShiftToReference_Hanging_20.png | 3 + ...ersBaselineShiftToReference_Hanging_40.png | 3 + ...ShiftToReference_VerticalLeftRight_-20.png | 3 + ...ShiftToReference_VerticalLeftRight_-40.png | 3 + ...neShiftToReference_VerticalLeftRight_0.png | 3 + ...eShiftToReference_VerticalLeftRight_20.png | 3 + ...eShiftToReference_VerticalLeftRight_40.png | 3 + ...ToReference_VerticalMixedLeftRight_-20.png | 3 + ...ToReference_VerticalMixedLeftRight_-40.png | 3 + ...ftToReference_VerticalMixedLeftRight_0.png | 3 + ...tToReference_VerticalMixedLeftRight_20.png | 3 + ...tToReference_VerticalMixedLeftRight_40.png | 3 + .../TextBaselineTests.cs | 247 ++++++++++++++++++ .../TextLayoutTestUtilities.cs | 1 + 29 files changed, 610 insertions(+), 9 deletions(-) create mode 100644 tests/Browser/TextBaselineShift.html create mode 100644 tests/Images/ReferenceOutput/RendersBaselineShiftToReference_-20.png create mode 100644 tests/Images/ReferenceOutput/RendersBaselineShiftToReference_-40.png create mode 100644 tests/Images/ReferenceOutput/RendersBaselineShiftToReference_0.png create mode 100644 tests/Images/ReferenceOutput/RendersBaselineShiftToReference_20.png create mode 100644 tests/Images/ReferenceOutput/RendersBaselineShiftToReference_40.png create mode 100644 tests/Images/ReferenceOutput/RendersBaselineShiftToReference_Hanging_-20.png create mode 100644 tests/Images/ReferenceOutput/RendersBaselineShiftToReference_Hanging_-40.png create mode 100644 tests/Images/ReferenceOutput/RendersBaselineShiftToReference_Hanging_0.png create mode 100644 tests/Images/ReferenceOutput/RendersBaselineShiftToReference_Hanging_20.png create mode 100644 tests/Images/ReferenceOutput/RendersBaselineShiftToReference_Hanging_40.png create mode 100644 tests/Images/ReferenceOutput/RendersBaselineShiftToReference_VerticalLeftRight_-20.png create mode 100644 tests/Images/ReferenceOutput/RendersBaselineShiftToReference_VerticalLeftRight_-40.png create mode 100644 tests/Images/ReferenceOutput/RendersBaselineShiftToReference_VerticalLeftRight_0.png create mode 100644 tests/Images/ReferenceOutput/RendersBaselineShiftToReference_VerticalLeftRight_20.png create mode 100644 tests/Images/ReferenceOutput/RendersBaselineShiftToReference_VerticalLeftRight_40.png create mode 100644 tests/Images/ReferenceOutput/RendersBaselineShiftToReference_VerticalMixedLeftRight_-20.png create mode 100644 tests/Images/ReferenceOutput/RendersBaselineShiftToReference_VerticalMixedLeftRight_-40.png create mode 100644 tests/Images/ReferenceOutput/RendersBaselineShiftToReference_VerticalMixedLeftRight_0.png create mode 100644 tests/Images/ReferenceOutput/RendersBaselineShiftToReference_VerticalMixedLeftRight_20.png create mode 100644 tests/Images/ReferenceOutput/RendersBaselineShiftToReference_VerticalMixedLeftRight_40.png diff --git a/src/SixLabors.Fonts/GlyphOptions.cs b/src/SixLabors.Fonts/GlyphOptions.cs index 94ade76c8..fafb0817a 100644 --- a/src/SixLabors.Fonts/GlyphOptions.cs +++ b/src/SixLabors.Fonts/GlyphOptions.cs @@ -69,6 +69,9 @@ public float Dpi /// public TextBaseline TextBaseline { get; set; } + /// + public float BaselineOffset { get; set; } + /// /// Gets or sets the zero-based grapheme cluster index represented by the glyph. /// diff --git a/src/SixLabors.Fonts/Rendering/TextRenderer.cs b/src/SixLabors.Fonts/Rendering/TextRenderer.cs index 4515623d3..a8736f141 100644 --- a/src/SixLabors.Fonts/Rendering/TextRenderer.cs +++ b/src/SixLabors.Fonts/Rendering/TextRenderer.cs @@ -113,15 +113,15 @@ public void Render(ushort glyphId, GlyphOptions options) if (glyphLayoutMode == GlyphLayoutMode.Horizontal) { // The renderer positions glyphs by their alphabetic baseline; shifting the origin - // by the selected reference line's offset from that baseline puts the reference on - // the caller's origin. - origin.Y -= TextLayout.GetBaselineOffset(options.TextBaseline, options.Font, false); + // by the combined anchor and baseline-shift offset puts the selected reference + // line, shifted as requested, on the caller's origin. + origin.Y -= TextLayout.GetBaselineOffset(options, false); } else { // Vertical rendering positions glyphs about the column's central axis; the same - // shift applies along X from that axis. - origin.X -= TextLayout.GetBaselineOffset(options.TextBaseline, options.Font, true); + // combined offset applies along X from that axis. + origin.X -= TextLayout.GetBaselineOffset(options, true); } if (options.VisibleBounds is FontRectangle visibleBounds) diff --git a/src/SixLabors.Fonts/TextBlock.cs b/src/SixLabors.Fonts/TextBlock.cs index 8903a2e62..15a48f3b0 100644 --- a/src/SixLabors.Fonts/TextBlock.cs +++ b/src/SixLabors.Fonts/TextBlock.cs @@ -498,6 +498,11 @@ or LayoutMode.VerticalRightLeft } } + // The baseline shift composes with whichever anchor placed the lines, matching the + // shift the layout walks fold into the first line so metrics agree with rendering. + // Line offsets are in pixel units here, hence the dpi conversion back. + lineOffset -= TextLayout.GetBaselineShift(options, !isHorizontalLayout) * options.Dpi; + int i = reverseLineOrder ? textBox.TextLines.Count - 1 : 0; int step = reverseLineOrder ? -1 : 1; int graphemeOffset = 0; diff --git a/src/SixLabors.Fonts/TextLayout.cs b/src/SixLabors.Fonts/TextLayout.cs index 36d300269..d6ea9118b 100644 --- a/src/SixLabors.Fonts/TextLayout.cs +++ b/src/SixLabors.Fonts/TextLayout.cs @@ -454,6 +454,43 @@ baseline is TextBaseline.Hanging or TextBaseline.Ideographic && return height - ((ascender + descender) * .5F); } + /// + /// Converts into a layout-unit offset signed for + /// the layout axis. This method owns the shift's sign convention: consumers subtract the + /// returned value from their flow-axis offset, which moves positive shifts toward the + /// over side on both axes: up for horizontal layouts and +X for vertical layouts. + /// + /// The text options supplying the shift and dpi. + /// Whether the layout flows vertically. + /// The baseline shift in layout units, to be subtracted by the consumer. + public static float GetBaselineShift(TextOptions options, bool isVerticalLayout) + => GetBaselineShift(options.BaselineOffset, options.Dpi, isVerticalLayout); + + /// + /// Computes the total anchor offset for a single glyph: the reference line selected by + /// composed with the + /// shift, in layout units. Consumers subtract + /// the returned value from the origin on the axis the layout mode selects. + /// + /// The glyph options supplying the baseline, font, shift, and dpi. + /// Whether the layout flows vertically. + /// The combined offset from the dominant baseline in layout units. + public static float GetBaselineOffset(GlyphOptions options, bool isVerticalLayout) + => GetBaselineOffset(options.TextBaseline, options.Font, isVerticalLayout) + + GetBaselineShift(options.BaselineOffset, options.Dpi, isVerticalLayout); + + /// + /// Converts a baseline shift in pixel units into a layout-unit offset signed for the + /// layout axis, under the subtract-from-offset convention documented on + /// . + /// + /// The baseline shift in pixel units, positive toward the over side. + /// The dpi converting pixel units into layout units. + /// Whether the layout flows vertically. + /// The baseline shift in layout units, to be subtracted by the consumer. + private static float GetBaselineShift(float baselineOffset, float dpi, bool isVerticalLayout) + => (isVerticalLayout ? -baselineOffset : baselineOffset) / dpi; + /// /// Lays out the supplied , streaming each laid-out glyph through the /// supplied in layout order, culling whole lines whose extent along @@ -731,6 +768,10 @@ private static void LayoutLineHorizontal( break; } } + + // The baseline shift composes with whichever anchor placed the first line. + // Later lines stack from the pen, so the whole block carries the shift. + offsetY -= GetBaselineShift(options, false); } penLocation.Y += offsetY; @@ -981,6 +1022,10 @@ private static void LayoutLineVertical( break; } } + + // The baseline shift composes with whichever anchor placed the first column. + // Later columns stack from the pen, so the whole block carries the shift. + offsetX -= GetBaselineShift(options, true); } penLocation.Y += offsetY; @@ -1368,6 +1413,10 @@ private static void LayoutLineVerticalMixed( break; } } + + // The baseline shift composes with whichever anchor placed the first column. + // Later columns stack from the pen, so the whole block carries the shift. + offsetX -= GetBaselineShift(options, true); } penLocation.Y += offsetY; diff --git a/src/SixLabors.Fonts/TextMeasurer.cs b/src/SixLabors.Fonts/TextMeasurer.cs index 3ea16b496..ec0fb8bc5 100644 --- a/src/SixLabors.Fonts/TextMeasurer.cs +++ b/src/SixLabors.Fonts/TextMeasurer.cs @@ -621,16 +621,17 @@ private static GlyphMetrics CreateGlyphMetrics(ushort glyphId, GlyphOptions opti /// The anchored origin. private static Vector2 GetAnchoredOrigin(GlyphOptions options, GlyphLayoutMode layoutMode) { - // Mirror the renderer's exact operation order (normalize to layout units, anchor, - // convert back to pixels) so measured bounds stay bit-identical to rendered bounds. + // Mirror the renderer's exact operation order (normalize to layout units, apply the + // combined anchor and baseline-shift offset, convert back to pixels) so measured + // bounds stay bit-identical to rendered bounds. Vector2 origin = options.Origin / options.Dpi; if (layoutMode == GlyphLayoutMode.Horizontal) { - origin.Y -= TextLayout.GetBaselineOffset(options.TextBaseline, options.Font, false); + origin.Y -= TextLayout.GetBaselineOffset(options, false); } else { - origin.X -= TextLayout.GetBaselineOffset(options.TextBaseline, options.Font, true); + origin.X -= TextLayout.GetBaselineOffset(options, true); } return origin * options.Dpi; diff --git a/src/SixLabors.Fonts/TextOptions.cs b/src/SixLabors.Fonts/TextOptions.cs index 285a6b218..fffd4d8a3 100644 --- a/src/SixLabors.Fonts/TextOptions.cs +++ b/src/SixLabors.Fonts/TextOptions.cs @@ -40,6 +40,7 @@ public TextOptions(TextOptions options) this.WrappingLength = options.WrappingLength; this.VisibleBounds = options.VisibleBounds; this.TextBaseline = options.TextBaseline; + this.BaselineOffset = options.BaselineOffset; this.MaxLines = options.MaxLines; this.WordBreaking = options.WordBreaking; this.TextEllipsis = options.TextEllipsis; @@ -172,6 +173,19 @@ public float LineSpacing /// public TextBaseline TextBaseline { get; set; } + /// + /// Gets or sets an additional shift of the text away from its anchored position, in pixel + /// units along the block flow axis. Positive values shift toward the text's over side: + /// upward for horizontal layouts, toward the over column side for vertical layouts, and + /// away from the line along its normal when the text follows a path. + /// + /// + /// The shift composes with and moves rendered glyphs, ink + /// bounds, and decorations as a unit. The logical advance is unaffected, matching the + /// CSS and SVG baseline-shift model. + /// + public float BaselineOffset { get; set; } + /// /// Gets or sets the maximum number of lines to lay out. /// diff --git a/tests/Browser/TextBaselineShift.html b/tests/Browser/TextBaselineShift.html new file mode 100644 index 000000000..6bb24d68d --- /dev/null +++ b/tests/Browser/TextBaselineShift.html @@ -0,0 +1,221 @@ + + + + + + Text baseline shift browser comparison + + + +
+

Browser text baseline shift comparison

+

+ The browser column applies SVG baseline-shift on a tspan, the browser surface for the + CSS baseline-shift model; vertical-align with a length is the equivalent in flow + layout. The library column renders the same text with TextOptions.BaselineOffset in + pixel units. Positive shifts move toward the over side and the logical advance is + unchanged in both columns. +

+
+ +
+

Open Sans horizontal, alphabetic baseline at the red rule, 72pt

+
+
+
Browser
+
SixLabors.Fonts actual output
+
+
+ +
+

Open Sans horizontal, hanging baseline at the red rule, 72pt

+

+ The shift composes with the selected anchor: at zero shift the hanging baseline sits + on the rule and every shifted row moves from that anchored position. +

+
+
+
Browser
+
SixLabors.Fonts actual output
+
+
+ +
+

Open Sans vertical upright, central baseline at the red rule, 72pt

+

+ In vertical flows the over side is physically right for both vertical-rl and + vertical-lr, so positive shifts move the columns right, matching the library's +X. +

+
+
+
Browser
+
SixLabors.Fonts actual output
+
+
+ +
+

Open Sans vertical mixed, central baseline at the red rule, 72pt

+
+
+
Browser
+
SixLabors.Fonts actual output
+
+
+ + + + diff --git a/tests/Images/ReferenceOutput/RendersBaselineShiftToReference_-20.png b/tests/Images/ReferenceOutput/RendersBaselineShiftToReference_-20.png new file mode 100644 index 000000000..3c1d430c1 --- /dev/null +++ b/tests/Images/ReferenceOutput/RendersBaselineShiftToReference_-20.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:20bb566f9d91b6f539e31ddc135744c141f2f7f9e63004d16c98af9c7e0853ab +size 3006 diff --git a/tests/Images/ReferenceOutput/RendersBaselineShiftToReference_-40.png b/tests/Images/ReferenceOutput/RendersBaselineShiftToReference_-40.png new file mode 100644 index 000000000..d9b1e9ebf --- /dev/null +++ b/tests/Images/ReferenceOutput/RendersBaselineShiftToReference_-40.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:e3a04ac4d74f22fb10faf21edf34d1ee186e13bbe99fa28e5ba27988eb914e17 +size 3008 diff --git a/tests/Images/ReferenceOutput/RendersBaselineShiftToReference_0.png b/tests/Images/ReferenceOutput/RendersBaselineShiftToReference_0.png new file mode 100644 index 000000000..47c99678a --- /dev/null +++ b/tests/Images/ReferenceOutput/RendersBaselineShiftToReference_0.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:df9fc0ebd7a739841625cded10fba1852aabc54de24f0d1a2551be5c997f3e35 +size 2991 diff --git a/tests/Images/ReferenceOutput/RendersBaselineShiftToReference_20.png b/tests/Images/ReferenceOutput/RendersBaselineShiftToReference_20.png new file mode 100644 index 000000000..73a2d1ab0 --- /dev/null +++ b/tests/Images/ReferenceOutput/RendersBaselineShiftToReference_20.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:919dee90086f9880f63d8ef06fdc465908f70f0889504a2fb6162bd2ff420df5 +size 2931 diff --git a/tests/Images/ReferenceOutput/RendersBaselineShiftToReference_40.png b/tests/Images/ReferenceOutput/RendersBaselineShiftToReference_40.png new file mode 100644 index 000000000..a08fae298 --- /dev/null +++ b/tests/Images/ReferenceOutput/RendersBaselineShiftToReference_40.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:ec29ed99eac41b5f21ecad6dbcbefbdfd2f4cce9f0570f13dc2e8f3c6b6f3d5e +size 2890 diff --git a/tests/Images/ReferenceOutput/RendersBaselineShiftToReference_Hanging_-20.png b/tests/Images/ReferenceOutput/RendersBaselineShiftToReference_Hanging_-20.png new file mode 100644 index 000000000..e12ef0e92 --- /dev/null +++ b/tests/Images/ReferenceOutput/RendersBaselineShiftToReference_Hanging_-20.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:9a373aa4b833a05fe560cc06ac6e8f54f03206216a27b51a8a44709546fa4f20 +size 3017 diff --git a/tests/Images/ReferenceOutput/RendersBaselineShiftToReference_Hanging_-40.png b/tests/Images/ReferenceOutput/RendersBaselineShiftToReference_Hanging_-40.png new file mode 100644 index 000000000..52b2df9d0 --- /dev/null +++ b/tests/Images/ReferenceOutput/RendersBaselineShiftToReference_Hanging_-40.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:dc2fcb18278b95767d87b0e78499dbdfb72905fe10c24950a0c66855d43700d3 +size 3030 diff --git a/tests/Images/ReferenceOutput/RendersBaselineShiftToReference_Hanging_0.png b/tests/Images/ReferenceOutput/RendersBaselineShiftToReference_Hanging_0.png new file mode 100644 index 000000000..ff4ddbb69 --- /dev/null +++ b/tests/Images/ReferenceOutput/RendersBaselineShiftToReference_Hanging_0.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:a1b18a559d2024ec58d0185964175641caf4ee6e70600bec15794401de5c8ba9 +size 3011 diff --git a/tests/Images/ReferenceOutput/RendersBaselineShiftToReference_Hanging_20.png b/tests/Images/ReferenceOutput/RendersBaselineShiftToReference_Hanging_20.png new file mode 100644 index 000000000..bb8b43cc5 --- /dev/null +++ b/tests/Images/ReferenceOutput/RendersBaselineShiftToReference_Hanging_20.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:23c04ce88a276f42406e557ffdfaa0ae3c0fb306d6cb49aacbc0bbc50947ba07 +size 3040 diff --git a/tests/Images/ReferenceOutput/RendersBaselineShiftToReference_Hanging_40.png b/tests/Images/ReferenceOutput/RendersBaselineShiftToReference_Hanging_40.png new file mode 100644 index 000000000..b6b53d949 --- /dev/null +++ b/tests/Images/ReferenceOutput/RendersBaselineShiftToReference_Hanging_40.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:ec7ca62c35d91219990c3935f11821381e67a4e46b2134abfb09a06c9746b77e +size 3099 diff --git a/tests/Images/ReferenceOutput/RendersBaselineShiftToReference_VerticalLeftRight_-20.png b/tests/Images/ReferenceOutput/RendersBaselineShiftToReference_VerticalLeftRight_-20.png new file mode 100644 index 000000000..ff0d91613 --- /dev/null +++ b/tests/Images/ReferenceOutput/RendersBaselineShiftToReference_VerticalLeftRight_-20.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:9370918364ed14e5af699ed1d9470e37634852b00f63e544ecd5d62a0e27f9f9 +size 3635 diff --git a/tests/Images/ReferenceOutput/RendersBaselineShiftToReference_VerticalLeftRight_-40.png b/tests/Images/ReferenceOutput/RendersBaselineShiftToReference_VerticalLeftRight_-40.png new file mode 100644 index 000000000..80d0a8fe3 --- /dev/null +++ b/tests/Images/ReferenceOutput/RendersBaselineShiftToReference_VerticalLeftRight_-40.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:fbb89557c9f0f68863af2f62c8d96a3ae59781eadb0f8680331a84de3fb5be94 +size 3536 diff --git a/tests/Images/ReferenceOutput/RendersBaselineShiftToReference_VerticalLeftRight_0.png b/tests/Images/ReferenceOutput/RendersBaselineShiftToReference_VerticalLeftRight_0.png new file mode 100644 index 000000000..18760dac9 --- /dev/null +++ b/tests/Images/ReferenceOutput/RendersBaselineShiftToReference_VerticalLeftRight_0.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:ae3eca51425bccb05ba3a342d049e71e18ce0234570e9b313717d1fc5cfb9603 +size 3997 diff --git a/tests/Images/ReferenceOutput/RendersBaselineShiftToReference_VerticalLeftRight_20.png b/tests/Images/ReferenceOutput/RendersBaselineShiftToReference_VerticalLeftRight_20.png new file mode 100644 index 000000000..9ef7864ba --- /dev/null +++ b/tests/Images/ReferenceOutput/RendersBaselineShiftToReference_VerticalLeftRight_20.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:2a7c9ba19450441b2a9c5b08aac04be21844b74810b5d4369db91b37f79fa120 +size 3999 diff --git a/tests/Images/ReferenceOutput/RendersBaselineShiftToReference_VerticalLeftRight_40.png b/tests/Images/ReferenceOutput/RendersBaselineShiftToReference_VerticalLeftRight_40.png new file mode 100644 index 000000000..290dfd058 --- /dev/null +++ b/tests/Images/ReferenceOutput/RendersBaselineShiftToReference_VerticalLeftRight_40.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:0788ab67daed913e72b8cbdbc2d654db659e872b8520af572386d03ba58ce036 +size 4248 diff --git a/tests/Images/ReferenceOutput/RendersBaselineShiftToReference_VerticalMixedLeftRight_-20.png b/tests/Images/ReferenceOutput/RendersBaselineShiftToReference_VerticalMixedLeftRight_-20.png new file mode 100644 index 000000000..99fcf820f --- /dev/null +++ b/tests/Images/ReferenceOutput/RendersBaselineShiftToReference_VerticalMixedLeftRight_-20.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:7ddde48529447238384b14ee8ac281eddbde2852e97725927b1a061f677067e7 +size 2957 diff --git a/tests/Images/ReferenceOutput/RendersBaselineShiftToReference_VerticalMixedLeftRight_-40.png b/tests/Images/ReferenceOutput/RendersBaselineShiftToReference_VerticalMixedLeftRight_-40.png new file mode 100644 index 000000000..81d67985b --- /dev/null +++ b/tests/Images/ReferenceOutput/RendersBaselineShiftToReference_VerticalMixedLeftRight_-40.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:0de8debdf39971c93e12b8a80f2bb4a4f37704ec8267f0e7d730b13efda4c0ee +size 2874 diff --git a/tests/Images/ReferenceOutput/RendersBaselineShiftToReference_VerticalMixedLeftRight_0.png b/tests/Images/ReferenceOutput/RendersBaselineShiftToReference_VerticalMixedLeftRight_0.png new file mode 100644 index 000000000..6ea297249 --- /dev/null +++ b/tests/Images/ReferenceOutput/RendersBaselineShiftToReference_VerticalMixedLeftRight_0.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:7cd2dab9ae5c1a14ea2c11e5d82754f12111324486059ce7f1595e7de251bdc4 +size 3035 diff --git a/tests/Images/ReferenceOutput/RendersBaselineShiftToReference_VerticalMixedLeftRight_20.png b/tests/Images/ReferenceOutput/RendersBaselineShiftToReference_VerticalMixedLeftRight_20.png new file mode 100644 index 000000000..ec5519652 --- /dev/null +++ b/tests/Images/ReferenceOutput/RendersBaselineShiftToReference_VerticalMixedLeftRight_20.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:15223f8473598f28c095947ea6ec83214d9e2e0db78a210a5bebaac5483fa473 +size 3088 diff --git a/tests/Images/ReferenceOutput/RendersBaselineShiftToReference_VerticalMixedLeftRight_40.png b/tests/Images/ReferenceOutput/RendersBaselineShiftToReference_VerticalMixedLeftRight_40.png new file mode 100644 index 000000000..5f1129e37 --- /dev/null +++ b/tests/Images/ReferenceOutput/RendersBaselineShiftToReference_VerticalMixedLeftRight_40.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:6c16360927a6a618e46922a6dfc966a520424a408460f30ceb009c2728738339 +size 3092 diff --git a/tests/SixLabors.Fonts.Tests/TextBaselineTests.cs b/tests/SixLabors.Fonts.Tests/TextBaselineTests.cs index 0d7a9a847..a45a93c20 100644 --- a/tests/SixLabors.Fonts.Tests/TextBaselineTests.cs +++ b/tests/SixLabors.Fonts.Tests/TextBaselineTests.cs @@ -208,6 +208,166 @@ public void GlyphId_Alphabetic_MatchesTextRenderedPosition() Assert.Equal(text.GlyphRects[0], glyphId.GlyphRects[0], Comparer); } + [Theory] + [InlineData(TextBaseline.LineBox)] + [InlineData(TextBaseline.Alphabetic)] + [InlineData(TextBaseline.Central)] + public void BaselineOffset_ShiftsInkTowardOverSide_AdvanceUnchanged(TextBaseline baseline) + { + const string text = "Hxp"; + const float offset = 12.5F; + Font font = Font; + + TextOptions reference = Options(font, baseline, 100); + TextOptions shifted = Options(font, baseline, 100); + shifted.BaselineOffset = offset; + + // A positive shift moves the whole block toward the over side, up for horizontal + // layouts, without touching the inline axis. LineBox exercises the block-aligned + // branch and the anchored baselines the reference-line branch. + GlyphRenderer referenceRenderer = new(); + TextRenderer.RenderTo(referenceRenderer, text, reference); + + GlyphRenderer shiftedRenderer = new(); + TextRenderer.RenderTo(shiftedRenderer, text, shifted); + + Assert.Equal(referenceRenderer.GlyphRects.Count, shiftedRenderer.GlyphRects.Count); + for (int i = 0; i < referenceRenderer.GlyphRects.Count; i++) + { + Assert.Equal(referenceRenderer.GlyphRects[i].Y - offset, shiftedRenderer.GlyphRects[i].Y, Comparer); + Assert.Equal(referenceRenderer.GlyphRects[i].X, shiftedRenderer.GlyphRects[i].X, Comparer); + } + + // The logical advance is unaffected, matching the CSS baseline-shift model. + Assert.Equal(TextMeasurer.MeasureAdvance(text, reference), TextMeasurer.MeasureAdvance(text, shifted)); + + // Measured ink carries the identical shift so measurement agrees with rendering. + FontRectangle referenceBounds = TextMeasurer.MeasureBounds(text, reference); + FontRectangle shiftedBounds = TextMeasurer.MeasureBounds(text, shifted); + + Assert.Equal(referenceBounds.Y - offset, shiftedBounds.Y, Comparer); + Assert.Equal(referenceBounds.X, shiftedBounds.X, Comparer); + + // Line metrics report the same shifted baseline position. + TextBlock referenceBlock = new(text, reference); + TextBlock shiftedBlock = new(text, shifted); + LineMetrics referenceLine = referenceBlock.GetLineMetrics(-1).Span[0]; + LineMetrics shiftedLine = shiftedBlock.GetLineMetrics(-1).Span[0]; + + Assert.Equal( + referenceLine.Start.Y + referenceLine.Baseline - offset, + shiftedLine.Start.Y + shiftedLine.Baseline, + Comparer); + } + + [Theory] + [InlineData(LayoutMode.VerticalLeftRight, TextBaseline.LineBox)] + [InlineData(LayoutMode.VerticalLeftRight, TextBaseline.Central)] + [InlineData(LayoutMode.VerticalMixedLeftRight, TextBaseline.LineBox)] + [InlineData(LayoutMode.VerticalMixedLeftRight, TextBaseline.Central)] + public void BaselineOffset_ShiftsInkTowardOverSide_Vertical(LayoutMode layoutMode, TextBaseline baseline) + { + const string text = "Hxp"; + const float offset = 12.5F; + Font font = Font; + + TextOptions reference = Options(font, baseline, 100); + reference.LayoutMode = layoutMode; + + TextOptions shifted = Options(font, baseline, 100); + shifted.LayoutMode = layoutMode; + shifted.BaselineOffset = offset; + + // In vertical layouts the over side is +X, so a positive shift moves columns right + // without touching the block flow axis. + GlyphRenderer referenceRenderer = new(); + TextRenderer.RenderTo(referenceRenderer, text, reference); + + GlyphRenderer shiftedRenderer = new(); + TextRenderer.RenderTo(shiftedRenderer, text, shifted); + + Assert.Equal(referenceRenderer.GlyphRects.Count, shiftedRenderer.GlyphRects.Count); + for (int i = 0; i < referenceRenderer.GlyphRects.Count; i++) + { + Assert.Equal(referenceRenderer.GlyphRects[i].X + offset, shiftedRenderer.GlyphRects[i].X, Comparer); + Assert.Equal(referenceRenderer.GlyphRects[i].Y, shiftedRenderer.GlyphRects[i].Y, Comparer); + } + + // The logical advance is unaffected, matching the CSS baseline-shift model. + Assert.Equal(TextMeasurer.MeasureAdvance(text, reference), TextMeasurer.MeasureAdvance(text, shifted)); + + // Line metrics report the columns' shifted flow-axis position. + TextBlock referenceBlock = new(text, reference); + TextBlock shiftedBlock = new(text, shifted); + LineMetrics referenceLine = referenceBlock.GetLineMetrics(-1).Span[0]; + LineMetrics shiftedLine = shiftedBlock.GetLineMetrics(-1).Span[0]; + + Assert.Equal(referenceLine.Start.X + offset, shiftedLine.Start.X, Comparer); + } + + [Fact] + public void BaselineOffset_IsPixelUnits_IndependentOfDpi() + { + const string text = "Hxp"; + const float offset = 10F; + Font font = Font; + + TextOptions reference = BrowserComparisonOptions(font, TextBaseline.Alphabetic, 100); + TextOptions shifted = BrowserComparisonOptions(font, TextBaseline.Alphabetic, 100); + shifted.BaselineOffset = offset; + + // The shift is specified in pixel units like the origin, so at 96 dpi the rendered + // displacement is still exactly the requested pixel amount. + FontRectangle referenceBounds = TextMeasurer.MeasureBounds(text, reference); + FontRectangle shiftedBounds = TextMeasurer.MeasureBounds(text, shifted); + + Assert.Equal(referenceBounds.Y - offset, shiftedBounds.Y, Comparer); + Assert.Equal(referenceBounds.X, shiftedBounds.X, Comparer); + } + + [Theory] + [InlineData(LayoutMode.HorizontalTopBottom)] + [InlineData(LayoutMode.VerticalLeftRight)] + public void GlyphId_BaselineOffset_ShiftsRenderAndMeasureTogether(LayoutMode layoutMode) + { + const float offset = 12.5F; + Font font = Font; + Assert.True(font.TryGetGlyphs(new CodePoint('A'), out Glyph? glyph)); + ushort glyphId = glyph.Value.GlyphMetrics.GlyphId; + + GlyphOptions reference = GlyphIdOptions(font, TextBaseline.Alphabetic); + reference.LayoutMode = layoutMode; + + GlyphOptions shifted = GlyphIdOptions(font, TextBaseline.Alphabetic); + shifted.LayoutMode = layoutMode; + shifted.BaselineOffset = offset; + + GlyphRenderer referenceRenderer = new(); + TextRenderer.RenderTo(referenceRenderer, glyphId, reference); + + GlyphRenderer shiftedRenderer = new(); + TextRenderer.RenderTo(shiftedRenderer, glyphId, shifted); + + // The single-glyph pipeline shares the text shift model: toward the over side on + // the axis the layout mode selects. + if (layoutMode == LayoutMode.HorizontalTopBottom) + { + Assert.Equal(referenceRenderer.GlyphRects[0].Y - offset, shiftedRenderer.GlyphRects[0].Y, Comparer); + Assert.Equal(referenceRenderer.GlyphRects[0].X, shiftedRenderer.GlyphRects[0].X, Comparer); + } + else + { + Assert.Equal(referenceRenderer.GlyphRects[0].X + offset, shiftedRenderer.GlyphRects[0].X, Comparer); + Assert.Equal(referenceRenderer.GlyphRects[0].Y, shiftedRenderer.GlyphRects[0].Y, Comparer); + } + + // Measurement mirrors the renderer exactly for the same options. + Assert.Equal(shiftedRenderer.GlyphRects[0], TextMeasurer.MeasureBounds(glyphId, shifted), Comparer); + + // The glyph advance is a logical measure the shift may not move. + Assert.Equal(TextMeasurer.MeasureAdvance(glyphId, reference), TextMeasurer.MeasureAdvance(glyphId, shifted)); + } + [Theory] [InlineData(TextBaseline.LineBox)] [InlineData(TextBaseline.TextTop)] @@ -305,6 +465,93 @@ public void RendersAnchoredToReference_CjkVerticalLeftRight(TextBaseline baselin public void RendersAnchoredToReference_CjkVerticalMixedLeftRight(TextBaseline baseline) => TestVerticalLayout(LayoutMode.VerticalMixedLeftRight, baseline, TestFonts.NotoSansSCBaselineSubsetFile, BrowserComparisonCjkText); + [Theory] + [InlineData(-40F)] + [InlineData(-20F)] + [InlineData(0F)] + [InlineData(20F)] + [InlineData(40F)] + public void RendersBaselineShiftToReference(float baselineOffset) + { + const float originY = 120; + Font font = TestFonts.GetFont(TestFonts.OpenSansFile, BrowserComparisonPointSize); + TextOptions options = BrowserComparisonOptions(font, TextBaseline.Alphabetic, originY); + options.BaselineOffset = baselineOffset; + + // The red rule marks the origin: the alphabetic baseline sits on it at zero shift + // and moves toward the over side, up, as the shift increases. + TextLayoutTestUtilities.TestLayout( + BrowserComparisonText, + options, + beforeAction: static image => DrawOriginRule(image, (int)originY), + properties: baselineOffset); + } + + [Theory] + [InlineData(-40F)] + [InlineData(-20F)] + [InlineData(0F)] + [InlineData(20F)] + [InlineData(40F)] + public void RendersBaselineShiftToReference_Hanging(float baselineOffset) + { + const float originY = 120; + Font font = TestFonts.GetFont(TestFonts.OpenSansFile, BrowserComparisonPointSize); + TextOptions options = BrowserComparisonOptions(font, TextBaseline.Hanging, originY); + options.BaselineOffset = baselineOffset; + + // The shift composes with the anchor: the hanging baseline sits on the rule at zero + // shift and the whole block moves from that anchored position as the shift changes. + TextLayoutTestUtilities.TestLayout( + BrowserComparisonText, + options, + beforeAction: static image => DrawOriginRule(image, (int)originY), + properties: baselineOffset); + } + + [Theory] + [InlineData(-40F)] + [InlineData(-20F)] + [InlineData(0F)] + [InlineData(20F)] + [InlineData(40F)] + public void RendersBaselineShiftToReference_VerticalLeftRight(float baselineOffset) + => TestVerticalShiftLayout(LayoutMode.VerticalLeftRight, baselineOffset); + + [Theory] + [InlineData(-40F)] + [InlineData(-20F)] + [InlineData(0F)] + [InlineData(20F)] + [InlineData(40F)] + public void RendersBaselineShiftToReference_VerticalMixedLeftRight(float baselineOffset) + => TestVerticalShiftLayout(LayoutMode.VerticalMixedLeftRight, baselineOffset); + + private static void TestVerticalShiftLayout( + LayoutMode layoutMode, + float baselineOffset, + [System.Runtime.CompilerServices.CallerMemberName] string test = "") + { + const float originX = 120; + TextOptions options = new(TestFonts.GetFont(TestFonts.OpenSansFile, BrowserComparisonPointSize)) + { + Origin = new Vector2(originX, 10), + TextBaseline = TextBaseline.Central, + BaselineOffset = baselineOffset, + LayoutMode = layoutMode, + Dpi = 96F + }; + + // Columns anchor the central axis on the origin rule at zero shift and move toward + // the over side, right, as the shift increases. + TextLayoutTestUtilities.TestLayout( + BrowserComparisonText, + options, + test: test, + beforeAction: static image => DrawOriginColumnRule(image, (int)originX), + properties: baselineOffset); + } + private static void TestVerticalLayout( LayoutMode layoutMode, TextBaseline baseline, diff --git a/tests/SixLabors.Fonts.Tests/TextLayoutTestUtilities.cs b/tests/SixLabors.Fonts.Tests/TextLayoutTestUtilities.cs index 902662809..8f7cff732 100644 --- a/tests/SixLabors.Fonts.Tests/TextLayoutTestUtilities.cs +++ b/tests/SixLabors.Fonts.Tests/TextLayoutTestUtilities.cs @@ -146,6 +146,7 @@ private static RichTextOptions FromTextOptions(TextOptions options, bool customD WrappingLength = options.WrappingLength, VisibleBounds = options.VisibleBounds, TextBaseline = options.TextBaseline, + BaselineOffset = options.BaselineOffset, MaxLines = options.MaxLines, WordBreaking = options.WordBreaking, TextHyphenation = options.TextHyphenation,