engine: add deterministic glyph mask core
This commit is contained in:
@@ -104,6 +104,8 @@ S:\Game Hacking\Eushully\Himegari\ ← workspace root (three siblings)
|
||||
│ ├── Age.Engine/Sys4/ runtime game-root selection, catalog parser, loose-first bounded
|
||||
│ ALF asset store, script provider, AGF/LZSS and Windows CUR decoders,
|
||||
│ and resource facade
|
||||
│ ├── Age.Engine/Text/ backend-neutral glyph-mask requests/results, deterministic AGE
|
||||
│ text compositor/layout, and bounded font/glyph cache primitives
|
||||
│ └── Age.Engine/Persistence/ native S3SD/S4SD + S3RT codecs, shared payload/ReadTextDB,
|
||||
│ layout-3 numbered state/history/gfx, DAT/STH pair + BMP codec,
|
||||
│ and profile-owned state
|
||||
|
||||
@@ -2938,13 +2938,20 @@ different:
|
||||
`GGO_GRAY4_BITMAP`. It retains GDI's `GLYPHMETRICS`, uses `gmCellIncX` as the per-glyph advance, and
|
||||
consumes the returned 17-level coverage mask (`0..16`) directly.
|
||||
3. `text_blit_glyph_bitmap@0x458c80` converts coverage `c` to integer alpha `c*255/16`. On a 32-bit
|
||||
surface it takes `max(destination alpha, glyph alpha)` and interpolates each RGB channel as
|
||||
`(dst*(255-alpha)+src*alpha)/255`.
|
||||
surface, an opaque glyph or a destination with alpha zero receives the source RGB and glyph alpha
|
||||
directly. Otherwise destination alpha becomes `max(destination alpha, glyph alpha)` and each RGB
|
||||
channel becomes `(dst*(255-alpha)+src*alpha)/255`, with integer truncation.
|
||||
4. Mode 3 does not invoke a font-renderer outline. Its step is
|
||||
`360/(sqrt(effect_x²+effect_y²)*8)`, with nearest-integer ellipse coordinates. Radius `(1,1)` makes
|
||||
twelve calls covering the eight neighboring offsets: each cardinal offset is visited twice and each
|
||||
diagonal once, followed by one primary-color glyph blit at the unshifted position.
|
||||
|
||||
Mode 2 uses the same primary color for a second unshifted pass after the ordinary primary glyph, but shifts
|
||||
every cached coverage value right by two first. On a transparent destination this lower-coverage duplicate
|
||||
does not change the already-written source RGB/max alpha; on an existing colored pixel it performs a second
|
||||
integer interpolation toward the primary color. Himegari does not select mode 2, but the shared compositor
|
||||
still reproduces it.
|
||||
|
||||
The port does none of those raster operations. It loads the same TTC into Godot/FreeType with grayscale
|
||||
antialiasing, light hinting, automatic subpixel positioning, embedded bitmaps disabled, and otherwise
|
||||
default `FontFile` policy. Bold is the unrelated `FontVariation.VariationEmbolden=0.53` operation plus
|
||||
@@ -2963,6 +2970,21 @@ face substitutions/defaults remain deferred to the broader runtime configuration
|
||||
is explicitly backlogged until gameplay settles; the scoped architecture and acceptance gates live in
|
||||
`docs/remake-architecture-and-roadmap.md` under “AGE-exact glyph-mask text renderer.”
|
||||
|
||||
The platform-neutral half of that correction landed on 2026-07-30 under `Age.Engine.Text`.
|
||||
`GlyphRasterRequest` carries face, positive pixel height, native requested width, weight, Unicode scalar,
|
||||
optional original CP932 code, and an explicit native/portable policy. `GlyphMask` owns normalized 0..16
|
||||
coverage plus stride, GDI-style origin, cell extent, and advance. `AgeGlyphMaskCompositor` implements the
|
||||
32-bit branch above without calling an OS or Godot API; its synthetic oracle includes transparent and
|
||||
existing-alpha pixels, negative bearings/clipping, all four effect modes, and overlapping mode-3 masks.
|
||||
|
||||
`RetainedGlyphLayoutEngine` consumes any `IGlyphMaskRasterizer`, writes those masks into an `RgbaImage`,
|
||||
and returns the five-dword edge records plus final cursor, observed overflow, wrap count, and vertical-stop
|
||||
state. It preserves native horizontal precedence: vertical-only overflow stops before compositing, while a
|
||||
simultaneous horizontal overflow follows the wrap/kinsoku path. A reusable bounded LRU provides eviction
|
||||
callbacks for future native font handles; `CachedGlyphMaskRasterizer` applies the same bound to masks. This
|
||||
core is not yet connected to ops `0x204`/`0x205`, live ADV, History, or Godot, so the Label backend and
|
||||
visible behavior remain unchanged until the later integration steps.
|
||||
|
||||
#### ADV wait indicator -- ops `0x73` / `0x72` (2026-07-11)
|
||||
|
||||
The small bat marker is a configured ADV-layout sprite, not a glyph or part of SO001. `SYSTEM4.BIN`
|
||||
|
||||
@@ -33,7 +33,7 @@ or replaced before claiming portable exports.
|
||||
| AGE movie decode (`0x236` scene movies; `0x20f` modal LOGO/OP/ED; `0x24d` movie masks) | `FfmpegMovieDecoder` is the sole factory over the project-owned `native/age_movie_ffmpeg` ABI | Windows-x64 passes the complete 213-payload installed video/audio corpus gate plus audible LOGO/OP/CHAPTER playback and real TEST.AGF green-mask decode | Add target-specific native builds and export packaging |
|
||||
| Movie integration | Each surface owns a unique playback-instance id; `MovieRuntime` owns `IMovieDecoder` from an injected factory; video-only streams use monotonic pacing while audio-bearing streams use the Godot output clock. Opcode `0x24d` redirects decoded green bytes through the platform-neutral retained rasterizer and exact managed packed-alpha mask helper | Concurrent/restarted uses of one asset have independent frame/audio/completion/teardown state. Ordinary and mask movies share VFS/FFmpeg ownership; the mask result is a backend-neutral dynamic RGBA surface consumed by either Godot renderer. Managed code is no longer Windows-annotated, while only the win-x64 native bundle exists today | Add Linux/macOS native builds and smoke gates |
|
||||
| Movie audio | ABI v2 returns timestamped stereo float PCM; bounded managed buffering feeds a per-playback Godot `AudioStreamGenerator` and routes native movie flags to engine buses | All 29 installed audio-bearing streams decode with signal; synchronized LOGO/OP/CHAPTER playback is audibly accepted | Treat absent, distorted, or unsynchronized audio from an audio-bearing movie as a runtime bug |
|
||||
| ADV font discovery/raster fidelity | Opcode `0x1a5` reaches presentation; `godot/Main.cs` loads Windows `MS 明朝`/`MS ゴシック` from their known TTC files when available, otherwise uses the existing Japanese-font/default fallback. AGE actually uses a display information DC, weight-700 `LOGFONTA`, `GGO_GRAY4_BITMAP`, and its own integer glyph/outline compositor; the current `Label` backend substitutes FreeType embolden and a Godot outline | On the reference Windows install, regular advances and direct SC0000 placement/bounds agree, but the behaviorally different provisional bold path has 45% fewer bright pixels and much softer edge coverage. Other platforms normally lack the proprietary faces, so substitute metrics remain profile-dependent | The Phase-E retained glyph-mask polish slice is now planned in `docs/remake-architecture-and-roadmap.md`: keep the GDI reference adapter outside the platform-neutral VM project, select it explicitly on Windows, and complete an explicitly non-identical portable rasterizer/substitution policy before removing the temporary Label fallback |
|
||||
| ADV font discovery/raster fidelity | Live presentation still uses `godot/Main.cs` to load Windows `MS 明朝`/`MS ゴシック` when available and otherwise choose the existing Japanese-font/default fallback; its `Label` path substitutes FreeType embolden and a Godot outline. The new `Age.Engine.Text` core is OS-neutral: requests select native-CP932 or portable-Unicode policy, mask results normalize to 0..16 coverage/GDI-style metrics, and managed code owns deterministic compositing/layout plus bounded caches | Visible behavior is unchanged. On the reference Windows install, regular advances and SC0000 placement agree, but provisional bold remains visibly lighter/softer. Synthetic masks now test the common compositor independently of any OS font; no authored runtime code calls GDI | Next add the independent Windows GDI adapter outside `Age.Engine`, then integrate immediate surface pixels. Keep explicit portable face substitution/raster policy and do not claim cross-backend pixel equivalence |
|
||||
| Filesystem semantics | Several filename and containment comparisons use `OrdinalIgnoreCase`; installed assets are conventionally uppercase | Needs validation on case-sensitive filesystems; may hide casing or containment mistakes | Add Linux/macOS tests with mixed-case synthetic roots and use filesystem-appropriate containment rules |
|
||||
| Save/profile/settings storage | `Sys4PersistencePaths` models AGE's independent `SAVEPATH` and `REGFILEPATH` resolutions. Godot replaces Himegari's related profile directory with `user://`, yielding `user://SAVE` for native S3SD/S4SD/S3RT files and thumbnails plus `user://SYS4REG.INI` for the BOM-less CP932 options file; the preserving writer changes only its nine `[sound]` keys | Save payloads and engine options are isolated together without changing either native format. Native/drop-in resolution remains available through `USEAPPDATAFOLDER` plus both SYS4INI paths. A single-root override is rejected when `SAVEPATH` is not beneath `REGFILEPATH`, preventing cross-profile guesses | Expose explicit profile/native selection through the future launcher and allow independent overrides for profiles whose two native paths are unrelated. Validate CP932 availability, replace/flush, case, permissions, and interrupted-write behavior on each export target |
|
||||
| Game-install and repository discovery | `GameRootSelection` accepts `--game-root`, then probes the executable directory and current working directory for `SYS4INI.BIN`; on Unix the frontend prefers inherited shell `PWD` because Godot may change the process directory during project startup. Godot injects the selected root into its catalog and loose-first ALF store. `Paths.cs` remains the development/test locator for generated opcode data, diagnostics, and CLI conveniences | Installed game data no longer depends on the workspace sibling layout, enabling an executable beside `AGE.EXE`, a terminal launch from the install, or a launcher-supplied absolute profile root. A packaged export still needs its generated runtime metadata bundled independently of repository discovery | Add export packaging for the opcode/profile artifacts, let the future profile launcher pass `--game-root`, and run executable-directory/CWD plus case/permission smoke gates on Linux and macOS |
|
||||
|
||||
@@ -579,11 +579,18 @@ primitive, which cannot be made equivalent by choosing another embolden constant
|
||||
decode and the ordinary saved-frame prologue/republication path reconstructs current-page presentation.
|
||||
`AdvRetainedTextContract` and focused engine tests pin the platform-neutral record, overflow, punctuation,
|
||||
capacity, SYSTEM4 binding, and restore boundaries before presentation changes.
|
||||
2. **Land the backend-neutral mask and compositor core.** Add request/result records and an
|
||||
`IGlyphMaskRasterizer`-style seam outside the VM. Keep CP932 byte identity available for native-compatible
|
||||
requests while permitting an explicit Unicode-capable portable policy for mods. Implement AGE's coverage
|
||||
conversion, modes 0–3, clipping, alpha/RGB integer blend, metrics, wrapping, final-cursor result, and
|
||||
bounded font/glyph caches in managed code. Synthetic masks, not OS fonts, are the unit-test oracle.
|
||||
2. **Land the backend-neutral mask and compositor core.** Completed 2026-07-30 without changing live
|
||||
presentation. `Age.Engine.Text` now owns immutable `GlyphRasterRequest`/`GlyphMask` contracts and the
|
||||
`IGlyphMaskRasterizer` seam outside the VM. Requests preserve Unicode scalar plus optional original CP932
|
||||
code and explicitly select native-CP932 or portable-Unicode policy; results normalize every backend to
|
||||
0..16 coverage, stride, GDI-style origin, cell extent, and cell advance. `AgeGlyphMaskCompositor`
|
||||
implements AGE's coverage conversion, modes 0–3, clipping, transparent-destination rule, maximum-alpha
|
||||
and integer RGB blend. `RetainedGlyphLayoutEngine` returns native-edge records, wrapping/kinsoku decisions,
|
||||
overflow state, and final cursor while rasterizing into a backend-neutral RGBA surface. A generic bounded
|
||||
LRU supports disposable backend font resources and `CachedGlyphMaskRasterizer` bounds masks. Fifteen
|
||||
synthetic-mask tests, independent of OS fonts, pin coverage rounding, bearings/clipping, mode-1 displacement,
|
||||
mode-2 quarter coverage, mode-3 sampling/overlap, wrapping, punctuation, vertical overflow, cursor results,
|
||||
and cache eviction.
|
||||
3. **Add the independent Windows GDI reference backend.** Keep the P/Invoke adapter outside the
|
||||
platform-neutral `Age.Engine` project so the VM continues to call no OS APIs. Reproduce the decoded display
|
||||
IC, LOGFONT, measurement, and `GGO_GRAY4_BITMAP` request, and compare masks/`GLYPHMETRICS`/advances directly
|
||||
|
||||
321
engine/Age.Engine.Tests/GlyphMaskCompositorTests.cs
Normal file
321
engine/Age.Engine.Tests/GlyphMaskCompositorTests.cs
Normal file
@@ -0,0 +1,321 @@
|
||||
using Age.Engine.Model;
|
||||
using Age.Engine.Sys4;
|
||||
using Age.Engine.Text;
|
||||
|
||||
public class GlyphMaskCompositorTests
|
||||
{
|
||||
private sealed class CountingRasterizer(Func<GlyphRasterRequest, GlyphMask> factory)
|
||||
: IGlyphMaskRasterizer
|
||||
{
|
||||
public int Calls { get; private set; }
|
||||
|
||||
public GlyphMask Rasterize(GlyphRasterRequest request)
|
||||
{
|
||||
Calls++;
|
||||
return factory(request);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void NativeRequestRequiresCp932IdentityAndValidScalar()
|
||||
{
|
||||
Assert.Throws<ArgumentException>(() => new GlyphRasterRequest(
|
||||
"face", 24, -12, 700, 0x3042, null, GlyphRasterPolicy.NativeCp932Gray4));
|
||||
Assert.Throws<ArgumentOutOfRangeException>(() => new GlyphRasterRequest(
|
||||
"face", 24, -12, 700, 0x110000, 0x82a0, GlyphRasterPolicy.NativeCp932Gray4));
|
||||
|
||||
var request = new GlyphRasterRequest(
|
||||
"face", 24, -12, 700, 0x3042, 0x82a0, GlyphRasterPolicy.NativeCp932Gray4);
|
||||
|
||||
Assert.Equal(("face", 24, -12, 700, 0x3042, (ushort)0x82a0),
|
||||
(request.FontFace, request.PixelHeight, request.RequestedWidth, request.Weight,
|
||||
request.UnicodeScalar, request.Cp932Code!.Value));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MaskOwnsValidatedNormalizedCoverage()
|
||||
{
|
||||
byte[] source = [0, 8, 16, 0];
|
||||
var mask = new GlyphMask(2, 1, 4, 0, 1, 2, 0, 2, 3, source);
|
||||
source[1] = 0;
|
||||
|
||||
Assert.Equal(new byte[] { 0, 8, 16, 0 }, mask.Coverage.ToArray());
|
||||
Assert.Throws<ArgumentException>(
|
||||
() => new GlyphMask(1, 1, 1, 0, 0, 1, 0, 1, 1, [17]));
|
||||
Assert.Throws<ArgumentOutOfRangeException>(
|
||||
() => new GlyphMask(2, 1, 1, 0, 0, 1, 0, 1, 1, [0]));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Gray4CoverageUsesNativeAlphaAndTransparentDestinationRule()
|
||||
{
|
||||
var destination = Image(1, 1);
|
||||
GlyphMask mask = Mask(coverage: 8);
|
||||
|
||||
Assert.True(AgeGlyphMaskCompositor.DrawMask(
|
||||
destination, mask, baselineX: 0, baselineY: 1, rgb: 0x123456));
|
||||
|
||||
Assert.Equal(new byte[] { 0x12, 0x34, 0x56, 127 }, destination.Pixels);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ExistingDestinationUsesIntegerRgbBlendAndMaximumAlpha()
|
||||
{
|
||||
var destination = new RgbaImage(1, 1, [10, 20, 30, 128]);
|
||||
|
||||
AgeGlyphMaskCompositor.DrawMask(
|
||||
destination, Mask(coverage: 8), baselineX: 0, baselineY: 1, rgb: 0x6edc0a);
|
||||
|
||||
Assert.Equal(new byte[] { 59, 119, 20, 128 }, destination.Pixels);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BearingsAndDestinationBoundsClipTheMask()
|
||||
{
|
||||
var destination = Image(2, 1);
|
||||
var mask = new GlyphMask(
|
||||
3, 1, 4, originX: -1, originY: 1,
|
||||
cellAdvanceX: 3, cellAdvanceY: 0, cellWidth: 3, cellHeight: 1,
|
||||
[16, 16, 16, 0]);
|
||||
|
||||
AgeGlyphMaskCompositor.DrawMask(
|
||||
destination, mask, baselineX: 0, baselineY: 1, rgb: 0x010203);
|
||||
|
||||
Assert.Equal(new byte[]
|
||||
{
|
||||
1, 2, 3, 255,
|
||||
1, 2, 3, 255,
|
||||
}, destination.Pixels);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ModeOneDrawsDisplacedEffectThenPrimaryMask()
|
||||
{
|
||||
var destination = Image(3, 1);
|
||||
AdvTextStyle style = Style(renderMode: 1) with
|
||||
{
|
||||
TextColor = 0xff0000,
|
||||
EffectColor = 0x0000ff,
|
||||
EffectOffsetX = 1,
|
||||
};
|
||||
|
||||
AgeGlyphMaskCompositor.DrawGlyph(
|
||||
destination, Mask(), cellX: 0, cellY: 0, topToBaseline: 1, style);
|
||||
|
||||
Assert.Equal(new byte[]
|
||||
{
|
||||
255, 0, 0, 255,
|
||||
0, 0, 255, 255,
|
||||
0, 0, 0, 0,
|
||||
}, destination.Pixels);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ModeTwoAddsQuarterCoveragePrimaryPass()
|
||||
{
|
||||
var destination = new RgbaImage(1, 1, [0, 0, 0, 255]);
|
||||
|
||||
AgeGlyphMaskCompositor.DrawGlyph(
|
||||
destination, Mask(coverage: 8), 0, 0, 1,
|
||||
Style(renderMode: 2) with { TextColor = 0xffffff });
|
||||
|
||||
Assert.Equal(new byte[] { 142, 142, 142, 255 }, destination.Pixels);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ModeThreeRadiusOneMatchesNativeTwelveSampleEllipse()
|
||||
{
|
||||
IReadOnlyList<(int X, int Y)> offsets =
|
||||
AgeGlyphMaskCompositor.GetMode3OutlineOffsets(1, 1);
|
||||
|
||||
Assert.Equal(12, offsets.Count);
|
||||
Assert.Equal(8, offsets.Distinct().Count());
|
||||
Assert.Equal(2, offsets.Count(offset => offset == (1, 0)));
|
||||
Assert.Equal(2, offsets.Count(offset => offset == (0, 1)));
|
||||
Assert.Equal(2, offsets.Count(offset => offset == (-1, 0)));
|
||||
Assert.Equal(2, offsets.Count(offset => offset == (0, -1)));
|
||||
Assert.Equal(1, offsets.Count(offset => offset == (1, 1)));
|
||||
Assert.Equal(1, offsets.Count(offset => offset == (-1, 1)));
|
||||
Assert.Equal(1, offsets.Count(offset => offset == (-1, -1)));
|
||||
Assert.Equal(1, offsets.Count(offset => offset == (1, -1)));
|
||||
Assert.DoesNotContain((0, 0), offsets);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ModeThreeOverlappingMasksUseTheSameIntegerCompositorBeforePrimary()
|
||||
{
|
||||
var destination = Image(5, 3);
|
||||
var mask = new GlyphMask(
|
||||
2, 1, 2, originX: 0, originY: 1,
|
||||
cellAdvanceX: 2, cellAdvanceY: 0, cellWidth: 2, cellHeight: 1,
|
||||
[8, 16]);
|
||||
AdvTextStyle style = Style(renderMode: 3) with
|
||||
{
|
||||
TextColor = 0xff0000,
|
||||
EffectColor = 0x0000ff,
|
||||
EffectOffsetX = 1,
|
||||
EffectOffsetY = 1,
|
||||
};
|
||||
|
||||
AgeGlyphMaskCompositor.DrawGlyph(
|
||||
destination, mask, cellX: 2, cellY: 1, topToBaseline: 1, style);
|
||||
|
||||
// The left horizontal outline copy puts opaque blue under the half-coverage primary texel.
|
||||
// AGE retains max alpha and blends RGB with integer truncation.
|
||||
Assert.Equal((127, 0, 128, 255), Pixel(destination, 2, 1));
|
||||
Assert.Equal((255, 0, 0, 255), Pixel(destination, 3, 1));
|
||||
Assert.Equal((0, 0, 255, 127), Pixel(destination, 1, 1));
|
||||
Assert.Equal((0, 0, 255, 255), Pixel(destination, 4, 1));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void LayoutWrapsBeforeOrdinaryGlyphAndReturnsNativeEdgeRecords()
|
||||
{
|
||||
var destination = Image(16, 16);
|
||||
var renderer = new CountingRasterizer(_ => CellMask());
|
||||
var engine = new RetainedGlyphLayoutEngine(renderer);
|
||||
|
||||
GlyphTextLayoutResult result = engine.Render(
|
||||
destination,
|
||||
new GlyphTextLayoutOptions(
|
||||
CursorX: 2, CursorY: 1, LineOriginX: 2,
|
||||
RightBound: 8, BottomBound: 16, WrapHorizontally: true, Style()),
|
||||
[NativeRequest(0x82a0, 0x3042), NativeRequest(0x82a2, 0x3044)]);
|
||||
|
||||
Assert.Equal(2, result.ConsumedGlyphs);
|
||||
Assert.Equal(1, result.WrappedLines);
|
||||
Assert.Equal((6, 7), (result.CursorX, result.CursorY));
|
||||
Assert.Equal(AdvTextOverflowFlags.Horizontal, result.ObservedOverflow);
|
||||
Assert.Collection(result.Records,
|
||||
first => Assert.Equal(new AdvRetainedGlyphRecord(0, 2, 1, 6, 6), first),
|
||||
second => Assert.Equal(new AdvRetainedGlyphRecord(0, 2, 7, 6, 12), second));
|
||||
Assert.Equal(255, Pixel(destination, 2, 1).A);
|
||||
Assert.Equal(255, Pixel(destination, 2, 7).A);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ClosingPunctuationStaysOnOverflowingPrecedingLine()
|
||||
{
|
||||
var engine = new RetainedGlyphLayoutEngine(new CountingRasterizer(_ => CellMask()));
|
||||
|
||||
GlyphTextLayoutResult result = engine.Render(
|
||||
Image(16, 16),
|
||||
new GlyphTextLayoutOptions(2, 1, 2, 8, 16, true, Style()),
|
||||
[NativeRequest(0x82a0, 0x3042), NativeRequest(0x8141, 0x3001)]);
|
||||
|
||||
Assert.Equal(0, result.WrappedLines);
|
||||
Assert.Equal((10, 1), (result.CursorX, result.CursorY));
|
||||
Assert.Equal(new AdvRetainedGlyphRecord(0, 6, 1, 10, 6), result.Records[1]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void VerticalOnlyOverflowStopsBeforeCompositingGlyph()
|
||||
{
|
||||
var rasterizer = new CountingRasterizer(_ => CellMask());
|
||||
var engine = new RetainedGlyphLayoutEngine(rasterizer);
|
||||
|
||||
GlyphTextLayoutResult result = engine.Render(
|
||||
Image(16, 16),
|
||||
new GlyphTextLayoutOptions(2, 12, 2, 8, 16, true, Style()),
|
||||
[NativeRequest(0x82a0, 0x3042)]);
|
||||
|
||||
Assert.True(result.StoppedOnVerticalOverflow);
|
||||
Assert.Equal(0, result.ConsumedGlyphs);
|
||||
Assert.Empty(result.Records);
|
||||
Assert.Equal(1, rasterizer.Calls); // metrics are required before the overflow decision
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SimultaneousHorizontalAndVerticalOverflowTakesNativeWrapPath()
|
||||
{
|
||||
var engine = new RetainedGlyphLayoutEngine(new CountingRasterizer(_ => CellMask()));
|
||||
|
||||
GlyphTextLayoutResult result = engine.Render(
|
||||
Image(16, 24),
|
||||
new GlyphTextLayoutOptions(6, 12, 2, 8, 16, true, Style()),
|
||||
[NativeRequest(0x82a0, 0x3042)]);
|
||||
|
||||
Assert.False(result.StoppedOnVerticalOverflow);
|
||||
Assert.Equal(1, result.ConsumedGlyphs);
|
||||
Assert.Equal(1, result.WrappedLines);
|
||||
Assert.Equal(
|
||||
AdvTextOverflowFlags.Horizontal | AdvTextOverflowFlags.Vertical,
|
||||
result.ObservedOverflow);
|
||||
Assert.Equal(new AdvRetainedGlyphRecord(0, 2, 18, 6, 23), Assert.Single(result.Records));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GlyphCacheIsBoundedAndUsesLruRecency()
|
||||
{
|
||||
var inner = new CountingRasterizer(_ => Mask());
|
||||
var cached = new CachedGlyphMaskRasterizer(inner, capacity: 2);
|
||||
GlyphRasterRequest a = NativeRequest(0x41, 0x41);
|
||||
GlyphRasterRequest b = NativeRequest(0x42, 0x42);
|
||||
GlyphRasterRequest c = NativeRequest(0x43, 0x43);
|
||||
|
||||
GlyphMask firstA = cached.Rasterize(a);
|
||||
cached.Rasterize(b);
|
||||
Assert.Same(firstA, cached.Rasterize(a));
|
||||
cached.Rasterize(c);
|
||||
cached.Rasterize(b);
|
||||
|
||||
Assert.Equal(2, cached.Count);
|
||||
Assert.Equal(1, cached.Hits);
|
||||
Assert.Equal(4, cached.Misses);
|
||||
Assert.Equal(4, inner.Calls);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GenericCacheEvictionCanReleaseBackendFontResources()
|
||||
{
|
||||
var evicted = new List<string>();
|
||||
var cache = new BoundedLruCache<int, string>(2, evicted.Add);
|
||||
cache.Set(1, "font-1");
|
||||
cache.Set(2, "font-2");
|
||||
Assert.True(cache.TryGetValue(1, out _));
|
||||
|
||||
cache.Set(3, "font-3");
|
||||
|
||||
Assert.Equal(["font-2"], evicted);
|
||||
Assert.False(cache.TryGetValue(2, out _));
|
||||
Assert.True(cache.TryGetValue(1, out _));
|
||||
Assert.True(cache.TryGetValue(3, out _));
|
||||
}
|
||||
|
||||
private static GlyphRasterRequest NativeRequest(ushort cp932, int scalar)
|
||||
=> new("face", 5, -2, 700, scalar, cp932, GlyphRasterPolicy.NativeCp932Gray4);
|
||||
|
||||
private static GlyphMask Mask(byte coverage = 16)
|
||||
=> new(1, 1, 1, originX: 0, originY: 1,
|
||||
cellAdvanceX: 1, cellAdvanceY: 0, cellWidth: 1, cellHeight: 1, [coverage]);
|
||||
|
||||
private static GlyphMask CellMask()
|
||||
=> new(1, 1, 1, originX: 0, originY: 5,
|
||||
cellAdvanceX: 4, cellAdvanceY: 0, cellWidth: 4, cellHeight: 5, [16]);
|
||||
|
||||
private static AdvTextStyle Style(int renderMode = 0)
|
||||
=> new(
|
||||
PrimaryFontSize: 5,
|
||||
RubyFontSize: 0,
|
||||
Bold: true,
|
||||
TextColor: 0xffffff,
|
||||
EffectColor: 0,
|
||||
RenderMode: renderMode,
|
||||
EffectOffsetX: 0,
|
||||
EffectOffsetY: 0,
|
||||
LineSpacing: 1,
|
||||
FontFace: "face");
|
||||
|
||||
private static RgbaImage Image(int width, int height)
|
||||
=> new(width, height, new byte[checked(width * height * 4)]);
|
||||
|
||||
private static (byte R, byte G, byte B, byte A) Pixel(RgbaImage image, int x, int y)
|
||||
{
|
||||
int offset = (y * image.Width + x) * 4;
|
||||
return (
|
||||
image.Pixels[offset],
|
||||
image.Pixels[offset + 1],
|
||||
image.Pixels[offset + 2],
|
||||
image.Pixels[offset + 3]);
|
||||
}
|
||||
}
|
||||
135
engine/Age.Engine/Text/AgeGlyphMaskCompositor.cs
Normal file
135
engine/Age.Engine/Text/AgeGlyphMaskCompositor.cs
Normal file
@@ -0,0 +1,135 @@
|
||||
using Age.Engine.Model;
|
||||
using Age.Engine.Sys4;
|
||||
|
||||
namespace Age.Engine.Text;
|
||||
|
||||
/// <summary>AGE's deterministic 32-bit glyph-mask compositor, independent of mask provenance.</summary>
|
||||
public static class AgeGlyphMaskCompositor
|
||||
{
|
||||
public static void DrawGlyph(
|
||||
RgbaImage destination,
|
||||
GlyphMask mask,
|
||||
int cellX,
|
||||
int cellY,
|
||||
int topToBaseline,
|
||||
AdvTextStyle style)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(destination);
|
||||
ArgumentNullException.ThrowIfNull(mask);
|
||||
ValidateDestination(destination);
|
||||
int baselineY = checked(cellY + topToBaseline);
|
||||
|
||||
if (style.RenderMode == 1)
|
||||
DrawMask(destination, mask,
|
||||
checked(cellX + style.EffectOffsetX),
|
||||
checked(baselineY + style.EffectOffsetY),
|
||||
style.EffectColor);
|
||||
else if (style.RenderMode == 3)
|
||||
foreach ((int x, int y) in GetMode3OutlineOffsets(
|
||||
style.EffectOffsetX, style.EffectOffsetY))
|
||||
DrawMask(destination, mask, checked(cellX + x), checked(baselineY + y),
|
||||
style.EffectColor);
|
||||
|
||||
DrawMask(destination, mask, cellX, baselineY, style.TextColor);
|
||||
if (style.RenderMode == 2)
|
||||
DrawMask(destination, mask, cellX, baselineY, style.TextColor, coverageShift: 2);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Draw at a GDI-style baseline. mask.OriginX moves right from the pen and mask.OriginY moves
|
||||
/// upward from the baseline. coverageShift=2 reproduces AGE's mode-2 quarter-coverage pass.
|
||||
/// </summary>
|
||||
public static bool DrawMask(
|
||||
RgbaImage destination,
|
||||
GlyphMask mask,
|
||||
int baselineX,
|
||||
int baselineY,
|
||||
long rgb,
|
||||
int coverageShift = 0)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(destination);
|
||||
ArgumentNullException.ThrowIfNull(mask);
|
||||
ValidateDestination(destination);
|
||||
if (coverageShift is < 0 or > 4)
|
||||
throw new ArgumentOutOfRangeException(nameof(coverageShift));
|
||||
|
||||
int red = (int)((rgb >> 16) & 0xff);
|
||||
int green = (int)((rgb >> 8) & 0xff);
|
||||
int blue = (int)(rgb & 0xff);
|
||||
int left = checked(baselineX + mask.OriginX);
|
||||
int top = checked(baselineY - mask.OriginY);
|
||||
ReadOnlySpan<byte> coverage = mask.Coverage.Span;
|
||||
bool changed = false;
|
||||
|
||||
for (int row = 0; row < mask.Height; row++)
|
||||
{
|
||||
int y = checked(top + row);
|
||||
if ((uint)y >= (uint)destination.Height) continue;
|
||||
int sourceRow = row * mask.Stride;
|
||||
for (int column = 0; column < mask.Width; column++)
|
||||
{
|
||||
int x = checked(left + column);
|
||||
if ((uint)x >= (uint)destination.Width) continue;
|
||||
int level = coverage[sourceRow + column] >> coverageShift;
|
||||
if (level == 0) continue;
|
||||
int alpha = level * 255 / 16;
|
||||
BlendTextPixel(destination.Pixels,
|
||||
checked((y * destination.Width + x) * 4),
|
||||
red, green, blue, alpha);
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
return changed;
|
||||
}
|
||||
|
||||
public static IReadOnlyList<(int X, int Y)> GetMode3OutlineOffsets(int radiusX, int radiusY)
|
||||
{
|
||||
double radius = Math.Sqrt((double)radiusX * radiusX + (double)radiusY * radiusY);
|
||||
if (radius == 0) return Array.Empty<(int, int)>();
|
||||
int sampleCount = checked((int)Math.Ceiling(radius * 8.0));
|
||||
var result = new (int X, int Y)[sampleCount];
|
||||
double step = 360.0 / (radius * 8.0);
|
||||
for (int index = 0; index < result.Length; index++)
|
||||
{
|
||||
double radians = index * step * Math.PI / 180.0;
|
||||
result[index] = (
|
||||
RoundNearest(Math.Cos(radians) * radiusX),
|
||||
RoundNearest(Math.Sin(radians) * radiusY));
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
private static int RoundNearest(double value)
|
||||
=> checked((int)(value < 0 ? value - 0.5 : value + 0.5));
|
||||
|
||||
private static void BlendTextPixel(
|
||||
byte[] destination, int offset, int red, int green, int blue, int alpha)
|
||||
{
|
||||
int destinationAlpha = destination[offset + 3];
|
||||
if (alpha == 255 || destinationAlpha == 0)
|
||||
{
|
||||
destination[offset] = (byte)red;
|
||||
destination[offset + 1] = (byte)green;
|
||||
destination[offset + 2] = (byte)blue;
|
||||
destination[offset + 3] = (byte)alpha;
|
||||
return;
|
||||
}
|
||||
|
||||
int inverse = 255 - alpha;
|
||||
destination[offset] =
|
||||
(byte)((destination[offset] * inverse + red * alpha) / 255);
|
||||
destination[offset + 1] =
|
||||
(byte)((destination[offset + 1] * inverse + green * alpha) / 255);
|
||||
destination[offset + 2] =
|
||||
(byte)((destination[offset + 2] * inverse + blue * alpha) / 255);
|
||||
destination[offset + 3] = (byte)Math.Max(destinationAlpha, alpha);
|
||||
}
|
||||
|
||||
private static void ValidateDestination(RgbaImage destination)
|
||||
{
|
||||
if (destination.Width <= 0 || destination.Height <= 0
|
||||
|| destination.Pixels.Length != checked(destination.Width * destination.Height * 4))
|
||||
throw new ArgumentException("Destination must be a non-empty tightly packed RGBA image.",
|
||||
nameof(destination));
|
||||
}
|
||||
}
|
||||
120
engine/Age.Engine/Text/BoundedLruCache.cs
Normal file
120
engine/Age.Engine/Text/BoundedLruCache.cs
Normal file
@@ -0,0 +1,120 @@
|
||||
namespace Age.Engine.Text;
|
||||
|
||||
/// <summary>A small thread-safe LRU used by glyph masks and future backend-owned font handles.</summary>
|
||||
public sealed class BoundedLruCache<TKey, TValue> where TKey : notnull
|
||||
{
|
||||
private sealed record Entry(TKey Key, TValue Value);
|
||||
|
||||
private readonly object _gate = new();
|
||||
private readonly int _capacity;
|
||||
private readonly Action<TValue>? _onEvicted;
|
||||
private readonly Dictionary<TKey, LinkedListNode<Entry>> _entries = new();
|
||||
private readonly LinkedList<Entry> _recency = new();
|
||||
|
||||
public BoundedLruCache(int capacity, Action<TValue>? onEvicted = null)
|
||||
{
|
||||
if (capacity <= 0) throw new ArgumentOutOfRangeException(nameof(capacity));
|
||||
_capacity = capacity;
|
||||
_onEvicted = onEvicted;
|
||||
}
|
||||
|
||||
public int Capacity => _capacity;
|
||||
|
||||
public int Count
|
||||
{
|
||||
get { lock (_gate) return _entries.Count; }
|
||||
}
|
||||
|
||||
public bool TryGetValue(TKey key, out TValue value)
|
||||
{
|
||||
lock (_gate)
|
||||
{
|
||||
if (!_entries.TryGetValue(key, out LinkedListNode<Entry>? node))
|
||||
{
|
||||
value = default!;
|
||||
return false;
|
||||
}
|
||||
_recency.Remove(node);
|
||||
_recency.AddFirst(node);
|
||||
value = node.Value.Value;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
public void Set(TKey key, TValue value)
|
||||
{
|
||||
List<TValue>? evicted = null;
|
||||
lock (_gate)
|
||||
{
|
||||
if (_entries.Remove(key, out LinkedListNode<Entry>? existing))
|
||||
{
|
||||
_recency.Remove(existing);
|
||||
(evicted ??= new List<TValue>(2)).Add(existing.Value.Value);
|
||||
}
|
||||
|
||||
var node = new LinkedListNode<Entry>(new Entry(key, value));
|
||||
_recency.AddFirst(node);
|
||||
_entries.Add(key, node);
|
||||
if (_entries.Count > _capacity)
|
||||
{
|
||||
LinkedListNode<Entry> oldest = _recency.Last!;
|
||||
_recency.RemoveLast();
|
||||
_entries.Remove(oldest.Value.Key);
|
||||
(evicted ??= new List<TValue>(1)).Add(oldest.Value.Value);
|
||||
}
|
||||
}
|
||||
if (_onEvicted != null && evicted != null)
|
||||
foreach (TValue removed in evicted) _onEvicted(removed);
|
||||
}
|
||||
|
||||
public void Clear()
|
||||
{
|
||||
TValue[] removed;
|
||||
lock (_gate)
|
||||
{
|
||||
removed = _recency.Select(entry => entry.Value).ToArray();
|
||||
_entries.Clear();
|
||||
_recency.Clear();
|
||||
}
|
||||
if (_onEvicted != null)
|
||||
foreach (TValue value in removed) _onEvicted(value);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Bounded caching decorator shared by exact and portable glyph-mask backends.</summary>
|
||||
public sealed class CachedGlyphMaskRasterizer : IGlyphMaskRasterizer
|
||||
{
|
||||
private readonly IGlyphMaskRasterizer _inner;
|
||||
private readonly BoundedLruCache<GlyphRasterRequest, GlyphMask> _cache;
|
||||
private long _hits;
|
||||
private long _misses;
|
||||
|
||||
public CachedGlyphMaskRasterizer(IGlyphMaskRasterizer inner, int capacity)
|
||||
{
|
||||
_inner = inner ?? throw new ArgumentNullException(nameof(inner));
|
||||
_cache = new BoundedLruCache<GlyphRasterRequest, GlyphMask>(capacity);
|
||||
}
|
||||
|
||||
public int Count => _cache.Count;
|
||||
public int Capacity => _cache.Capacity;
|
||||
public long Hits => Interlocked.Read(ref _hits);
|
||||
public long Misses => Interlocked.Read(ref _misses);
|
||||
|
||||
public GlyphMask Rasterize(GlyphRasterRequest request)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(request);
|
||||
if (_cache.TryGetValue(request, out GlyphMask? cached))
|
||||
{
|
||||
Interlocked.Increment(ref _hits);
|
||||
return cached;
|
||||
}
|
||||
|
||||
GlyphMask result = _inner.Rasterize(request)
|
||||
?? throw new InvalidOperationException("The glyph rasterizer returned null.");
|
||||
_cache.Set(request, result);
|
||||
Interlocked.Increment(ref _misses);
|
||||
return result;
|
||||
}
|
||||
|
||||
public void Clear() => _cache.Clear();
|
||||
}
|
||||
112
engine/Age.Engine/Text/GlyphMaskContracts.cs
Normal file
112
engine/Age.Engine/Text/GlyphMaskContracts.cs
Normal file
@@ -0,0 +1,112 @@
|
||||
using System.Text;
|
||||
|
||||
namespace Age.Engine.Text;
|
||||
|
||||
public enum GlyphRasterPolicy
|
||||
{
|
||||
NativeCp932Gray4,
|
||||
PortableUnicode,
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Backend-neutral description of one requested glyph. Native-compatible requests preserve both
|
||||
/// the Unicode scalar used by authored code and the original CP932 code consumed by AGE/GDI.
|
||||
/// </summary>
|
||||
public sealed record GlyphRasterRequest
|
||||
{
|
||||
public GlyphRasterRequest(
|
||||
string fontFace,
|
||||
int pixelHeight,
|
||||
int requestedWidth,
|
||||
int weight,
|
||||
int unicodeScalar,
|
||||
ushort? cp932Code,
|
||||
GlyphRasterPolicy policy)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(fontFace);
|
||||
if (pixelHeight <= 0) throw new ArgumentOutOfRangeException(nameof(pixelHeight));
|
||||
if (weight is < 0 or > 1000) throw new ArgumentOutOfRangeException(nameof(weight));
|
||||
if (!Rune.IsValid(unicodeScalar)) throw new ArgumentOutOfRangeException(nameof(unicodeScalar));
|
||||
if (policy == GlyphRasterPolicy.NativeCp932Gray4 && cp932Code == null)
|
||||
throw new ArgumentException("Native CP932 glyph requests require the original code.", nameof(cp932Code));
|
||||
|
||||
FontFace = fontFace;
|
||||
PixelHeight = pixelHeight;
|
||||
RequestedWidth = requestedWidth;
|
||||
Weight = weight;
|
||||
UnicodeScalar = unicodeScalar;
|
||||
Cp932Code = cp932Code;
|
||||
Policy = policy;
|
||||
}
|
||||
|
||||
public string FontFace { get; }
|
||||
public int PixelHeight { get; }
|
||||
public int RequestedWidth { get; }
|
||||
public int Weight { get; }
|
||||
public int UnicodeScalar { get; }
|
||||
public ushort? Cp932Code { get; }
|
||||
public GlyphRasterPolicy Policy { get; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// One grayscale glyph mask plus GDI-compatible placement and cell metrics. Coverage values use
|
||||
/// AGE's normalized 0..16 range, regardless of which platform rasterizer produced them.
|
||||
/// </summary>
|
||||
public sealed class GlyphMask
|
||||
{
|
||||
private readonly byte[] _coverage;
|
||||
|
||||
public GlyphMask(
|
||||
int width,
|
||||
int height,
|
||||
int stride,
|
||||
int originX,
|
||||
int originY,
|
||||
int cellAdvanceX,
|
||||
int cellAdvanceY,
|
||||
int cellWidth,
|
||||
int cellHeight,
|
||||
ReadOnlySpan<byte> coverage)
|
||||
{
|
||||
if (width < 0) throw new ArgumentOutOfRangeException(nameof(width));
|
||||
if (height < 0) throw new ArgumentOutOfRangeException(nameof(height));
|
||||
if (stride < width) throw new ArgumentOutOfRangeException(nameof(stride));
|
||||
if (cellWidth < 0) throw new ArgumentOutOfRangeException(nameof(cellWidth));
|
||||
if (cellHeight < 0) throw new ArgumentOutOfRangeException(nameof(cellHeight));
|
||||
int required = checked(stride * height);
|
||||
if (coverage.Length != required)
|
||||
throw new ArgumentException($"Glyph coverage has {coverage.Length} bytes; expected {required}.",
|
||||
nameof(coverage));
|
||||
foreach (byte value in coverage)
|
||||
if (value > 16)
|
||||
throw new ArgumentException("Glyph coverage values must be in AGE's 0..16 range.",
|
||||
nameof(coverage));
|
||||
|
||||
Width = width;
|
||||
Height = height;
|
||||
Stride = stride;
|
||||
OriginX = originX;
|
||||
OriginY = originY;
|
||||
CellAdvanceX = cellAdvanceX;
|
||||
CellAdvanceY = cellAdvanceY;
|
||||
CellWidth = cellWidth;
|
||||
CellHeight = cellHeight;
|
||||
_coverage = coverage.ToArray();
|
||||
}
|
||||
|
||||
public int Width { get; }
|
||||
public int Height { get; }
|
||||
public int Stride { get; }
|
||||
public int OriginX { get; }
|
||||
public int OriginY { get; }
|
||||
public int CellAdvanceX { get; }
|
||||
public int CellAdvanceY { get; }
|
||||
public int CellWidth { get; }
|
||||
public int CellHeight { get; }
|
||||
public ReadOnlyMemory<byte> Coverage => _coverage;
|
||||
}
|
||||
|
||||
public interface IGlyphMaskRasterizer
|
||||
{
|
||||
GlyphMask Rasterize(GlyphRasterRequest request);
|
||||
}
|
||||
91
engine/Age.Engine/Text/RetainedGlyphLayoutEngine.cs
Normal file
91
engine/Age.Engine/Text/RetainedGlyphLayoutEngine.cs
Normal file
@@ -0,0 +1,91 @@
|
||||
using Age.Engine.Model;
|
||||
using Age.Engine.Sys4;
|
||||
|
||||
namespace Age.Engine.Text;
|
||||
|
||||
public readonly record struct GlyphTextLayoutOptions(
|
||||
int CursorX,
|
||||
int CursorY,
|
||||
int LineOriginX,
|
||||
int RightBound,
|
||||
int BottomBound,
|
||||
bool WrapHorizontally,
|
||||
AdvTextStyle Style);
|
||||
|
||||
public sealed record GlyphTextLayoutResult(
|
||||
IReadOnlyList<AdvRetainedGlyphRecord> Records,
|
||||
int CursorX,
|
||||
int CursorY,
|
||||
int ConsumedGlyphs,
|
||||
int WrappedLines,
|
||||
AdvTextOverflowFlags ObservedOverflow,
|
||||
bool StoppedOnVerticalOverflow);
|
||||
|
||||
/// <summary>
|
||||
/// Builds native-edge retained records while rasterizing masks into the layout's RGBA surface.
|
||||
/// This is deliberately presentation-neutral: binding records to GfxState belongs to a later slice.
|
||||
/// </summary>
|
||||
public sealed class RetainedGlyphLayoutEngine
|
||||
{
|
||||
private readonly IGlyphMaskRasterizer _rasterizer;
|
||||
|
||||
public RetainedGlyphLayoutEngine(IGlyphMaskRasterizer rasterizer)
|
||||
=> _rasterizer = rasterizer ?? throw new ArgumentNullException(nameof(rasterizer));
|
||||
|
||||
public GlyphTextLayoutResult Render(
|
||||
RgbaImage destination,
|
||||
GlyphTextLayoutOptions options,
|
||||
IReadOnlyList<GlyphRasterRequest> glyphs)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(destination);
|
||||
ArgumentNullException.ThrowIfNull(glyphs);
|
||||
var records = new List<AdvRetainedGlyphRecord>(glyphs.Count);
|
||||
int cursorX = options.CursorX;
|
||||
int cursorY = options.CursorY;
|
||||
int wrappedLines = 0;
|
||||
var observed = AdvTextOverflowFlags.None;
|
||||
bool stopped = false;
|
||||
|
||||
foreach (GlyphRasterRequest request in glyphs)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(request);
|
||||
GlyphMask mask = _rasterizer.Rasterize(request);
|
||||
int right = checked(cursorX + mask.CellWidth);
|
||||
int bottom = checked(cursorY + mask.CellHeight);
|
||||
AdvTextOverflowFlags overflow = AdvRetainedTextContract.CheckOverflow(
|
||||
options.RightBound, options.BottomBound, right, bottom);
|
||||
observed |= overflow;
|
||||
|
||||
if (overflow == AdvTextOverflowFlags.Vertical)
|
||||
{
|
||||
stopped = true;
|
||||
break;
|
||||
}
|
||||
|
||||
if (overflow.HasFlag(AdvTextOverflowFlags.Horizontal)
|
||||
&& options.WrapHorizontally
|
||||
&& (request.Cp932Code == null
|
||||
|| !AdvRetainedTextContract.PreventsHorizontalWrapBefore(
|
||||
request.Cp932Code.Value)))
|
||||
{
|
||||
int fontHeight = options.Style.PrimaryFontSize > 0
|
||||
? options.Style.PrimaryFontSize
|
||||
: request.PixelHeight;
|
||||
cursorX = options.LineOriginX;
|
||||
cursorY = checked(cursorY + fontHeight + options.Style.LineSpacing);
|
||||
wrappedLines++;
|
||||
right = checked(cursorX + mask.CellWidth);
|
||||
bottom = checked(cursorY + mask.CellHeight);
|
||||
}
|
||||
|
||||
AgeGlyphMaskCompositor.DrawGlyph(
|
||||
destination, mask, cursorX, cursorY, request.PixelHeight, options.Style);
|
||||
records.Add(new AdvRetainedGlyphRecord(0, cursorX, cursorY, right, bottom));
|
||||
cursorX = checked(cursorX + mask.CellAdvanceX);
|
||||
cursorY = checked(cursorY + mask.CellAdvanceY);
|
||||
}
|
||||
|
||||
return new GlyphTextLayoutResult(
|
||||
records, cursorX, cursorY, records.Count, wrappedLines, observed, stopped);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user