Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,19 @@ An additive or breaking public API change needs all of these:

## [Unreleased]

### Changed

- **`vrtr` in vertical mixed layout.** Glyphs that are rotated in `VerticalMixedLeftRight` and
`VerticalMixedRightLeft` layouts now get the OpenType `vrtr` feature. The font's `vrtr` glyphs are
designed to be rotated by the layout engine, so they are still rotated. Fonts without `vrtr` are
not affected. (#51)

### Fixed

- **`vrt2` in vertical mixed layout.** A `vrt2` feature requested in `TextOptions.FeatureTags` is no
longer applied to glyphs that are rotated in vertical mixed layout. Its glyphs are pre-rotated, so
before, these glyphs were rotated twice. `vrt2` is still never applied by default. (#51)

## [0.1.0] - 2026-10-26

### Added
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,10 @@ internal class DefaultShaper : BaseShaper

protected static readonly Tag VKernTag = Tag.Parse("vkrn");

protected static readonly Tag VrtrTag = Tag.Parse("vrtr");

protected static readonly Tag Vrt2Tag = Tag.Parse("vrt2");

private static readonly CodePoint FractionSlash = new(0x2044);

private static readonly CodePoint Slash = new(0x002F);
Expand Down Expand Up @@ -132,6 +136,14 @@ protected override void PlanPostprocessingFeatures(IGlyphShapingCollection colle
this.AddFeature(collection, i, 1, RcltTag);
this.AddFeature(collection, i, 1, CursTag);
this.AddFeature(collection, i, 1, KernTag);

// Rotated glyphs in vertical mixed layout get `vrtr`. Its glyphs are designed to be
// rotated by the layout engine, so they are still rotated synthetically. See:
// https://learn.microsoft.com/en-us/typography/opentype/spec/features_uz#tag-vrtr
if (layoutMode.IsVerticalMixed())
{
this.AddFeature(collection, i, 1, VrtrTag);
}
}
else
{
Expand All @@ -151,10 +163,27 @@ protected override void PlanPostprocessingFeatures(IGlyphShapingCollection colle
foreach (Tag feature in this.featureTags)
{
// We've already dealt with fractional features.
if (feature != FracTag && feature != NumrTag && feature != DnomTag)
if (feature == FracTag || feature == NumrTag || feature == DnomTag)
{
continue;
}

// `vrt2` supplies pre-rotated glyphs. Glyphs that are rotated synthetically in vertical
// mixed layout must not get it, or they would be rotated twice.
if (feature == Vrt2Tag && layoutMode.IsVerticalMixed())
{
this.AddFeature(collection, index, count, feature);
for (int i = index; i < index + count; i++)
{
if (AdvancedTypographicUtils.IsVerticalGlyph(collection[i].CodePoint, layoutMode, collection[i].HasVerticalForm))
{
this.AddFeature(collection, i, 1, feature);
}
}

continue;
}

this.AddFeature(collection, index, count, feature);
}
}

Expand Down
5 changes: 5 additions & 0 deletions src/PinataLabs.Typography/TextOptions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -186,6 +186,11 @@ public float LineSpacing
/// <summary>
/// Gets or sets the collection of additional feature tags to apply during glyph shaping.
/// </summary>
/// <remarks>
/// In <see cref="LayoutMode.VerticalMixedLeftRight"/> and <see cref="LayoutMode.VerticalMixedRightLeft"/>
/// layouts the <c>vrt2</c> feature is only applied to upright glyphs. Its glyphs are pre-rotated, and
/// sideways glyphs are already rotated by the layout engine.
/// </remarks>
public IReadOnlyList<Tag> FeatureTags { get; set; } = [];

/// <summary>
Expand Down
86 changes: 81 additions & 5 deletions tests/PinataLabs.Typography.Tests/VerticalGlyphFormTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -30,10 +30,10 @@ public class VerticalGlyphFormTests
private const ushort VerticalIdeographicComma = 21594;
private const ushort VerticalProlongedSoundMark = 24724;

private static List<ushort> GlyphIds(string text, LayoutMode mode)
private static List<ushort> GlyphIds(string text, LayoutMode mode, params Tag[] featureTags)
{
FontFamily family = new FontCollection().Add(TestFonts.NotoSansKRRegular);
TextOptions options = new(family.CreateFont(30)) { LayoutMode = mode };
TextOptions options = new(family.CreateFont(30)) { LayoutMode = mode, FeatureTags = featureTags };
GlyphRenderer renderer = new();
TextRenderer.RenderTextTo(renderer, text, options);
return renderer.GlyphKeys.Select(k => k.GlyphId).ToList();
Expand Down Expand Up @@ -225,12 +225,88 @@ public async Task UprightTrGlyphIsShapedWithVertOnly()
await Assert.That(tags).DoesNotContain("kern");
}

[Test]
public async Task RotatedGlyphIsShapedWithVrtrInVerticalMixedLayout()
{
List<string> tags = PlanTrGlyph(hasVerticalForm: false);

await Assert.That(tags).Contains("vrtr");
}

[Test]
[Arguments("ー", true)]
[Arguments("あ", false)]
public async Task UprightGlyphIsNotShapedWithVrtrInVerticalMixedLayout(string text, bool hasVerticalForm)
{
List<string> tags = PlanGlyph(text, LayoutMode.VerticalMixedRightLeft, hasVerticalForm);

await Assert.That(tags).Contains("vert");
await Assert.That(tags).DoesNotContain("vrtr");
}

[Test]
[Arguments(LayoutMode.HorizontalTopBottom)]
[Arguments(LayoutMode.VerticalRightLeft)]
[Arguments(LayoutMode.VerticalLeftRight)]
public async Task VrtrIsOnlyUsedInVerticalMixedLayout(LayoutMode mode)
{
List<string> tags = PlanGlyph("A", mode, false);

await Assert.That(tags).DoesNotContain("vrtr");
}

[Test]
[Arguments("A", LayoutMode.HorizontalTopBottom)]
[Arguments("A", LayoutMode.VerticalRightLeft)]
[Arguments("A", LayoutMode.VerticalMixedRightLeft)]
[Arguments("あ", LayoutMode.VerticalMixedRightLeft)]
public async Task Vrt2IsNotUsedByDefault(string text, LayoutMode mode)
{
List<string> tags = PlanGlyph(text, mode, false);

await Assert.That(tags).DoesNotContain("vrt2");
}

[Test]
public async Task UserVrt2IsNotUsedForRotatedGlyphInVerticalMixedLayout()
{
List<string> tags = PlanGlyph("A", LayoutMode.VerticalMixedRightLeft, false, Tag.Parse("vrt2"));

await Assert.That(tags).DoesNotContain("vrt2");
}

[Test]
[Arguments("あ", LayoutMode.VerticalMixedRightLeft)]
[Arguments("A", LayoutMode.VerticalRightLeft)]
public async Task UserVrt2IsUsedForUprightGlyph(string text, LayoutMode mode)
{
List<string> tags = PlanGlyph(text, mode, false, Tag.Parse("vrt2"));

await Assert.That(tags).Contains("vrt2");
}

[Test]
public async Task UserVrt2DoesNotRotateRotatedGlyphTwiceInVerticalMixedLayout()
{
// U+2026 HORIZONTAL ELLIPSIS has vertical orientation R. NotoSansKR's vrt2 substitutes a
// vertical glyph for it, otherwise this test proves nothing.
await Assert.That(GlyphIds("…", LayoutMode.HorizontalTopBottom, Tag.Parse("vrt2")))
.IsNotEquivalentTo(GlyphIds("…", LayoutMode.HorizontalTopBottom));

// In vertical mixed layout the ellipsis is rotated synthetically, so it must keep its horizontal glyph.
await Assert.That(GlyphIds("あ…い", LayoutMode.VerticalMixedRightLeft, Tag.Parse("vrt2")))
.IsEquivalentTo(GlyphIds("あ…い", LayoutMode.VerticalMixedRightLeft));
}

private static List<string> PlanTrGlyph(bool hasVerticalForm)
=> PlanGlyph("ー", LayoutMode.VerticalMixedRightLeft, hasVerticalForm);

private static List<string> PlanGlyph(string text, LayoutMode mode, bool hasVerticalForm, params Tag[] featureTags)
{
Font font = FakeFont.CreateFont("ー");
TextOptions options = new(font) { LayoutMode = LayoutMode.VerticalMixedRightLeft };
Font font = FakeFont.CreateFont(text);
TextOptions options = new(font) { LayoutMode = mode, FeatureTags = featureTags };
GlyphSubstitutionCollection collection = new(options);
collection.AddGlyph(1, new CodePoint(0x30FC), TextDirection.LeftToRight, new TextRun { Start = 0, End = 1, Font = font }, 0);
collection.AddGlyph(1, CodePoint.DecodeFromUtf16At(text, 0), TextDirection.LeftToRight, new TextRun { Start = 0, End = 1, Font = font }, 0);
collection[0].HasVerticalForm = hasVerticalForm;

new DefaultShaper(ScriptClass.Common, options).Plan(collection, 0, 1);
Expand Down
Loading