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..fafb0817a 100644 --- a/src/SixLabors.Fonts/GlyphOptions.cs +++ b/src/SixLabors.Fonts/GlyphOptions.cs @@ -50,6 +50,28 @@ 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; } + + /// + public float BaselineOffset { 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..a8736f141 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 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 + // combined offset applies along X from that axis. + origin.X -= TextLayout.GetBaselineOffset(options, 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..15a48f3b0 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,33 @@ 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; + } + } + + // 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; @@ -648,7 +711,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..d6ea9118b 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,229 @@ 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); + } + + /// + /// 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 + /// 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 +556,8 @@ public static void LayoutText( maxScaledAdvance, options, i, + visibleFlowMin, + visibleFlowMax, ref boxLocation, ref penLocation, ref visitor); @@ -343,6 +578,8 @@ public static void LayoutText( maxScaledAdvance, options, index++, + visibleFlowMin, + visibleFlowMax, ref boxLocation, ref penLocation, ref visitor); @@ -362,6 +599,8 @@ public static void LayoutText( maxScaledAdvance, options, i, + visibleFlowMin, + visibleFlowMax, ref boxLocation, ref penLocation, ref visitor); @@ -382,6 +621,8 @@ public static void LayoutText( maxScaledAdvance, options, index++, + visibleFlowMin, + visibleFlowMax, ref boxLocation, ref penLocation, ref visitor); @@ -401,6 +642,8 @@ public static void LayoutText( maxScaledAdvance, options, i, + visibleFlowMin, + visibleFlowMax, ref boxLocation, ref penLocation, ref visitor); @@ -421,6 +664,8 @@ public static void LayoutText( maxScaledAdvance, options, index++, + visibleFlowMin, + visibleFlowMax, ref boxLocation, ref penLocation, ref visitor); @@ -442,6 +687,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 +699,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 +724,70 @@ 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; + } } + + // 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; + // 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 +828,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 +887,6 @@ private static void LayoutLineHorizontal( i == 0 && j == 0, data.GraphemeIndex, data.StringIndex)); - - emitted = true; } boxLocation.X += layoutAdvance; @@ -622,11 +896,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 +913,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 +925,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 +989,66 @@ 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; + } } + + // 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; 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 +1260,6 @@ private static void LayoutLineVertical( data.GraphemeIndex, data.StringIndex)); - emitted = true; j++; } @@ -986,11 +1287,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 +1304,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 +1316,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 +1380,64 @@ 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; + } } + + // 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; 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 +1490,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; - - // 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; + // 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; - 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 +1517,6 @@ private static void LayoutLineVerticalMixed( i == 0 && j == 0, data.GraphemeIndex, data.StringIndex)); - - emitted = true; } } else @@ -1224,8 +1552,6 @@ private static void LayoutLineVerticalMixed( i == 0 && j == 0, data.GraphemeIndex, data.StringIndex)); - - emitted = true; } } @@ -1235,11 +1561,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..ec0fb8bc5 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,37 @@ 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, 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, false); + } + else + { + origin.X -= TextLayout.GetBaselineOffset(options, 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 +646,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..fffd4d8a3 100644 --- a/src/SixLabors.Fonts/TextOptions.cs +++ b/src/SixLabors.Fonts/TextOptions.cs @@ -38,6 +38,9 @@ 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.BaselineOffset = options.BaselineOffset; this.MaxLines = options.MaxLines; this.WordBreaking = options.WordBreaking; this.TextEllipsis = options.TextEllipsis; @@ -147,6 +150,42 @@ 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 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/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/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/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/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/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..a45a93c20 --- /dev/null +++ b/tests/SixLabors.Fonts.Tests/TextBaselineTests.cs @@ -0,0 +1,648 @@ +// 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.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)] + [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); + + [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, + 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..8f7cff732 100644 --- a/tests/SixLabors.Fonts.Tests/TextLayoutTestUtilities.cs +++ b/tests/SixLabors.Fonts.Tests/TextLayoutTestUtilities.cs @@ -144,6 +144,9 @@ private static RichTextOptions FromTextOptions(TextOptions options, bool customD LineSpacing = options.LineSpacing, Origin = options.Origin, WrappingLength = options.WrappingLength, + VisibleBounds = options.VisibleBounds, + TextBaseline = options.TextBaseline, + BaselineOffset = options.BaselineOffset, 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; + } +}