/// 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;
+ }
+}