From 44bf2b93e9b9b769d096567a03aba31d8f881241 Mon Sep 17 00:00:00 2001 From: Cheng Ming <8456918+Windorain@users.noreply.github.com> Date: Sun, 4 Oct 2026 04:55:30 +0800 Subject: [PATCH 1/6] build: build-time native library pipeline Add the copyRustNative Gradle task that copies the built layout-engine library into the resources tree during processResources, skipping cleanly when the library has not been built. Resolve the cargo target directory through a documented chain (gradle property, environment variable, local cargo config, in-tree default) instead of any machine-local path. Remove the dead runGlyphTest/runParleySmoke tasks and make the flatc regeneration script resolve its toolchain the same way. --- build.gradle.kts | 119 ++++++++++++++++++++ dependencies.gradle | 4 +- tools/regen_java_flatc.bat | 225 +++++++++++++++++++++++++++++++++++++ 3 files changed, 346 insertions(+), 2 deletions(-) create mode 100644 tools/regen_java_flatc.bat diff --git a/build.gradle.kts b/build.gradle.kts index 0639be27..e1eb89d4 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -8,12 +8,21 @@ minecraft { extraRunJvmArguments.addAll("-Xmx4G", "-Xms512m", "-Dgtnhlib.dumpkeys=true") } +dependencies { + // fastutil is available at MC runtime but not in test scope + testImplementation("it.unimi.dsi:fastutil:8.5.12") +} + tasks.withType().configureEach { options.annotationProcessorPath = configurations.annotationProcessor.get() } tasks.withType().configureEach { useJUnitPlatform() + val nativeLib = System.getProperty("guide.native.lib.path") + if (nativeLib != null) { + jvmArgs("-Dguide.native.lib.path=$nativeLib") + } } tasks.named("shadowJar") { @@ -50,5 +59,115 @@ runConfigs.forEach { (taskName, path) -> doFirst { workingDir.mkdirs() } + // Forward the layout-overlay flag to the client JVM: + // ./gradlew runClient25 -Dguidenh.layoutOverlay=true + providers.systemProperty("guidenh.layoutOverlay").orNull?.let { + jvmArgs("-Dguidenh.layoutOverlay=$it") + } + providers.systemProperty("guidenh.debug.scenerender").orNull?.let { + jvmArgs("-Dguidenh.debug.scenerender=$it") + } + // Forward extra development resource-pack source (visual-test fixture pack): + // ./gradlew runClient25 -Dguidenh.guide.sources=/test/visual/resourcepack + providers.systemProperty("guidenh.guide.sources").orNull?.let { + jvmArgs("-Dguideme.resourcePack.sources=$it") + } + // Forward headless-render driver props to the client JVM: + // ./gradlew runClient25 -Dguidenh.headlessRender=true -Dguidenh.renderpage.guide=guidenh:guidenh -Dguidenh.renderpage.page=guidenh:guidenh/en_us/markdown + providers.systemProperty("guidenh.headlessRender").orNull?.let { + jvmArgs("-Dguidenh.headlessRender=$it") + } + listOf("guide", "page", "md", "width", "out", "lang", "bounds", "overlay", "world", "scale", "allPages", "list", "chrome", "navscroll", "mermaidzoom", "mermaidoffset", "guiscale", "route", "query", "title").forEach { key -> + providers.systemProperty("guidenh.renderpage.$key").orNull?.let { + jvmArgs("-Dguidenh.renderpage.$key=$it") + } + } + } +} + + +/** Resolve the Rust DLL path eagerly (configuration-cache friendly). + * Resolution order for the cargo target dir: + * 1. Gradle property `guidenh.rustTargetDir` (-Pguidenh.rustTargetDir=...); + * 2. Environment variable GUIDENH_RUST_TARGET_DIR; + * 3. `target-dir` declared in `src/rust/layout-engine/.cargo/config.toml` + * (local, untracked cargo target redirection; relative values resolve + * against the crate directory); + * 4. In-tree default `src/rust/layout-engine/target`. */ +val rustTargetDir: String = run { + val fromProperty = providers.gradleProperty("guidenh.rustTargetDir").orNull + val fromEnv = System.getenv("GUIDENH_RUST_TARGET_DIR") + val crateDir = File("${rootDir}/src/rust/layout-engine") + val cargoConfig = File(crateDir, ".cargo/config.toml") + val fromCargoConfig = if (cargoConfig.isFile) { + Regex("""(?m)^\s*target-dir\s*=\s*"([^"]+)"""") + .find(cargoConfig.readText()) + ?.groupValues?.get(1) + ?.let { val f = File(it); if (f.isAbsolute) f.absolutePath else File(crateDir, it).absolutePath } + } else { + null } + (fromProperty ?: fromEnv ?: fromCargoConfig ?: File(crateDir, "target").absolutePath) +} +val rustDllPath: String = "$rustTargetDir/release/guide_layout_engine.dll" + +/** Headless diagnostic: print glyph pipeline to console. */ +val runGlyphDiag by tasks.registering(JavaExec::class) { + description = "Run GlyphDiag: headless glyph data diagnostic" + group = "verification" + classpath = sourceSets.test.get().runtimeClasspath + mainClass.set("com.hfstudio.guidenh.guide.layout.GlyphDiag") + jvmArgs("-Dguide.native.lib.path=$rustDllPath", "-Dsun.java2d.uiScale=1.0") +} + +/** Headless layout pipeline test bench: synthetic pages -> invariants + tree dump. */ +val runLayoutDump by tasks.registering(JavaExec::class) { + description = "Run LayoutPipelineHarness: headless layout pipeline verification" + group = "verification" + classpath = sourceSets.test.get().runtimeClasspath + mainClass.set("com.hfstudio.guidenh.guide.layout.LayoutPipelineHarness") + jvmArgs("-Dguide.native.lib.path=$rustDllPath", "-Dsun.java2d.uiScale=1.0") + setIgnoreExitValue(true) +} + +/** Standalone task: build the Rust native library. Run manually with + * `./gradlew buildRustNative`; it is not part of the main build pipeline and + * requires a Rust toolchain (https://rustup.rs). + */ +val buildRustNative by tasks.registering(Exec::class) { + description = "Build Rust native library (src/rust/layout-engine, guide_layout_engine.dll)" + group = "build" + workingDir = file("src/rust/layout-engine") + commandLine("cargo", "build", "--release") + outputs.file(rustDllPath) +} + +/** Copy the built Rust native library into the resources tree so it lands on the + * runtime classpath (`/natives/guide_layout_engine.dll`). The library is a build + * artifact and `src/main/resources/natives/` is not tracked. The copy is skipped + * (with a note) when the library has not been built, so checkouts without a Rust + * toolchain still build; CI never runs cargo. */ +val copyRustNative by tasks.registering(Copy::class) { + description = "Copy the built layout-engine native library into src/main/resources/natives (skipped when absent)" + group = "build" + val dll = File(rustDllPath) + onlyIf { + if (dll.isFile) { + true + } else { + logger.lifecycle("copyRustNative: $dll not built, skipping (build it with ./gradlew buildRustNative)") + false + } + } + from(dll.parentFile) { include(dll.name) } + into("src/main/resources/natives") +} + +tasks.named("processResources") { dependsOn(copyRustNative) } + +tasks.named("sourcesJar") { + // Declared dependency: sourcesJar reads src/main/resources, which copyRustNative + // writes into. The native library is not a source and is excluded from the jar. + dependsOn(copyRustNative) + exclude("natives/**") } diff --git a/dependencies.gradle b/dependencies.gradle index 9238e601..8487f54a 100644 --- a/dependencies.gradle +++ b/dependencies.gradle @@ -43,16 +43,16 @@ dependencies { runtimeOnlyNonPublishable(rfg.deobf("curse.maven:spark-361579:4271867")) compileOnlyApi("com.github.GTNewHorizons:nei-custom-diagram:1.8.36:dev") { transitive = false } + compileOnlyApi("com.github.slprime:ChromaticTooltips:1.0.28-GTNH:dev") { transitive = false } compileOnlyApi("com.github.GTNewHorizons:TinkersConstruct:1.14.112-GTNH:dev") { transitive = false } compileOnlyApi("com.github.GTNewHorizons:Mantle:0.5.4:dev") { transitive = false } compileOnlyApi("com.github.GTNewHorizons:Mobs-Info:0.6.0-GTNH:dev") { transitive = false } compileOnlyApi("com.github.GTNewHorizons:LogisticsPipes:1.5.36-GTNH:dev") { transitive = false } compileOnlyApi("com.github.GTNewHorizons:BuildCraft:7.1.64:dev") { transitive = false } compileOnlyApi("com.github.GTNewHorizons:Translocators:1.4.5:dev") { transitive = false } + compileOnlyApi("ganymedes01.etfuturum:Et-Futurum-Requiem:2.6.59-GTNH") { transitive = false } compileOnlyApi("com.github.GTNewHorizons:SimpleSkinBackport:1.0.3-GTNH:dev") { transitive = false } compileOnlyApi("com.github.GTNewHorizons:OpenComputers:1.12.61-GTNH:dev") { transitive = false } - compileOnlyApi("com.github.slprime:ChromaticTooltips:1.0.28-GTNH:dev") { transitive = false } - compileOnlyApi("ganymedes01.etfuturum:Et-Futurum-Requiem:2.6.59-GTNH") { transitive = false } // jvmDowngrader compileOnly("javax.xml.bind:jaxb-api:2.3.1") diff --git a/tools/regen_java_flatc.bat b/tools/regen_java_flatc.bat new file mode 100644 index 00000000..daf239e6 --- /dev/null +++ b/tools/regen_java_flatc.bat @@ -0,0 +1,225 @@ +@echo off +setlocal EnableDelayedExpansion +chcp 65001 >nul 2>&1 + +rem GuideNH Java FlatBuffers regenerator (tools/regen_java_flatc.bat) +rem +rem Pipeline: locate flatc -> verify version 23.5.26 +rem -> flatc --java into temp dir -> diff checked-in classes +rem -> (apply mode) copy over src/main/java -> hint to run gate +rem +rem Modes: +rem (no args) apply full regen, overwrite src/main/java +rem --dry-run dry generate + diff report only, never overwrite +rem --check-only check diff only, exit 1 if out of sync (CI) +rem --help / -h usage + +for %%I in ("%~dp0..") do set "REPO_ROOT=%%~fI" +set "SCHEMA=%REPO_ROOT%\src\rust\layout-engine\schema\guidenh_layout.fbs" +set "JAVA_DST=%REPO_ROOT%\src\main\java\com\hfstudio\guidenh\guide\layout\flatbuffers" +set "SCRIPT_NAME=%~nx0" + +rem argument parsing +set "MODE=apply" +:parse_args +if "%~1"=="" goto args_done +if /i "%~1"=="--help" goto usage +if /i "%~1"=="-h" goto usage +if /i "%~1"=="--dry-run" ( + set "MODE=dry" + shift + goto parse_args +) +if /i "%~1"=="--check-only" ( + set "MODE=check" + shift + goto parse_args +) +echo [ERROR] Unknown option: %~1 +echo Hint: run !SCRIPT_NAME! --help for usage. +exit /b 2 + +:args_done +echo. +echo ============================================================ +echo GuideNH Java FlatBuffers regenerator (flatc 23.5.26) +echo schema : !SCHEMA! +echo dst : !JAVA_DST! +echo mode : !MODE! ^(apply=overwrite / dry-run=preview / check-only=CI check^) +echo usage : !SCRIPT_NAME! [--dry-run ^| --check-only] no-arg = overwrite regen +echo ============================================================ +echo. + +rem 1) locate flatc +set "FLATC_BIN=" +if defined FLATC ( + set "FLATC_BIN=!FLATC!" +) else ( + rem Resolve the cargo target dir the same way the build does: + rem GUIDENH_RUST_TARGET_DIR, then target-dir in the crate .cargo\config.toml + rem (relative values resolve against the crate dir), then the in-tree default. + rem The Gradle property guidenh.rustTargetDir is a build-time input and is not + rem visible here, so set FLATC when that property is the one in use. + set "RUST_CRATE=!REPO_ROOT!\src\rust\layout-engine" + set "RUST_TARGET_DIR=!GUIDENH_RUST_TARGET_DIR!" + if not defined RUST_TARGET_DIR ( + set "RUST_CARGO_CONFIG=!RUST_CRATE!\.cargo\config.toml" + if exist "!RUST_CARGO_CONFIG!" ( + for /f "usebackq tokens=1,* delims== " %%A in ("!RUST_CARGO_CONFIG!") do ( + if /i "%%A"=="target-dir" if not defined RUST_TARGET_DIR set "RUST_TARGET_DIR=%%~B" + ) + ) + ) + if not defined RUST_TARGET_DIR set "RUST_TARGET_DIR=!RUST_CRATE!\target" + if not "!RUST_TARGET_DIR:~1,1!"==":" ( + if not "!RUST_TARGET_DIR:~0,1!"=="\" set "RUST_TARGET_DIR=!RUST_CRATE!\!RUST_TARGET_DIR!" + ) + for %%R in ("!RUST_TARGET_DIR!\debug\build" "!RUST_TARGET_DIR!\release\build") do ( + if not defined FLATC_BIN ( + for /f "delims=" %%D in ('dir /b /ad /o-d "%%~R\flatc-*" 2^>nul') do ( + if not defined FLATC_BIN ( + if exist "%%~R\%%~D\out\bin\flatc.exe" set "FLATC_BIN=%%~R\%%~D\out\bin\flatc.exe" + ) + ) + ) + ) +) +if not defined FLATC_BIN ( + echo [ERROR] flatc.exe not found. + echo searched cargo target dir: !RUST_TARGET_DIR! + echo expected under it : {debug,release}\build\flatc-*\out\bin\flatc.exe + echo Option 1: build the Rust side first to produce flatc: + echo cd src\rust\layout-engine ^&^& cargo build --release + echo ^(the searched dir follows GUIDENH_RUST_TARGET_DIR, then + echo target-dir in src\rust\layout-engine\.cargo\config.toml, + echo then the in-tree default src\rust\layout-engine\target;^) + echo if the target dir lives elsewhere, set FLATC to the flatc.exe found there + echo Option 2: set env var FLATC to point at flatc.exe + exit /b 1 +) +echo [ok] flatc located : !FLATC_BIN! + +rem 2) version check +set "FLATC_VERSION=" +for /f "delims=" %%V in ('"!FLATC_BIN!" --version 2^>nul') do set "FLATC_VERSION=%%V" +echo !FLATC_VERSION! | findstr /c:"23.5.26" >nul +if errorlevel 1 ( + echo [ERROR] flatc version must be 23.5.26 ^(got: !FLATC_VERSION!^) + echo Java generated classes carry a Constants.FLATBUFFERS_23_5_26 version + echo guard and must match the flatbuffers-java runtime version. + echo See src/rust/layout-engine/schema/README.md section 4 for upgrade policy. + exit /b 1 +) +echo [ok] flatc version : !FLATC_VERSION! + +rem 3) generate into temp dir +if not exist "!SCHEMA!" ( + echo [ERROR] schema not found: !SCHEMA! + exit /b 1 +) +set "OUT_DIR=%TEMP%\guidenh_flatc_regen_!RANDOM!!RANDOM!" +if exist "!OUT_DIR!" rmdir /s /q "!OUT_DIR!" >nul 2>&1 +mkdir "!OUT_DIR!" >nul 2>&1 +if errorlevel 1 ( + echo [ERROR] cannot create temp dir: !OUT_DIR! + exit /b 1 +) +set "GEN_DIR=!OUT_DIR!\com\hfstudio\guidenh\guide\layout\flatbuffers" +echo [gen] !FLATC_BIN! --java -o "!OUT_DIR!" "!SCHEMA!" +"!FLATC_BIN!" --java -o "!OUT_DIR!" "!SCHEMA!" +if errorlevel 1 ( + echo [ERROR] flatc --java generation failed. + rmdir /s /q "!OUT_DIR!" >nul 2>&1 + exit /b 1 +) +if not exist "!GEN_DIR!\*.java" ( + echo [ERROR] no .java generated ^(expected dir: !GEN_DIR!^) + rmdir /s /q "!OUT_DIR!" >nul 2>&1 + exit /b 1 +) + +rem 4) diff against checked-in classes +if not exist "!JAVA_DST!" mkdir "!JAVA_DST!" >nul 2>&1 +set "CHANGED=0" +set "NEW_COUNT=0" +set "MOD_COUNT=0" +for %%F in ("!GEN_DIR!\*.java") do ( + set "BASE=%%~nxF" + if not exist "!JAVA_DST!\!BASE!" ( + set /a CHANGED+=1 + set /a NEW_COUNT+=1 + echo [NEW] !BASE! + if "!MODE!"=="apply" copy /y "%%F" "!JAVA_DST!\!BASE!" >nul + ) else ( + fc /b "%%F" "!JAVA_DST!\!BASE!" >nul 2>&1 + if errorlevel 1 ( + set /a CHANGED+=1 + set /a MOD_COUNT+=1 + echo [CHANGED] !BASE! + if "!MODE!"=="apply" copy /y "%%F" "!JAVA_DST!\!BASE!" >nul + ) + ) +) +set "STALE_COUNT=0" +for %%F in ("!JAVA_DST!\*.java") do ( + set "BASE=%%~nxF" + if not exist "!GEN_DIR!\!BASE!" ( + set /a STALE_COUNT+=1 + echo [STALE] !BASE! ^(no such type in schema; wire-compat is append-only, script will not delete^) + ) +) + +rem 5) report +echo. +if !CHANGED!==0 ( + echo [ok] no changes: generated classes match the checked-in classes. +) else ( + echo [summary] out-of-sync files: !CHANGED! ^(new !NEW_COUNT! / modified !MOD_COUNT!^) + if "!MODE!"=="apply" ( + echo [done] copied changed files into src\main\java\com\hfstudio\guidenh\guide\layout\flatbuffers\ + echo [next] run the gate to verify: .\gradlew compileJava compileTestJava test runLayoutDump + ) else if "!MODE!"=="dry" ( + echo [dry-run] src\main\java untouched. Re-run without --dry-run to apply. + ) else ( + echo [check-only] schema and checked-in classes are OUT OF SYNC, exiting 1. + ) +) +if !STALE_COUNT! GTR 0 echo [warn] !STALE_COUNT! checked-in class(es) no longer in schema; please confirm manually ^(wire-compat keeps them^). +rmdir /s /q "!OUT_DIR!" >nul 2>&1 + +rem NOTE: chcp 65001 makes cmd lose errorlevel for exit /b inside 2+ nested +rem blocks, so we set a flag inside and exit at top level. +set "FAIL=0" +if "!MODE!"=="check" ( + if !CHANGED! GTR 0 set "FAIL=1" + if !STALE_COUNT! GTR 0 set "FAIL=1" +) +if !FAIL!==1 exit /b 1 +exit /b 0 + +:usage +echo. +echo Usage: !SCRIPT_NAME! [--dry-run ^| --check-only] +echo. +echo no-arg full regen: locate flatc - verify version - generate - diff, +echo overwrite src\main\java on mismatch, then hint the gate. +echo --dry-run generate into temp dir and diff-report only; never overwrite. +echo --check-only diff only; exit 1 if out of sync ^(CI / manual sync check^). +echo --help / -h show this help. +echo. +echo Prerequisites: +echo - flatc: env var FLATC wins; otherwise searched newest-first under +echo {debug,release}\build\flatc-*\out\bin\flatc.exe inside the cargo +echo target dir, which resolves as: GUIDENH_RUST_TARGET_DIR, then +echo target-dir in src\rust\layout-engine\.cargo\config.toml ^(relative +echo values resolve against the crate dir^), then the in-tree default +echo src\rust\layout-engine\target +echo ^(produced by cargo build --release in src\rust\layout-engine via the flatc crate^) +echo - if the target dir is redirected through the Gradle property +echo guidenh.rustTargetDir, set FLATC explicitly to the flatc.exe found there +echo - flatc version must be 23.5.26 ^(= flatbuffers-java runtime; generated classes +echo carry the FLATBUFFERS_23_5_26 version guard^) +echo - schema: src\rust\layout-engine\schema\guidenh_layout.fbs +echo. +echo See src\rust\layout-engine\schema\README.md for change flow / version / wire-compat policy. +exit /b 0 From ae489c2f64b3064d0e2045bc818b09b3c117e90d Mon Sep 17 00:00:00 2001 From: Cheng Ming <8456918+Windorain@users.noreply.github.com> Date: Sun, 4 Oct 2026 04:55:31 +0800 Subject: [PATCH 2/6] feat(layout): Rust layout engine crate under src/rust Move the layout engine crate from the repository root to src/rust/layout-engine. No source changes beyond comment conventions. --- src/rust/layout-engine/Cargo.lock | 2036 +++++++ src/rust/layout-engine/Cargo.toml | 41 + src/rust/layout-engine/build.rs | 44 + src/rust/layout-engine/examples/flex_probe.rs | 72 + .../examples/float_bridge_probe.rs | 410 ++ .../layout-engine/examples/space_probe.rs | 76 + src/rust/layout-engine/schema/README.md | 98 + .../layout-engine/schema/guidenh_layout.fbs | 647 +++ src/rust/layout-engine/src/jni_bridge.rs | 24 + src/rust/layout-engine/src/layout.rs | 5042 +++++++++++++++++ src/rust/layout-engine/src/lib.rs | 155 + src/rust/layout-engine/src/measure.rs | 1510 +++++ src/rust/layout-engine/src/parley_text.rs | 1084 ++++ src/rust/layout-engine/src/style_convert.rs | 174 + src/rust/layout-engine/src/text.rs | 22 + 15 files changed, 11435 insertions(+) create mode 100644 src/rust/layout-engine/Cargo.lock create mode 100644 src/rust/layout-engine/Cargo.toml create mode 100644 src/rust/layout-engine/build.rs create mode 100644 src/rust/layout-engine/examples/flex_probe.rs create mode 100644 src/rust/layout-engine/examples/float_bridge_probe.rs create mode 100644 src/rust/layout-engine/examples/space_probe.rs create mode 100644 src/rust/layout-engine/schema/README.md create mode 100644 src/rust/layout-engine/schema/guidenh_layout.fbs create mode 100644 src/rust/layout-engine/src/jni_bridge.rs create mode 100644 src/rust/layout-engine/src/layout.rs create mode 100644 src/rust/layout-engine/src/lib.rs create mode 100644 src/rust/layout-engine/src/measure.rs create mode 100644 src/rust/layout-engine/src/parley_text.rs create mode 100644 src/rust/layout-engine/src/style_convert.rs create mode 100644 src/rust/layout-engine/src/text.rs diff --git a/src/rust/layout-engine/Cargo.lock b/src/rust/layout-engine/Cargo.lock new file mode 100644 index 00000000..ae1eea4b --- /dev/null +++ b/src/rust/layout-engine/Cargo.lock @@ -0,0 +1,2036 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "adler2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" + +[[package]] +name = "aligned" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee4508988c62edf04abd8d92897fca0c2995d907ce1dfeaf369dac3716a40685" +dependencies = [ + "as-slice", +] + +[[package]] +name = "aligned-vec" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc890384c8602f339876ded803c97ad529f3842aba97f6392b3dba0dd171769b" +dependencies = [ + "equator", +] + +[[package]] +name = "anyhow" +version = "1.0.104" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470" + +[[package]] +name = "arbitrary" +version = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3d036a3c4ab069c7b410a2ce876bd74808d2d0888a82667669f8e783a898bf1" + +[[package]] +name = "arg_enum_proc_macro" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ae92a5119aa49cdbcf6b9f893fe4e1d98b04ccbf82ee0584ad948a44a734dea" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.118", +] + +[[package]] +name = "arrayvec" +version = "0.7.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3fb67a6e08acf24fdeccbac2cb6ac4305825bd1f117462e0e6f2f193345ad56" + +[[package]] +name = "as-slice" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "516b6b4f0e40d50dcda9365d53964ec74560ad4284da2e7fc97122cd83174516" +dependencies = [ + "stable_deref_trait", +] + +[[package]] +name = "autocfg" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" + +[[package]] +name = "av-scenechange" +version = "0.14.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f321d77c20e19b92c39e7471cf986812cbb46659d2af674adc4331ef3f18394" +dependencies = [ + "aligned", + "anyhow", + "arg_enum_proc_macro", + "arrayvec", + "log", + "num-rational", + "num-traits", + "pastey", + "rayon", + "thiserror 2.0.19", + "v_frame", + "y4m", +] + +[[package]] +name = "av1-grain" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8cfddb07216410377231960af4fcab838eaa12e013417781b78bd95ee22077f8" +dependencies = [ + "anyhow", + "arrayvec", + "log", + "nom", + "num-rational", + "v_frame", +] + +[[package]] +name = "avif-serialize" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7178fe5f7d460b13895ebb9dcb28a3a6216d2df2574a0806cb51b555d297f38" +dependencies = [ + "arrayvec", +] + +[[package]] +name = "bit_field" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e4b40c7323adcfc0a41c4b88143ed58346ff65a288fc144329c5c45e05d70c6" + +[[package]] +name = "bitflags" +version = "1.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" + +[[package]] +name = "bitflags" +version = "2.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4388bee8683e3d04af747c73422af53102d2bd24d9eadb6cbc100baef4b43f8" + +[[package]] +name = "bitstream-io" +version = "4.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7eff00be299a18769011411c9def0d827e8f2d7bf0c3dbf53633147a8867fd1f" +dependencies = [ + "no_std_io2", +] + +[[package]] +name = "built" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c0e531d93d39c34eef561e929e8a7f86d77a5af08aac4f6d6e39976c51858e9" + +[[package]] +name = "bumpalo" +version = "3.20.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" + +[[package]] +name = "bytemuck" +version = "1.25.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6aedf8ae72766347502cf3cb4f41cf5e9cc37d28bee90f1fdaaae15f9cf9424" +dependencies = [ + "bytemuck_derive", +] + +[[package]] +name = "bytemuck_derive" +version = "1.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f65693059b6b9c588b9f62fed1cedbf0a8b805631457ea162d68f0de186f3de5" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.118", +] + +[[package]] +name = "byteorder-lite" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f1fe948ff07f4bd06c30984e69f5b4899c516a3ef74f34df92a2df2ab535495" + +[[package]] +name = "bytes" +version = "1.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" + +[[package]] +name = "cc" +version = "1.2.67" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e17dd265a7d0f31ef544e1b20e03add05d3b45b491b633b10d67145d2acc1a38" +dependencies = [ + "find-msvc-tools", + "jobserver", + "libc", + "shlex", +] + +[[package]] +name = "cesu8" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6d43a04d8753f35258c91f8ec639f792891f748a1edbd759cf1dcea3382ad83c" + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "cmake" +version = "0.1.58" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0f78a02292a74a88ac736019ab962ece0bc380e3f977bf72e376c5d78ff0678" +dependencies = [ + "cc", +] + +[[package]] +name = "color_quant" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d7b894f5411737b7867f4827955924d7c254fc9f4d91a6aad6b097804b1018b" + +[[package]] +name = "combine" +version = "4.6.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba5a308b75df32fe02788e748662718f03fde005016435c444eea572398219fd" +dependencies = [ + "bytes", + "memchr", +] + +[[package]] +name = "crc32fast" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "crossbeam-deque" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5181e0de7b61eb03a81e347d6dd8797bae9da5146707b51077e2d71a54ec0ceb" +dependencies = [ + "crossbeam-epoch", + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-epoch" +version = "0.9.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2d6914041f254d6e9176c01941b21115dcfb7089e55135a35411081bd106ef3f" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-utils" +version = "0.8.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61803da095bee82a81bb1a452ecc25d3b2f1416d1897eb86430c6159ef717c17" + +[[package]] +name = "crunchy" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5" + +[[package]] +name = "displaydoc" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ac70aa55017e108007fbaf5aa0f54b021c98f92ff8af59d42eda9da96e3dd4f" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.118", +] + +[[package]] +name = "dlib" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab8ecd87370524b461f8557c119c405552c396ed91fc0a8eec68679eab26f94a" +dependencies = [ + "libloading", +] + +[[package]] +name = "either" +version = "1.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91622ff5e7162018101f2fea40d6ebf4a78bbe5a49736a2020649edf9693679e" + +[[package]] +name = "equator" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4711b213838dfee0117e3be6ac926007d7f433d7bbe33595975d4190cb07e6fc" +dependencies = [ + "equator-macro", +] + +[[package]] +name = "equator-macro" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44f23cf4b44bfce11a86ace86f8a73ffdec849c9fd00a386a53d278bd9e81fb3" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.118", +] + +[[package]] +name = "exr" +version = "1.74.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "711fe42c9964295e01ee3fba3f9fe0e1d24b98886950d68efe81b1c76e21adf3" +dependencies = [ + "bit_field", + "half", + "lebe", + "miniz_oxide", + "num-complex", + "pulp", + "rayon-core", + "smallvec", + "zune-inflate", +] + +[[package]] +name = "fax" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "caf1079563223d5d59d83c85886a56e586cfd5c1a26292e971a0fa266531ac5a" + +[[package]] +name = "fdeflate" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e6853b52649d4ac5c0bd02320cddc5ba956bdb407c4b75a2c6b75bf51500f8c" +dependencies = [ + "simd-adler32", +] + +[[package]] +name = "find-msvc-tools" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" + +[[package]] +name = "flatbuffers" +version = "24.12.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4f1baf0dbf96932ec9a3038d57900329c015b0bfb7b63d904f3bc27e2b02a096" +dependencies = [ + "bitflags 1.3.2", + "rustc_version", +] + +[[package]] +name = "flatc" +version = "0.2.2+23.5.26" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4bba3050358223ece3d79720f9800139503b600e93b1d68493500a2d8c3721e" +dependencies = [ + "cmake", +] + +[[package]] +name = "flate2" +version = "1.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c" +dependencies = [ + "crc32fast", + "miniz_oxide", +] + +[[package]] +name = "foldhash" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" + +[[package]] +name = "font-types" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b38ad915f6dadd993ced50848a8291a543bd41ca62bc10740d5e64e2ab4cfd7" +dependencies = [ + "bytemuck", +] + +[[package]] +name = "font-types" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ad67eced03f5504d9cbd3a879b5958b5c54d4e5fd794361c6eb21b05fb703411" +dependencies = [ + "bytemuck", +] + +[[package]] +name = "fontique" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e04c4750a17111ebd77c3e0aea00476ce33f59235bc4d9e7f0aded5033ad3fc" +dependencies = [ + "hashbrown", + "linebender_resource_handle", + "memmap2", + "objc2", + "objc2-core-foundation", + "objc2-core-text", + "objc2-foundation", + "parlance", + "read-fonts 0.40.2", + "roxmltree", + "smallvec", + "windows", + "windows-core", + "yeslogic-fontconfig-sys", +] + +[[package]] +name = "getrandom" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "libc", + "r-efi 5.3.0", + "wasip2", +] + +[[package]] +name = "getrandom" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" +dependencies = [ + "cfg-if", + "libc", + "r-efi 6.0.0", +] + +[[package]] +name = "gif" +version = "0.14.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee8cfcc411d9adbbaba82fb72661cc1bcca13e8bba98b364e62b2dba8f960159" +dependencies = [ + "color_quant", + "weezl", +] + +[[package]] +name = "grid" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b40ca9252762c466af32d0b1002e91e4e1bc5398f77455e55474deb466355ff5" + +[[package]] +name = "guide-layout-engine" +version = "0.1.0" +dependencies = [ + "flatbuffers", + "flatc", + "fontique", + "image", + "jni", + "parley", + "swash", + "taffy", +] + +[[package]] +name = "half" +version = "2.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ea2d84b969582b4b1864a92dc5d27cd2b77b622a8d79306834f1be5ba20d84b" +dependencies = [ + "cfg-if", + "crunchy", + "zerocopy", +] + +[[package]] +name = "harfrust" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0589ddd0d2935dd2845827ac606b4081c266225d613b268ed2910f832889cab" +dependencies = [ + "bitflags 2.13.0", + "bytemuck", + "read-fonts 0.40.2", + "smallvec", +] + +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" +dependencies = [ + "foldhash", +] + +[[package]] +name = "icu_collections" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2984d1cd16c883d7935b9e07e44071dca8d917fd52ecc02c04d5fa0b5a3f191c" +dependencies = [ + "displaydoc", + "potential_utf", + "utf8_iter", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d5a396343c7208121dc86e35623d3dfe19814a7613cfd14964994cdc9c9a2e26" +dependencies = [ + "icu_collections", + "icu_locale_core", + "icu_locale_data", + "icu_provider", + "potential_utf", + "tinystr", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92219b62b3e2b4d88ac5119f8904c10f8f61bf7e95b640d25ba3075e6cac2c29" +dependencies = [ + "displaydoc", + "litemap", + "serde", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_locale_data" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d5fdcc9ac77c6d74ff5cf6e65ef3181d6af32003b16fce3a77fb451d2f695993" + +[[package]] +name = "icu_normalizer" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c56e5ee99d6e3d33bd91c5d85458b6005a22140021cc324cea84dd0e72cff3b4" +dependencies = [ + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da3be0ae77ea334f4da67c12f149704f19f81d1adf7c51cf482943e84a2bad38" + +[[package]] +name = "icu_properties" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bee3b67d0ea5c2cca5003417989af8996f8604e34fb9ddf96208a033901e70de" +dependencies = [ + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e2bbb201e0c04f7b4b3e14382af113e17ba4f63e2c9d2ee626b720cbce54a14" + +[[package]] +name = "icu_provider" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "139c4cf31c8b5f33d7e199446eff9c1e02decfc2f0eec2c8d71f65befa45b421" +dependencies = [ + "displaydoc", + "icu_locale_core", + "serde", + "stable_deref_trait", + "writeable", + "yoke", + "zerofrom", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_segmenter" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c0794db0b1a86193ac9c48768d0e6c52c54448e0870ad87907d456ee0dac964" +dependencies = [ + "icu_collections", + "icu_locale", + "icu_provider", + "icu_segmenter_data", + "potential_utf", + "utf8_iter", + "zerovec", +] + +[[package]] +name = "icu_segmenter_data" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e4a2c462a4d927d512f5f882a033ddd62f33a05bb9f230d98f736ac3dc85938f" + +[[package]] +name = "image" +version = "0.25.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85ab80394333c02fe689eaf900ab500fbd0c2213da414687ebf995a65d5a6104" +dependencies = [ + "bytemuck", + "byteorder-lite", + "color_quant", + "exr", + "gif", + "image-webp", + "moxcms", + "num-traits", + "png", + "qoi", + "ravif", + "rayon", + "rgb", + "tiff", + "zune-core", + "zune-jpeg", +] + +[[package]] +name = "image-webp" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "525e9ff3e1a4be2fbea1fdf0e98686a6d98b4d8f937e1bf7402245af1909e8c3" +dependencies = [ + "byteorder-lite", + "quick-error", +] + +[[package]] +name = "imgref" +version = "1.12.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "89194689a993ab15268672e99e7b0e19da2da3268ac682e8f02d29d4d1434cd7" + +[[package]] +name = "interpolate_name" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c34819042dc3d3971c46c2190835914dfbe0c3c13f61449b2997f4e9722dfa60" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.118", +] + +[[package]] +name = "itertools" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b192c782037fadd9cfa75548310488aabdbf3d2da73885b31bd0abd03351285" +dependencies = [ + "either", +] + +[[package]] +name = "jni" +version = "0.21.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a87aa2bb7d2af34197c04845522473242e1aa17c12f4935d5856491a7fb8c97" +dependencies = [ + "cesu8", + "cfg-if", + "combine", + "jni-sys 0.3.1", + "log", + "thiserror 1.0.69", + "walkdir", + "windows-sys 0.45.0", +] + +[[package]] +name = "jni-sys" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41a652e1f9b6e0275df1f15b32661cf0d4b78d4d87ddec5e0c3c20f097433258" +dependencies = [ + "jni-sys 0.4.1", +] + +[[package]] +name = "jni-sys" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6377a88cb3910bee9b0fa88d4f42e1d2da8e79915598f65fb0c7ee14c878af2" +dependencies = [ + "jni-sys-macros", +] + +[[package]] +name = "jni-sys-macros" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38c0b942f458fe50cdac086d2f946512305e5631e720728f2a61aabcd47a6264" +dependencies = [ + "quote", + "syn 2.0.118", +] + +[[package]] +name = "jobserver" +version = "0.1.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1c00acbd29eabad4a2392fa0e921c874934dbbf4194312ad20f04a0ed67a3cb3" +dependencies = [ + "getrandom 0.4.3", + "libc", +] + +[[package]] +name = "lebe" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a79a3332a6609480d7d0c9eab957bca6b455b91bb84e66d19f5ff66294b85b8" + +[[package]] +name = "libc" +version = "0.2.186" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" + +[[package]] +name = "libfuzzer-sys" +version = "0.4.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a9fd2f41a1cba099f79a0b6b6c35656cf7c03351a7bae8ff0f28f25270f929d2" +dependencies = [ + "arbitrary", + "cc", +] + +[[package]] +name = "libloading" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7c4b02199fee7c5d21a5ae7d8cfa79a6ef5bb2fc834d6e9058e89c825efdc55" +dependencies = [ + "cfg-if", + "windows-link", +] + +[[package]] +name = "libm" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" + +[[package]] +name = "linebender_resource_handle" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4a5ff6bcca6c4867b1c4fd4ef63e4db7436ef363e0ad7531d1558856bae64f4" + +[[package]] +name = "litemap" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0" + +[[package]] +name = "log" +version = "0.4.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad" + +[[package]] +name = "loop9" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fae87c125b03c1d2c0150c90365d7d6bcc53fb73a9acaef207d2d065860f062" +dependencies = [ + "imgref", +] + +[[package]] +name = "maybe-rayon" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ea1f30cedd69f0a2954655f7188c6a834246d2bcf1e315e2ac40c4b24dc9519" +dependencies = [ + "cfg-if", + "rayon", +] + +[[package]] +name = "memchr" +version = "2.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" + +[[package]] +name = "memmap2" +version = "0.9.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1219ed1b7f229ee7104d281dd01d6802fe28bb6e95d292942c4daacdeb798c0" +dependencies = [ + "libc", +] + +[[package]] +name = "miniz_oxide" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" +dependencies = [ + "adler2", + "simd-adler32", +] + +[[package]] +name = "moxcms" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb85c154ba489f01b25c0d36ae69a87e4a1c73a72631fc6c0eb6dde34a73e44b" +dependencies = [ + "num-traits", + "pxfm", +] + +[[package]] +name = "new_debug_unreachable" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086" + +[[package]] +name = "no_std_io2" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "418abd1b6d34fbf6cae440dc874771b0525a604428704c76e48b29a5e67b8003" +dependencies = [ + "memchr", +] + +[[package]] +name = "nom" +version = "8.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df9761775871bdef83bee530e60050f7e54b1105350d6884eb0fb4f46c2f9405" +dependencies = [ + "memchr", +] + +[[package]] +name = "noop_proc_macro" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0676bb32a98c1a483ce53e500a81ad9c3d5b3f7c920c28c24e9cb0980d0b5bc8" + +[[package]] +name = "num-bigint" +version = "0.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c89e69e7e0f03bea5ef08013795c25018e101932225a656383bd384495ecc367" +dependencies = [ + "num-integer", + "num-traits", +] + +[[package]] +name = "num-complex" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73f88a1307638156682bada9d7604135552957b7818057dcef22705b4d509495" +dependencies = [ + "bytemuck", + "num-traits", +] + +[[package]] +name = "num-derive" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed3955f1a9c7c0c15e092f9c887db08b1fc683305fdf6eb6684f22555355e202" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.118", +] + +[[package]] +name = "num-integer" +version = "0.1.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7969661fd2958a5cb096e56c8e1ad0444ac2bbcd0061bd28660485a44879858f" +dependencies = [ + "num-traits", +] + +[[package]] +name = "num-rational" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f83d14da390562dca69fc84082e73e548e1ad308d24accdedd2720017cb37824" +dependencies = [ + "num-bigint", + "num-integer", + "num-traits", +] + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + +[[package]] +name = "objc2" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a12a8ed07aefc768292f076dc3ac8c48f3781c8f2d5851dd3d98950e8c5a89f" +dependencies = [ + "objc2-encode", +] + +[[package]] +name = "objc2-core-foundation" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536" +dependencies = [ + "bitflags 2.13.0", +] + +[[package]] +name = "objc2-core-text" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0cde0dfb48d25d2b4862161a4d5fcc0e3c24367869ad306b0c9ec0073bfed92d" +dependencies = [ + "bitflags 2.13.0", + "objc2-core-foundation", +] + +[[package]] +name = "objc2-encode" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef25abbcd74fb2609453eb695bd2f860d389e457f67dc17cafc8b8cbc89d0c33" + +[[package]] +name = "objc2-foundation" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272" +dependencies = [ + "bitflags 2.13.0", + "objc2", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "parlance" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b6937eda350acc1a5d05872c3cbf99fe78619c269096e2be3d4a350058639d5" + +[[package]] +name = "parley" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e0478b47dd9885a5e0a4f1c0782ffc42bf6ee8c41dea0917d7a9bcee3e6585fc" +dependencies = [ + "fontique", + "harfrust", + "hashbrown", + "icu_normalizer", + "icu_properties", + "icu_segmenter", + "linebender_resource_handle", + "parlance", + "parley_data", + "skrifa 0.43.2", +] + +[[package]] +name = "parley_data" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a649e01a1acc917247ee147b56b8a1fa91824acf7117bd003b4204306d601255" +dependencies = [ + "icu_properties", +] + +[[package]] +name = "paste" +version = "1.0.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" + +[[package]] +name = "pastey" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "35fb2e5f958ec131621fdd531e9fc186ed768cbe395337403ae56c17a74c68ec" + +[[package]] +name = "pkg-config" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e" + +[[package]] +name = "png" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "60769b8b31b2a9f263dae2776c37b1b28ae246943cf719eb6946a1db05128a61" +dependencies = [ + "bitflags 2.13.0", + "crc32fast", + "fdeflate", + "flate2", + "miniz_oxide", +] + +[[package]] +name = "potential_utf" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0103b1cef7ec0cf76490e969665504990193874ea05c85ff9bab8b911d0a0564" +dependencies = [ + "serde_core", + "writeable", + "zerovec", +] + +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + +[[package]] +name = "proc-macro2" +version = "1.0.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "profiling" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d595e54a326bc53c1c197b32d295e14b169e3cfeaa8dc82b529f947fba6bcf5" +dependencies = [ + "profiling-procmacros", +] + +[[package]] +name = "profiling-procmacros" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4488a4a36b9a4ba6b9334a32a39971f77c1436ec82c38707bce707699cc3bbcb" +dependencies = [ + "quote", + "syn 2.0.118", +] + +[[package]] +name = "pulp" +version = "0.22.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "046aa45b989642ec2e4717c8e72d677b13edd831a4d3b6cf37d9a3e54912496a" +dependencies = [ + "bytemuck", + "cfg-if", + "libm", + "num-complex", + "paste", + "pulp-wasm-simd-flag", + "raw-cpuid", + "reborrow", + "version_check", +] + +[[package]] +name = "pulp-wasm-simd-flag" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d8f70e07b9c3962945a74e59ca1c511bba65b6419468acc217c457d93f3c740" + +[[package]] +name = "pxfm" +version = "0.1.30" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d55d956fa96f5ec02be2e13af0e20391a5aa83d6a074e3ad368959d0fab299ea" + +[[package]] +name = "qoi" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f6d64c71eb498fe9eae14ce4ec935c555749aef511cca85b5568910d6e48001" +dependencies = [ + "bytemuck", +] + +[[package]] +name = "quick-error" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a993555f31e5a609f617c12db6250dedcac1b0a85076912c436e6fc9b2c8e6a3" + +[[package]] +name = "quote" +version = "1.0.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dfbc457d0c7a0759a614551b11a6409e5951f6c7537be1f1b7682b9ae9230368" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "rand" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9ef1d0d795eb7d84685bca4f72f3649f064e6641543d3a8c415898726a57b41" +dependencies = [ + "rand_chacha", + "rand_core", +] + +[[package]] +name = "rand_chacha" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" +dependencies = [ + "ppv-lite86", + "rand_core", +] + +[[package]] +name = "rand_core" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" +dependencies = [ + "getrandom 0.3.4", +] + +[[package]] +name = "rav1e" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "43b6dd56e85d9483277cde964fd1bdb0428de4fec5ebba7540995639a21cb32b" +dependencies = [ + "aligned-vec", + "arbitrary", + "arg_enum_proc_macro", + "arrayvec", + "av-scenechange", + "av1-grain", + "bitstream-io", + "built", + "cfg-if", + "interpolate_name", + "itertools", + "libc", + "libfuzzer-sys", + "log", + "maybe-rayon", + "new_debug_unreachable", + "noop_proc_macro", + "num-derive", + "num-traits", + "paste", + "profiling", + "rand", + "rand_chacha", + "simd_helpers", + "thiserror 2.0.19", + "v_frame", + "wasm-bindgen", +] + +[[package]] +name = "ravif" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e52310197d971b0f5be7fe6b57530dcd27beb35c1b013f29d66c1ad73fbbcc45" +dependencies = [ + "avif-serialize", + "imgref", + "loop9", + "quick-error", + "rav1e", + "rayon", + "rgb", +] + +[[package]] +name = "raw-cpuid" +version = "11.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "498cd0dc59d73224351ee52a95fee0f1a617a2eae0e7d9d720cc622c73a54186" +dependencies = [ + "bitflags 2.13.0", +] + +[[package]] +name = "rayon" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fb39b166781f92d482534ef4b4b1b2568f42613b53e5b6c160e24cfbfa30926d" +dependencies = [ + "either", + "rayon-core", +] + +[[package]] +name = "rayon-core" +version = "1.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22e18b0f0062d30d4230b2e85ff77fdfe4326feb054b9783a3460d8435c8ab91" +dependencies = [ + "crossbeam-deque", + "crossbeam-utils", +] + +[[package]] +name = "read-fonts" +version = "0.39.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4ed38b89c2c77ff968c524145ad65fb010f38af5c7a224b53b81d47ac2daa81" +dependencies = [ + "bytemuck", + "font-types 0.11.3", +] + +[[package]] +name = "read-fonts" +version = "0.40.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "487889119a5f19ff7c0a20637196bdc76b9f54ebec17e3588b5d75e4999f8773" +dependencies = [ + "bytemuck", + "font-types 0.12.1", + "once_cell", +] + +[[package]] +name = "reborrow" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "03251193000f4bd3b042892be858ee50e8b3719f2b08e5833ac4353724632430" + +[[package]] +name = "rgb" +version = "0.8.53" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47b34b781b31e5d73e9fbc8689c70551fd1ade9a19e3e28cfec8580a79290cc4" + +[[package]] +name = "roxmltree" +version = "0.21.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1964b10c76125c36f8afe190065a4bf9a87bf324842c05701330bba9f1cacbb" +dependencies = [ + "memchr", +] + +[[package]] +name = "rustc_version" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" +dependencies = [ + "semver", +] + +[[package]] +name = "rustversion" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" + +[[package]] +name = "same-file" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" +dependencies = [ + "winapi-util", +] + +[[package]] +name = "semver" +version = "1.0.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" + +[[package]] +name = "serde" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.118", +] + +[[package]] +name = "shlex" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" + +[[package]] +name = "simd-adler32" +version = "0.3.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a219298ac11a56ea9a6d2120044824d6f01aeb034955e7af7bc16858527deea" + +[[package]] +name = "simd_helpers" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95890f873bec569a0362c235787f3aca6e1e887302ba4840839bcc6459c42da6" +dependencies = [ + "quote", +] + +[[package]] +name = "skrifa" +version = "0.42.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c34617370ae968efb7161bb2beb517d9084659aae19e24b89e3db25b46e4564" +dependencies = [ + "bytemuck", + "read-fonts 0.39.2", +] + +[[package]] +name = "skrifa" +version = "0.43.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cbe997d0f2480442d727488fbe2150779114cbe480ecdbadef58b33e0318ffb" +dependencies = [ + "bytemuck", + "read-fonts 0.40.2", +] + +[[package]] +name = "slotmap" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bdd58c3c93c3d278ca835519292445cb4b0d4dc59ccfdf7ceadaab3f8aeb4038" +dependencies = [ + "version_check", +] + +[[package]] +name = "smallvec" +version = "1.15.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90" + +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + +[[package]] +name = "swash" +version = "0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0811b01ca2c4e8718760713911feaf4675c24f94e50530a015ec646cfb622f7c" +dependencies = [ + "skrifa 0.42.1", + "yazi", + "zeno", +] + +[[package]] +name = "syn" +version = "2.0.118" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b9ae57f904213ebb649ce6895b8a66c66f0203b9319718f69a5612a065b1422" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "3.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a207d6d6a2b7fc470b80443726053f18a2481b7e1eee970597051596567987a3" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "synstructure" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.118", +] + +[[package]] +name = "taffy" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73afc801dd6bd47529eaa7c7e90557f107527d1b7c9c7ed7d7803c7b8d0c357f" +dependencies = [ + "arrayvec", + "grid", + "serde", + "slotmap", +] + +[[package]] +name = "thiserror" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +dependencies = [ + "thiserror-impl 1.0.69", +] + +[[package]] +name = "thiserror" +version = "2.0.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09a43598840e33d5b0331f38c5e30d13bb11c11210a4b58f0d9b18a5a5eefcd9" +dependencies = [ + "thiserror-impl 2.0.19", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.118", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "43cbfe0cf76104d42a574802844187e84a305e531ed54455f11fbde0f10541cd" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.2", +] + +[[package]] +name = "tiff" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b63feaf3343d35b6ca4d50483f94843803b0f51634937cc2ec519fc32232bc52" +dependencies = [ + "fax", + "flate2", + "half", + "quick-error", + "weezl", + "zune-jpeg", +] + +[[package]] +name = "tinystr" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8323304221c2a851516f22236c5722a72eaa19749016521d6dff0824447d96d" +dependencies = [ + "displaydoc", + "serde_core", + "zerovec", +] + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "utf8_iter" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" + +[[package]] +name = "v_frame" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "666b7727c8875d6ab5db9533418d7c764233ac9c0cff1d469aec8fa127597be2" +dependencies = [ + "aligned-vec", + "num-traits", + "wasm-bindgen", +] + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "walkdir" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" +dependencies = [ + "same-file", + "winapi-util", +] + +[[package]] +name = "wasip2" +version = "1.0.4+wasi-0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b67efb37e106e55ce722a510d6b5f9c17f083e5fc79afc2badeb12cc313d9487" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "wasm-bindgen" +version = "0.2.126" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b067c0c11094aef6b7a801c1e34a26affafdf3d051dba08456b868789aaf9a4" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.126" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "167ce5e579f6bcf889c4f7175a8a5a585de84e8ff93976ce393efa5f2837aab1" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.126" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3997c7839262f4ef12cf90b818d6340c18e80f263f1a94bf157d0ec4420380e" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn 2.0.118", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.126" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc1b4cb0cc549fcf58d7dfc081778139b3d283a081644e833e84682ad71cea24" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "weezl" +version = "0.1.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a28ac98ddc8b9274cb41bb4d9d4d5c425b6020c50c46f25559911905610b4a88" + +[[package]] +name = "winapi-util" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "windows" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "527fadee13e0c05939a6a05d5bd6eec6cd2e3dbd648b9f8e447c6518133d8580" +dependencies = [ + "windows-collections", + "windows-core", + "windows-future", + "windows-numerics", +] + +[[package]] +name = "windows-collections" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23b2d95af1a8a14a3c7367e1ed4fc9c20e0a26e79551b1454d72583c97cc6610" +dependencies = [ + "windows-core", +] + +[[package]] +name = "windows-core" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-link", + "windows-result", + "windows-strings", +] + +[[package]] +name = "windows-future" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1d6f90251fe18a279739e78025bd6ddc52a7e22f921070ccdc67dde84c605cb" +dependencies = [ + "windows-core", + "windows-link", + "windows-threading", +] + +[[package]] +name = "windows-implement" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.118", +] + +[[package]] +name = "windows-interface" +version = "0.59.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.118", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-numerics" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e2e40844ac143cdb44aead537bbf727de9b044e107a0f1220392177d15b0f26" +dependencies = [ + "windows-core", + "windows-link", +] + +[[package]] +name = "windows-result" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-strings" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-sys" +version = "0.45.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75283be5efb2831d37ea142365f009c02ec203cd29a3ebecbc093d52315b66d0" +dependencies = [ + "windows-targets", +] + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-targets" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e5180c00cd44c9b1c88adb3693291f1cd93605ded80c250a75d472756b4d071" +dependencies = [ + "windows_aarch64_gnullvm", + "windows_aarch64_msvc", + "windows_i686_gnu", + "windows_i686_msvc", + "windows_x86_64_gnu", + "windows_x86_64_gnullvm", + "windows_x86_64_msvc", +] + +[[package]] +name = "windows-threading" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3949bd5b99cafdf1c7ca86b43ca564028dfe27d66958f2470940f73d86d75b37" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "597a5118570b68bc08d8d59125332c54f1ba9d9adeedeef5b99b02ba2b0698f8" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e08e8864a60f06ef0d0ff4ba04124db8b0fb3be5776a5cd47641e942e58c4d43" + +[[package]] +name = "windows_i686_gnu" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c61d927d8da41da96a81f029489353e68739737d3beca43145c8afec9a31a84f" + +[[package]] +name = "windows_i686_msvc" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44d840b6ec649f480a41c8d80f9c65108b92d89345dd94027bfe06ac444d1060" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8de912b8b8feb55c064867cf047dda097f92d51efad5b491dfb98f6bbb70cb36" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26d41b46a36d453748aedef1486d5c7a85db22e56aff34643984ea85514e94a3" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9aec5da331524158c6d1a4ac0ab1541149c0b9505fde06423b02f5ef0106b9f0" + +[[package]] +name = "wit-bindgen" +version = "0.57.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" + +[[package]] +name = "writeable" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4" + +[[package]] +name = "y4m" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a5a4b21e1a62b67a2970e6831bc091d7b87e119e7f9791aef9702e3bef04448" + +[[package]] +name = "yazi" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e01738255b5a16e78bbb83e7fbba0a1e7dd506905cfc53f4622d89015a03fbb5" + +[[package]] +name = "yeslogic-fontconfig-sys" +version = "6.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d8b8abf912b9a29ff112e1671c97c33636903d13a69712037190e6805af4f76" +dependencies = [ + "dlib", + "once_cell", + "pkg-config", +] + +[[package]] +name = "yoke" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "709fe23a0424b6a435d82152b1bd3fdfb0833487d5fa90d05d42762a9891fef5" +dependencies = [ + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.118", + "synstructure", +] + +[[package]] +name = "zeno" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6df3dc4292935e51816d896edcd52aa30bc297907c26167fec31e2b0c6a32524" + +[[package]] +name = "zerocopy" +version = "0.8.54" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7cbbc0a705a0fd05cc3676525980d2bf5a9bc4adac6d6475209a7887cf59d19" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.54" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e2e817b7b52d0c7358d3246da9d69935ebb18116b2b102b4230dac079b4862f5" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.118", +] + +[[package]] +name = "zerofrom" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.118", + "synstructure", +] + +[[package]] +name = "zerotrie" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f9152d31db0792fa83f70fb2f83148effb5c1f5b8c7686c3459e361d9bc20bf" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "zerovec" +version = "0.11.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "90f911cbc359ab6af17377d242225f4d75119aec87ea711a880987b18cd7b239" +dependencies = [ + "serde", + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "625dc425cab0dca6dc3c3319506e6593dcb08a9f387ea3b284dbd52a92c40555" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.118", +] + +[[package]] +name = "zune-core" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb8a0807f7c01457d0379ba880ba6322660448ddebc890ce29bb64da71fb40f9" + +[[package]] +name = "zune-inflate" +version = "0.2.54" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73ab332fe2f6680068f3582b16a24f90ad7096d5d39b974d1c0aff0125116f02" +dependencies = [ + "simd-adler32", +] + +[[package]] +name = "zune-jpeg" +version = "0.5.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27bc9d5b815bc103f142aa054f561d9187d191692ec7c2d1e2b4737f8dbd7296" +dependencies = [ + "zune-core", +] diff --git a/src/rust/layout-engine/Cargo.toml b/src/rust/layout-engine/Cargo.toml new file mode 100644 index 00000000..03beffee --- /dev/null +++ b/src/rust/layout-engine/Cargo.toml @@ -0,0 +1,41 @@ +[package] +name = "guide-layout-engine" +version = "0.1.0" +edition = "2021" + +[lib] +crate-type = ["cdylib", "lib"] + +[features] +default = [] + +[dependencies] +# JNI bridge +jni = "0.21" + +# Layout engine +taffy = { version = "0.12", features = ["flexbox", "grid", "block_layout", "float_layout"] } + +# Line-box text layout (per-line geometry control) +parley = { version = "0.11", features = ["complex-scripts"] } + +# Fontique: enable runtime-dlopen of fontconfig on Linux (cross-compile friendly) +fontique = { version = "0.11", features = ["fontconfig-dlopen"] } + +# Rasterization +swash = "0.2" + +# Serialization +flatbuffers = "24" + +[build-dependencies] +flatc = "0.2" + +[profile.release] +opt-level = 3 +lto = true +codegen-units = 1 +strip = "symbols" + +[dev-dependencies] +image = "0.25" diff --git a/src/rust/layout-engine/build.rs b/src/rust/layout-engine/build.rs new file mode 100644 index 00000000..0ec458a0 --- /dev/null +++ b/src/rust/layout-engine/build.rs @@ -0,0 +1,44 @@ +use std::fs; +use std::path::Path; +use std::process::Command; + +fn main() { + let schema = Path::new("schema").join("guidenh_layout.fbs"); + let out_dir = Path::new("src"); + let generated = out_dir.join("guidenh_layout_generated.rs"); + + println!("cargo:rerun-if-changed={}", schema.display()); + + let flatc_path = flatc::flatc(); + + let status = Command::new(&flatc_path) + .args(["--rust", "-o", out_dir.to_str().unwrap(), schema.to_str().unwrap()]) + .status() + .expect("flatc binary failed to execute"); + + if !status.success() { + panic!("flatc exited with code {:?}", status.code()); + } + + // Patch the generated file to work with Rust 2021: + // 1. Remove all `extern crate flatbuffers;` - Rust 2021 auto-imports extern crates + // 2. Change `use self::flatbuffers::` to `use ::flatbuffers::` - `self::flatbuffers` + // inside `mod flatbuffers { }` refers to the module, not the crate + if generated.exists() { + let content = fs::read_to_string(&generated).unwrap_or_default(); + // Remove ALL `extern crate flatbuffers;` regardless of indentation or line endings + let patched = content + .lines() + .filter(|l| !l.trim().starts_with("extern crate flatbuffers")) + .collect::>() + .join("\n") + .replace("use self::flatbuffers::{EndianScalar, Follow};", "use ::flatbuffers::{EndianScalar, Follow};") + .replace("use self::flatbuffers::Verifiable;", "use ::flatbuffers::Verifiable;"); + if patched != content { + fs::write(&generated, patched).expect("Failed to patch generated file"); + println!("cargo:warning=Patched generated file (extern crate + self::flatbuffers fixed)"); + } + } + + println!("cargo:warning=Generated {}", generated.display()); +} diff --git a/src/rust/layout-engine/examples/flex_probe.rs b/src/rust/layout-engine/examples/flex_probe.rs new file mode 100644 index 00000000..68a29441 --- /dev/null +++ b/src/rust/layout-engine/examples/flex_probe.rs @@ -0,0 +1,72 @@ +//! Minimal Taffy flex behavior probe: Row[grow text child, fixed-size leaf], +//! mirroring the production toolbar style (align CENTER + padding 8/8/4/4) +//! and the production measure dispatch. +use taffy::prelude::*; + +fn main() { + let mut t: TaffyTree<()> = TaffyTree::new(); + let label = t + .new_leaf(Style { + flex_grow: 1.0, + ..Default::default() + }) + .unwrap(); + let button = t + .new_leaf(Style { + size: Size { + width: Dimension::length(16.0), + height: Dimension::length(16.0), + }, + ..Default::default() + }) + .unwrap(); + let row = t + .new_with_children( + Style { + display: Display::Flex, + flex_direction: FlexDirection::Row, + align_items: Some(AlignItems::CENTER), + padding: Rect { + left: LengthPercentage::length(8.0), + right: LengthPercentage::length(8.0), + top: LengthPercentage::length(4.0), + bottom: LengthPercentage::length(4.0), + }, + size: Size { + width: Dimension::length(533.0), + height: Dimension::AUTO, + }, + ..Default::default() + }, + &[label, button], + ) + .unwrap(); + + t.compute_layout_with_measure( + row, + Size { + width: AvailableSpace::Definite(533.0), + height: AvailableSpace::MaxContent, + }, + |known, _avail, node_id, _ctx, _style| { + // Mirror production dispatch: label (node_type 1) measures text at + // 24x23; button (node_type 0) gets ZERO then known-dims wrap. + let measured = if node_id == NodeId::from(0usize) { + Size { + width: 24.0, + height: 23.0, + } + } else { + Size::ZERO + }; + Size { + width: known.width.unwrap_or(measured.width), + height: known.height.unwrap_or(measured.height), + } + }, + ) + .unwrap(); + + println!("label: {:?}", t.layout(label).unwrap()); + println!("button: {:?}", t.layout(button).unwrap()); +} diff --git a/src/rust/layout-engine/examples/float_bridge_probe.rs b/src/rust/layout-engine/examples/float_bridge_probe.rs new file mode 100644 index 00000000..54d93342 --- /dev/null +++ b/src/rust/layout-engine/examples/float_bridge_probe.rs @@ -0,0 +1,410 @@ +use taffy::prelude::*; +use taffy::style::Float; + +#[derive(Clone)] +struct Ctx { + kind: u8, + id: usize, +} + +#[derive(Clone, Copy)] +struct FRect { + x: f32, + y: f32, + w: f32, + h: f32, + right: bool, +} + +#[derive(Clone, Copy)] +struct Clip { + y_top: f32, + y_bottom: f32, + x: f32, + width: f32, +} + +#[derive(Clone, Copy)] +enum Kind { + F, + P(usize), +} + +const LH: f32 = 10.0; +const GAP: f32 = 5.0; +const SPACE: f32 = 3.0; + +fn clip_w(rel_y: f32, clips: &[Clip], w: f32) -> f32 { + let mut x0: f32 = 0.0; + let mut x1 = w; + for c in clips { + if c.y_bottom <= rel_y || c.y_top >= rel_y + LH { + continue; + } + if c.x <= 0.0 { + x0 = x0.max(c.x + c.width); + } else { + x1 = x1.min(c.x); + } + } + (x1 - x0).max(1.0) +} + +fn greedy(words: &[f32], mut line_w: impl FnMut(f32) -> f32, y0: f32) -> (f32, Vec) { + let mut lines: Vec = Vec::new(); + let mut rel = 0.0; + let mut i = 0; + while i < words.len() { + let lw = line_w(y0 + rel); + let mut used = 0.0; + let mut took = 0; + while i + took < words.len() { + let add = if took == 0 { + words[i + took] + } else { + used + SPACE + words[i + took] + }; + if add > lw && took > 0 { + break; + } + used = add; + took += 1; + if used >= lw { + break; + } + } + if took == 0 { + took = 1; + used = words[i]; + } + lines.push(used); + i += took; + rel += LH; + } + if lines.is_empty() { + lines.push(0.0); + rel = LH; + } + (rel, lines) +} + +fn wrap_clips(words: &[f32], clips: &[Clip], w: f32) -> (f32, Vec) { + greedy(words, |rel_y| clip_w(rel_y, clips, w), 0.0) +} + +fn float_edges(abs_y: f32, floats: &[FRect], para_x: f32, para_w: f32) -> f32 { + let mut l = para_x; + let mut r = para_x + para_w; + for f in floats { + if f.y + f.h <= abs_y || f.y >= abs_y + LH { + continue; + } + if f.right { + r = r.min(f.x); + } else { + l = l.max(f.x + f.w); + } + } + (r - l).max(1.0) +} + +fn wrap_floats(words: &[f32], floats: &[FRect], para_x: f32, para_w: f32, para_abs_y: f32) -> (f32, Vec) { + greedy( + words, + |abs_y| float_edges(abs_y, floats, para_x, para_w), + para_abs_y, + ) +} + +fn words(n: usize) -> Vec { + vec![30.0; n] +} + +fn run_scene(name: &str, avail: f32, fw: f32, fh: f32, paras: &[Vec], layout: &[Kind]) { + println!("==== {} (avail={} fw={} fh={}) ====", name, avail, fw, fh); + + // Algorithm Y: single-pass document-flow driver (Java-homologous) + let mut y_floats: Vec = Vec::new(); + let mut y_h = vec![0.0f32; paras.len()]; + let mut y_lines: Vec> = vec![Vec::new(); paras.len()]; + let mut cursor = 0.0f32; + for k in layout { + match k { + Kind::F => { + let fx = avail - fw; + y_floats.push(FRect { + x: fx - GAP, + y: cursor, + w: fw + GAP, + h: fh + GAP, + right: true, + }); + } + Kind::P(idx) => { + let (h, lines) = wrap_floats(¶s[*idx], &y_floats, 0.0, avail, cursor); + y_h[*idx] = h; + y_lines[*idx] = lines; + cursor += h; + } + } + } + + // Algorithm X: two-or-more-pass taffy, clips replayed from taffy geometry + let mut t: TaffyTree = TaffyTree::new(); + let mut node_ids: Vec<(Kind, NodeId)> = Vec::new(); + for k in layout { + let (style, ctx) = match k { + Kind::F => ( + Style { + float: Float::Right, + size: Size { + width: Dimension::length(fw), + height: Dimension::length(fh), + }, + ..Default::default() + }, + Ctx { kind: 0, id: usize::MAX }, + ), + Kind::P(idx) => ( + Style { + size: Size { + width: Dimension::AUTO, + height: Dimension::AUTO, + }, + ..Default::default() + }, + Ctx { kind: 1, id: *idx }, + ), + }; + let id = t.new_leaf_with_context(style, ctx).unwrap(); + node_ids.push((*k, id)); + } + let child_ids: Vec = node_ids.iter().map(|(_, id)| *id).collect(); + let root = t + .new_with_children( + Style { + display: Display::Block, + size: Size { + width: Dimension::length(avail), + height: Dimension::AUTO, + }, + ..Default::default() + }, + &child_ids, + ) + .unwrap(); + + let n = paras.len(); + let mut clips_per_para: Vec> = vec![Vec::new(); n]; + let mut history: Vec> = Vec::new(); + let mut x_lines: Vec> = vec![Vec::new(); n]; + let mut converged = false; + let mut oscillate = false; + let max_pass = 6; + + for pass in 0..max_pass { + let mut lines_out: Vec> = vec![Vec::new(); n]; + let clips_ref = &clips_per_para; + let paras_ref = paras; + let lines_ref = &mut lines_out; + t.compute_layout_with_measure( + root, + Size { + width: AvailableSpace::Definite(avail), + height: AvailableSpace::MaxContent, + }, + move |known, available, _node_id, ctx, _style| { + let c = match ctx { + Some(c) => c, + None => return Size::ZERO, + }; + if c.kind == 0 { + return Size { + width: known.width.unwrap_or(0.0), + height: known.height.unwrap_or(0.0), + }; + } + let id = c.id; + let w = match available.width { + AvailableSpace::Definite(x) => x, + _ => avail, + }; + let (h, lines) = wrap_clips(¶s_ref[id], &clips_ref[id], w); + lines_ref[id] = lines; + Size { + width: known.width.unwrap_or(w), + height: known.height.unwrap_or(h), + } + }, + ) + .unwrap(); + + let mut float_rect: Option<(f32, f32, f32, f32)> = None; + for (k, id) in &node_ids { + if let Kind::F = k { + let l = t.layout(*id).unwrap(); + float_rect = Some((l.location.x, l.location.y, l.size.width, l.size.height)); + } + } + let (frx, fry, _frw, frh) = float_rect.unwrap(); + let reg_x = frx - GAP; + let reg_h = frh + GAP; + + let mut new_clips: Vec> = vec![Vec::new(); n]; + let mut heights = vec![0.0f32; n]; + for (k, id) in &node_ids { + if let Kind::P(idx) = k { + let l = t.layout(*id).unwrap(); + let px = l.location.x; + let py = l.location.y; + let pw = l.size.width; + let ph = l.size.height; + heights[*idx] = ph; + if reg_h > 0.0 && !(fry + reg_h <= py || fry >= py + ph) { + let y_top = fry.max(py) - py; + let y_bottom = (fry + reg_h).min(py + ph) - py; + if y_bottom > y_top { + new_clips[*idx].push(Clip { + y_top, + y_bottom, + x: reg_x - px, + width: ((px + pw) - reg_x).max(1.0), + }); + } + } + } + } + x_lines = lines_out; + clips_per_para = new_clips; + + let stable = history.last().map_or(false, |prev| *prev == heights); + history.push(heights.clone()); + if pass > 0 && stable { + converged = true; + break; + } + let len = history.len(); + if len >= 3 && history[len - 1] == history[len - 3] && history[len - 1] != history[len - 2] { + oscillate = true; + break; + } + } + + println!(" X pass heights:"); + for (i, h) in history.iter().enumerate() { + println!(" pass {}: {:?}", i, h); + } + println!( + " X status: {}{}", + if converged { "CONVERGED" } else { "NOT-CONVERGED" }, + if oscillate { " OSCILLATE" } else { "" } + ); + println!(" X final lines: {:?}", x_lines); + println!(" Y heights: {:?}", y_h); + println!(" Y lines: {:?}", y_lines); + + let x_final = history.last().cloned().unwrap_or_default(); + let h_match = x_final == y_h; + let l_match = x_lines == y_lines; + println!( + " VERDICT: heights {} | lines {} | oscillate {}", + if h_match { "MATCH" } else { "DIVERGE" }, + if l_match { "MATCH" } else { "DIVERGE" }, + oscillate + ); + println!(); +} + +fn probe_float_display() { + println!("==== float under FLEX vs BLOCK container ===="); + let avail = 400.0; + let fw = 120.0; + let fh = 80.0; + + for (label, display) in [("FLEX", Display::Flex), ("BLOCK", Display::Block)] { + let mut t: TaffyTree = TaffyTree::new(); + let f = t + .new_leaf_with_context( + Style { + float: Float::Right, + size: Size { + width: Dimension::length(fw), + height: Dimension::length(fh), + }, + ..Default::default() + }, + Ctx { kind: 0, id: 0 }, + ) + .unwrap(); + let p = t + .new_leaf_with_context( + Style::default(), + Ctx { kind: 1, id: 0 }, + ) + .unwrap(); + let root = t + .new_with_children( + Style { + display, + flex_direction: FlexDirection::Column, + size: Size { + width: Dimension::length(avail), + height: Dimension::AUTO, + }, + ..Default::default() + }, + &[f, p], + ) + .unwrap(); + t.compute_layout_with_measure( + root, + Size { + width: AvailableSpace::Definite(avail), + height: AvailableSpace::MaxContent, + }, + |known, _a, _n, ctx, _s| { + let c = match ctx { + Some(c) => c, + None => return Size::ZERO, + }; + if c.kind == 1 { + return Size { width: 200.0, height: 30.0 }; + } + Size { + width: known.width.unwrap_or(0.0), + height: known.height.unwrap_or(0.0), + } + }, + ) + .unwrap(); + let fl = t.layout(f).unwrap(); + let pl = t.layout(p).unwrap(); + println!( + " {} -> float loc=({:.0},{:.0}) size=({:.0},{:.0}) | para loc=({:.0},{:.0})", + label, fl.location.x, fl.location.y, fl.size.width, fl.size.height, pl.location.x, + pl.location.y + ); + } + println!(" (float active = para.y stays at 0 and float.x right-aligned; float ignored under flex = para.y pushed below float height)"); + println!(); +} + +fn main() { + probe_float_display(); + run_scene( + "S1 float-first, bottom lands inside para2", + 400.0, + 120.0, + 75.0, + &[words(36), words(36), words(36)], + &[Kind::F, Kind::P(0), Kind::P(1), Kind::P(2)], + ); + run_scene( + "S2 float after a short paragraph", + 400.0, + 120.0, + 75.0, + &[words(4), words(36), words(36)], + &[Kind::P(0), Kind::F, Kind::P(1), Kind::P(2)], + ); +} diff --git a/src/rust/layout-engine/examples/space_probe.rs b/src/rust/layout-engine/examples/space_probe.rs new file mode 100644 index 00000000..dee70b26 --- /dev/null +++ b/src/rust/layout-engine/examples/space_probe.rs @@ -0,0 +1,76 @@ +use guide_layout_engine::parley_text::ParleyFonts; + +fn main() { + let mut parley = ParleyFonts::new(); + + let text = " indented line"; + let layout = parley.layout_paragraph(text, 9.0, 10.0 / 9.0, Some(500.0)); + + let mut total_advance = 0.0f32; + let mut glyph_count = 0; + for line in layout.lines() { + for item in line.items() { + if let parley::PositionedLayoutItem::GlyphRun(gr) = item { + for g in gr.positioned_glyphs() { + glyph_count += 1; + total_advance = g.x + g.advance; + } + } + } + } + + let single_space = parley.layout_paragraph(" indented line", 9.0, 10.0 / 9.0, Some(500.0)); + let mut single_advance = 0.0f32; + let mut single_count = 0; + for line in single_space.lines() { + for item in line.items() { + if let parley::PositionedLayoutItem::GlyphRun(gr) = item { + for g in gr.positioned_glyphs() { + single_count += 1; + single_advance = g.x + g.advance; + } + } + } + } + + println!("4-space: glyphs={} advance={:.2}", glyph_count, total_advance); + println!("1-space: glyphs={} advance={:.2}", single_count, single_advance); + println!( + "diff={:.2} (expect ~3 space widths if NOT collapsed)", + total_advance - single_advance + ); + + let one_space = parley.layout_paragraph(" ", 9.0, 10.0 / 9.0, Some(500.0)); + let mut space_w = 0.0f32; + for line in one_space.lines() { + for item in line.items() { + if let parley::PositionedLayoutItem::GlyphRun(gr) = item { + for g in gr.positioned_glyphs() { + space_w = g.advance; + } + } + } + } + println!("single space advance={:.2}", space_w); + println!( + "VERDICT: {}", + if (total_advance - single_advance - 3.0 * space_w).abs() < 1.0 { + "SPACES PRESERVED (not collapsed)" + } else { + "SPACES COLLAPSED" + } + ); + + let nl_text = "line1\nline2"; + let nl_layout = parley.layout_paragraph(nl_text, 9.0, 10.0 / 9.0, Some(500.0)); + let nl_lines = nl_layout.lines().count(); + println!("\nnewline test: \"line1\\nline2\" → {} line(s)", nl_lines); + println!( + "VERDICT: {}", + if nl_lines == 1 { + "\\n FOLDED TO SPACE (need breaks mechanism)" + } else { + "\\n TREATED AS LINE BREAK" + } + ); +} diff --git a/src/rust/layout-engine/schema/README.md b/src/rust/layout-engine/schema/README.md new file mode 100644 index 00000000..0ed3a941 --- /dev/null +++ b/src/rust/layout-engine/schema/README.md @@ -0,0 +1,98 @@ +# layout-engine schema: guidenh_layout.fbs generation chain and change policy + +Read this before changing the FlatBuffers schema. It describes where the schema lives, how each +side regenerates its bindings, which flatc version is pinned, and which changes are wire +compatible. + +## 1. Schema location and namespace + +- Schema file: `src/rust/layout-engine/schema/guidenh_layout.fbs`, 39 tables. +- namespace: `com.hfstudio.guidenh.guide.layout.flatbuffers`. +- Contract direction: Java serializes, the Rust layout engine consumes. +- Checked-in Java bindings: `src/main/java/com/hfstudio/guidenh/guide/layout/flatbuffers/`, 39 + `.java` files, one per table. + +## 2. Generation chain + +### Rust side (automatic) + +- `src/rust/layout-engine/build.rs` calls the binary provided by the `flatc` crate + (`flatc::flatc()`) during cargo build, running + `flatc --rust -o src schema/guidenh_layout.fbs` with `src/rust/layout-engine` as the working + directory. +- The output is `src/rust/layout-engine/src/guidenh_layout_generated.rs`, which build.rs then + patches: + 1. it removes every `extern crate flatbuffers;` (Rust 2021 imports extern crates + automatically); + 2. it rewrites `use self::flatbuffers::` to `use ::flatbuffers::`, because `self::flatbuffers` + inside `mod flatbuffers { }` names the module rather than the crate. +- No manual regeneration is required on the Rust side. The generated file is a build artifact + that Git ignores, so editing a schema comment changes only what the next build emits, never the + wire format. + +### Java side (script) + +- `tools/regen_java_flatc.bat` locates flatc, verifies its version, runs `flatc --java` into a + temporary directory, compares the result with the checked-in classes, and overwrites them when + they differ (`--dry-run` previews, `--check-only` verifies in CI). +- Never hand-edit a generated class. Earlier hand-edits of generated + classes made the checked-in classes drift from the schema. Status check (2026-08-02): + `TextData.java` still carries hand-edit traces, because its doc comment and the order of its + `addAlignment`/`addSeparator` calls differ from flatc output; running the script in apply mode + normalizes it. That residue is deliberately left to the script. +- flatc comes from exactly one source: the binary built from the cargo `flatc` crate. That is a + `flatc.exe` under the `flatc-*` build-script output directory of the cargo build, in its debug + or release profile, so where it lands follows from how the cargo target directory resolves and + must not be hardcoded. The version observed there is 23.5.26. Gradle has no flatc configuration + (a repository-wide search finds none), so a Gradle build never regenerates the Java classes. + +## 3. Change procedure + +1. Edit `src/rust/layout-engine/schema/guidenh_layout.fbs`, respecting the wire-compatibility + policy in section 5. +2. Regenerate the Java classes: + - preview the difference: `tools\regen_java_flatc.bat --dry-run`; + - apply the overwrite: `tools\regen_java_flatc.bat` (no argument means overwrite-regenerate); + - verify for CI or by hand: `tools\regen_java_flatc.bat --check-only` (exit 1 on mismatch). +3. Do nothing on the Rust side: build.rs regenerates the file and patches it on the next cargo + build. +4. Run the gate: `./gradlew compileJava compileTestJava test runLayoutDump`. +5. Commit the schema, the Java classes and the generated Rust file in one commit. + +## 4. Version policy + +- flatc must be 23.5.26, exactly matching the `flatbuffers-java` 23.5.26 runtime. +- Every generated Java class carries a version guard, + `ValidateVersion() { Constants.FLATBUFFERS_23_5_26(); }`, so a mismatch with the runtime version + fails at the deserialization entry point. +- Upgrading the flatbuffers-java runtime requires all of the following in one change: upgrade + flatc, regenerate all 39 classes, and run the gate. +- `tools/regen_java_flatc.bat` verifies the version itself and exits with an error when + `flatc --version` does not contain `23.5.26`. + +## 5. Wire-compatibility policy + +- Schema changes must be append-only: add fields or tables, and never remove a field, change a + type, or change what a field means. +- Deprecation convention for a field: keep field, write zero. The field stays in the schema (its + vtable slot number is unchanged) and the writing side sends 0 or the default value. +- Deprecated fields present today: 4 field names across 5 occurrences. The line numbers are a + 2026-10-03 snapshot and drift with later edits. + + | Table | Field | Location | + |---|---|---| + | `TextData` | `bands` | guidenh_layout.fbs:138 | + | `TextData` | `float_clips` | guidenh_layout.fbs:140 | + | `PieChartData` | `chrome_height` | guidenh_layout.fbs:259 | + | `ChartData` | `chrome_height` | guidenh_layout.fbs:279 | + | `MediaWikiSpecialGeneratedData` | `max_content_height` | guidenh_layout.fbs:328 | + +- Current semantics of those fields: the writing side sends 0 or the default, and the Rust side + computes the value internally (parley migration, chart chrome, maxColumnHeight). + +## 6. Second generated set (not covered by this script) + +- `src/main/java/guideme/flatbuffers/scene/` holds 16 `Exp*` classes (`ExpScene`, `ExpMesh`, + `ExpMaterial` and so on) generated from the upstream GuideME schema. +- This repository has no corresponding `.fbs` source and `tools/regen_java_flatc.bat` does not + cover that set, so changes there have to be made in the upstream project. diff --git a/src/rust/layout-engine/schema/guidenh_layout.fbs b/src/rust/layout-engine/schema/guidenh_layout.fbs new file mode 100644 index 00000000..8c0c507c --- /dev/null +++ b/src/rust/layout-engine/schema/guidenh_layout.fbs @@ -0,0 +1,647 @@ +/// GuideNH FlatBuffer schema: the data contract between Java and Rust. +/// Generation command: flatc --rust -o src/ schema/guidenh_layout.fbs +/// +/// Regeneration mechanism: +/// - Rust side: src/rust/layout-engine/build.rs runs the flatc.exe shipped by the flatc +/// crate during cargo build. It regenerates src/guidenh_layout_generated.rs and patches +/// it, so no manual step is required. +/// - Java side: tools/regen_java_flatc.bat (no argument overwrites the generated classes +/// under src/main/java/.../flatbuffers/, --dry-run previews, --check-only verifies; +/// flatc 23.5.26 is required). +/// - Wire compatibility: appended fields may only be added at the end of a table (vtable +/// slot numbering stays unchanged); deprecating a field means keep field, write zero; +/// reviving a dead field means retyping it in place, because a dead field never carried +/// a non-default value, so its slot is absent from the wire and the change is safe. +/// +/// Field groups that carry atom metrics: +/// - InlineBlockRef.ink_left/ink_right/ink_bottom: ink box of an inline atom (icon or +/// formula), declared by Java and readable by Rust, which uses it for advance, kerning +/// and baseline placement. +/// - GlyphRun.baseline and PlacedGlyph.baseline: baseline feedback fields, quantized by a +/// divide-by-scale plus truncation, matching the existing PlacedGlyph x/y convention. +/// - TextStyle.font_family: revival of the dead TextStyle.font field (originally uint = 0, +/// renamed and retyped in place). +/// - FlatNode.list_marker and ListMarkerData: shared gutter marker declaration for a list +/// item (text plus style). Rust shapes, positions and emits it as an ordinary glyph quad, +/// so Java needs no special rendering path. +/// Horizontal anchoring: the marker's ink right edge sits exactly on the list item's own +/// left edge, which is the document text line of that nesting level (L1=14, L2=24, +/// L3=34). The whole box hangs into the left margin, so no gutter constant is needed. + +namespace com.hfstudio.guidenh.guide.layout.flatbuffers; + +/// A size value: auto, points, or percent. +table Dimension { + value: float = 0.0; + unit: byte = 0; // 0=Auto 1=Points 2=Percent +} + +table Style { + display: byte = 0; // 0=Flex 1=Grid 2=Block 3=None + flex_direction: byte = 1; // 0=Row 1=Column + flex_wrap: byte = 0; // 0=NoWrap 1=Wrap + align_items: byte = 0; // 0=Start 1=Center 2=End 3=Stretch 4=Baseline + align_self: byte = 0; // 0=Auto 1=Start 2=Center 3=End 4=Stretch + justify_content: byte = 0; // 0=Start 1=Center 2=End 3=SpaceBetween 4=SpaceAround 5=SpaceEvenly + gap_w: Dimension; + gap_h: Dimension; + size_w: Dimension; + size_h: Dimension; + min_w: Dimension; + min_h: Dimension; + max_w: Dimension; + max_h: Dimension; + aspect_ratio: float = 0.0; + margin_left: float = 0.0; + margin_right: float = 0.0; + margin_top: float = 0.0; + margin_bottom: float = 0.0; + margin_auto_left: bool = false; + margin_auto_right: bool = false; + margin_auto_top: bool = false; + margin_auto_bottom: bool = false; + padding_left: float = 0.0; + padding_right: float = 0.0; + padding_top: float = 0.0; + padding_bottom: float = 0.0; + border_left: float = 0.0; + border_right: float = 0.0; + border_top: float = 0.0; + border_bottom: float = 0.0; + overflow: byte = 0; // 0=Visible 1=Hidden 2=Scroll + flex_grow: float = 0.0; + flex_shrink: float = 1.0; + flex_basis: Dimension; + float: byte = 0; // 0=None 1=Left 2=Right + clear: byte = 0; // 0=None 1=Left 2=Right 3=Both + position: byte = 0; // 0=Relative 1=Absolute + inset_top: Dimension; + inset_right: Dimension; + inset_bottom: Dimension; + inset_left: Dimension; +} + +table TextStyle { + font_size: float = 14.0; + bold: bool = false; + italic: bool = false; + font_scale: float = 1.0; + color: uint = 0xFFFFFFFF; + font_family: string; // Font family name. Revives the dead `font` field (originally + // uint = 0) by renaming and retyping it in place: old data never + // wrote a non-default value to that slot (it stays absent from the + // vtable), so readers get null and the wire stays compatible. + underline: bool = false; + strikethrough: bool = false; + highlight_argb: uint = 0; // span background fill (0 = none) + inline_code: bool = false; // highlight geometry variant (no side padding) + baseline_shift: float = 0.0; // vertical offset in EM (fraction of font_size x font_scale); sup=-0.3 sub=+0.3 + wavy_underline: bool = false; // wavy underline decoration (Rust emits DecorationRect kind 4) + dotted_underline: bool = false; // dotted underline decoration (Rust emits DecorationRect kind 5) +} + +/// Inline block reference: pairs a U+FFFC placeholder (in document order) with +/// the inner block's flat index and its vertical alignment request. +/// align: 0 = block bottom sits 2px below the text baseline (default) +/// 1 = baseline ascent: block top sits `param` px above the text baseline +/// (LaTeX math-baseline alignment: param = formula ascent above its +/// math baseline, so the formula's baseline lands on the text's) +/// 2 = center on the text line, then shift down by `param` px (item icons) +/// 3 = float left: block floats to paragraph left edge, text wraps right +/// 4 = float right: block floats to paragraph right edge, text wraps left +table InlineBlockRef { + node: uint; + align: byte = 0; + param: float = 0.0; + /// Inline atom metrics: the ink box of an inline atom (icon or formula), declared by Java + /// and read by Rust. Left, right and bottom edges in the 16-unit icon space + /// (inkLeft/inkRight/inkBottom). The top edge does not take part in alignment and is + /// derived by the consumer from the baseline anchor. Default 0 means not declared, which + /// leaves the behavior for older data unchanged. + ink_left: float = 0.0; + ink_right: float = 0.0; + ink_bottom: float = 0.0; +} + +/// One styled run of a rich paragraph: `text` concatenated over all spans +/// equals TextData.text (same document order, U+FFFC placeholders included). +table TextSpan { + text: string; + style: TextStyle; +} + +table TextData { + text: string; + style: TextStyle; + white_space: byte = 0; // 0=Normal 1=PreWrap + inline_blocks: [InlineBlockRef]; // paired with U+FFFC placeholders, in document order + bands: [TextBand]; // DEPRECATED (parley migration): replaced by float_clips + spans: [TextSpan]; // rich multi-style runs (empty = single-style shaping) + float_clips: [FloatClip]; // DEPRECATED: per-line wrapping now owned by the Rust pusher's live float table + clears: [ClearBreak]; // in-paragraph
breaks (raw UTF-8 byte offset + side) + breaks: [uint]; // raw UTF-8 byte offsets of every in-paragraph
(hard line break); + // the Rust pusher splits the paragraph at these offsets and shapes + // each piece independently (a hard break, not a whitespace char), so + //
works under any white-space mode and PRE_WRAP code bodies keep + // their line structure. Coordinates are in the break-free text. + separator: bool = false; // heading paragraph (depth 1/2): Rust emits a kind=3 + // DecorationRect for the last line's full float-compressed window + // so Java can draw a themed separator across the real text area. + alignment: byte = 0; // per-paragraph text alignment. + // 0=Start(Left) 1=Center 2=End(Right) +} + +/// One in-paragraph clear break (`
`). `raw_offset` is the +/// UTF-8 byte offset into TextData.text (the original text, U+FFFC included) +/// at which the break occurs; the Rust pusher maps it to its cleaned text and, +/// after the line covering that offset is laid out, drops subsequent lines to +/// the cleared floats' bottom edge. side: 1=left 2=right 3=both. +table ClearBreak { + raw_offset: uint = 0; + side: byte = 0; +} + +/// One forbidden interval the text must not occupy, in paragraph-relative +/// coordinates (typically the intersection of a float's rect and the paragraph). The Rust line +/// breaker subtracts clips from the available width per line: x<=0 is a +/// left-side clip (text starts right of x+width), otherwise a right-side +/// clip (text ends at x). +table FloatClip { + y_top: float = 0.0; + y_bottom: float = 0.0; + x: float = 0.0; + width: float = 0.0; +} + +/// One float-wrap band: the paragraph's text is shaped in sequential bands at +/// per-band widths (CSS float wrapping: narrow beside the float, full width +/// below it). Bands are ordered; the first band starts at split_byte 0, each +/// later band starts at its split_byte and continues to the next band's start. +table TextBand { + split_byte: uint = 0; // start byte offset (UTF-8) of this band's text + width: float = 0.0; // shaping width for this band (px) + margin_left: float = 0.0; // extra left inset for this band's glyphs +} + +table ImageData { + natural_w: float; + natural_h: float; + crop_x: int = 0; + crop_y: int = 0; + crop_w: int = -1; + crop_h: int = -1; + scale_x: float = 1.0; + scale_y: float = 1.0; + explicit_w: float = -1.0; + explicit_h: float = -1.0; +} + +table SlotData { + slot_size: float = 18.0; +} + +table ThematicBreakData { + height: float = 6.0; +} + +table CustomData { + type_id: uint; // Rust consumes: LatexDisplay(8) + // Java-only: MediaWiki/Mermaid/StructureView/Chart/Scene3D/Mindmap/ContentTabs/RecipeBox + payload: [ubyte]; +} + +table LatexDisplayData { + formula: string; + fill_color_argb: uint = 0xFFFFFFFF; + /// DEPRECATED: legacy jlatexmath render-size passthrough. Java no longer writes it + /// (the slot stays absent = default 100.0, no wire bytes) and Rust never reads it. + /// Kept for wire compatibility with old serialized data. + source_scale: float = 100.0; + user_scale: float = 1.0; + offset_x: int = 0; + offset_y: int = 0; + raw_w: float; + raw_h: float; + /// DEPRECATED: legacy reference-height passthrough. Java no longer writes it + /// (the slot stays absent = default 0.0, no wire bytes) and Rust never reads it. + /// Kept for wire compatibility with old serialized data. + ref_h: float; +} + +table RecipeBoxData { + /// Handler-reported body content width (px), from registry.lookupRecipeHandlerWidth. + body_width: float; + /// Resolved body content height (px), from NeiRecipeLayoutMetrics.resolveBodyHeight. + body_height: float; + /// Additional top inset for GregTech handler (0 for normal handlers). + body_top_inset: float = 0.0; + /// Vertical shift applied by the handler registry. + body_y_shift: float = 0.0; + /// Title text pixel width, computed via Minecraft font metrics (Java-only). + title_text_width: float; + /// Icon display size: 8 if an icon (stack or image) is present, else 0. + icon_size: float; + /// Whether the action button (jump to NEI) is shown. + recipe_jump_enabled: bool = false; + /// Title bar height = Math.max(ICON_SIZE, FONT_HEIGHT) + TITLE_PAD_TOP + TITLE_PAD_BOTTOM. + title_height: float; +} + +table PieChartData { + /// preferred_width = (explicitWidth > 0 ? explicitWidth : DEFAULT_WIDTH) + extraPlotWidth. + /// Java-precomputed input to the sizing formula. + preferred_width: float; + /// total_height = explicitHeight > 0 ? explicitHeight : DEFAULT_HEIGHT. + /// Java-precomputed height baseline (before body scaling). + total_height: float; + /// DEPRECATED: Rust now computes chrome internally from the final width w. + /// Kept for backward compatibility with old serialized data. + chrome_height: float; + /// Title lineHeight(TITLE_STYLE) + TITLE_GAP; 0 if no title (width-independent). + title_chrome: float = 0.0; + /// Legend position: 0=NONE 1=TOP 2=BOTTOM 3=LEFT 4=RIGHT. + legend_position: byte = 0; + /// Row height for legend wrapping: max(LEGEND_SWATCH_SIZE, lineHeight(LEGEND_LABEL_STYLE)). + legend_row_height: float = 10.0; + /// Per-entry legend label widths: LEGEND_SWATCH_SIZE + SWATCH_TEXT_GAP + measureWidth(label, style). + legend_label_widths: [float]; +} + +/// Generic Cartesian chart sizing data (Bar/Column/Line/Scatter). +/// Same fields as PieChartData, kept as a separate table so node_type 21 +/// (PieChart) retains backward compatibility. node_type 22 (BarChart) and +/// future Cartesian charts use this table. +table ChartData { + preferred_width: float; + total_height: float; + /// DEPRECATED: Rust now computes chrome internally from the final width w. + /// Kept for backward compatibility with old serialized data. + chrome_height: float; + /// Title lineHeight(TITLE_STYLE) + TITLE_GAP; 0 if no title (width-independent). + title_chrome: float = 0.0; + /// Legend position: 0=NONE 1=TOP 2=BOTTOM 3=LEFT 4=RIGHT. + legend_position: byte = 0; + /// Row height for legend wrapping: max(LEGEND_SWATCH_SIZE, lineHeight(LEGEND_LABEL_STYLE)). + legend_row_height: float = 10.0; + /// Per-entry legend label widths: LEGEND_SWATCH_SIZE + SWATCH_TEXT_GAP + measureWidth(label, style). + legend_label_widths: [float]; +} + +/// Isometric structure view sizing data (node_type 26). +/// Mirrors LytStructureView.computeLayout: view_width/view_height are the +/// block's intrinsic dimensions (setViewSize or DEFAULT_WIDTH/HEIGHT), used +/// by the Rust measure function with visual_scale and available_width to +/// compute the responsive box. +table StructureViewData { + view_width: float; + view_height: float; +} + +/// Function graph sizing data (node_type 28). +/// Mirrors LytFunctionGraph.computeLayout term for term. +/// Minecraft-dependent values (label widths, line heights) are precomputed +/// by Java; Rust applies the responsive formula (scale_width, legend +/// wrapping, scale_body_height_for_width) with available_width and visual_scale. +table FunctionGraphData { + /// Intrinsic base width: explicitWidth > 0 ? explicitWidth : DEFAULT_WIDTH. + base_width: float; + /// Intrinsic base height: explicitHeight > 0 ? explicitHeight : DEFAULT_HEIGHT. + base_height: float; + /// lineHeight(TITLE_STYLE) + TITLE_GAP if title present and non-empty, else 0. + title_chrome: float; + /// Row height for legend wrapping: max(LEGEND_SWATCH_SIZE, lineHeight(LEGEND_LABEL_STYLE)). + legend_row_height: float; + /// Per-plot legend item widths (0 for plots without labels): + /// LEGEND_SWATCH_SIZE + LEGEND_SWATCH_TEXT_GAP + measureWidth(label, style). + label_item_widths: [float]; +} + +/// MediaWiki special generated block sizing data (node_type 30). +/// Java passes width-independent font facts (title widths, subtitle word widths, +/// line heights, estimated heights). Rust computes columnWidth from availableWidth, +/// wraps subtitle words per entry, packs columns (shortest-column-first), and +/// produces maxColumnHeight. Width is always availableWidth: the block fills +/// the parent's content box. +table MediaWikiSpecialGeneratedData { + /// DEPRECATED: Rust now computes maxColumnHeight internally. + /// Kept for backward compatibility with old serialized data. + max_content_height: float; + + /// resolveColumnCount result (depends only on definition.name()/kind). + column_count: int = 1; + /// hasMore flag from the visible result: adds LOAD_MORE_HEIGHT after the + /// column content when true. + has_more: bool = false; + /// Number of groups (for grouped/group-index kinds; flat builds one group + /// per column, see group_flat_entry_count). + group_count: int = 0; + + /// Per-group title measureWidth: 0 means no title (no header row). + /// Length = group_count. + group_title_widths: [float]; + /// Per-group entry count in the visible result's entry order. + /// Length = group_count. + group_entry_counts: [int]; + /// Per-group estimated total height (estimateHeight), width-independent, + /// used by Rust's shortest-column-first packing. Length = group_count. + group_estimated_heights: [float]; + + /// Total number of entries across all groups. Entries are indexed 0..N-1 + /// in the order they appear in the visible result (group-major). + total_entry_count: int = 0; + + /// Per-entry title pixel width: measureWidth(title, LINK_STYLE). + /// Width-independent font fact. Length = total_entry_count. + entry_title_widths: [float]; + /// Per-entry icon presence (1 = has icon). Length = total_entry_count. + entry_has_icon: [byte]; + /// Per-entry estimated height (estimateEntryHeight), width-independent. + /// Length = total_entry_count. + entry_estimated_heights: [float]; + /// Per-entry raw subtitle line count (GuideStringLines.splitLines result, + /// 0 = no subtitle). Length = total_entry_count. + entry_subtitle_line_counts: [int]; + + /// Per-raw-subtitle-line word count (GuideStringLines.splitLines then + /// split by \\s+), concatenated across all entries' raw lines. + subtitle_line_word_counts: [int]; + /// Per-word pixel width (measureWidth(word, SUBTITLE_STYLE)), concatenated + /// across all words of all raw lines of all entries. + subtitle_word_widths: [float]; + /// Width of a space character in SUBTITLE_STYLE (for word-gap calculations). + subtitle_space_width: float = 4.0; + + /// Line height of LINK_STYLE, from GuideText.lineHeight(LINK_STYLE) (= 17 at + /// scale 1). Mirrors Java computeEntryHeight's getLineHeight(LINK_STYLE) / + /// rowContentHeight's GuideText.lineHeight(rowStyle). Default 10.0 keeps + /// old serialized data (which lacked this field) rendering as before. + link_line_height: float = 10.0; + /// Line height of SUBTITLE_STYLE, from GuideText.lineHeight(SUBTITLE_STYLE) + /// (= 17 at scale 1). Mirrors Java computeEntryHeight/rowContentHeight's + /// GuideText.lineHeight(SUBTITLE_STYLE). Default 10.0 keeps old serialized + /// data (which lacked this field) rendering as before. + subtitle_line_height: float = 10.0; +} + +/// MediaWiki generated list sizing data (node_type 29). +/// Mirrors MediaWikiGeneratedListBlock.computeLayout term for term. +/// The column-planning algorithm depends on Java object data (entry sort keys, +/// titles, section grouping); Rust cannot replicate it. Java precomputes the +/// max column content height (the tallest column's content, excluding +/// TOP_PADDING/BOTTOM_PADDING) via a getter set during computeLayout. +/// Rust adds the padding constants to produce the total block height. +/// Width is always availableWidth: the block fills the parent's content box. +table MediaWikiGeneratedListData { + /// Content height of the tallest column (not including TOP_PADDING=6 and + /// BOTTOM_PADDING=6). When entries are empty, this is ROW_HEIGHT=20. + /// Mirrors maxColumnHeight or ROW_HEIGHT in computeLayout, which is the + /// max over columns of (columnY - y - TOP_PADDING). + max_content_height: float; +} + +/// Shared gutter marker declaration for a list item. +/// Java writes this field only for a LytListItem that uses a shared marker (ordered "N." +/// with start semantics, unordered U+2022); subclasses with a custom gutter marker (task +/// checkbox and similar) leave it null, which means no marker declaration and no change in +/// behavior. +/// +/// Rust consumption (Rust owns all geometry): +/// * shape `text` through the same shaping pipeline as the body text (parley +/// layout_styled, no line breaking); +/// * vertical baseline = the baseline of the content's first line (the first line of the +/// first content child that has glyph content; see list_marker_pass in layout.rs for the +/// fallback); +/// * horizontal right alignment = the marker's ink right edge lands on the list item's own +/// left edge, which is the document text line of that nesting level (L1=14, L2=24, +/// L3=34); the whole box hangs into the left margin, so no gutter constant is needed; +/// * the marker glyphs join the page-wide output stream as ordinary glyph quads (no new +/// primitive type), with the run color taken from `style.color` (Java declares the gray +/// COL_MARKER). +/// The marker never takes part in line breaking and does not affect the geometry of any +/// content child: content bounds stay byte-for-byte identical. +table ListMarkerData { + /// Marker text: ordered = "N." (N carries the start value), unordered = U+2022. + text: string; + /// Marker style: body font style (BASE_FONT_SIZE 11 / regular), color COL_MARKER gray. + style: TextStyle; +} + +/// Guidebook scene sizing data (node_type 27). +/// Mirrors LytGuidebookScene.computeLayout term for term. +/// Minecraft-dependent values (block stats geometry, button visibility, +/// bottom control areas) are precomputed by Java; Rust applies the +/// responsive scene sizing formula (scale_width, dock clamping, +/// computeResponsiveSceneHeight) with available_width and visual_scale. +table GuidebookSceneData { + /// Intrinsic scene pixel width (setSceneSize or DEFAULT_WIDTH = 256). + scene_width: float; + /// Intrinsic scene pixel height (setSceneSize or DEFAULT_HEIGHT = 192). + scene_height: float; + /// Horizontal space reserved for the floating button column: + /// interactive && sceneButtonsVisible ? (BTN_OUTSIDE_GAP + BTN_SIZE) : 0. + button_column_reserve: float; + /// Total pixel height of scene buttons when stacked vertically: + /// interactive && sceneButtonsVisible ? (BTN_SIZE * count + BTN_GAP * max(0, count-1)) : 0. + buttons_total_height: float; + /// Block-stats left dock size + BLOCK_STATS_DOCK_GAP, or 0. + left_dock: float; + /// Block-stats right dock size + BLOCK_STATS_DOCK_GAP, or 0. + right_dock: float; + /// Block-stats top dock size + BLOCK_STATS_DOCK_GAP, or 0. + top_dock: float; + /// Block-stats bottom dock size + BLOCK_STATS_DOCK_GAP, or 0. + bottom_dock: float; + /// Bottom control area height from getBottomControlAreaHeight(). + bottom_control_area_height: float; + /// Whether the layout should reserve room for bottom controls. + reserve_bottom_control: bool = false; +} + +table FlatNode { + style: Style; + node_type: byte = 0; // 0=Container 1=Text 2=Image 3=Slot + // 4=ThematicBreak 5=Custom 6=FileTree + // 7=Table 8=Latex 20=RecipeBox 21=PieChart 22=BarChart + // 23=ColumnChart 24=LineChart 25=ScatterChart 26=StructureView + // 27=GuidebookScene (Rust MeasureFunc) + // 28=FunctionGraph (Rust MeasureFunc) + // 29=MediaWikiGeneratedList (Rust MeasureFunc) + // 30=MediaWikiSpecialGenerated (Rust MeasureFunc) + text: TextData; + image: ImageData; + slot: SlotData; + break_: ThematicBreakData; + custom: CustomData; + latex: LatexDisplayData; + custom_layout: byte = 0; // 0=None 1=ItemGrid 2=SlotGrid + children: [uint]; + recipe_box: RecipeBoxData; // node_type 20: NEI recipe box sizing data + pie_chart: PieChartData; // node_type 21: Pie chart sizing data + chart_data: ChartData; // node_type 22: Cartesian chart sizing data (Bar/Column/Line/Scatter) + structure_view_data: StructureViewData; // node_type 26: StructureView sizing data + guidebook_scene_data: GuidebookSceneData; // node_type 27: GuidebookScene sizing data + function_graph_data: FunctionGraphData; // node_type 28: FunctionGraph sizing data + mediawiki_generated_list_data: MediaWikiGeneratedListData; // node_type 29: MediaWikiGeneratedList sizing data + mediawiki_special_generated_data: MediaWikiSpecialGeneratedData; // node_type 30: MediaWikiSpecialGeneratedBlock sizing data + /// Shared gutter marker declaration for a list item (null = no marker). + /// Java declares it (LytListItem marker text plus the COL_MARKER style), Rust computes the + /// shape and position and emits an ordinary glyph quad, and Java renders it without a + /// special path. + list_marker: ListMarkerData; +} + +table LayoutInput { + available_width: float; + visual_scale: float = 1.0; + render_scale: float = 1.0; // display pixel ratio (MC guiScale) for hi-res glyph bitmaps + justify: byte = 1; // 0=off 1=auto (justify Latin-dominant lines by stretching spaces) + nodes: [FlatNode]; + /// DEPRECATED (GLYPH_SHEAR_K is now render-only): Java stopped declaring it + /// (LayoutTreeSerializer no longer writes it, so the slot stays absent = default 0.25, no + /// wire bytes) and Rust no longer reads it from the wire; the engine owns the constant + /// SHEAR_K = 0.25 in layout.rs. + /// Historical meaning: synthetic shear factor K, which the italic-to-upright boundary + /// correction used to compute the top shear offset of the last glyph as + /// K * (shearBase - glyphTop). + /// Kept for wire compatibility with old serialized data. + shear_k: float = 0.25; +} + +table LayoutResult { + nodes: [FlatLayout]; + glyph_runs: [GlyphRun]; + bitmaps: [GlyphBitmap]; + decorations: [DecorationRect]; + content_height: float; + debug_info: string; +} + +table FlatLayout { + x: float; + y: float; + w: float; + h: float; + order: uint = 0; +} + +/// One glyph run of a paragraph, grouped by span: `argb` tints the (white) +/// atlas bitmaps, `shear` asks the engine for a synthetic-italic slant. +/// Default values keep legacy single-style runs white and unslanted. +table GlyphRun { + node_index: uint; + glyphs: [PlacedGlyph]; + argb: uint = 0xFFFFFFFF; + shear: bool = false; + /// Baseline Y of this run in absolute document coordinates, already quantized onto the + /// display pixel grid by dividing by render_scale and truncating. Default 0 keeps older + /// data compatible; the Java reader ignores the field until it consumes it. + baseline: float = 0.0; +} + +/// A decoration rectangle in absolute document coordinates (top-left origin), +/// derived from a span's glyph extents: kind 0 = background (highlight / +/// inline-code), 1 = underline, 2 = strikethrough. +table DecorationRect { + node: uint = 0; + x: float; + y: float; + w: float; + h: float; + argb: uint; + kind: byte = 0; +} + +/// A glyph quad in absolute document coordinates (top-left origin). +/// w/h are the bitmap dimensions divided by render_scale (i.e. document units). +/// start/end are the glyph's byte range in the source text (for band-split +/// computations; 0 when not meaningful). +table PlacedGlyph { + bitmap_key: ulong; // opaque dedupe key into LayoutResult.bitmaps + x: float; + y: float; + w: float; + h: float; + start: uint = 0; + end: uint = 0; + line_index: uint = 0; // visual (wrapped) line index within the shaped buffer + /// Baseline Y of the line this glyph belongs to, in absolute document coordinates, + /// already quantized onto the display pixel grid by dividing by render_scale and + /// truncating, the same precision convention as x/y/w/h. Default 0 keeps older data + /// compatible; the Java reader ignores the field until it consumes it. + baseline: float = 0.0; +} + +/// A unique rasterized glyph bitmap, at render_scale resolution. +/// Placement is already baked into the owning PlacedGlyph's quad. +table GlyphBitmap { + key: ulong; + w: uint; + h: uint; + rgba: [ubyte]; +} + +table RasterInput { + requests: [FontRequest]; +} + +table FontRequest { + font_id: uint; + font_size: float; + glyph_ids: [uint]; + pos_x: [float]; // document X positions, same length as glyph_ids + pos_y: [float]; // document Y positions +} + +table RasterResult { + glyphs: [GlyphPixels]; +} + +table GlyphPixels { + font_id: uint; + font_size: float; + glyph_id: uint; + width: uint; + height: uint; + rgba: [ubyte]; +} + +// shapeText (the unified text pipeline) inputs and outputs. The RenderGlyph/RenderResult +// pair below serves renderText, which combines shaping and rasterization in one call. + +/// One-off text shaping for the GuideText unified entry: same shaping and +/// rasterization as the layout pipeline's text path, returning atlas-keyed +/// quads (buffer-local, origin = text top-left) so Java can reuse the shared +/// glyph atlas and DrawGlyphRun execution as-is. +table ShapeTextInput { + text: string; + style: TextStyle; + max_width: float = -1.0; // <= 0: no wrapping + render_scale: float = 1.0; // display pixel ratio for hi-res bitmaps +} + +table ShapeTextResult { + width: float; + height: float; + ascent: float; // first-line baseline offset below the line top + line_height: float; + glyphs: [PlacedGlyph]; // buffer-local quads (origin = text top-left) + bitmaps: [GlyphBitmap]; + x_height: float = 0.0; // baseline->lowercase x top, shaped-size px (first run); Rust fallback ascent*0.625 + cap_height: float = 0.0; // baseline->cap top, shaped-size px (first run); Rust fallback ascent*0.7 +} + +table RenderGlyph { + x: int; // pixel X (SubpixelBin integer + placement, no rounding needed) + y: int; // pixel Y + w: uint; // bitmap width + h: uint; // bitmap height + rgba: [ubyte]; // RGBA pixel data +} + +table RenderResult { + width: float; // total content width + height: float; // total content height + glyphs: [RenderGlyph]; +} + +root_type LayoutInput; diff --git a/src/rust/layout-engine/src/jni_bridge.rs b/src/rust/layout-engine/src/jni_bridge.rs new file mode 100644 index 00000000..6eb06ea6 --- /dev/null +++ b/src/rust/layout-engine/src/jni_bridge.rs @@ -0,0 +1,24 @@ +use jni::JNIEnv; +use jni::objects::JByteArray; + +/// Convert JNI jbyteArray to Rust Vec. +pub fn jbytearray_to_vec( + env: &mut JNIEnv, + array: &JByteArray, +) -> Result, jni::errors::Error> { + let size = env.get_array_length(array)? as usize; + let mut buf = vec![0i8; size]; + env.get_byte_array_region(array, 0, &mut buf)?; + Ok(buf.iter().map(|&b| b as u8).collect()) +} + +/// Convert Rust &[u8] to JNI jbyteArray. +pub fn vec_to_jbytearray( + env: &mut JNIEnv, + data: &[u8], +) -> Result { + let arr = env.new_byte_array(data.len() as i32)?; + let data_i8: Vec = data.iter().map(|&b| b as i8).collect(); + env.set_byte_array_region(&arr, 0, &data_i8)?; + Ok(arr.into_raw()) +} diff --git a/src/rust/layout-engine/src/layout.rs b/src/rust/layout-engine/src/layout.rs new file mode 100644 index 00000000..a4e8e603 --- /dev/null +++ b/src/rust/layout-engine/src/layout.rs @@ -0,0 +1,5042 @@ +use std::collections::HashMap; + +use crate::fb::{ + DecorationRect, DecorationRectArgs, FlatLayout, FlatLayoutArgs, FlatNode, GlyphBitmap, + GlyphBitmapArgs, GlyphRun, GlyphRunArgs, LayoutInput, LayoutResult, LayoutResultArgs, + PlacedGlyph, PlacedGlyphArgs, +}; +use crate::measure::{create_measure_closure, measure_text, GlyphAccum, NodeContext}; +use crate::parley_text::FloatRect; +use crate::style_convert::flat_style_to_taffy; +use crate::text::GuideFontSystem; +use flatbuffers::FlatBufferBuilder; +use taffy::prelude::*; + +/// Document content-box padding, matching the legacy synthetic root and the +/// Java document padding (14px each side). +const CONTENT_PAD: f32 = 14.0; + +/// The synthetic shear coefficient K (GLYPH_SHEAR_K, now render-only on the Java +/// side). Java no longer declares it, since +/// the LayoutInput.shear_k wire field is DEPRECATED with its slot retained and a +/// default of 0.25, so the Rust side owns the K value for that boundary correction end +/// to end and hardens it into this constant. Its only consumer is the italic-to-upright +/// boundary correction (apply_italic_boundary_correction / +/// apply_italic_atom_boundary_correction); the draw-time slant remains the Java +/// renderer's concern (GuideRenderEngine.SHEAR_K holds the same value, 0.25). +const SHEAR_K: f32 = 0.25; + +/// Compute the available horizontal lane (absolute x and width) for a block +/// at the given absolute Y, consulting the float table. If floats fully +/// block the lane at start_y, push down past the nearest blocking float's +/// bottom and retry. Returns (lane_x, lane_width, adjusted_y). +/// +/// Mirrors the Java LytFloatAwareBlock.computeLayout loop: +/// query left/right floats at the current Y; if the resulting lane has +/// positive width, use it; otherwise find the next float bottom below and +/// skip to it. +fn compute_lane( + start_y: f32, + content_x: f32, + content_w: f32, + float_table: &[FloatRect], +) -> (f32, f32, f32) { + let mut lane_y = start_y; + loop { + let mut x0 = content_x; + let mut x1 = content_x + content_w; + let mut max_bottom: Option = None; + for f in float_table { + if f.y + f.h <= lane_y || f.y >= lane_y + 1.0 { + continue; + } + if f.right { + x1 = x1.min(f.x); + } else { + x0 = x0.max(f.x + f.w); + } + let b = f.y + f.h; + max_bottom = Some(max_bottom.map_or(b, |p| p.max(b))); + } + let lane_w = x1 - x0; + if lane_w > 0.0 { + return (x0, lane_w, lane_y); + } + // Fully blocked: jump below the blocking floats. + match max_bottom { + Some(b) => lane_y = b, + None => return (content_x, content_w, lane_y), + } + } +} + +/// Document-flow pusher (v1). Owns the single authoritative float table and +/// drives the top-level sequence in document order: top-level paragraphs are +/// shaped directly against the live table (real per-line wrapping: the +/// "bridge" is this in-process query, no precomputed clip table crosses any +/// boundary); top-level floats register into the table at the current cursor +/// without advancing it; top-level blocks are laid out as taffy subtrees. +/// Nested paragraphs inside those subtrees still wrap at full width (the +/// pusher does not yet recurse into block float contexts, which is transitional). +pub fn compute_layout( + input_bytes: &[u8], + font_system: &mut GuideFontSystem, +) -> Vec { + let input = flatbuffers::root::(input_bytes) + .expect("Invalid LayoutInput FlatBuffer"); + + let avail_width = input.available_width(); + // NB: visual_scale is intentionally NOT applied to the root width, + // Java blocks already pre-apply it per block (ResponsiveVisualSizing). + let visual_scale = input.visual_scale(); + // Display pixel ratio (MC guiScale): glyph bitmaps are rasterized at + // font_size * render_scale so 1 texel maps to 1 physical pixel; quad + // coordinates are then divided back into document units. + let render_scale = input.render_scale().max(0.25); + // K no longer travels the wire: LayoutInput.shear_k is DEPRECATED and + // Java stopped declaring it, so Rust owns it end to end (see the SHEAR_K constant). + // Consumed only by the italic→upright boundary correction; the draw-time slant itself + // stays a Java renderer concern. + let shear_k = SHEAR_K; + let fb_nodes = input.nodes(); + let justify = input.justify() != 0; + + let flat_nodes: Vec = fb_nodes + .map_or_else(Vec::new, |v| (0..v.len()).map(|i| v.get(i)).collect()); + + let content_x = CONTENT_PAD; + let content_y = CONTENT_PAD; + let content_w = (avail_width - 2.0 * CONTENT_PAD).max(1.0); + + // Document-top sequence = flat nodes not claimed as any container's child + // (the orphans the legacy synthetic root adopted; the pusher drives them). + let mut claimed = vec![false; flat_nodes.len()]; + for fb in flat_nodes.iter() { + if let Some(ch) = fb.children() { + for ci in ch.iter() { + claimed[ci as usize] = true; + } + } + } + let top_seq: Vec = (0..flat_nodes.len()).filter(|i| !claimed[*i]).collect(); + + let mut taffy: TaffyTree = TaffyTree::new(); + let mut abs_positions: Vec<(f32, f32)> = vec![(0.0, 0.0); flat_nodes.len()]; + let mut sizes: Vec<(f32, f32)> = vec![(0.0, 0.0); flat_nodes.len()]; + let mut glyph_acc: HashMap = HashMap::new(); + let mut float_table: Vec = Vec::new(); + let mut cursor: f32 = 0.0; + + for &idx in &top_seq { + let fb = &flat_nodes[idx]; + let (ml, mt, mr, mb, pos_abs, float_side, node_type) = match fb.style() { + Some(s) => ( + s.margin_left(), + s.margin_top(), + s.margin_right(), + s.margin_bottom(), + s.position() == 1, + s.float(), + fb.node_type(), + ), + None => (0.0, 0.0, 0.0, 0.0, false, 0, fb.node_type()), + }; + + if pos_abs { + // Inline block: position is assigned later by the inline post-pass; + // only its subtree size is needed here. + let (w, h, _sub) = build_subtree( + &mut taffy, + idx, + &flat_nodes, + font_system, + &mut glyph_acc, + justify, + visual_scale, + &mut abs_positions, + &mut sizes, + 0.0, + 0.0, + None, + ); + sizes[idx] = (w, h); + abs_positions[idx] = (0.0, 0.0); + continue; + } + + if float_side == 1 || float_side == 2 { + let right = float_side == 2; + let (w, h, sub) = build_subtree( + &mut taffy, + idx, + &flat_nodes, + font_system, + &mut glyph_acc, + justify, + visual_scale, + &mut abs_positions, + &mut sizes, + 0.0, + 0.0, + None, + ); + let fy = content_y + cursor; + let fx = if right { + content_x + content_w - w + } else { + content_x + }; + for &si in &sub { + abs_positions[si] = (abs_positions[si].0 + fx, abs_positions[si].1 + fy); + } + sizes[idx] = (w, h); + // The float's gap is expressed as the inner's margin (CSS-correct): + // the registered rectangle is the margin box, the drawn box is the + // content box. + float_table.push(FloatRect { + x: fx - ml, + y: fy - mt, + w: w + ml + mr, + h: h + mt + mb, + right, + }); + // A float does not advance the vertical cursor (zero flow height). + continue; + } + + if node_type == 1 { + // CSS-preposed margin: this paragraph's top margin opens the gap + // above its box, so the box starts at the already-advanced cursor + // (mirrors taffy subtrees and the legacy Java pusher). + cursor += mt; + let para_abs_y = content_y + cursor; + let para_x = content_x; + let avail = Size { + width: AvailableSpace::Definite(content_w), + height: AvailableSpace::MaxContent, + }; + let clears_raw: Vec<(usize, u8)> = flat_nodes[idx] + .text() + .and_then(|t| t.clears()) + .map(|v| { + v.iter() + .map(|c| (c.raw_offset() as usize, c.side() as u8)) + .collect() + }) + .unwrap_or_default(); + let (sz, clear_floor) = measure_text( + font_system, + &flat_nodes, + idx, + &mut glyph_acc, + avail, + justify, + &float_table, + para_abs_y, + para_x, + &clears_raw, + ); + abs_positions[idx] = (para_x, para_abs_y); + sizes[idx] = (sz.width, sz.height); + cursor += sz.height + mb; + // A trailing in-paragraph clear does not stretch this paragraph's + // box; it pushes the flow that follows it below the cleared float. + // Advance the cursor to that floor so the next block (a callout) + // starts below the float while this paragraph hugs its text. + if let Some(f) = clear_floor { + let f_rel = (f - content_y).max(0.0); + if f_rel > cursor { + cursor = f_rel; + } + } + continue; + } + + // Block container / image / slot / latex / break: compute the + // horizontal lane from the float table so blocks avoid overlapping + // with left/right floats (Java LytFloatAwareBlock behavior). + // CSS-preposed margin: advance past the top margin first, so the lane + // query and the block box both start at the margin box top. + cursor += mt; + let by = content_y + cursor; + let (lane_x, lane_w, lane_y) = + compute_lane(by, content_x, content_w, &float_table); + let (w, h, _sub) = build_subtree( + &mut taffy, + idx, + &flat_nodes, + font_system, + &mut glyph_acc, + justify, + visual_scale, + &mut abs_positions, + &mut sizes, + lane_x, + lane_y, + Some(lane_w), + ); + sizes[idx] = (w, h); + if let Some(c) = fb.style().map(|s| s.clear()) { + if c != 0 { + let mut cleared: f32 = 0.0; + for f in &float_table { + let side_match = + c == 3 || (c == 1 && !f.right) || (c == 2 && f.right); + if side_match { + cleared = cleared.max(f.y + f.h); + } + } + let cleared_rel = (cleared - content_y).max(0.0); + if cleared_rel > cursor { + cursor = cleared_rel; + } + } + } + // Advance cursor past the block. If compute_lane pushed the block + // down (lane_y > by), account for the gap; if CSS clear already + // pushed beyond that, respect the clear. + cursor = (lane_y - content_y).max(cursor) + h + mb; + } + + // Inline post-pass: anchor inline blocks at their parley InlineBox + // positions and grow lines vertically per their align modes. + inline_post_pass(&flat_nodes, &mut glyph_acc, &mut abs_positions, &mut sizes); + + // List-marker pass. + // Java only declares the data (ListMarkerData text plus style); Rust shapes the + // marker string through the same shaping pipeline as body text and positions it: + // * the vertical baseline is the content first-line baseline, taken from the first + // content child that has glyph content, with the fallback described in the + // list_marker_pass documentation; + // * marker glyphs are appended to the list item node's GlyphAccum as ordinary + // glyph quads, travelling the same rasterize/emit path as paragraph glyphs, + // with no new primitive type. + // The horizontal right alignment, with the ink right edge on the list item's own + // left edge, happens at emit time, because it needs the rasterized ink width. + // This must run after inline_post_pass, so content paragraph baselines already carry + // the line growth shift, and before the rasterize pre-pass, so the marker glyphs + // reach quad_cache. + let marker_anchors = list_marker_pass(font_system, &flat_nodes, &mut glyph_acc, &abs_positions); + + // Italic→atom boundary pre-pass + // Rasterize every paragraph once, apply the text boundary correction + // then the atom boundary correction (which shifts atom abs_positions), + // and cache the corrected quads for the emit loop below. The pre-pass MUST + // run before any FlatLayout is emitted: the atom boundary correction shifts + // the ATOM's layout position, and atom nodes can precede their paragraph in + // the flat index order (paragraph index < atom index holds for the Java + // serializer, but the contract must not depend on it). + let mut quad_cache: HashMap< + usize, + (Vec, Vec<(u64, u32, u32, Vec)>), + > = HashMap::new(); + for (i, acc) in glyph_acc.iter() { + if acc.glyphs.is_empty() { + continue; + } + let span_styles = span_style_table(&flat_nodes[*i]); + let base_italic = flat_nodes[*i] + .text() + .and_then(|t| t.style()) + .map(|s| s.italic()) + .unwrap_or(false); + let (mut quads, new_bitmaps) = + crate::parley_text::rasterize_out_glyphs(&acc.glyphs, render_scale); + // Rejection fix: the text boundary pass receives this paragraph's + // markers, and when an atom lies between an italic and an upright boundary, its + // pen falling between the two runs, that boundary is skipped so it cannot + // double-apply alongside the atom correction's pass 2 tail shift of the same + // upright quads. + apply_italic_boundary_correction(&mut quads, &acc.markers, &span_styles, base_italic, shear_k); + apply_italic_atom_boundary_correction( + &mut quads, + &acc.markers, + &flat_nodes[*i], + &sizes, + &mut abs_positions, + &span_styles, + base_italic, + shear_k, + ); + quad_cache.insert(*i, (quads, new_bitmaps)); + } + + // Content height = cursor plus any trailing float that extends below it. + let mut total_height = content_y + cursor; + for f in &float_table { + total_height = total_height.max(f.y + f.h); + } + + // ── Collect results ── + let mut fbb = FlatBufferBuilder::with_capacity(4096); + let mut flat_layout_offsets: Vec> = Vec::new(); + let mut glyph_run_offsets: Vec> = Vec::new(); + let mut decoration_offsets: Vec> = Vec::new(); + + let mut bitmap_keys: Vec = Vec::new(); + let mut bitmap_data: Vec<(u32, u32, Vec)> = Vec::new(); + let mut bitmap_index: std::collections::HashSet = std::collections::HashSet::new(); + + for (i, _fb_node) in flat_nodes.iter().enumerate() { + let (x, y) = abs_positions[i]; + let (w, h) = sizes[i]; + + flat_layout_offsets.push(FlatLayout::create( + &mut fbb, + &FlatLayoutArgs { + x, + y, + w, + h, + order: 0, + }, + )); + + if let Some(acc) = glyph_acc.remove(&i) { + let span_styles = span_style_table(&flat_nodes[i]); + // Base (span-uncovered) style: a single-style paragraph carries its + // color AND italic on TextData.style, not on any span. Reads both + // so GlyphRun.shear falls back to the base italic: a fully-italic + // paragraph (base style italic, no span override) must shear + // (re-applied on the Rust side). + // A list item marker node has no TextData, so its run color and + // shear come from ListMarkerData.style; Java declares COL_MARKER gray with a + // regular body style. + let base_style = flat_nodes[i].text().and_then(|t| t.style()); + let marker_style = flat_nodes[i].list_marker().and_then(|m| m.style()); + let base_color = marker_style + .map(|s| s.color()) + .or_else(|| base_style.map(|s| s.color())) + .unwrap_or(0xFFFFFFFF); + let base_italic = marker_style + .map(|s| s.italic()) + .or_else(|| base_style.map(|s| s.italic())) + .unwrap_or(false); + // The boundary pre-pass already rasterized + applied both boundary + // corrections (text and atom) to the cached quads. + let (mut quads, new_bitmaps) = quad_cache.remove(&i).unwrap_or_default(); + // Right-aligned marker placement. The ink right edge lands + // exactly on this level's text line, whose target is the list item's own + // left edge (abs_positions[i].x = that nesting level's document text line: + // L1=14, L2=24, L3=34), so the whole box hangs into the left margin. x is + // the list item's absolute x and the quads are list-item relative, so a + // single dx translation brings the ink right edge onto x. The anchor now + // only supplies run ownership and the vertical baseline (see + // list_marker_pass) and no longer decides the horizontal position. Only the + // marker node's quads are shifted, and that node's accumulator holds marker + // glyphs alone, because a list item is a container with no body text, so + // content geometry is untouched. + let marker_anchor = marker_anchors.get(&i).copied(); + if marker_anchor.is_some() { + let target = x; // the list item's own left edge = this level's text line + let max_right = quads.iter().map(|q| q.x + q.w).fold(0.0f32, f32::max); + let dx = target - (x + max_right); + if dx.abs() > 1e-4 { + for q in quads.iter_mut() { + q.x += dx; + } + } + } + for (key, bw, bh, rgba) in new_bitmaps { + if bitmap_index.insert(key) { + bitmap_keys.push(key); + bitmap_data.push((bw, bh, rgba)); + } + } + let mut groups: std::collections::BTreeMap< + u32, + (f32, Vec>), + > = Default::default(); + for q in quads { + let e = groups.entry(q.span_index).or_insert_with(|| (q.baseline, Vec::new())); + e.1.push(PlacedGlyph::create( + &mut fbb, + &PlacedGlyphArgs { + bitmap_key: q.bitmap_key, + x: x + q.x, + y: y + q.y, + w: q.w, + h: q.h, + start: 0, + end: 0, + line_index: q.line_index, + // Baseline round-trip, following the + // divide-by-scale-then-truncate convention; q.baseline is + // already truncated, and adding the node's absolute y gives + // document coordinates. + baseline: y + q.baseline, + }, + )); + } + for (si, (run_baseline, placed_offsets)) in groups { + if placed_offsets.is_empty() { + continue; + } + let (argb, shear) = span_styles + .get(si as usize) + .map(|s| (s.color, s.italic)) + .unwrap_or((base_color, base_italic)); + let glyphs_vec = fbb.create_vector(&placed_offsets); + // The marker run attaches to the content anchor child + // (node_index = anchor) rather than to the list item container, because + // Java's runsByNode only dispatches a glyph run to a GlyphRunHolder + // (LytParagraph). The content paragraph is that holder, so the marker + // renders naturally with the quad stream through no special path. The + // list item container itself is not a holder. + let run_node_index = marker_anchor.unwrap_or(i) as u32; + glyph_run_offsets.push(GlyphRun::create( + &mut fbb, + &GlyphRunArgs { + node_index: run_node_index, + glyphs: Some(glyphs_vec), + argb, + // The run-level baseline is the first glyph's + // baseline, in absolute document coordinates and following the + // divide-by-scale-then-truncate convention, for line + // anchoring. + baseline: y + run_baseline, + shear, + }, + )); + } + emit_decorations( + &acc.glyphs, + &span_styles, + i as u32, + x, + y, + &mut fbb, + &mut decoration_offsets, + ); + // Emit separator-line window (kind=3) for heading paragraphs. + // The rect spans the full float-compressed line width, not just + // the glyph extents: Java LytHeading draws the themed separator + // across this interval. + if let Some((x_off, line_width)) = acc.last_line_window { + if flat_nodes[i].text().map(|t| t.separator()).unwrap_or(false) { + decoration_offsets.push(DecorationRect::create( + &mut fbb, + &DecorationRectArgs { + node: i as u32, + x: x + x_off, + y: 0.0, + w: line_width, + h: 0.0, + argb: 0, + kind: 3, + }, + )); + } + } + } + } + + let nodes_vec = fbb.create_vector(&flat_layout_offsets); + let glyph_runs_vec = fbb.create_vector(&glyph_run_offsets); + let decorations_vec = fbb.create_vector(&decoration_offsets); + + let mut bitmap_offsets: Vec> = Vec::new(); + for (i, (bw, bh, rgba)) in bitmap_data.iter().enumerate() { + let rgba_vec = fbb.create_vector(rgba); + bitmap_offsets.push(GlyphBitmap::create( + &mut fbb, + &GlyphBitmapArgs { + key: bitmap_keys[i], + w: *bw, + h: *bh, + rgba: Some(rgba_vec), + }, + )); + } + let bitmaps_vec = fbb.create_vector(&bitmap_offsets); + + let debug_info_str = fbb.create_string(&format!( + "total_height={} nodes={} top={} floats={}", + total_height, + flat_nodes.len(), + top_seq.len(), + float_table.len(), + )); + + let result = LayoutResult::create( + &mut fbb, + &LayoutResultArgs { + nodes: Some(nodes_vec), + glyph_runs: Some(glyph_runs_vec), + bitmaps: Some(bitmaps_vec), + decorations: Some(decorations_vec), + content_height: total_height, + debug_info: Some(debug_info_str), + }, + ); + + fbb.finish(result, None); + fbb.finished_data().to_vec() +} + +/// Lay out one flat node and its descendants as an isolated taffy subtree, +/// returning the node's size and the list of flat indices it covers. Absolute +/// positions are written relative to `(base_x, base_y)`. Paragraphs inside the +/// subtree are measured at full width (transition: the pusher's float context +/// does not yet recurse into block subtrees). +fn build_subtree( + taffy: &mut TaffyTree, + idx: usize, + flat_nodes: &[FlatNode], + font_system: &mut GuideFontSystem, + glyph_acc: &mut HashMap, + justify: bool, + visual_scale: f32, + abs_positions: &mut Vec<(f32, f32)>, + sizes: &mut Vec<(f32, f32)>, + base_x: f32, + base_y: f32, + known_w: Option, +) -> (f32, f32, Vec) { + let mut node_id_of: Vec> = vec![None; flat_nodes.len()]; + let mut sub: Vec = Vec::new(); + + fn build( + taffy: &mut TaffyTree, + idx: usize, + flat_nodes: &[FlatNode], + node_id_of: &mut Vec>, + sub: &mut Vec, + ) -> NodeId { + sub.push(idx); + let fb = &flat_nodes[idx]; + let style = fb + .style() + .map(|s| flat_style_to_taffy(&s)) + .unwrap_or_default(); + let nt = fb.node_type(); + let has_children = fb.children().map_or(false, |c| !c.is_empty()); + if !has_children || nt == 1 { + let id = taffy + .new_leaf_with_context( + style, + NodeContext { + flat_index: idx, + node_type: nt as u8, + }, + ) + .expect("leaf"); + node_id_of[idx] = Some(id); + return id; + } + let child_idxs: Vec = fb + .children() + .unwrap() + .iter() + .map(|ci| ci as usize) + .collect(); + let child_ids: Vec = child_idxs + .iter() + .map(|ci| build(taffy, *ci, flat_nodes, node_id_of, sub)) + .collect(); + let id = taffy + .new_with_children(style, &child_ids) + .expect("container"); + if nt == 1 { + let _ = taffy.set_node_context( + id, + Some(NodeContext { + flat_index: idx, + node_type: nt as u8, + }), + ); + } + node_id_of[idx] = Some(id); + id + } + + let root_id = build(taffy, idx, flat_nodes, &mut node_id_of, &mut sub); + + let mut measure_fn = create_measure_closure(font_system, flat_nodes, glyph_acc, justify, visual_scale); + let avail = Size { + width: known_w + .map(AvailableSpace::Definite) + .unwrap_or(AvailableSpace::MaxContent), + height: AvailableSpace::MaxContent, + }; + taffy + .compute_layout_with_measure(root_id, avail, &mut measure_fn) + .expect("subtree layout"); + + fn read( + taffy: &TaffyTree, + idx: usize, + flat_nodes: &[FlatNode], + node_id_of: &[Option], + abs_positions: &mut Vec<(f32, f32)>, + sizes: &mut Vec<(f32, f32)>, + parent_abs: (f32, f32), + ) { + let id = node_id_of[idx].expect("node id"); + let l = taffy.layout(id).expect("layout"); + let abs = (parent_abs.0 + l.location.x, parent_abs.1 + l.location.y); + abs_positions[idx] = abs; + sizes[idx] = (l.size.width, l.size.height); + if let Some(ch) = flat_nodes[idx].children() { + for ci in ch.iter() { + read(taffy, ci as usize, flat_nodes, node_id_of, abs_positions, sizes, abs); + } + } + } + + let rl = taffy.layout(root_id).expect("root layout"); + let root_abs = (base_x + rl.location.x, base_y + rl.location.y); + abs_positions[idx] = root_abs; + sizes[idx] = (rl.size.width, rl.size.height); + if let Some(ch) = flat_nodes[idx].children() { + for ci in ch.iter() { + read( + taffy, + ci as usize, + flat_nodes, + &node_id_of, + abs_positions, + sizes, + root_abs, + ); + } + } + + (rl.size.width, rl.size.height, sub) +} + +/// Inline post-pass: for every text node with inline-block markers, anchor +/// each block at its marker and grow the lines vertically per the block's +/// align mode. Parley's InlineBox already accounts block widths in pen +/// positions, so no glyph kerning shifts are needed: only the vertical +/// handling, mirroring the legacy layout's per-line box growth: a line +/// holding blocks grows by the space they need above the baseline and below +/// the line, pushing later lines down (the paragraph's measured height +/// already reserves the total; see measure.rs). +fn inline_post_pass( + flat_nodes: &[FlatNode], + glyph_acc: &mut HashMap, + abs_positions: &mut Vec<(f32, f32)>, + sizes: &mut Vec<(f32, f32)>, +) { + use crate::measure::marker_needs; + + for (i, acc) in glyph_acc.iter_mut() { + if acc.markers.is_empty() && acc.float_anchors.is_empty() { + continue; + } + let refs = match flat_nodes[*i].text().and_then(|t| t.inline_blocks()) { + Some(v) => v, + None => continue, + }; + let (node_x, node_y) = abs_positions[*i]; + let content_w = sizes[*i].0; + + // 1) Per-line growth from regular inline markers. + let mut by_line: std::collections::BTreeMap = Default::default(); + for (mi, m) in acc.markers.iter().enumerate() { + if mi >= refs.len() { + break; + } + let r = refs.get(mi); + let bh = sizes[r.node() as usize].1; + // The ink-box anchor arm, with the ink bottom edge + // on the baseline, wins over the align mode; the align=2 centering hack is + // retired, so leftover values fall through to the default arm. + let ink_bp = crate::measure::ink_bottom_px(&r, bh); + let (na, nb) = marker_needs(m, bh, r.align(), r.param(), ink_bp); + let e = by_line.entry(m.line_index).or_default(); + e.0 = e.0.max(na); + e.1 = e.1.max(nb); + } + let grown: Vec<(usize, f32, f32)> = by_line + .iter() + .map(|(l, (na, nb))| (*l, *na, *nb)) + .collect(); + let shift_of = |line: usize| -> f32 { + let mut s = 0.0; + for (l, na, nb) in &grown { + if *l < line { + s += na + nb; + } else { + if *l == line { + s += na; + } + break; + } + } + s + }; + if !grown.is_empty() { + for g in acc.glyphs.iter_mut() { + let s = shift_of(g.line_index); + g.y += s; + } + for m in acc.markers.iter_mut() { + let s = shift_of(m.line_index); + m.baseline_y += s; + m.line_top += s; + } + } + + // 2) Anchor regular inline blocks per their alignment mode. + // Markers are paired with refs by document order (both exclude floats). + let mut reg_mi = 0usize; + for ri in 0..refs.len() { + let r = refs.get(ri); + if r.align() >= 3 { + continue; + } + if reg_mi >= acc.markers.len() { + break; + } + let m = &acc.markers[reg_mi]; + let ci = r.node() as usize; + let (_, bh) = sizes[ci]; + // The atomic ink box's bottom edge is aligned with + // the text baseline, that is, the ink bottom edge equals the baseline. + // The align=2 centering hack is retired, so a leftover align=2 without an + // ink box falls through to the default arm, where the block bottom sits 2px + // below the baseline instead of being centered. + // Review follow-up (yOffset consumption restored): param = yOffset, which + // inlineYOffsetOverride puts on the wire unchanged through the serializer, + // takes part in the vertical placement again: block top = baseline - + // ink_bottom_px + param, or block top = baseline + 2 - bh + param in the + // default arm. The sign convention matches the retired align=2 centering arm + // top_off=(line_height-bh)/2+param, so a positive value moves the block down. + let ink_bp = crate::measure::ink_bottom_px(&r, bh); + let top = match ink_bp { + Some(ib) => m.baseline_y - ib + r.param(), + None => match r.align() { + 1 => m.baseline_y - r.param(), + // Retired: the align=2 centering hack, which used to + // center an item icon on the line. Its consumer is gone and the hack + // itself, the align=2 arm of marker_needs, is retired, so leftover + // align=2 values fall through to the default arm with the block + // bottom 2px below the baseline. The review follow-up restored param + // (yOffset) consumption in that arm, where block top = baseline + 2 - + // bh + param. + _ => m.baseline_y + 2.0 - bh + r.param(), + }, + }; + abs_positions[ci] = (node_x + m.pen_x, node_y + top); + reg_mi += 1; + } + + // 3) Anchor float-aligned inline blocks. + // float_anchors contains (node_index, paragraph-relative-y) in order. + // Pair with InlineBlockRef entries that have align=3 (float-left) or + // align=4 (float-right). + let mut float_i = 0usize; + for ri in 0..refs.len() { + let r = refs.get(ri); + if r.align() < 3 { + continue; + } + if float_i >= acc.float_anchors.len() { + break; + } + let (_, para_rel_y) = acc.float_anchors[float_i]; + let ci = r.node() as usize; + let (bw, _bh) = sizes[ci]; + // Float at paragraph edge; margins are already in sizes. + let x = if r.align() == 3 { + node_x + } else { + node_x + content_w - bw + }; + abs_positions[ci] = (x, node_y + para_rel_y); + float_i += 1; + } + } +} + +/// The list item's shared-gutter marker becomes an atom. +/// +/// Java declares the data (ListMarkerData text plus style) and Rust computes: +/// * shaping through the same pipeline as body text: parley `layout_styled` +/// without wrapping shapes the marker string; +/// * the vertical baseline equals the content first-line baseline: the first +/// glyph's pen y of the first content child that has glyphs, usually the first +/// paragraph, plus that child's absolute y. This is the same baseline as the body +/// text, since per-span baseline_shift and the inline_post_pass line growth are both +/// already reflected in the final glyph positions; +/// * marker glyphs are appended to that list item node's own GlyphAccum as ordinary +/// glyph quads and travel exactly the same rasterize/emit path as paragraph +/// glyphs, with no new primitive type and no Java special case; +/// * the marker takes no part in wrapping and changes no content-child geometry: it +/// writes no layout quantity beyond sizes/abs_positions, so content block bounds +/// are byte-identical, which is the hard gate. +/// +/// The marker glyph's line_index is rewritten to the sentinel `usize::MAX`, because the +/// content paragraph's `firstLineBoundsFromGlyphs` in Java filters glyphs by line 0 to +/// take the first-line bbox and the marker must not pollute its minX/maxY. Rendering +/// and hit-testing do not filter by line, so the marker still draws normally; +/// hit-testing may include the marker, which is registered and gets no special case. +/// +/// The horizontal right alignment, with the marker's ink right edge on the list item's +/// own left edge abs_positions[i].x, is done at emit time, because it needs the +/// rasterized ink width; this pass only performs shaping and vertical placement. The +/// anchor supplies only the vertical baseline and this pass's return value, which is +/// the run ownership used at emit. +/// Return value: a map from the marker node's flat index to the content anchor child's +/// flat index, used at emit time for the right-aligned placement and run ownership. The +/// marker run attaches to the content paragraph, and Java's runsByNode dispatches by +/// node_index, where the content paragraph is the GlyphRunHolder, so there is no +/// special path. +fn list_marker_pass( + font_system: &mut GuideFontSystem, + flat_nodes: &[FlatNode], + glyph_acc: &mut HashMap, + abs_positions: &[(f32, f32)], +) -> HashMap { + use crate::parley_text::collect_layout; + let mut anchors: HashMap = HashMap::new(); + for (i, fb) in flat_nodes.iter().enumerate() { + let Some(md) = fb.list_marker() else { continue }; + let text = md.text().unwrap_or(""); + if text.is_empty() { + continue; + } + // The content anchor is the first content child that has glyph content, which + // gives both the start of the list item's content block and the authoritative + // first-line baseline. The cascade order, as corrected, exhausts the item's own + // direct content lines before descending into descendants: + // ① a direct child has glyphs, so the first line is a text paragraph and + // glyphs[0].y is the first-line baseline; + // ② a direct child has inline atoms only, with non-empty markers and empty + // glyphs, so the first line is a pure atom line and its + // markers[0].baseline_y is the first-line baseline. An atom line always has + // empty glyphs, so the anchor comes from the inline atom's own marker; + // ③ the first_glyph_descendant DFS, for a direct child with no content whose + // content sits in grandchildren, which happens only with nested lists such + // as the real-corpus AE2 getting-started items 13 and 22. + // The marker is skipped only when the whole subtree has no glyph content, which + // keeps the documented fallback: content geometry is unaffected and that marker + // is simply not generated. + // Step ② must precede step ③. For an item whose first line is pure atoms + // followed by a nested list, running ③ first would walk the subtree DFS and + // anchor the marker to the nested child's text line, which would put two markers on one row, + // and ② would never get a turn. + let anchor = fb.children().and_then(|ch| { + (0..ch.len()) + .map(|k| ch.get(k) as usize) + .find(|&c| glyph_acc.get(&c).map_or(false, |a| !a.glyphs.is_empty())) + .or_else(|| { + (0..ch.len()) + .map(|k| ch.get(k) as usize) + .find(|&c| { + glyph_acc + .get(&c) + .map_or(false, |a| a.glyphs.is_empty() && !a.markers.is_empty()) + }) + }) + .or_else(|| { + (0..ch.len()) + .map(|k| ch.get(k) as usize) + .find_map(|c| first_glyph_descendant(flat_nodes, glyph_acc, c)) + }) + }); + let Some(anchor) = anchor else { + continue; + }; + let baseline_abs = { + let a = glyph_acc + .get(&anchor) + .expect("list marker anchor acc must exist"); + if a.glyphs.is_empty() { + // A content child whose first line holds inline atoms only has no + // glyphs, so anchor to its first inline atom marker's baseline, which is + // that line's baseline with the line growth shift already included. + abs_positions[anchor].1 + a.markers[0].baseline_y + } else { + abs_positions[anchor].1 + a.glyphs[0].y + } + }; + let st = md.style().unwrap_or_else(|| unreachable!("ListMarkerData.style declared")); + let scaled = st.font_size() * st.font_scale(); + // The same shaping pipeline as body text, layout_styled, matching measure and + // emitText: no wrapping, since a marker is a single line, and no baseline shift, + // because there are no spans. + let layout = font_system + .parley + .layout_styled( + text, + scaled, + 1.55, + st.bold(), + st.italic(), + None, + // Marker style font_family, shaped through the same pipeline as body + // text; Java declares the list item's body font. + st.font_family(), + ); + let (glyphs, _markers, _max_x, _content_h, _clear_floor, _last_window) = + collect_layout(&layout, &[], 0.0, 0.0, f32::MAX, &[]); + if glyphs.is_empty() { + continue; + } + // Vertical placement: the marker baseline equals the content first-line + // baseline, in list-item relative coordinates, since emit adds the list item's + // absolute y to every PlacedGlyph. + // + // Rejected-then-fixed behavior, where the marker dropped by one line: the + // marker's own glyph y already includes its single-line paragraph's baseline + // offset in marker-local coordinates, so glyphs[0].y is line 0's pen y, about + // the in-line baseline distance, measured at 12.0 logical px or one line height. + // The first version did `g.y += baseline_off`, which moved the marker as a whole + // above baseline_off and then stacked its own baseline distance on top, the + // one-line misalignment marker_top - item_top measured +19 against a + // baseline delta of +6.5. The correct shift is baseline_off minus the marker's + // own baseline, which lands the marker baseline exactly on the content first-line + // baseline. Every glyph of a single-line marker shares one baseline, so + // g.y - marker_baseline keeps the relative y_offset differences inside the font. + let marker_baseline = glyphs[0].y; + let shift = (baseline_abs - abs_positions[i].1) - marker_baseline; + let acc = glyph_acc.entry(i).or_default(); + for mut g in glyphs { + g.y += shift; + g.line_top += shift; + // Sentinel line index (see the function documentation), isolating the marker + // from the body visual line. + g.line_index = usize::MAX; + acc.glyphs.push(g); + } + anchors.insert(i, anchor); + } + anchors +} + +/// Last cascade step: search the subtree depth-first from `idx` and return the first +/// descendant child that has glyphs. This applies when a list item contains only a +/// nested list, with the content in a grandchild paragraph, and its direct children +/// have neither glyphs nor inline atom markers. The walk descends only after the item's +/// own direct content lines are exhausted, as described in list_marker_pass. Returns +/// None when the whole subtree has no glyphs, in which case list_marker_pass keeps its +/// documented fallback and skips the marker. +fn first_glyph_descendant( + flat_nodes: &[FlatNode], + glyph_acc: &HashMap, + idx: usize, +) -> Option { + if glyph_acc.get(&idx).map_or(false, |a| !a.glyphs.is_empty()) { + return Some(idx); + } + let ch = flat_nodes[idx].children()?; + (0..ch.len()) + .map(|k| ch.get(k) as usize) + .find_map(|c| first_glyph_descendant(flat_nodes, glyph_acc, c)) +} + +/// Overhang compensation at an italic-to-upright text +/// boundary, with TeX \/ semantics. +/// +/// The renderer's synthetic shear (GuideRenderEngine.emitGlyphQuads) pushes glyph tops +/// to the right along `xTop = x + K × (shearBaseY - y)`, where shearBaseY is the +/// largest quad bottom over all glyphs of that run. When an italic run sits directly +/// against an upright run on one line, the last glyph's top intrudes to the right into +/// the next run. This function inserts a one-shot gap at every italic-to-upright text +/// boundary: +/// correction = max(0, K × (shearBase - lastItalic.y) - inkGap) +/// where shearBase = max(y + h) over all quads of that italic run across lines, +/// matching the renderer's convention, and inkGap = firstUpright.x - +/// (lastItalic.x + lastItalic.w) is the existing gap between the two runs' ink when +/// nothing is sheared. The correction applies only to all glyphs on this line of the +/// upright run immediately following the boundary, as a uniform shift, and never moves +/// the advance inside a run. Multiple italic-to-upright boundaries on one line each +/// insert their own one-shot gap and never accumulate, the same cascading semantics as +/// An upright-to-italic boundary, the inside of a run and purely upright text are +/// all left alone. +/// +/// Rejection fix, from a double-application analysis and +/// statically confirmed: in the italic-to-atom-to-upright configuration the text +/// boundary pass is blind to the atom, because an atom is not a quad. The last italic +/// glyph and the next upright glyph are adjacent in x order, so this function still +/// computes a correction for what it sees as a direct text boundary, and its `inkGap` +/// already contains the atom's whole advance (`inkGap = naturalGap + atomAdvance`). +/// Meanwhile pass 2 of the atom boundary correction +/// (apply_italic_atom_boundary_correction) applies `c_23 = max(0, S - naturalGap)` to +/// the tail of the upright quads after the atom on the same line. When both are +/// positive, and `S > inkGap` implies `S > naturalGap`, the tail is pushed by +/// `c_23 + correction_22` instead of a single `c_23`. The fix: this function receives +/// the paragraph's markers and skips the boundary when an atom marker's pen falls +/// between the two runs on this line. The atom ink box advance already carries the tail +/// past the overhang front, and `c_23 ≥ correction_22` always holds: the first upright +/// glyph after the atom pen, the pivot, has a left edge `first.x ≥ pivot + ink_left_px`, +/// since a valid ink box has inkRight ≥ inkLeft and the following glyph's left bearing +/// is non-negative, so first.x ≥ pivot + atomAdvance ≥ pivot + ink_left_px. It follows +/// that inkGap = first.x - (last.x + last.w) ≥ naturalGap and S - inkGap ≤ +/// S - naturalGap, so after both take max(0, ·) the relation correction_22 ≤ c_23 +/// holds. Valid ink boxes from IconMetrics together with ordinary glyphs always satisfy +/// those premises, which makes the text boundary correction purely redundant in this +/// configuration. +/// +/// The data source is the rasterized quads, the same geometry the Java renderer uses, +/// so the correction is exact for the actual drawn output. +fn apply_italic_boundary_correction( + quads: &mut Vec, + markers: &[crate::measure::InlineMarker], + span_styles: &[SpanStyleInfo], + base_italic: bool, + shear_k: f32, +) { + if quads.is_empty() || shear_k <= 0.0 { + return; + } + let italic_of = |si: u32| -> bool { + span_styles + .get(si as usize) + .map(|s| s.italic) + .unwrap_or(base_italic) + }; + // All-upright paragraph: geometry zero-change, output identical. + if !quads.iter().any(|q| italic_of(q.span_index)) { + return; + } + + // Per-span shear baseline over the whole span and all its lines; mirrors the + // renderer's per-DrawGlyphRun max(g.y + g.h). + let mut shear_base: std::collections::HashMap = Default::default(); + for q in quads.iter() { + let e = shear_base.entry(q.span_index).or_insert(0.0f32); + *e = e.max(q.y + q.h); + } + + // Per-quad rightward shift (document units); 0 = untouched. + let mut shifts: Vec = vec![0.0; quads.len()]; + + // Group quad indices by visual line; walk each line in pen order. + let mut by_line: std::collections::BTreeMap> = Default::default(); + for (i, q) in quads.iter().enumerate() { + by_line.entry(q.line_index).or_default().push(i); + } + + for (line, idxs) in by_line.iter_mut() { + idxs.sort_by(|&a, &b| { + quads[a] + .x + .partial_cmp(&quads[b].x) + .unwrap_or(std::cmp::Ordering::Equal) + }); + let n = idxs.len(); + let mut i = 0usize; + while i < n { + let si = quads[idxs[i]].span_index; + let mut j = i; + while j < n && quads[idxs[j]].span_index == si { + j += 1; + } + // [i, j) is one contiguous span run on this line. Only a + // italic→upright flip at the run's right edge is a boundary + // (upright→italic and run interiors are untouched). + if j < n { + let next_si = quads[idxs[j]].span_index; + if italic_of(si) && !italic_of(next_si) { + let last = idxs[j - 1]; + let first = idxs[j]; + // Rejection fix: skip the boundary when an inline atom + // separates it, that is italic→atom→upright with the atom marker's + // pen falling between the two runs on this line. The text boundary + // pass is blind to the atom, so the inkGap it measures already + // contains the atom advance, while the atom correction's pass 2 + // applies c_23 to the same upright quads, and firing both would + // double-apply. The atom ink box advance already carries the tail past + // the overhang front, and c_23 ≥ correction_22 always holds, because + // the first upright glyph after the atom pen has first.x ≥ pivot + + // ink_left_px: a valid ink box has inkRight ≥ inkLeft and the + // following glyph's left bearing is non-negative, which valid + // IconMetrics ink boxes plus ordinary glyphs satisfy. The text + // boundary correction is therefore purely redundant in this + // configuration. + let atom_between = markers.iter().any(|m| { + m.line_index as u32 == *line + && quads[last].x < m.pen_x + && m.pen_x < quads[first].x + }); + if !atom_between { + let sb = shear_base[&si]; + let shear_shift = shear_k * (sb - quads[last].y); + let ink_gap = quads[first].x - (quads[last].x + quads[last].w); + let correction = (shear_shift - ink_gap).max(0.0); + if correction > 0.0 { + // One-shot insertion: shift every glyph of the following + // upright run on this line by `correction`. Per-boundary + // (no accumulation across multiple boundaries). + let mut k = j; + while k < n && quads[idxs[k]].span_index == next_si { + shifts[idxs[k]] = shifts[idxs[k]].max(correction); + k += 1; + } + } + } + } + } + i = j; + } + } + + for (i, d) in shifts.iter().enumerate() { + if *d > 0.0 { + quads[i].x += *d; + } + } +} + + /// The italic-to-atom boundary overhang correction, with + /// TeX \/ semantics, applied to the atom's layout position rather than to a glyph + /// quad shift, since the text boundary pass proved that an emit-level quad shift + /// cannot reach an atom. + /// + /// The renderer's synthetic shear pushes an italic run's last glyph top to the + /// right along `xTop = x + K × (shearBaseY - y)`. When an italic run sits directly + /// against an inline atom, an icon InlineBox, that overhang intrudes into the atom + /// ink box's left edge. This function inserts a one-shot gap at such a boundary, + /// with the same semantics as the text boundary correction + /// correction = max(0, K × (shearBase - lastQuadTop) - naturalGap) + /// where shearBase = max(y + h) over all quads of that italic span across lines, + /// matching the convention of the text boundary pass and of the renderer, and + /// naturalGap = (marker.pen_x + ink_left_px) - (lastQuad.x + lastQuad.w) is the + /// existing gap from the last italic glyph's ink right edge to the atom ink box's + /// left edge, counting the box's left side bearing ink_left, because the atoms here + /// are character-level atoms. The correction applies to the atom's layout position + /// (abs_positions.x, the Rust placement layer) and shifts everything after the atom + /// on the same line consistently, both the quads at x ≥ the atom pen_x and the + /// block positions of later markers, keeping the pen stream consistent. + /// Each boundary fires once and boundaries never accumulate: every correction is + /// computed from natural geometry, the quads and pens before any insertion, and is + /// never re-derived from earlier insertions. + /// + /// An upright-to-atom boundary is left alone, and an atom-to-text boundary needs no + /// correction, because the overhang lies to the right of the italic glyph and by + /// nature only affects what follows it. Relation to the text boundary pass under the + /// Rejection fix: in the italic-to-atom-to-upright configuration the text + /// boundary pass is blind to the atom and used to apply correction_22 as if this + /// were a direct text boundary containing the atom advance, double-applying along + /// with this pass's tail shift of the same upright quads. That is fixed: the text + /// boundary pass now receives the markers and skips the boundary when an atom pen + /// falls between the two runs (see apply_italic_boundary_correction), leaving this + /// boundary to the single c_23 issued here. + /// The safety of that skip rests on `c_23 ≥ correction_22`: the first upright glyph + /// after the atom pen, the pivot, has a left edge `first.x ≥ pivot + ink_left_px`, + /// because a valid ink box has inkRight ≥ inkLeft and the following glyph's left + /// bearing is non-negative, so first.x ≥ pivot + atomAdvance ≥ pivot + ink_left_px. + /// Both hold for the valid ink boxes IconMetrics produces together with ordinary + /// glyphs, hence gap_22 ≥ natural_gap, and after both take max(0, ·) the relation + /// correction_22 ≤ c_23 follows. Even if both fired, the net displacement would not + /// change, so the text boundary correction is purely redundant here. +fn apply_italic_atom_boundary_correction( + quads: &mut Vec, + markers: &[crate::measure::InlineMarker], + node: &FlatNode, + sizes: &[(f32, f32)], + abs_positions: &mut [(f32, f32)], + span_styles: &[SpanStyleInfo], + base_italic: bool, + shear_k: f32, +) { + if shear_k <= 0.0 || markers.is_empty() || quads.is_empty() { + return; + } + let refs = match node.text().and_then(|t| t.inline_blocks()) { + Some(v) => v, + None => return, + }; + let italic_of = |si: u32| -> bool { + span_styles + .get(si as usize) + .map(|s| s.italic) + .unwrap_or(base_italic) + }; + // All-upright paragraph: geometry zero-change, output identical. + if !quads.iter().any(|q| italic_of(q.span_index)) { + return; + } + + // Per-span shear baseline over the whole span and all its lines; mirrors the text + // boundary pass. + let mut shear_base: std::collections::HashMap = Default::default(); + for q in quads.iter() { + let e = shear_base.entry(q.span_index).or_insert(0.0f32); + *e = e.max(q.y + q.h); + } + + // Per-line quad indices sorted by x (find the glyph immediately before each + // atom's pen). + let mut by_line: std::collections::BTreeMap> = Default::default(); + for (i, q) in quads.iter().enumerate() { + by_line.entry(q.line_index).or_default().push(i); + } + for (_line, idxs) in by_line.iter_mut() { + idxs.sort_by(|&a, &b| { + quads[a] + .x + .partial_cmp(&quads[b].x) + .unwrap_or(std::cmp::Ordering::Equal) + }); + } + + // Pair regular markers with their refs (skip float refs align>=3), mirroring + // inline_post_pass's document-order pairing. + let mut reg: Vec<(usize, usize)> = Vec::new(); // (marker_idx, ref_idx) + let mut mi = 0usize; + for ri in 0..refs.len() { + if refs.get(ri).align() >= 3 { + continue; + } + if mi >= markers.len() { + break; + } + reg.push((mi, ri)); + mi += 1; + } + + // Pass 1 computes every boundary's correction from NATURAL geometry, the quads and + // pens before any insertion, so boundaries never accumulate into each other's + // computation and each fires once. + // + // Pass-2 fix: record a natural-x snapshot of the whole line's quads for pass 2 to + // test tail membership against. Pass 2 shifts quads in place, so `q.x` already + // contains the accumulated displacement from earlier boundaries. If pass 2 kept + // testing the polluted `q.x >= pivot - 0.5`, the trailing glyph selected by this + // boundary in pass 1, whose natural x < pivot - 0.5, would be pushed past the + // threshold by an earlier boundary and shifted AGAIN: a single glyph + // double-applied and detached from its word body, measured as +2.36px + // for the trailing glyph of "Gold Ingo t" against its word body. The snapshot puts + // pass 2's membership test on the same geometry convention as pass 1, so the + // documented contract holds: every boundary fires once from natural geometry and + // they never accumulate. + let natural_x: Vec = quads.iter().map(|q| q.x).collect(); + let mut corrections: Vec<(usize, usize, f32)> = Vec::new(); // (marker_idx, ref_idx, c) + for &(marker_idx, ref_idx) in ® { + let m = &markers[marker_idx]; + let r = refs.get(ref_idx); + let bh = sizes[r.node() as usize].1; + let ink_left_px = r.ink_left() * bh / 16.0; // 16-cell icon space to px + let pivot = m.pen_x; + let last = by_line + .get(&(m.line_index as u32)) + .and_then(|idxs| idxs.iter().rev().copied().find(|&qi| quads[qi].x < pivot - 0.5)); + let Some(qi) = last else { continue }; + let last_q = &quads[qi]; + if !italic_of(last_q.span_index) { + continue; // upright→atom: nothing to do + } + let sb = shear_base[&last_q.span_index]; + let shear_shift = shear_k * (sb - last_q.y); + let natural_gap = (pivot + ink_left_px) - (last_q.x + last_q.w); + let c = (shear_shift - natural_gap).max(0.0); + if c > 0.0 { + corrections.push((marker_idx, ref_idx, c)); + } + } + + // Pass 2 applies in pen order: each boundary shifts the atom's layout + // position AND everything after it on the same line (consistent tail shift). + for (marker_idx, ref_idx, c) in corrections { + let r = refs.get(ref_idx); + let ci = r.node() as usize; + let m = &markers[marker_idx]; + let line = m.line_index as u32; + let pivot = m.pen_x; + // The boundary atom's own layout position, the Rust placement layer, which is + // the position Java renders from. + abs_positions[ci].0 += c; + // Tail: same-line quads at/after the atom's pen shift with it. + // Knife-edge window note: this pass's pass 1 searches for the last glyph with + // `x < pivot - 0.5` (see above), while pass 2's tail shift uses + // `x >= pivot - 0.5`, and the text boundary pass's atom skip requires + // `pen_x < quads[first].x` (see apply_italic_boundary_correction). A + // theoretical knife-edge window is squeezed between the three thresholds: a + // quad whose left edge lands in [pivot - 0.5, pivot). Pass 1 never selects it as + // this boundary's trailing glyph, so it takes no part in the correction + // computation, yet pass 2 shifts it with no precondition. If it were also the + // first upright glyph after the atom, the skip condition would fail because + // pen_x >= first.x and the double-application risk would return. Filling that + // window would need a glyph with a negative left bearing: first.x = pen_first + + // lb_first < pivot together with pen_first >= pivot + atomAdvance for a valid + // ink box of at least 1px gives lb_first < -atomAdvance < 0, meaning the glyph's + // ink reaches left past its own pen by more than the atom advance. With ordinary + // glyphs, whose left bearing is non-negative, and a valid IconMetrics ink box, + // first.x >= pivot + atomAdvance >= pivot + 1 > pivot - 0.5 and the first glyph + // lies after the pivot, so the window is always empty and the two thresholds + // are equivalent under those premises, with 0.5 serving as a floating-point + // safety margin. + // + // Pass-2 fix: membership is now tested against the natural-x snapshot recorded + // in pass 1. Pass 2 shifts quads in place in document order, so `q.x` is the + // accumulated position. Testing `q.x >= pivot - 0.5` there would shift this + // boundary's pass-1 trailing glyph, whose natural x < pivot - 0.5, a second + // time once an earlier boundary's tail shift had pushed it past the threshold: + // a single glyph double-applied and detached from its word body, + // measured at +2.36px. The snapshot puts pass 2 on the same convention as pass + // 1, so glyphs with natural x < pivot - 0.5 belong left of the boundary, taking + // no part in this boundary's shift and only accumulating earlier ones, while + // glyphs at or above the threshold belong right of it and shift with this + // boundary's atom. That makes the documented contract hold: every boundary + // fires once from natural geometry and they never accumulate. + for (qi, q) in quads.iter_mut().enumerate() { + if q.line_index == line && natural_x[qi] >= pivot - 0.5 { + q.x += c; + } + } + // Tail: same-line later atoms' layout positions shift with it. + for &(mj, rj) in ® { + if mj != marker_idx && markers[mj].line_index as u32 == line && markers[mj].pen_x >= pivot - 0.5 { + let rj_ref = refs.get(rj); + abs_positions[rj_ref.node() as usize].0 += c; + } + } + } +} + +/// Per-span style facts needed by Pass B (grouping + decorations), extracted +/// from the node's TextData.spans vector. Empty for single-style paragraphs. +struct SpanStyleInfo { + color: u32, + italic: bool, + underline: bool, + strikethrough: bool, + highlight_argb: u32, + inline_code: bool, + wavy_underline: bool, + dotted_underline: bool, +} + +fn span_style_table(node: &FlatNode) -> Vec { + let mut out = Vec::new(); + if let Some(spans) = node.text().and_then(|t| t.spans()) { + for s in spans.iter() { + let st = s.style().unwrap(); + out.push(SpanStyleInfo { + color: st.color(), + italic: st.italic(), + underline: st.underline(), + strikethrough: st.strikethrough(), + highlight_argb: st.highlight_argb(), + inline_code: st.inline_code(), + wavy_underline: st.wavy_underline(), + dotted_underline: st.dotted_underline(), + }); + } + } + out +} + +/// Emit span decoration rects (background highlights, underline, +/// strikethrough) from per-line glyph extents, in absolute document +/// coordinates. Runs after the inline post-pass, so the shaped glyphs already +/// carry final (growth-adjusted) positions. +fn emit_decorations<'a>( + glyphs: &[crate::parley_text::OutGlyph], + span_styles: &[SpanStyleInfo], + node_index: u32, + node_x: f32, + node_y: f32, + fbb: &mut FlatBufferBuilder<'a>, + out: &mut Vec>>, +) { + if span_styles.is_empty() { + return; + } + struct Extent { + min_x: f32, + max_x: f32, + baseline: f32, + line_top: f32, + line_height: f32, + } + let mut by_line: std::collections::BTreeMap<(u32, usize), Extent> = Default::default(); + for g in glyphs { + let e = by_line.entry((g.span_index, g.line_index)).or_insert(Extent { + min_x: g.x, + max_x: g.x + g.w, + baseline: g.y, + line_top: g.line_top, + line_height: g.line_height, + }); + e.min_x = e.min_x.min(g.x); + e.max_x = e.max_x.max(g.x + g.w); + } + for ((span_index, _line), e) in by_line { + let Some(st) = span_styles.get(span_index as usize) else { continue }; + let w = e.max_x - e.min_x; + if st.highlight_argb != 0 { + // Background: inline-code hugs the text run; plain highlight pads + // 1px on each side (mirrors the legacy LineTextRun geometry). + let (bx, bw) = if st.inline_code { + (e.min_x, w) + } else { + (e.min_x - 1.0, w + 2.0) + }; + out.push(DecorationRect::create( + fbb, + &DecorationRectArgs { + node: node_index, + x: node_x + bx, + y: node_y + e.line_top - 1.0, + w: bw, + h: e.line_height, + argb: st.highlight_argb, + kind: 0, + }, + )); + } + if st.underline { + out.push(DecorationRect::create( + fbb, + &DecorationRectArgs { + node: node_index, + x: node_x + e.min_x, + y: node_y + e.baseline + 1.0, + w, + h: 1.0, + argb: st.color, + kind: 1, + }, + )); + } + if st.wavy_underline { + // Wavy underline, a wave amplitude band 2px tall (Java draws the + // squiggle inside this rect). Same geometry as the underline band. + out.push(DecorationRect::create( + fbb, + &DecorationRectArgs { + node: node_index, + x: node_x + e.min_x, + y: node_y + e.baseline + 1.0, + w, + h: 2.0, + argb: st.color, + kind: 4, + }, + )); + } + if st.dotted_underline { + // Dotted underline, the same band as the underline; Java rasterizes + // the dot pattern inside the rect. + out.push(DecorationRect::create( + fbb, + &DecorationRectArgs { + node: node_index, + x: node_x + e.min_x, + y: node_y + e.baseline + 1.0, + w, + h: 1.0, + argb: st.color, + kind: 5, + }, + )); + } + if st.strikethrough { + out.push(DecorationRect::create( + fbb, + &DecorationRectArgs { + node: node_index, + x: node_x + e.min_x, + y: node_y + e.line_top + e.line_height / 2.0, + w, + h: 1.0, + argb: st.color, + kind: 2, + }, + )); + } + } +} + +/// shapeText JNI command: shape + rasterize a single styled text, returning a +/// ShapeTextResult FlatBuffer with atlas-keyed buffer-local quads and metrics. +pub fn shape_text_cmd(font_system: &mut GuideFontSystem, input_bytes: &[u8]) -> Vec { + use crate::fb::{ShapeTextInput, ShapeTextResult, ShapeTextResultArgs}; + + let input = flatbuffers::root::(input_bytes) + .expect("Invalid ShapeTextInput FlatBuffer"); + let text = input.text().unwrap_or(""); + let style = input.style().expect("ShapeTextInput.style missing"); + let render_scale = input.render_scale().max(0.25); + let max_w = if input.max_width() > 0.0 { + // Buffer is already at the scaled font size, so the width is used as-is. + Some(input.max_width()) + } else { + None + }; + + let scaled = style.font_size() * style.font_scale(); + // Italic fix: `italic` is carried as a brush + // MARKER, NOT via `StyleProperty::FontStyle`. Forwarding + // FontStyle::Italic made fontique pick a true italic face on real systems + // (Arial Italic) and swap the Latin outlines. Shaping always matches + // Normal now, so glyphs/advances are byte-identical to the upright case; + // the marker is restored into the output (`OutGlyph.italic`) for the + // boundary correction. The synthetic slant stays a Java draw-time + // transform (MC §o parity), not re-applied here, and since shaping + // selects the upright face there is no double-slant. + let layout = font_system + .parley + .layout_styled( + text, + scaled, + 1.55, + style.bold(), + style.italic(), + max_w, + // TextStyle.font_family is now active: a non-empty value becomes + // FontFamily::named, while an empty or absent one falls back to the + // SansSerif generic, leaving the default path byte for byte unchanged. + style.font_family(), + ); + let content_height = layout.height(); + let ascent = layout + .lines() + .next() + .map(|l| l.metrics().baseline - l.metrics().block_min_coord) + .unwrap_or(scaled); + // First-run real x_height/cap_height (shaped-size px, skrifa OS/2 + // sxHeight/sCapHeight scaled by font size); RunMetrics is Copy so this + // value-extension ends all borrows before collect_layout below. + let run_metrics = layout.lines().next().and_then(|l| l.runs().next()).map(|r| *r.metrics()); + let x_height = run_metrics.and_then(|m| m.x_height).unwrap_or(ascent * 0.625); + let cap_height = run_metrics.and_then(|m| m.cap_height).unwrap_or(ascent * 0.7); + let (glyphs, _markers, max_x, _content_height, _clear_floor, _last_window) = + crate::parley_text::collect_layout(&layout, &[], 0.0, 0.0, max_w.unwrap_or(f32::MAX), &[]); + let (quads, bitmaps) = crate::parley_text::rasterize_out_glyphs(&glyphs, render_scale); + + let mut fbb = flatbuffers::FlatBufferBuilder::with_capacity(4096); + let glyph_offsets: Vec> = quads + .iter() + .map(|q| { + PlacedGlyph::create( + &mut fbb, + &PlacedGlyphArgs { + bitmap_key: q.bitmap_key, + x: q.x, + y: q.y, + w: q.w, + h: q.h, + start: 0, + end: 0, + line_index: q.line_index, + // Buffer-local baseline, following the + // divide-by-scale-then-truncate convention; q.baseline is already + // truncated. + baseline: q.baseline, + }, + ) + }) + .collect(); + let glyphs_vec = fbb.create_vector(&glyph_offsets); + let bitmap_offsets: Vec> = bitmaps + .iter() + .map(|(key, w, h, rgba)| { + let rgba_vec = fbb.create_vector(rgba); + GlyphBitmap::create( + &mut fbb, + &GlyphBitmapArgs { + key: *key, + w: *w, + h: *h, + rgba: Some(rgba_vec), + }, + ) + }) + .collect(); + let bitmaps_vec = fbb.create_vector(&bitmap_offsets); + + let result = ShapeTextResult::create( + &mut fbb, + &ShapeTextResultArgs { + // Real advance, zero allowed (zero-width chars must measure 0; + // the 1px clamp belongs to Taffy node sizing only, see measure.rs). + width: max_x, + height: content_height.max(1.0), + ascent, + line_height: scaled * 1.55, + glyphs: Some(glyphs_vec), + bitmaps: Some(bitmaps_vec), + x_height, + cap_height, + }, + ); + fbb.finish(result, None); + fbb.finished_data().to_vec() +} + +#[cfg(test)] +mod tests { + use crate::parley_text::{ParleyFonts, SpanBrush}; + use crate::text::GuideFontSystem; + use crate::compute_layout; + use parley::{FontStyle, Layout}; + + /// Sum of run advances: the paragraph's shaped width, independent of + /// line wrapping. + fn total_advance(layout: &Layout) -> f32 { + layout + .lines() + .flat_map(|l| l.runs()) + .map(|r| r.advance()) + .sum() + } + + /// Build a LayoutInput FlatBuffer with a single node_type=1 paragraph. + /// `position`: 1 = pos_abs inline paragraph (lowered inline block); + /// `size_w`/`size_h`: declared px box (Some → Dimension px, None → auto). + /// `avail_width`: document width; `children_of_0`: when Some, node 0 + /// becomes a flex-column container owning the given child flat indices. + fn build_paragraph_input( + text: &str, + position: i8, + size_w: Option, + size_h: Option, + avail_width: f32, + ) -> Vec { + use flatbuffers::FlatBufferBuilder; + let mut fbb = FlatBufferBuilder::with_capacity(1024); + + let style_args = crate::fb::StyleArgs { + position, + size_w: size_w.map(|w| { + crate::fb::Dimension::create( + &mut fbb, + &crate::fb::DimensionArgs { value: w, unit: 1 }, + ) + }), + size_h: size_h.map(|h| { + crate::fb::Dimension::create( + &mut fbb, + &crate::fb::DimensionArgs { value: h, unit: 1 }, + ) + }), + ..Default::default() + }; + let style = crate::fb::Style::create(&mut fbb, &style_args); + let ts = crate::fb::TextStyle::create( + &mut fbb, + &crate::fb::TextStyleArgs { + font_size: 11.0, + ..Default::default() + }, + ); + let text_off = fbb.create_string(text); + let td = crate::fb::TextData::create( + &mut fbb, + &crate::fb::TextDataArgs { + text: Some(text_off), + style: Some(ts), + ..Default::default() + }, + ); + let node = crate::fb::FlatNode::create( + &mut fbb, + &crate::fb::FlatNodeArgs { + style: Some(style), + node_type: 1, + text: Some(td), + ..Default::default() + }, + ); + let nodes = fbb.create_vector(&[node]); + let input = crate::fb::LayoutInput::create( + &mut fbb, + &crate::fb::LayoutInputArgs { + available_width: avail_width, + justify: 0, + nodes: Some(nodes), + ..Default::default() + }, + ); + fbb.finish(input, None); + fbb.finished_data().to_vec() + } + + /// Build a LayoutInput with ONE relative-flow paragraph (position 0) whose + /// rich spans are given as `(text, italic)` parts in document order. The + /// concatenated span texts must equal TextData.text (they do, since we build + /// it from the parts). Mirrors LayoutNodeSerializer's rich-span emission + /// so the boundary correction can be tested end-to-end. + fn build_rich_paragraph_input(parts: &[(&str, bool)], avail_width: f32) -> Vec { + use flatbuffers::FlatBufferBuilder; + let mut fbb = FlatBufferBuilder::with_capacity(1024); + + // Base paragraph style (spans override it; defaults not italic). + let ts = crate::fb::TextStyle::create( + &mut fbb, + &crate::fb::TextStyleArgs { + font_size: 11.0, + ..Default::default() + }, + ); + let mut span_offsets = Vec::new(); + let mut full = String::new(); + for (t, italic) in parts { + let st = crate::fb::TextStyle::create( + &mut fbb, + &crate::fb::TextStyleArgs { + font_size: 11.0, + italic: *italic, + ..Default::default() + }, + ); + let span_text_off = fbb.create_string(t); + span_offsets.push(crate::fb::TextSpan::create( + &mut fbb, + &crate::fb::TextSpanArgs { + text: Some(span_text_off), + style: Some(st), + }, + )); + full.push_str(t); + } + let spans_vec = fbb.create_vector(&span_offsets); + let text_off = fbb.create_string(&full); + let td = crate::fb::TextData::create( + &mut fbb, + &crate::fb::TextDataArgs { + text: Some(text_off), + style: Some(ts), + spans: Some(spans_vec), + ..Default::default() + }, + ); + let style = crate::fb::Style::create( + &mut fbb, + &crate::fb::StyleArgs { + position: 0, + ..Default::default() + }, + ); + let node = crate::fb::FlatNode::create( + &mut fbb, + &crate::fb::FlatNodeArgs { + style: Some(style), + node_type: 1, + text: Some(td), + ..Default::default() + }, + ); + let nodes = fbb.create_vector(&[node]); + // The shear_k slot is DEPRECATED, with the Rust-owned SHEAR_K + // constant (default 0.25) as the fallback, so tests no longer declare it. + let input = crate::fb::LayoutInput::create( + &mut fbb, + &crate::fb::LayoutInputArgs { + available_width: avail_width, + justify: 0, + nodes: Some(nodes), + ..Default::default() + }, + ); + fbb.finish(input, None); + fbb.finished_data().to_vec() + } + + /// Build a LayoutInput with a flex-column container (node 0, no declared + /// size) owning one relative-flow paragraph (node 1, position 0): the + /// path that must keep wrapping at the container's available width. + fn build_relative_flow_input(text: &str, avail_width: f32) -> Vec { + use flatbuffers::FlatBufferBuilder; + let mut fbb = FlatBufferBuilder::with_capacity(1024); + + let ts = crate::fb::TextStyle::create( + &mut fbb, + &crate::fb::TextStyleArgs { + font_size: 11.0, + ..Default::default() + }, + ); + let text_off = fbb.create_string(text); + let td = crate::fb::TextData::create( + &mut fbb, + &crate::fb::TextDataArgs { + text: Some(text_off), + style: Some(ts), + ..Default::default() + }, + ); + let child_style = crate::fb::Style::create( + &mut fbb, + &crate::fb::StyleArgs { + position: 0, + ..Default::default() + }, + ); + let child = crate::fb::FlatNode::create( + &mut fbb, + &crate::fb::FlatNodeArgs { + style: Some(child_style), + node_type: 1, + text: Some(td), + ..Default::default() + }, + ); + // Container: display 0 = Flex, flex_direction 1 = Column (defaults). + // FlatNode.children is a vector of u32 node indices into the nodes + // vector, so the container (index 0) owns the child at index 1. + let cont_style = crate::fb::Style::create(&mut fbb, &crate::fb::StyleArgs::default()); + let children = fbb.create_vector(&[1u32]); + let cont = crate::fb::FlatNode::create( + &mut fbb, + &crate::fb::FlatNodeArgs { + style: Some(cont_style), + node_type: 0, + children: Some(children), + ..Default::default() + }, + ); + let nodes = fbb.create_vector(&[cont, child]); + let input = crate::fb::LayoutInput::create( + &mut fbb, + &crate::fb::LayoutInputArgs { + available_width: avail_width, + justify: 0, + nodes: Some(nodes), + ..Default::default() + }, + ); + fbb.finish(input, None); + fbb.finished_data().to_vec() + } + + /// Highest visual line index across every emitted glyph of a result. + fn max_glyph_line(result: &crate::fb::LayoutResult) -> u32 { + let mut max_line = 0u32; + if let Some(runs) = result.glyph_runs() { + for ri in 0..runs.len() { + let run = runs.get(ri); + if let Some(glyphs) = run.glyphs() { + for gi in 0..glyphs.len() { + max_line = max_line.max(glyphs.get(gi).line_index()); + } + } + } + } + max_line + } + + /// Regression: a pos_abs (position:absolute) inline paragraph must + /// shape at its NATURAL width, so its declared px size stays only as the + /// box size and pen reservation, never as the wrap constraint. Taffy + /// feeds a leaf's own declared width back as the measure available width + /// (compute/leaf.rs maybe_set(node_size.width)); Java declares + /// round(measureWidth), so a round-down lands the declared width a hair + /// UNDER the natural advance and used to make parley wrap the paragraph + /// at its last space, and the wrapped second line then overflowed the + /// declared 17px box. This test drives the exact condition (declared < + /// natural) and asserts the paragraph stays on its natural single line. + #[test] + fn pos_abs_inline_paragraph_does_not_wrap_at_declared_width() { + const TEXT: &str = "Guide Error Fallback Text Item"; + let mut fs = GuideFontSystem::new(); + let natural = total_advance(&fs.parley.layout_styled(TEXT, 11.0, 1.55, false, false, None, None)); + // Java measureWidth = round(natural); a round-down declares a width + // strictly below the natural advance. Force it unconditionally so the + // old buggy path would ALWAYS wrap here. + let declared_w = (natural - 0.25).floor(); + let declared_h: f32 = 17.0; + + let input = build_paragraph_input(TEXT, 1, Some(declared_w), Some(declared_h), 500.0); + let out = compute_layout(&input, &mut fs); + let result = flatbuffers::root::(&out).expect("LayoutResult"); + + // Box stays the declared px size: width ≈ natural advance (box + pen + // reservation), height covers the actual single shaped line. + let node0 = result.nodes().expect("nodes").get(0); + assert_eq!( + node0.h(), + declared_h, + "inline block box height must cover its lines (17px for one line)" + ); + assert!( + (node0.w() - declared_w).abs() < 0.01, + "inline block box width must stay the declared width ({declared_w}), got {}", + node0.w() + ); + + // The text itself must NOT wrap at the declared width. + let max_line = max_glyph_line(&result); + assert_eq!( + max_line, + 0, + "pos_abs paragraph must shape as ONE natural line despite declared width \ + {declared_w} < natural advance {natural} (would wrap on last space before the fix)" + ); + println!( + "[inline_natural] natural={natural:.3} declared_w={declared_w} box_w={} box_h={} lines={}", + node0.w(), + node0.h(), + max_line + 1 + ); + } + + /// Guard for the other half of the fix: relative-flow paragraphs (position + /// 0) must keep wrapping at the container's available width: the + /// MaxContent override is scoped to inline paragraphs only. + #[test] + fn relative_flow_paragraph_still_wraps_at_container_width() { + const TEXT: &str = "Guide Error Fallback Text Item"; + let mut fs = GuideFontSystem::new(); + + let input = build_relative_flow_input(TEXT, 120.0); + let out = compute_layout(&input, &mut fs); + let result = flatbuffers::root::(&out).expect("LayoutResult"); + + let max_line = max_glyph_line(&result); + assert!( + max_line >= 1, + "relative-flow paragraph must still wrap at the narrow container width, \ + got {max_line} lines (MaxContent must NOT leak to relative-flow nodes)" + ); + } + + /// Italic fix: the italic marker must NOT enter + /// parley font matching; shaping always matches `FontStyle::Normal`, so + /// every glyph comes from the regular face. The marker rides the brush + /// and is restored into the glyph-run output (`OutGlyph.italic`) for + /// the boundary correction. + /// + /// Assertions (reworked from the earlier test, which asserted + /// `Run::font_attrs().style == FontStyle::Italic`, the regression + /// source; the marker is now read from the brush instead): + /// * the new marker bit is readable on the output glyphs (italic=true + /// vs false); + /// * Latin/CJK advance diff == 0 (shaping-decided geometry identical); + /// * every glyph quad key (rasterize_out_glyphs `bitmap_key`) is + /// byte-identical under italic=true vs false. That is the core regression + /// criterion: a true-italic face swap (Arial Italic) would change the + /// font bytes / glyph ids and break this. + #[test] + fn italic_marker_rides_brush_and_shaping_stays_normal() { + use crate::parley_text::{collect_layout, rasterize_out_glyphs}; + let mut pf = ParleyFonts::new(); + // Second entry is a CJK italic item name (byte-identical value via escapes). + for text in ["Italic item", "\u{659C}\u{4F53}\u{7269}\u{54C1}\u{540D}"] { + let normal = pf.layout_styled(text, 22.0, 1.55, false, false, None, None); + let italic = pf.layout_styled(text, 22.0, 1.55, false, true, None, None); + + // Shaping must ALWAYS have matched Normal. font_attrs().style is + // the matcher's verdict, and it must be Normal in BOTH cases + // (that propagation would have made the italic layout + // record FontStyle::Italic here). + for line in normal.lines() { + for r in line.runs() { + assert_eq!( + r.font_attrs().style, + FontStyle::Normal, + "upright run must keep FontStyle::Normal for {text:?}" + ); + } + } + for line in italic.lines() { + for r in line.runs() { + assert_eq!( + r.font_attrs().style, + FontStyle::Normal, + "italic run must STILL match Normal (marker must not enter \ + font matching) for {text:?}" + ); + } + } + + // ① Marker readable on the glyph-run output layer. + let (n_glyphs, ..) = collect_layout(&normal, &[], 0.0, 0.0, f32::MAX, &[]); + let (i_glyphs, ..) = collect_layout(&italic, &[], 0.0, 0.0, f32::MAX, &[]); + assert!( + !n_glyphs.is_empty() && !i_glyphs.is_empty(), + "expected shaped glyphs for {text:?} (system fonts required) - \ + normal={} italic={}", + n_glyphs.len(), + i_glyphs.len() + ); + assert!( + n_glyphs.iter().all(|g| !g.italic), + "upright glyphs must carry italic=false for {text:?}" + ); + assert!( + i_glyphs.iter().all(|g| g.italic), + "italic marker must be readable on output glyphs for {text:?}" + ); + + // ② Latin/CJK advance diff == 0. + let an = total_advance(&normal); + let ai = total_advance(&italic); + assert!(an.is_finite() && an >= 0.0, "normal advance {an} invalid"); + assert!(ai.is_finite() && ai >= 0.0, "italic advance {ai} invalid"); + assert!( + (ai - an).abs() < 1e-3, + "italic must NOT change shaping: advance diff must be 0 for \ + {text:?}, got normal={an:.3} italic={ai:.3} diff={:+.3}", + ai - an + ); + + // ③ Core regression criterion: quad keys byte-identical under + // italic=true/false. A true-italic face selection would change + // font bytes / glyph ids / advance and shift these keys. + let (n_quads, _) = rasterize_out_glyphs(&n_glyphs, 2.0); + let (i_quads, _) = rasterize_out_glyphs(&i_glyphs, 2.0); + let n_keys: Vec = n_quads.iter().map(|q| q.bitmap_key).collect(); + let i_keys: Vec = i_quads.iter().map(|q| q.bitmap_key).collect(); + assert_eq!( + n_keys, i_keys, + "quad keys must be byte-identical under italic=true/false for \ + {text:?} (a true-italic face swap would break this) - \ + normal={n_keys:?} italic={i_keys:?}" + ); + + println!( + "[italic_shaping] text={text:?} glyphs(normal/italic)={}/{} \ + advance(normal)={an:.3} advance(italic)={ai:.3} diff={:+.3} \ + quad-keys-identical={}", + n_glyphs.len(), + i_glyphs.len(), + ai - an, + n_keys == i_keys + ); + } + } + + /// Atom-metrics declaration round-trip: build a FlatBuffer carrying + /// the three new field groups the way the Java serializer writes them, and assert + /// what the Rust reader reads back: + /// 1. the InlineBlockRef ink box: ink_left/ink_right/ink_bottom + /// 2. GlyphRun.baseline + PlacedGlyph.baseline, divide-by-scale-then-truncate + /// 3. TextStyle.font_family, activating a previously dead field + #[test] + fn phase21_schema_fields_round_trip() { + use flatbuffers::FlatBufferBuilder; + let mut fbb = FlatBufferBuilder::with_capacity(1024); + + // TextStyle.font_family is written as a non-empty string. + let ff = fbb.create_string("Minecraft Default"); + let ts = crate::fb::TextStyle::create( + &mut fbb, + &crate::fb::TextStyleArgs { + font_size: 11.0, + font_family: Some(ff), + ..Default::default() + }, + ); + + // InlineBlockRef ink box, written with non-zero values. + let ibr = crate::fb::InlineBlockRef::create( + &mut fbb, + &crate::fb::InlineBlockRefArgs { + node: 7, + align: 2, + param: 0.5, + ink_left: 2.0, + ink_right: 13.0, + ink_bottom: 15.0, + }, + ); + let ibr_vec = fbb.create_vector(&[ibr]); + + // TextData carries both. + let text_off = fbb.create_string("A\u{FFFC}B"); + let td = crate::fb::TextData::create( + &mut fbb, + &crate::fb::TextDataArgs { + text: Some(text_off), + style: Some(ts), + inline_blocks: Some(ibr_vec), + ..Default::default() + }, + ); + let style = crate::fb::StyleArgs::default(); + let style = crate::fb::Style::create(&mut fbb, &style); + let node = crate::fb::FlatNode::create( + &mut fbb, + &crate::fb::FlatNodeArgs { + style: Some(style), + node_type: 1, + text: Some(td), + ..Default::default() + }, + ); + let nodes = fbb.create_vector(&[node]); + let input = crate::fb::LayoutInput::create( + &mut fbb, + &crate::fb::LayoutInputArgs { + available_width: 500.0, + nodes: Some(nodes), + ..Default::default() + }, + ); + fbb.finish(input, None); + let buf = fbb.finished_data(); + + // Read-back assertions on the reader side. + let input = flatbuffers::root::(buf).expect("LayoutInput"); + let node0 = input.nodes().expect("nodes").get(0); + let td = node0.text().expect("text"); + // 1) font_family + let ts = td.style().expect("style"); + assert_eq!( + ts.font_family(), + Some("Minecraft Default"), + "font_family must round-trip" + ); + // 2) ink box + let refs = td.inline_blocks().expect("inline_blocks"); + assert_eq!(refs.len(), 1); + let r = refs.get(0); + assert_eq!(r.node(), 7); + assert_eq!(r.align(), 2); + assert!((r.param() - 0.5).abs() < 1e-6); + assert!((r.ink_left() - 2.0).abs() < 1e-6, "ink_left round-trip"); + assert!((r.ink_right() - 13.0).abs() < 1e-6, "ink_right round-trip"); + assert!((r.ink_bottom() - 15.0).abs() < 1e-6, "ink_bottom round-trip"); + } + + /// Font-family wire round-trip: the Java serializer is simulated by writing + /// "Consolas" into both TextData.style.font_family and TextSpan.style.font_family, + /// the Rust reader asserts what it reads back, and a full compute_layout run + /// exercises the consumption path end to end, from the read in measure.rs through + /// the ShapeRequest/SpanStyle carrier and shape_paragraph to the + /// FontFamily::named injection. + #[test] + fn phase26_font_family_wire_round_trip() { + use flatbuffers::FlatBufferBuilder; + let mut fbb = FlatBufferBuilder::with_capacity(1024); + + // Write side, simulating LayoutNodeSerializer.buildTextData + buildFbTextStyle. + let ff = fbb.create_string("Consolas"); + let ts = crate::fb::TextStyle::create( + &mut fbb, + &crate::fb::TextStyleArgs { + font_size: 11.0, + font_family: Some(ff), + ..Default::default() + }, + ); + let span_style = crate::fb::TextStyle::create( + &mut fbb, + &crate::fb::TextStyleArgs { + font_size: 11.0, + font_family: Some(ff), + ..Default::default() + }, + ); + let span_text_off = fbb.create_string("Consolas probe"); + let span = crate::fb::TextSpan::create( + &mut fbb, + &crate::fb::TextSpanArgs { + text: Some(span_text_off), + style: Some(span_style), + }, + ); + let spans_vec = fbb.create_vector(&[span]); + let text_off = fbb.create_string("Consolas probe"); + let td = crate::fb::TextData::create( + &mut fbb, + &crate::fb::TextDataArgs { + text: Some(text_off), + style: Some(ts), + spans: Some(spans_vec), + ..Default::default() + }, + ); + let style = crate::fb::Style::create( + &mut fbb, + &crate::fb::StyleArgs { + position: 0, + ..Default::default() + }, + ); + let node = crate::fb::FlatNode::create( + &mut fbb, + &crate::fb::FlatNodeArgs { + style: Some(style), + node_type: 1, + text: Some(td), + ..Default::default() + }, + ); + let nodes = fbb.create_vector(&[node]); + let input = crate::fb::LayoutInput::create( + &mut fbb, + &crate::fb::LayoutInputArgs { + available_width: 500.0, + justify: 0, + nodes: Some(nodes), + ..Default::default() + }, + ); + fbb.finish(input, None); + let buf = fbb.finished_data(); + + // Read-back assertions on the reader side, at the wire level. + let input = flatbuffers::root::(buf).expect("LayoutInput"); + let node0 = input.nodes().expect("nodes").get(0); + let td = node0.text().expect("text"); + assert_eq!( + td.style().expect("style").font_family(), + Some("Consolas"), + "base font_family must round-trip" + ); + let span0 = td.spans().expect("spans").get(0); + assert_eq!( + span0.style().expect("style").font_family(), + Some("Consolas"), + "span font_family must round-trip" + ); + + // Full-pipeline consumption: a named family travels measure, shape and emit. + let mut fs = GuideFontSystem::new(); + let out = compute_layout(buf, &mut fs); + let result = flatbuffers::root::(&out).expect("LayoutResult"); + let runs = result.glyph_runs().expect("glyph_runs"); + let mut total_glyphs = 0usize; + for ri in 0..runs.len() { + if let Some(g) = runs.get(ri).glyphs() { + total_glyphs += g.len(); + } + } + assert!( + total_glyphs > 0, + "paragraph with font_family=\"Consolas\" must shape glyphs end-to-end \ + (system fonts required)" + ); + println!( + "[phase26_wire] base/span font_family round-trip OK, glyphs={total_glyphs}" + ); + } + + /// Consumption injection assertion: once layout_styled's family parameter injects + /// `FontFamily::named("Consolas")`, every glyph run's brush reads back + /// `family == Some("Consolas")`, closing the observable loop from the write side + /// through the wire and the carrier to the brush. This does not depend on Consolas + /// actually being matched, because the brush carries the requested value. + #[test] + fn phase26_font_family_named_rides_brush() { + use parley::PositionedLayoutItem; + let mut pf = ParleyFonts::new(); + let layout = pf.layout_styled("Consolas", 22.0, 1.55, false, false, None, Some("Consolas")); + let mut runs = 0usize; + for line in layout.lines() { + for item in line.items() { + let PositionedLayoutItem::GlyphRun(gr) = item else { + continue; + }; + runs += 1; + assert_eq!( + gr.style().brush.family.as_deref(), + Some("Consolas"), + "named family must ride the run brush so the read-back is observable" + ); + } + } + assert!( + runs > 0, + "expected shaped glyph runs for \"Consolas\" (system fonts required)" + ); + } + + /// Fallback assertion, the key evidence that the change is behavior-neutral: when + /// font_family is null, an empty string or all whitespace, no named family is + /// injected, so brush.family is always None and shaping takes the SansSerif + /// generic default of the original push_defaults, byte for byte unchanged. + #[test] + fn phase26_font_family_null_falls_back_to_sansserif() { + use parley::PositionedLayoutItem; + let mut pf = ParleyFonts::new(); + for (label, family) in [("null", None), ("empty", Some("")), ("whitespace", Some(" "))] { + let layout = pf.layout_styled("abc", 22.0, 1.55, false, false, None, family); + let mut runs = 0usize; + for line in layout.lines() { + for item in line.items() { + let PositionedLayoutItem::GlyphRun(gr) = item else { + continue; + }; + runs += 1; + assert_eq!( + gr.style().brush.family, + None, + "family={label} must NOT inject a named family (SansSerif generic \ + fallback - pre-wiring behavior)" + ); + } + } + assert!( + runs > 0, + "expected shaped glyph runs for family={label} (system fonts required)" + ); + } + } + + /// Observable assertion that an inline_code span's monospace declaration survives + /// the whole pipeline. The write side, simulating + /// LayoutNodeSerializer.buildFbTextStyle, writes font_family="monospace" for the + /// inline_code span and nothing (null) for the body spans. After wire → + /// measure_text → shape_paragraph → push_spans → collect_layout → OutGlyph: + /// 1. glyphs of the mono span (span_index=1) carry Some("monospace"), the + /// monospace declaration; + /// 2. glyphs of the body spans (span_index=0/2) carry None, meaning the + /// SansSerif generic fallback with no named family injected, which is the + /// criterion that distinguishes them from the mono span; + /// the two being distinguishable means the inline_code monospace font really is + /// active and does not contaminate the body runs. + #[test] + fn phase26b_inline_code_mono_glyph_family() { + use flatbuffers::FlatBufferBuilder; + use crate::measure::measure_text; + + let mut fbb = FlatBufferBuilder::with_capacity(1024); + let mono_off = fbb.create_string("monospace"); + // spans: [0]="abc" body, [1]="123" inline_code mono, [2]="xyz" body + let mut span_offsets = Vec::new(); + for (t, fam, inline_code) in [ + ("abc", None, false), + ("123", Some(mono_off), true), + ("xyz", None, false), + ] { + let st = crate::fb::TextStyle::create( + &mut fbb, + &crate::fb::TextStyleArgs { + font_size: 11.0, + font_family: fam, + inline_code, + ..Default::default() + }, + ); + let span_text_off = fbb.create_string(t); + span_offsets.push(crate::fb::TextSpan::create( + &mut fbb, + &crate::fb::TextSpanArgs { + text: Some(span_text_off), + style: Some(st), + }, + )); + } + let spans_vec = fbb.create_vector(&span_offsets); + let text_off = fbb.create_string("abc123xyz"); + let ts = crate::fb::TextStyle::create( + &mut fbb, + &crate::fb::TextStyleArgs { + font_size: 11.0, + ..Default::default() + }, + ); + let td = crate::fb::TextData::create( + &mut fbb, + &crate::fb::TextDataArgs { + text: Some(text_off), + style: Some(ts), + spans: Some(spans_vec), + ..Default::default() + }, + ); + let style = crate::fb::Style::create( + &mut fbb, + &crate::fb::StyleArgs { + position: 0, + ..Default::default() + }, + ); + let node = crate::fb::FlatNode::create( + &mut fbb, + &crate::fb::FlatNodeArgs { + style: Some(style), + node_type: 1, + text: Some(td), + ..Default::default() + }, + ); + let nodes_vec = fbb.create_vector(&[node]); + let input = crate::fb::LayoutInput::create( + &mut fbb, + &crate::fb::LayoutInputArgs { + available_width: 500.0, + justify: 0, + nodes: Some(nodes_vec), + ..Default::default() + }, + ); + fbb.finish(input, None); + let buf = fbb.finished_data(); + + // Full pipeline: wire → measure_text → shape_paragraph → collect_layout → OutGlyph + let input = flatbuffers::root::(buf).expect("LayoutInput"); + let fb_nodes = input.nodes().expect("nodes"); + let nodes: Vec = (0..fb_nodes.len()).map(|i| fb_nodes.get(i)).collect(); + let mut fs = GuideFontSystem::new(); + let mut acc: std::collections::HashMap = Default::default(); + let _ = measure_text( + &mut fs, + &nodes, + 0, + &mut acc, + taffy::Size { + width: taffy::AvailableSpace::Definite(500.0), + height: taffy::AvailableSpace::MaxContent, + }, + false, + &[], + 0.0, + 0.0, + &[], + ); + let glyphs = &acc[&0].glyphs; + assert!( + !glyphs.is_empty(), + "paragraph must shape glyphs (system fonts required)" + ); + + // span_index 0/2 are body spans (SansSerif fallback, brush.family = None); + // span_index 1 is the inline_code span, declared "monospace", so + // brush.family = Some("monospace"). + let mono: Vec<_> = glyphs.iter().filter(|g| g.span_index == 1).collect(); + let body: Vec<_> = glyphs.iter().filter(|g| g.span_index != 1).collect(); + assert!(!mono.is_empty(), "inline_code span must produce glyphs"); + assert!(!body.is_empty(), "body spans must produce glyphs"); + for g in &mono { + assert_eq!( + g.family.as_deref(), + Some("monospace"), + "inline_code span glyphs must carry the declared mono family" + ); + } + for g in &body { + assert_eq!( + g.family, None, + "body span glyphs must fall back to SansSerif (no named family injected)" + ); + } + // Discrimination assertion: the family requests of the mono span and the body + // spans must differ, showing that the inline_code monospace declaration + // survives the whole pipeline without contaminating the body runs. + assert!( + mono.iter().any(|g| g.family.as_deref() == Some("monospace")) + && body.iter().all(|g| g.family.is_none()), + "mono and body glyph families must be distinguishable \ + (mono=Some(monospace), body=None)" + ); + println!( + "[phase26b] inline_code mono: {} mono glyphs (family=monospace), \ + {} body glyphs (family=None)", + mono.len(), + body.len() + ); + + // Observable at the brush level (the layout_styled single-style path, which is + // the Java shapeText pipeline): after a "monospace" declaration goes through + // resolve_family to Generic(Monospace), the run brush still carries the + // requested name Some("monospace"), because the brush is not rewritten by the + // generic mapping. + use parley::PositionedLayoutItem; + let layout = ParleyFonts::new().layout_styled( + "mono", 22.0, 1.55, false, false, None, Some("monospace")); + let mut mono_runs = 0usize; + for line in layout.lines() { + for item in line.items() { + let PositionedLayoutItem::GlyphRun(gr) = item else { + continue; + }; + mono_runs += 1; + assert_eq!( + gr.style().brush.family.as_deref(), + Some("monospace"), + "mono declaration must ride the run brush (Generic(Monospace) \ + resolution must not rewrite the brush request)" + ); + } + } + assert!( + mono_runs > 0, + "expected shaped glyph runs for \"mono\" with family=monospace \ + (system fonts required)" + ); + } + + /// GlyphRun/PlacedGlyph baseline round-trip. A real layout run asserts + /// that the emitted baseline and the glyph y (the quad top) follow the same + /// divide-by-scale-then-truncate convention: + /// q.baseline = (g.y * render_scale).trunc() / render_scale + /// q.y = ((g.y * render_scale).trunc() - top) / render_scale + /// Both derive from the same truncated pixel yi, and both add the same node + /// absolute y on PlacedGlyph output, so baseline - y always equals the bitmap's + /// placement.top, an integer. + /// The test also asserts that the baseline falls inside the glyph quad's y..y+h + /// range, that is, inside the glyph band. + #[test] + fn phase21_baseline_return_on_real_layout() { + const TEXT: &str = "Baseline probe"; + let mut fs = GuideFontSystem::new(); + let input = build_paragraph_input(TEXT, 0, None, None, 500.0); + let out = compute_layout(&input, &mut fs); + let result = flatbuffers::root::(&out).expect("LayoutResult"); + + let runs = result.glyph_runs().expect("glyph_runs"); + assert!( + !runs.is_empty(), + "expected at least one glyph run for {TEXT:?} (system fonts required)" + ); + let mut glyphs_seen = 0usize; + for ri in 0..runs.len() { + let run = runs.get(ri); + // The run-level baseline is the first glyph's baseline, in absolute document + // coordinates. + let run_baseline = run.baseline(); + let gs = run.glyphs().expect("run glyphs"); + assert!(!gs.is_empty(), "run must have glyphs"); + for gi in 0..gs.len() { + let pg = gs.get(gi); + glyphs_seen += 1; + let bl = pg.baseline(); + // Divide-by-scale-then-truncate consistency: the baseline and the quad + // top y derive from the same truncated pixel yi, so their difference is + // placement.top, an integer. At render_scale=1 this is pixel-grid + // alignment. + let diff = bl - pg.y(); + assert!( + (diff - diff.trunc()).abs() < 1e-3, + "baseline-y diff must be pixel-aligned (÷scale+trunc), got {diff}" + ); + // The baseline falls inside the glyph quad's vertical range, that is, + // within the glyph band. + assert!( + bl >= pg.y() - 1.0 && bl <= pg.y() + pg.h() + 1.0, + "baseline {bl} must sit near glyph quad [y={}, y+h={}]", + pg.y(), + pg.y() + pg.h() + ); + if gi == 0 { + assert!( + (bl - run_baseline).abs() < 1e-3, + "GlyphRun.baseline must equal its first glyph's baseline \ + (run={run_baseline}, glyph={bl})" + ); + } + } + } + assert!(glyphs_seen > 0, "expected shaped glyphs for {TEXT:?}"); + println!( + "[phase21_baseline] runs={} glyphs={glyphs_seen} first_run_baseline={}", + runs.len(), + runs.get(0).baseline() + ); + } + + /// One synthetic rasterized glyph quad for the functional boundary tests. + fn mk_quad(x: f32, y: f32, w: f32, h: f32, line: u32, span: u32) -> crate::parley_text::ParleyRasterGlyph { + crate::parley_text::ParleyRasterGlyph { + bitmap_key: 0, + x, + y, + w, + h, + line_index: line, + span_index: span, + baseline: y + h, + } + } + + fn mk_style(italic: bool) -> super::SpanStyleInfo { + super::SpanStyleInfo { + color: 0xFFFFFFFF, + italic, + underline: false, + strikethrough: false, + highlight_argb: 0, + inline_code: false, + wavy_underline: false, + dotted_underline: false, + } + } + + /// The boundary-correction formula itself. + /// Drives `apply_italic_boundary_correction` with synthetic quads so every + /// acceptance branch is asserted exactly: + /// ① positive italic→upright boundary inserts the shear-overhang gap and + /// leaves the italic run's interior untouched (run-internal advance + /// unchanged, a uniform per-run shift); + /// ② negative boundary (natural gap > overhang) yields zero correction; + /// ③ upright→italic boundary is never corrected; + /// ④ cascading boundaries each insert their own one-shot gap with NO + /// accumulation across boundaries; + /// ⑤ all-upright paragraph is byte-identical output; + /// ⑥ REJECT fix: an atom marker's pen between the italic run and the next + /// upright run (italic→atom→upright) makes the boundary pass SKIP it; the + /// the atom boundary correction owns it (same-geometry control without + /// the marker still corrects, proving the skip is atom-scoped). + #[test] + fn phase22_italic_boundary_correction_functional() { + use super::apply_italic_boundary_correction; + + // ① positive boundary: italic(span0) → upright(span1), zero natural gap. + let mut quads = vec![ + mk_quad(0.0, 2.0, 8.0, 10.0, 0, 0), + mk_quad(8.0, 5.0, 8.0, 10.0, 0, 0), + mk_quad(16.0, 2.0, 8.0, 10.0, 0, 1), + mk_quad(24.0, 2.0, 8.0, 10.0, 0, 1), + ]; + let styles = vec![mk_style(true), mk_style(false)]; + apply_italic_boundary_correction(&mut quads, &[], &styles, false, 0.25); + // shearBase(span0) = max(2+10, 5+10) = 15; last glyph top y = 5 + // shearShift = 0.25 × (15 − 5) = 2.5; inkGap = 16 − (8+8) = 0 → c = 2.5 + assert!((quads[0].x - 0.0).abs() < 1e-6, "italic run interior must not move"); + assert!((quads[1].x - 8.0).abs() < 1e-6, "italic run interior must not move"); + assert!( + (quads[2].x - 18.5).abs() < 1e-6, + "upright run shifted by correction, got {}", + quads[2].x + ); + assert!((quads[3].x - 26.5).abs() < 1e-6); + // run-internal advance unchanged: upright glyph spacing stays 8. + assert!( + ((quads[3].x - quads[2].x) - 8.0).abs() < 1e-6, + "run-internal advance must be preserved by a uniform shift" + ); + + // ② negative boundary: natural gap (4) > shear overhang (2.5) → zero. + let mut quads = vec![ + mk_quad(0.0, 2.0, 8.0, 10.0, 0, 0), + mk_quad(8.0, 5.0, 8.0, 10.0, 0, 0), + mk_quad(20.0, 2.0, 8.0, 10.0, 0, 1), // gap = 20 − 16 = 4 > 2.5 + ]; + apply_italic_boundary_correction(&mut quads, &[], &styles, false, 0.25); + assert!( + (quads[2].x - 20.0).abs() < 1e-6, + "zero correction when natural gap exceeds the overhang" + ); + + // ③ upright→italic boundary: never corrected. + let mut quads = vec![ + mk_quad(0.0, 2.0, 8.0, 10.0, 0, 0), + mk_quad(8.0, 5.0, 8.0, 10.0, 0, 1), + ]; + let styles_rev = vec![mk_style(false), mk_style(true)]; + apply_italic_boundary_correction(&mut quads, &[], &styles_rev, false, 0.25); + assert!( + (quads[1].x - 8.0).abs() < 1e-6, + "upright→italic boundary must be untouched" + ); + + // ④ cascading: italic → upright → italic → upright, one-shot each, no accumulation. + let mut quads = vec![ + mk_quad(0.0, 4.0, 6.0, 12.0, 0, 0), + mk_quad(6.0, 2.0, 6.0, 8.0, 0, 1), + mk_quad(12.0, 4.0, 6.0, 12.0, 0, 2), + mk_quad(18.0, 2.0, 6.0, 8.0, 0, 3), + ]; + let styles4 = vec![ + mk_style(true), + mk_style(false), + mk_style(true), + mk_style(false), + ]; + apply_italic_boundary_correction(&mut quads, &[], &styles4, false, 0.25); + // boundary1: span0 shearBase = 4+12 = 16; last top y = 4 → shift = 3; gap = 6−(0+6)=0 → c1 = 3 + // boundary2: span2 shearBase = 16; last top y = 4 → shift = 3; gap = 18−(12+6)=0 → c2 = 3 + assert!( + (quads[1].x - 9.0).abs() < 1e-6, + "first upright shifted by its own c1, got {}", + quads[1].x + ); + assert!( + (quads[2].x - 12.0).abs() < 1e-6, + "middle italic run must NOT shift" + ); + assert!( + (quads[3].x - 21.0).abs() < 1e-6, + "second upright shifted by c2 ONLY (no accumulation), got {}", + quads[3].x + ); + + // ⑤ all-upright paragraph: identical output. + let mut quads = vec![ + mk_quad(0.0, 2.0, 8.0, 10.0, 0, 0), + mk_quad(8.0, 2.0, 8.0, 10.0, 0, 0), + ]; + let styles_flat = vec![mk_style(false)]; + apply_italic_boundary_correction(&mut quads, &[], &styles_flat, false, 0.25); + assert!( + (quads[1].x - 8.0).abs() < 1e-6, + "all-upright paragraph must be byte-identical" + ); + + // ⑥ Rejection fix (double application): italic→atom→upright, with the atom + // marker's pen falling between the last italic glyph and the next upright + // glyph on the same line. The boundary pass is blind to the atom, so without + // the skip it would apply correction_22 as if this were a direct text boundary + // whose gap contains the atom advance, while the atom correction's pass 2 + // shifts the same upright quads' tail by c_23: a double application. + // The geometry here is gap_22 = 17 - (8+8) = 1, below the shear overhang of + // 2.5, so without the atom the upright text would move right by 1.5; with the + // atom (pen=12 inside [8,17)) the whole boundary must be skipped and nothing + // may move at all. + let mut quads = vec![ + mk_quad(0.0, 2.0, 8.0, 10.0, 0, 0), + mk_quad(8.0, 5.0, 8.0, 10.0, 0, 0), + mk_quad(17.0, 2.0, 8.0, 10.0, 0, 1), // gap_22 = 1 < 2.5 + mk_quad(25.0, 2.0, 8.0, 10.0, 0, 1), + ]; + let markers = vec![crate::measure::InlineMarker { + pen_x: 12.0, + baseline_y: 10.0, + line_top: 0.0, + line_height: 15.0, + line_index: 0, + advance: 5.0, // the atom box occupies [12,17), so the upright run starts at 17 + }]; + apply_italic_boundary_correction(&mut quads, &markers, &styles, false, 0.25); + assert!( + (quads[2].x - 17.0).abs() < 1e-6, + "italic→atom→upright boundary must be SKIPPED by 2.2 (2.3 owns it), \ + got {} - double application would have shifted it by correction_22", + quads[2].x + ); + assert!( + (quads[3].x - 25.0).abs() < 1e-6, + "atom-between upright run must stay untouched, got {}", + quads[3].x + ); + assert!((quads[0].x - 0.0).abs() < 1e-6 && (quads[1].x - 8.0).abs() < 1e-6); + // The same geometry with the marker removed, so the atom is gone and this is a + // plain text boundary: the upright text must still move right by 1.5, proving + // the skip is triggered precisely by an atom pen falling between the two runs + // and does not affect ordinary boundaries. + let mut quads2 = vec![ + mk_quad(0.0, 2.0, 8.0, 10.0, 0, 0), + mk_quad(8.0, 5.0, 8.0, 10.0, 0, 0), + mk_quad(17.0, 2.0, 8.0, 10.0, 0, 1), + mk_quad(25.0, 2.0, 8.0, 10.0, 0, 1), + ]; + apply_italic_boundary_correction(&mut quads2, &[], &styles, false, 0.25); + assert!( + (quads2[2].x - 18.5).abs() < 1e-6, + "without the atom the same boundary must still correct (c=1.5), got {}", + quads2[2].x + ); + } + + /// End-to-end: a rich paragraph of `italic run + upright run` on one + /// line gets a positive boundary gap in the emitted PlacedGlyph positions. That + /// gap is the overhang the renderer's synthetic shear would otherwise push into + /// the following upright run, and it matches the shear overhang + /// K × (shearBase − lastTop) within rasterization rounding. + #[test] + fn phase22_italic_boundary_positive_gap_on_real_layout() { + let mut fs = GuideFontSystem::new(); + let input = build_rich_paragraph_input(&[("Hi", true), ("tail", false)], 500.0); + let out = compute_layout(&input, &mut fs); + let result = flatbuffers::root::(&out).expect("LayoutResult"); + let runs = result.glyph_runs().expect("glyph_runs"); + let mut italic_run = None; + let mut upright_run = None; + for ri in 0..runs.len() { + let run = runs.get(ri); + if run.shear() { + italic_run = Some(run); + } else { + upright_run = Some(run); + } + } + let (italic_run, upright_run) = match (italic_run, upright_run) { + (Some(a), Some(b)) => (a, b), + _ => panic!("expected one italic and one upright run (system fonts required)"), + }; + let it = italic_run.glyphs().expect("italic glyphs"); + let up = upright_run.glyphs().expect("upright glyphs"); + assert!(!it.is_empty() && !up.is_empty(), "both runs must have glyphs"); + + let mut last_ix = f32::MIN; + let mut last_top = 0.0f32; + let mut last_w = 0.0f32; + let mut shear_base = 0.0f32; + for gi in 0..it.len() { + let g = it.get(gi); + shear_base = shear_base.max(g.y() + g.h()); + if g.x() > last_ix { + last_ix = g.x(); + last_top = g.y(); + last_w = g.w(); + } + } + let mut first_ux = f32::MAX; + for gi in 0..up.len() { + let g = up.get(gi); + first_ux = first_ux.min(g.x()); + } + let gap = first_ux - (last_ix + last_w); + let expected = 0.25 * (shear_base - last_top); + assert!( + gap > 0.0, + "positive italic→upright boundary must insert a gap, got {gap}" + ); + assert!( + (gap - expected).abs() < 1.5, + "boundary gap {gap} should match the shear overhang {expected} \ + (within 1px rasterization rounding)" + ); + println!( + "[italic_correction] runs={} gap={gap:.3} expected_overhang={expected:.3} \ + shear_base={shear_base:.3} last_top={last_top:.3}", + runs.len() + ); + } + + /// End-to-end, negative boundary: an italic run followed by a + /// SPACE then an upright run already has a natural gap (the space advance). + /// The correction may only ADD at most the shear overhang; it must not + /// overshoot into the natural gap, i.e. the resulting gap must stay within + /// [spaceAdvance, spaceAdvance + overhang]. + #[test] + fn phase22_italic_boundary_negative_gap_stays_within_natural() { + let mut fs = GuideFontSystem::new(); + // Same run pair, but the italic run carries a trailing space: natural + /// gap = space advance (no space quad is emitted, since rasterize skips zero-ink + /// bitmaps, so the gap is visible between the runs). + let input = build_rich_paragraph_input(&[("Hi ", true), ("tail", false)], 500.0); + let out = compute_layout(&input, &mut fs); + let result = flatbuffers::root::(&out).expect("LayoutResult"); + let runs = result.glyph_runs().expect("glyph_runs"); + let mut italic_run = None; + let mut upright_run = None; + for ri in 0..runs.len() { + let run = runs.get(ri); + if run.shear() { + italic_run = Some(run); + } else { + upright_run = Some(run); + } + } + let (italic_run, upright_run) = match (italic_run, upright_run) { + (Some(a), Some(b)) => (a, b), + _ => panic!("expected one italic and one upright run (system fonts required)"), + }; + let it = italic_run.glyphs().expect("italic glyphs"); + let up = upright_run.glyphs().expect("upright glyphs"); + + let mut last_ix = f32::MIN; + let mut last_top = 0.0f32; + let mut last_w = 0.0f32; + let mut shear_base = 0.0f32; + for gi in 0..it.len() { + let g = it.get(gi); + shear_base = shear_base.max(g.y() + g.h()); + if g.x() > last_ix { + last_ix = g.x(); + last_top = g.y(); + last_w = g.w(); + } + } + let mut first_ux = f32::MAX; + for gi in 0..up.len() { + let g = up.get(gi); + first_ux = first_ux.min(g.x()); + } + let gap = first_ux - (last_ix + last_w); + let overhang = 0.25 * (shear_base - last_top); + // Natural space gap is positive; the correction may at most close the + // remaining overhang, never inflate the gap beyond natural + overhang. + assert!( + gap >= 0.5, + "natural space gap must remain, got {gap}" + ); + assert!( + gap <= overhang + 6.0, + "negative-boundary gap {gap} must not overshoot natural space + overhang ({overhang})" + ); + println!( + "[italic_correction_negative] gap={gap:.3} overhang={overhang:.3}", + ); + } + + /// A SINGLE-STYLE (no spans) italic paragraph must still shear, so + /// GlyphRun.shear falls back to TextData.style.italic. This + /// direction is re-applied on the Rust side: without it the + /// whole-paragraph italic fixture (*H I l H I l g y.*) renders unslanted. + #[test] + fn phase22_single_style_italic_paragraph_shears() { + use flatbuffers::FlatBufferBuilder; + let mut fbb = FlatBufferBuilder::with_capacity(1024); + let ts = crate::fb::TextStyle::create( + &mut fbb, + &crate::fb::TextStyleArgs { + font_size: 11.0, + italic: true, + ..Default::default() + }, + ); + let text_off = fbb.create_string("H I l"); + let td = crate::fb::TextData::create( + &mut fbb, + &crate::fb::TextDataArgs { + text: Some(text_off), + style: Some(ts), + ..Default::default() + }, + ); + let style = crate::fb::Style::create( + &mut fbb, + &crate::fb::StyleArgs { + position: 0, + ..Default::default() + }, + ); + let node = crate::fb::FlatNode::create( + &mut fbb, + &crate::fb::FlatNodeArgs { + style: Some(style), + node_type: 1, + text: Some(td), + ..Default::default() + }, + ); + let nodes = fbb.create_vector(&[node]); + let input = crate::fb::LayoutInput::create( + &mut fbb, + &crate::fb::LayoutInputArgs { + available_width: 500.0, + nodes: Some(nodes), + ..Default::default() + }, + ); + fbb.finish(input, None); + let mut fs = GuideFontSystem::new(); + let out = compute_layout(&fbb.finished_data(), &mut fs); + let result = flatbuffers::root::(&out).expect("LayoutResult"); + let runs = result.glyph_runs().expect("glyph_runs"); + assert!( + !runs.is_empty(), + "expected glyph runs for single-style italic paragraph" + ); + let run = runs.get(0); + assert!( + run.shear(), + "single-style italic paragraph must shear (base_italic fallback)" + ); + assert!( + run.glyphs().expect("glyphs").len() > 0, + "run must have glyphs" + ); + println!( + "[italic_single_style] runs={} shear={} glyphs={}", + runs.len(), + run.shear(), + run.glyphs().expect("glyphs").len() + ); + } + + /// Test helper: build a LayoutInput with node 0 = paragraph whose + /// rich spans are `before` (document order), then a U+FFFC atom placeholder, + /// then `after`; node 1 = the atom block (node_type 2 image, explicit square + /// size, position:absolute so the inline post-pass anchors it). `ink` = + /// (ink_left, ink_right, ink_bottom) in 16-unit icon space, or None (all 0 = + /// undeclared). `atom_align` is the InlineBlockRef.align (2 = the retired + /// centering mode, meaningful only when ink is None). Default font 11. + fn build_atom_input( + before: &[(&str, bool)], + after: &[(&str, bool)], + atom_size: (f32, f32), + ink: Option<(f32, f32, f32)>, + atom_align: i8, + avail_width: f32, + ) -> Vec { + build_atom_input_at(before, after, atom_size, ink, atom_align, avail_width, 11.0, 0.0) + } + + /// `build_atom_input` with an explicit paragraph font size (REJECT-fix test + /// magnifies the shear overhang S so that gap_22 < S holds and correction_22 > 0, + /// which lets an end-to-end test tell a single c_23 from the double application + /// c_23 + correction_22). `param` is InlineBlockRef.param, the yOffset on the + /// serialization side, which the review test uses to check that yOffset + /// consumption is restored. + #[allow(clippy::too_many_arguments)] + fn build_atom_input_at( + before: &[(&str, bool)], + after: &[(&str, bool)], + atom_size: (f32, f32), + ink: Option<(f32, f32, f32)>, + atom_align: i8, + avail_width: f32, + font_size: f32, + param: f32, + ) -> Vec { + use flatbuffers::FlatBufferBuilder; + let mut fbb = FlatBufferBuilder::with_capacity(1024); + + let ts = crate::fb::TextStyle::create( + &mut fbb, + &crate::fb::TextStyleArgs { + font_size, + ..Default::default() + }, + ); + let mut span_offsets = Vec::new(); + let mut full = String::new(); + for (t, italic) in before { + let st = crate::fb::TextStyle::create( + &mut fbb, + &crate::fb::TextStyleArgs { + font_size, + italic: *italic, + ..Default::default() + }, + ); + let t_off = fbb.create_string(t); + span_offsets.push(crate::fb::TextSpan::create( + &mut fbb, + &crate::fb::TextSpanArgs { + text: Some(t_off), + style: Some(st), + }, + )); + full.push_str(t); + } + // U+FFFC placeholder part in the paragraph base style, mirroring Java's + // collectSpanParts ("Inline blocks become U+FFFC placeholder parts"). + { + let st = crate::fb::TextStyle::create( + &mut fbb, + &crate::fb::TextStyleArgs { + font_size, + italic: false, + ..Default::default() + }, + ); + let t_off = fbb.create_string("\u{FFFC}"); + span_offsets.push(crate::fb::TextSpan::create( + &mut fbb, + &crate::fb::TextSpanArgs { + text: Some(t_off), + style: Some(st), + }, + )); + full.push('\u{FFFC}'); + } + for (t, italic) in after { + let st = crate::fb::TextStyle::create( + &mut fbb, + &crate::fb::TextStyleArgs { + font_size, + italic: *italic, + ..Default::default() + }, + ); + let t_off = fbb.create_string(t); + span_offsets.push(crate::fb::TextSpan::create( + &mut fbb, + &crate::fb::TextSpanArgs { + text: Some(t_off), + style: Some(st), + }, + )); + full.push_str(t); + } + + let (il, ir, ib) = ink.unwrap_or((0.0, 0.0, 0.0)); + let ibr = crate::fb::InlineBlockRef::create( + &mut fbb, + &crate::fb::InlineBlockRefArgs { + node: 1, + align: atom_align, + param, + ink_left: il, + ink_right: ir, + ink_bottom: ib, + }, + ); + let ibr_vec = fbb.create_vector(&[ibr]); + let spans_vec = fbb.create_vector(&span_offsets); + let text_off = fbb.create_string(&full); + let td = crate::fb::TextData::create( + &mut fbb, + &crate::fb::TextDataArgs { + text: Some(text_off), + style: Some(ts), + inline_blocks: Some(ibr_vec), + spans: Some(spans_vec), + ..Default::default() + }, + ); + let para_style = crate::fb::Style::create( + &mut fbb, + &crate::fb::StyleArgs { + position: 0, + ..Default::default() + }, + ); + let para = crate::fb::FlatNode::create( + &mut fbb, + &crate::fb::FlatNodeArgs { + style: Some(para_style), + node_type: 1, + text: Some(td), + ..Default::default() + }, + ); + let sz_w = crate::fb::Dimension::create( + &mut fbb, + &crate::fb::DimensionArgs { + value: atom_size.0, + unit: 1, + }, + ); + let sz_h = crate::fb::Dimension::create( + &mut fbb, + &crate::fb::DimensionArgs { + value: atom_size.1, + unit: 1, + }, + ); + let atom_style = crate::fb::Style::create( + &mut fbb, + &crate::fb::StyleArgs { + position: 1, // pos_abs → anchored by the inline post-pass + size_w: Some(sz_w), + size_h: Some(sz_h), + ..Default::default() + }, + ); + let img = crate::fb::ImageData::create( + &mut fbb, + &crate::fb::ImageDataArgs { + explicit_w: atom_size.0, + explicit_h: atom_size.1, + ..Default::default() + }, + ); + let atom = crate::fb::FlatNode::create( + &mut fbb, + &crate::fb::FlatNodeArgs { + style: Some(atom_style), + node_type: 2, + image: Some(img), + ..Default::default() + }, + ); + let nodes = fbb.create_vector(&[para, atom]); + let input = crate::fb::LayoutInput::create( + &mut fbb, + &crate::fb::LayoutInputArgs { + available_width: avail_width, + justify: 0, + nodes: Some(nodes), + ..Default::default() + }, + ); + fbb.finish(input, None); + fbb.finished_data().to_vec() + } + + /// Multi-atom paragraph input: N words, each immediately followed by an + /// inline-block atom in document order (`word_k → atom_k`), mirroring + /// LayoutNodeSerializer's per-icon rich-span emission. Each word is its own + /// TextSpan; each atom its own node (1+k) + InlineBlockRef with the given + /// ink box. `words_italic` toggles the word spans (the upright twin builds + /// the identical natural geometry, because italic only rides the brush). + #[allow(clippy::too_many_arguments)] + fn build_multi_atom_input( + words: &[&str], + words_italic: bool, + inks: &[(f32, f32, f32)], + atom_size: (f32, f32), + avail_width: f32, + font_size: f32, + ) -> Vec { + use flatbuffers::FlatBufferBuilder; + let mut fbb = FlatBufferBuilder::with_capacity(1024); + + let ts = crate::fb::TextStyle::create( + &mut fbb, + &crate::fb::TextStyleArgs { + font_size, + ..Default::default() + }, + ); + let mut span_offsets = Vec::new(); + let mut refs = Vec::new(); + let mut full = String::new(); + for (wi, w) in words.iter().enumerate() { + let st = crate::fb::TextStyle::create( + &mut fbb, + &crate::fb::TextStyleArgs { + font_size, + italic: words_italic, + ..Default::default() + }, + ); + let t_off = fbb.create_string(w); + span_offsets.push(crate::fb::TextSpan::create( + &mut fbb, + &crate::fb::TextSpanArgs { + text: Some(t_off), + style: Some(st), + }, + )); + full.push_str(w); + // U+FFFC placeholder span (base style), mirroring collectSpanParts. + let st2 = crate::fb::TextStyle::create( + &mut fbb, + &crate::fb::TextStyleArgs { + font_size, + italic: false, + ..Default::default() + }, + ); + let p_off = fbb.create_string("\u{FFFC}"); + span_offsets.push(crate::fb::TextSpan::create( + &mut fbb, + &crate::fb::TextSpanArgs { + text: Some(p_off), + style: Some(st2), + }, + )); + full.push('\u{FFFC}'); + let (il, ir, ib) = inks[wi]; + refs.push(crate::fb::InlineBlockRef::create( + &mut fbb, + &crate::fb::InlineBlockRefArgs { + node: (1 + wi) as u32, + align: 2, + param: 0.0, + ink_left: il, + ink_right: ir, + ink_bottom: ib, + }, + )); + } + let refs_vec = fbb.create_vector(&refs); + let spans_vec = fbb.create_vector(&span_offsets); + let text_off = fbb.create_string(&full); + let td = crate::fb::TextData::create( + &mut fbb, + &crate::fb::TextDataArgs { + text: Some(text_off), + style: Some(ts), + inline_blocks: Some(refs_vec), + spans: Some(spans_vec), + ..Default::default() + }, + ); + let para_style = crate::fb::Style::create( + &mut fbb, + &crate::fb::StyleArgs { + position: 0, + ..Default::default() + }, + ); + let para = crate::fb::FlatNode::create( + &mut fbb, + &crate::fb::FlatNodeArgs { + style: Some(para_style), + node_type: 1, + text: Some(td), + ..Default::default() + }, + ); + let mut node_offsets = vec![para]; + for _ in 0..words.len() { + let sz_w = crate::fb::Dimension::create( + &mut fbb, + &crate::fb::DimensionArgs { + value: atom_size.0, + unit: 1, + }, + ); + let sz_h = crate::fb::Dimension::create( + &mut fbb, + &crate::fb::DimensionArgs { + value: atom_size.1, + unit: 1, + }, + ); + let atom_style = crate::fb::Style::create( + &mut fbb, + &crate::fb::StyleArgs { + position: 1, + size_w: Some(sz_w), + size_h: Some(sz_h), + ..Default::default() + }, + ); + let img = crate::fb::ImageData::create( + &mut fbb, + &crate::fb::ImageDataArgs { + explicit_w: atom_size.0, + explicit_h: atom_size.1, + ..Default::default() + }, + ); + node_offsets.push(crate::fb::FlatNode::create( + &mut fbb, + &crate::fb::FlatNodeArgs { + style: Some(atom_style), + node_type: 2, + image: Some(img), + ..Default::default() + }, + )); + } + let nodes = fbb.create_vector(&node_offsets); + let input = crate::fb::LayoutInput::create( + &mut fbb, + &crate::fb::LayoutInputArgs { + available_width: avail_width, + justify: 0, + nodes: Some(nodes), + ..Default::default() + }, + ); + fbb.finish(input, None); + fbb.finished_data().to_vec() + } + + /// All emitted glyphs of a result as (x, y, w, h, baseline), sorted by x + /// (paragraph-absolute document coordinates, top-left origin). + fn sorted_glyphs(result: &crate::fb::LayoutResult) -> Vec<(f32, f32, f32, f32, f32)> { + let mut v = Vec::new(); + if let Some(runs) = result.glyph_runs() { + for ri in 0..runs.len() { + let run = runs.get(ri); + if let Some(gs) = run.glyphs() { + for gi in 0..gs.len() { + let g = gs.get(gi); + v.push((g.x(), g.y(), g.w(), g.h(), g.baseline())); + } + } + } + } + v.sort_by(|a, b| a.0.partial_cmp(&b.0).unwrap()); + v + } + + /// The atomic ink box decides advance and + /// baseline. + /// ① the ink box values reach the wire and are non-zero (a + /// round-trip through the Rust reader); + /// ② the advance semantics. The older formula treated the ink box's + /// right edge position ir (0-15 within the cell) as the right side-bearing + /// width, which for real icons (il around 0..1, ir around 13..15) blew the + /// advance up to 27-33px against a cell width of only 16. Under the + /// inclusive-endpoint IconMetrics convention (width = ir - il + 1) the + /// correct right bearing is the cell right edge (15) minus the ink right + /// edge (ir), and the il term cancels, so the advance is always one full + /// 16-unit cell = cell_h = 16px: + /// - full grid (il=0, ir=15): advance is exactly 16, one whole cell; + /// - typical (il=1, ir=14): advance = 16, and the optical gap from the icon + /// ink to the next glyph's ink left edge is the right bearing 15 - ir = 1 + /// logical px, instead of the swallowed 29px gap; + /// - small-value boundary (il=2, ir=3): advance = 16 as well, since any + /// declared ink box is one whole cell, correcting the old 2+2+3=7 + /// shrinkage; + /// - no-ink control = a 16px grid cell, so the advance semantics converge on + /// the same cell width. + /// ③ the atomic ink bottom edge sits on the text baseline, with + /// ink_bottom × cell/16 = 10px above the baseline. + #[test] + fn phase23_ink_atom_advance_and_baseline() { + let mut fs = GuideFontSystem::new(); + // Realistic-magnitude ink boxes: full grid (0,15), typical (1,14) and the + // small-value boundary (2,3). + let full = Some((0.0, 15.0, 10.0)); + let typical = Some((1.0, 14.0, 10.0)); + let small = Some((2.0, 3.0, 10.0)); + let ink_input = build_atom_input( + &[("A", false)], + &[("B", false)], + (16.0, 16.0), + typical, + 2, + 500.0, + ); + let full_input = build_atom_input(&[("A", false)], &[("B", false)], (16.0, 16.0), full, 2, 500.0); + let small_input = build_atom_input( + &[("A", false)], + &[("B", false)], + (16.0, 16.0), + small, + 2, + 500.0, + ); + let plain_input = build_atom_input( + &[("A", false)], + &[("B", false)], + (16.0, 16.0), + None, + 2, + 500.0, + ); + + // ① Wire truth: the ink box rides the wire nonzero (round-trip). + let wire = flatbuffers::root::(&ink_input).expect("LayoutInput"); + let wref = wire + .nodes() + .expect("nodes") + .get(0) + .text() + .expect("text") + .inline_blocks() + .expect("inline_blocks") + .get(0); + assert!( + wref.ink_left() > 0.0 && wref.ink_right() > 0.0 && wref.ink_bottom() > 0.0, + "ink box truth must ride the wire nonzero (got {} {} {})", + wref.ink_left(), + wref.ink_right(), + wref.ink_bottom() + ); + + // Layout+measure helper: (atom_x, atom_y, next-glyph x, next-glyph baseline). + let measure = |fs: &mut GuideFontSystem, bytes: &[u8]| -> (f32, f32, f32, f32) { + let out = compute_layout(bytes, fs); + let result = flatbuffers::root::(&out).expect("LayoutResult"); + let atom = result.nodes().expect("nodes").get(1); + let (ax, ay) = (atom.x(), atom.y()); + let glyphs = sorted_glyphs(&result); + assert_eq!( + glyphs.len(), + 2, + "expected the 'A' and 'B' glyphs, got {}", + glyphs.len() + ); + (ax, ay, glyphs[1].0, glyphs[1].4) + }; + + let (ax, ay, b_x, baseline) = measure(&mut fs, &ink_input); + let (fx, _, f_bx, _) = measure(&mut fs, &full_input); + let (sx, _, s_bx, _) = measure(&mut fs, &small_input); + let (px, _, p_bx, _) = measure(&mut fs, &plain_input); + + // ② Full grid (il=0, ir=15): the advance is exactly 16, one whole cell. + let full_advance = f_bx - fx; + assert!( + (full_advance - 16.0).abs() < 1.5, + "full-grid ink box must advance exactly one cell (16), got {full_advance}" + ); + // Typical (il=1, ir=14): advance = 16, so the old (1+14+14) = 29px explosion is corrected. + let ink_advance = b_x - ax; + assert!( + (ink_advance - 16.0).abs() < 1.5, + "typical ink box (ir=14) must advance a full cell (16), got {ink_advance}" + ); + assert!( + ink_advance < 20.0, + "the fix must kill the old formula's 27-33px explosion (got {ink_advance})" + ); + // Icon ink right edge = ax + (il+width) × cell/16 = ax + (ir+1) = ax + 15; the + // optical gap to the next glyph's ink left edge b_x is the right bearing + // 15 - ir = 1 logical px. + let gap = b_x - (ax + 15.0); + assert!( + (gap - 1.0).abs() < 2.0, + "typical icon ink→text optical gap must equal the right bearing 15−ir = 1, got {gap}" + ); + assert!( + gap < 5.0, + "the old formula's ~14px post-icon gap must be gone (got {gap})" + ); + // Small-value boundary (il=2, ir=3): advance = 16 as well, because the il term + // cancels and any declared ink box is one whole cell, no longer shrinking to + // the old 2+2+3=7. + let small_advance = s_bx - sx; + assert!( + (small_advance - 16.0).abs() < 1.5, + "small ink box (boundary) must also advance a full cell (16), got {small_advance}" + ); + // No-ink control: advance = the declared cell width of 16px, so it converges on + // the same cell width as the ink-box semantics. + let cell_advance = p_bx - px; + assert!( + (cell_advance - 16.0).abs() < 1.5, + "no-ink atom keeps the grid-cell advance, got {cell_advance}" + ); + + // ③ The ink bottom edge sits on the baseline: atom.y + ink_bottom_px = baseline, + // with ink_bottom_px = 10 × 16/16 = 10. + let ink_bottom_px = 10.0; + assert!( + ((ay + ink_bottom_px) - baseline).abs() < 1.5, + "ink bottom edge must sit on the text baseline: atom.y({ay}) + {ink_bottom_px} \ + vs baseline {baseline}" + ); + println!( + "[phase23_ink_atom] full_advance={full_advance:.3} ink_advance={ink_advance:.3} \ + gap_after_ink={gap:.3} small_advance={small_advance:.3} \ + cell_advance={cell_advance:.3} atom_y={ay:.3} baseline={baseline:.3}" + ); + } + + /// The italic-to-atom boundary overhang correction acts on + /// the atom's layout position. When the synthetic shear pushes the last italic + /// glyph's top into the atom ink box's left edge, the atom x must move right by + /// correction = max(0, K × (shearBase - lastTop) - naturalGap). Since the italic + /// marker does not change glyph geometry, the upright control is the uncorrected + /// natural geometry: it yields the expected correction, and the assertion checks + /// that the italic atom's x moves right by exactly that amount. + /// + /// Two scenarios: + /// (a) "Hi" + ink_left=2: the ink box's left side bearing already covers the + /// overhang, so correction = 0; + /// (b) "W" + ink_left=0: a dense glyph sits on the pen, the overhang exceeds + /// the natural gap, so correction > 0 and the atom x moves right by that + /// amount. + #[test] + fn phase23_italic_to_atom_boundary_correction() { + let mut fs = GuideFontSystem::new(); + for (text, ink_left) in [("Hi", 2.0f32), ("W", 0.0f32)] { + let ink = Some((ink_left, 3.0, 10.0)); + let italic_input = + build_atom_input(&[(text, true)], &[], (16.0, 16.0), ink, 2, 500.0); + let upright_input = + build_atom_input(&[(text, false)], &[], (16.0, 16.0), ink, 2, 500.0); + + let out = compute_layout(&italic_input, &mut fs); + let res_i = flatbuffers::root::(&out).expect("LayoutResult"); + let out2 = compute_layout(&upright_input, &mut fs); + let res_u = flatbuffers::root::(&out2).expect("LayoutResult"); + + let atom_x_i = res_i.nodes().expect("nodes").get(1).x(); + let atom_x_u = res_u.nodes().expect("nodes").get(1).x(); + let para_x = res_u.nodes().expect("nodes").get(0).x(); + let para_y = res_u.nodes().expect("nodes").get(0).y(); + let pen_x = atom_x_u - para_x; // paragraph-relative pen (natural position) + + // Natural quad geometry from the upright control, byte-identical to the + // italic one because italic only rides the brush. + let quads: Vec<(f32, f32, f32, f32)> = sorted_glyphs(&res_u) + .into_iter() + .map(|(x, y, w, h, _)| (x - para_x, y - para_y, w, h)) + .collect(); + let last = *quads.last().expect("quads"); + let shear_base = quads.iter().map(|(_, y, _, h)| y + h).fold(0.0f32, f32::max); + let shear_shift = 0.25 * (shear_base - last.1); + let natural_gap = (pen_x + ink_left) - (last.0 + last.2); + let expected = (shear_shift - natural_gap).max(0.0); + let actual = atom_x_i - atom_x_u; + + assert!( + actual >= 0.0, + "{text:?}: the atom must never move left under the boundary correction (got {actual})" + ); + assert!( + (actual - expected).abs() < 1.5, + "{text:?}: italic→atom correction must shift the ATOM's layout position by the \ + overhang: got {actual}, expected {expected} \ + (shear_shift={shear_shift:.3}, natural_gap={natural_gap:.3})" + ); + if ink_left == 0.0 { + assert!( + actual > 0.0, + "{text:?}: dense glyph + zero left bearing must yield a POSITIVE \ + boundary correction (expected {expected}), got {actual}" + ); + } + println!( + "[phase23_italic_atom] text={text:?} ink_left={ink_left} \ + shear_shift={shear_shift:.3} natural_gap={natural_gap:.3} \ + expected={expected:.3} atom_x_u={atom_x_u:.3} atom_x_i={atom_x_i:.3} actual={actual:.3}" + ); + } + } + + /// Rejection fix, end to end: the single-line italic-to-atom-to-upright + /// configuration. The boundary correction is blind to the atom, so it applies + /// correction_22 to what it sees as a direct text boundary using a gap that + /// already contains the atom advance, while the atom correction's pass 2 shifts + /// the tail of the upright quads after the atom on the same line by c_23. With the + /// fix the boundary is skipped, because the atom pen falls between the two runs, + /// and the upright tail receives a single c_23. This test asserts that the text + /// offset after the atom equals c_23, not c_23 + correction_22. + /// + /// Geometry, re-derived after the advance correction: the atom ink box advance is always a whole + /// 16px cell because the il term cancels, so the double-application window + /// gap_22 = natural_gap + advance + left bearing must fall below S, which needs a + /// large font size to magnify the shear overhang S. The old construction with + /// ink=(0,0,10) produced a 1px advance from the incorrect formula and is no longer + /// usable. Font size 120, still below MAX_RASTER_SIZE 128, gives S around 21-23 + /// logical px against gap_22 around 13, which reopens the correction_22 > 3.0 + /// discrimination window. Without the fix the correction would push X by an extra + /// correction_22, which the assertion detects. + #[test] + fn phase23_italic_to_atom_to_upright_single_c23_tail_shift() { + let mut fs = GuideFontSystem::new(); + let ink = Some((0.0, 0.0, 10.0)); + let italic_input = build_atom_input_at( + &[("W", true)], + &[("X", false)], + (16.0, 16.0), + ink, + 2, + 500.0, + 120.0, + 0.0, + ); + let upright_input = build_atom_input_at( + &[("W", false)], + &[("X", false)], + (16.0, 16.0), + ink, + 2, + 500.0, + 120.0, + 0.0, + ); + + let out = compute_layout(&italic_input, &mut fs); + let res_i = flatbuffers::root::(&out).expect("LayoutResult"); + let out2 = compute_layout(&upright_input, &mut fs); + let res_u = flatbuffers::root::(&out2).expect("LayoutResult"); + + let para_x_u = res_u.nodes().expect("nodes").get(0).x(); + let para_y_u = res_u.nodes().expect("nodes").get(0).y(); + let para_x_i = res_i.nodes().expect("nodes").get(0).x(); + let para_y_i = res_i.nodes().expect("nodes").get(0).y(); + let atom_x_u = res_u.nodes().expect("nodes").get(1).x(); + let atom_x_i = res_i.nodes().expect("nodes").get(1).x(); + let pen_x = atom_x_u - para_x_u; // paragraph-relative pen (natural position) + + // Natural quad geometry from the upright control; italic only rides the brush, + // so the two inputs are byte-identical. + let quads_u: Vec<(f32, f32, f32, f32)> = sorted_glyphs(&res_u) + .into_iter() + .map(|(x, y, w, h, _)| (x - para_x_u, y - para_y_u, w, h)) + .collect(); + let quads_i: Vec<(f32, f32, f32, f32)> = sorted_glyphs(&res_i) + .into_iter() + .map(|(x, y, w, h, _)| (x - para_x_i, y - para_y_i, w, h)) + .collect(); + assert_eq!(quads_u.len(), 2, "expected W and X glyphs, got {}", quads_u.len()); + assert_eq!(quads_i.len(), 2, "expected W and X glyphs, got {}", quads_i.len()); + let last = quads_u[0]; // the italic trailing glyph before the atom (the only before glyph, W) + let first = quads_u[1]; // the upright leading glyph after the atom, X + + let shear_base = quads_u.iter().map(|(_, y, _, h)| y + h).fold(0.0f32, f32::max); + let shear_shift = 0.25 * (shear_base - last.1); + let natural_gap = (pen_x + 0.0) - (last.0 + last.2); // ink_left_px = 0 + let c_23 = (shear_shift - natural_gap).max(0.0); + // The correction the boundary pass would apply without the fix, whose gap + // contains the atom advance: + let gap_22 = first.0 - (last.0 + last.2); + let correction_22 = (shear_shift - gap_22).max(0.0); + + let atom_shift = atom_x_i - atom_x_u; + let after_shift = quads_i[1].0 - quads_u[1].0; + assert!( + correction_22 > 3.0, + "test geometry must make correction_22 > 3.0 for the double-application \ + branch to be reachable (discrimination window: real value ~7.25; tightening to 3.0 \ + exposes implementation drift below 3.0 immediately instead of passing a loose >1.0 window), got {correction_22:.3} \ + (shear_shift={shear_shift:.3}, gap_22={gap_22:.3})" + ); + assert!( + (atom_shift - c_23).abs() < 1.5, + "the atom itself must shift by c_23: got {atom_shift}, expected {c_23}" + ); + assert!( + (after_shift - c_23).abs() < 1.5, + "italic→atom→upright: the text AFTER the atom must shift by EXACTLY c_23, \ + got {after_shift}, expected {c_23:.3} - the double application would have \ + shifted it by c_23+correction_22 = {:.3}", + c_23 + correction_22 + ); + assert!( + (after_shift - (c_23 + correction_22)).abs() > 1.5, + "the post-atom shift must be distinguishable from the double-applied \ + c_23+correction_22 = {:.3}, got {after_shift}", + c_23 + correction_22 + ); + assert!( + (atom_shift - after_shift).abs() < 0.1, + "tail must move WITH the atom (pen consistency): atom_shift={atom_shift}, \ + after_shift={after_shift}" + ); + println!( + "[phase23_atom_tail] font=120 pen_x={pen_x:.3} shear_shift={shear_shift:.3} \ + natural_gap={natural_gap:.3} gap_22={gap_22:.3} c_23={c_23:.3} \ + correction_22={correction_22:.3} atom_shift={atom_shift:.3} \ + after_shift={after_shift:.3}" + ); + } + + /// Knife-edge regression (pass-2 double-application fix): the accumulating + /// case of a line carrying three or more italic-word to atom boundaries. Root + /// cause: pass-1 picks the trailing italic glyph per boundary from natural + /// geometry and computes a correction c, while pass-2's tail-shift membership + /// test `q.x >= pivot - 0.5` reads the position already accumulated by earlier + /// boundaries. The trailing glyph selected by the current boundary is pushed + /// past the threshold by an earlier boundary and is then shifted again by the + /// current one, so a single glyph receives a double application and detaches + /// from its word body. The fix has pass-1 record a natural-geometry snapshot of + /// the whole line's quads and pass-2 test membership against that same snapshot, + /// so every boundary fires once from natural geometry and boundaries never + /// accumulate into each other. + /// + /// Assertions: the third boundary's trailing glyph ends up displaced by the + /// prior sum c_1 + c_2 only, never by its own c_3, and its relative spacing to + /// the neighbouring glyph of the same word stays at the natural value within + /// ±0.5. Counter-check: before the fix this test fails, because the trailing + /// glyph, once pushed past the pass-2 threshold, is displaced by c_1 + c_2 + c_3. + /// + /// Construction (diagnostic values: icon ink_left=(0,0,0,3), all three chain + /// corrections positive): font size 72 magnifies the shear overhang (still below + /// MAX_RASTER_SIZE 128), and the word shape "pf" makes shear_base include the + /// descender of p while the trailing glyph f is narrow and tall, so every + /// boundary has a large c and a small pass-2 threshold, advance_f - lb - 0.5. + /// All expected values are derived from the upright control's natural geometry + /// through the pass-1 convention, which makes the test immune to system font + /// metrics. + #[test] + fn phase23_italic_atom_pass2_no_double_application() { + let mut fs = GuideFontSystem::new(); + let font = 72.0; + let words = ["pf", "pf", "pf", "pf"]; + let inks = [ + (0.0, 0.0, 10.0), + (0.0, 0.0, 10.0), + (0.0, 0.0, 10.0), + (3.0, 3.0, 10.0), + ]; + let italic_input = build_multi_atom_input(&words, true, &inks, (16.0, 16.0), 500.0, font); + let upright_input = + build_multi_atom_input(&words, false, &inks, (16.0, 16.0), 500.0, font); + + let out_i = compute_layout(&italic_input, &mut fs); + let res_i = flatbuffers::root::(&out_i).expect("LayoutResult"); + let out_u = compute_layout(&upright_input, &mut fs); + let res_u = flatbuffers::root::(&out_u).expect("LayoutResult"); + + let para_x_u = res_u.nodes().expect("nodes").get(0).x(); + let para_y_u = res_u.nodes().expect("nodes").get(0).y(); + let para_x_i = res_i.nodes().expect("nodes").get(0).x(); + let para_y_i = res_i.nodes().expect("nodes").get(0).y(); + let atom_x_u: Vec = (0..words.len()) + .map(|k| res_u.nodes().expect("nodes").get(1 + k).x() - para_x_u) + .collect(); + let atom_x_i: Vec = (0..words.len()) + .map(|k| res_i.nodes().expect("nodes").get(1 + k).x() - para_x_i) + .collect(); + + // Natural geometry comes from the upright control; italic only rides the + // brush, so the two inputs are byte-identical. + let quads_u: Vec<(f32, f32, f32, f32)> = sorted_glyphs(&res_u) + .into_iter() + .map(|(x, y, w, h, _)| (x - para_x_u, y - para_y_u, w, h)) + .collect(); + let quads_i: Vec<(f32, f32, f32, f32)> = sorted_glyphs(&res_i) + .into_iter() + .map(|(x, y, w, h, _)| (x - para_x_i, y - para_y_i, w, h)) + .collect(); + // Four words × 2 glyphs ("pf"); atoms and U+FFFC placeholders produce no + // glyphs. The whole line must fit the available width without wrapping, + // otherwise the k*2 index assumption breaks. + assert_eq!( + quads_u.len(), + words.len() * 2, + "upright twin must shape {} glyphs, got {}", + words.len() * 2, + quads_u.len() + ); + assert_eq!(quads_i.len(), words.len() * 2, "italic glyph count"); + let last_q_u = quads_u.last().expect("quads"); + assert!( + last_q_u.0 + last_q_u.2 < 500.0, + "test paragraph must stay on one line (right edge {:.1} >= avail 500)", + last_q_u.0 + last_q_u.2 + ); + + // Reproduce the pass-1 natural-geometry convention: each boundary's + // trailing glyph is the rightmost quad before the atom's pen (x < pivot - + // 0.5), and shear_base is taken over that span, which is the word's own two + // glyphs. ink_left_px = ink_left × bh/16 = ink_left, since bh = 16. + let pivot = |k: usize| atom_x_u[k]; + let c_of = |k: usize| -> f32 { + let p = pivot(k); + let l = quads_u + .iter() + .enumerate() + .filter(|(_, q)| q.0 < p - 0.5) + .max_by(|a, b| a.1.0.partial_cmp(&b.1.0).unwrap()) + .map(|(_, q)| *q) + .expect("a glyph must precede every atom pen"); + let si = k * 2; + let sb = quads_u[si..si + 2] + .iter() + .map(|(_, y, _, h)| y + h) + .fold(0.0f32, f32::max); + let ss = 0.25 * (sb - l.1); + let ng = (p + inks[k].0) - (l.0 + l.2); + (ss - ng).max(0.0) + }; + let (c1, c2, c3) = (c_of(0), c_of(1), c_of(2)); + // Each word's trailing glyph must really sit left of its atom pen for the + // pass-1 selection to hold. + for k in 0..words.len() { + assert!( + quads_u[k * 2 + 1].0 < pivot(k) - 0.5, + "word {k} last glyph must lie left of its atom pen" + ); + } + assert!( + c3 > 2.0, + "the 3rd boundary's correction must exceed the displacement tolerance \ + for discrimination (got c3={c3:.3}, c1={c1:.3}, c2={c2:.3})" + ); + + // Self-check of the geometry's discriminating power: under natural geometry + // the prior tail shift c1+c2 must push the third boundary's trailing glyph + // past the pass-2 threshold (pivot_3 - 0.5). Otherwise the old code never + // double-applies and this test could not tell the fix from the bug. + let l3 = quads_u[2 * 2 + 1]; + let l3_x = l3.0; + let p3 = pivot(2); + let prior_sum = c1 + c2; + let trig_threshold = p3 - 0.5; + assert!( + l3_x + prior_sum >= trig_threshold, + "test geometry must make the pre-fix pass-2 threshold crossing real: \ + last3.x({l3_x:.3}) + c1+c2({prior_sum:.3}) must be >= pivot3−0.5({trig_threshold:.3})" + ); + + let last3_shift = quads_i[2 * 2 + 1].0 - quads_u[2 * 2 + 1].0; + let penult3_shift = quads_i[2 * 2].0 - quads_u[2 * 2].0; + let atom3_shift = atom_x_i[2] - atom_x_u[2]; + + // After the fix the third boundary's trailing glyph is displaced by the + // prior sum only, never by its own c_3; a double application in the old + // code produced c1+c2+c3. + assert!( + (last3_shift - (c1 + c2)).abs() < 1.5, + "the 3rd boundary's last glyph must be displaced by ONLY the prior \ + boundary shifts c1+c2 = {:.3}, got {last3_shift:.3} (the pre-fix \ + double application would give c1+c2+c3 = {:.3})", + c1 + c2, + c1 + c2 + c3 + ); + assert!( + (last3_shift - (c1 + c2 + c3)).abs() > 1.5, + "the observed displacement must be distinguishable from the double-applied \ + c1+c2+c3 = {:.3}, got {last3_shift:.3}", + c1 + c2 + c3 + ); + // The neighbouring glyph inside the word body receives only the prior tails, + // in step with the trailing glyph, so their relative spacing stays at the + // natural value within ±0.5. + let spacing_u = l3.0 - quads_u[2 * 2].0; + let spacing_i = quads_i[2 * 2 + 1].0 - quads_i[2 * 2].0; + assert!( + (spacing_i - spacing_u).abs() < 0.5, + "intra-word spacing between the 3rd boundary's last glyph and its \ + word-mate must stay at the natural value {spacing_u:.3}, got \ + {spacing_i:.3} (last3_shift={last3_shift:.3}, \ + penult3_shift={penult3_shift:.3})" + ); + // The atom itself still carries this boundary's correction (c1+c2+c3). The + // single-shot semantics are unchanged; it just no longer reaches the + // preceding glyph of the word body. + assert!( + (atom3_shift - (c1 + c2 + c3)).abs() < 1.5, + "the 3rd atom must still shift by its own correction plus prior tails \ + c1+c2+c3 = {:.3}, got {atom3_shift:.3}", + c1 + c2 + c3 + ); + println!( + "[phase23_atom_pass2] font={font} c1={c1:.3} c2={c2:.3} c3={c3:.3} \ + pivot3={p3:.3} last3_natural={l3_x:.3} last3_shift={last3_shift:.3} \ + penult3_shift={penult3_shift:.3} atom3_shift={atom3_shift:.3} \ + spacing_natural={spacing_u:.3} spacing_italic={spacing_i:.3}" + ); + } + + /// The align=2 centering hack is retired and its + /// consumer removed. A leftover align=2 atom without an ink box must fall + /// through to the default arm, with the block bottom 2px below the baseline, + /// instead of being centered on the line. The centering top would be about + /// baseline - line_height/2 + block_h/2, roughly 5px away from the default arm, + /// which the assertion can distinguish. + #[test] + fn phase23_align2_centering_hack_retired() { + let mut fs = GuideFontSystem::new(); + // No ink box → align=2 must fall through to the default anchoring. + let input = build_atom_input(&[("A", false)], &[("B", false)], (16.0, 16.0), None, 2, 500.0); + let out = compute_layout(&input, &mut fs); + let result = flatbuffers::root::(&out).expect("LayoutResult"); + let atom = result.nodes().expect("nodes").get(1); + let runs = result.glyph_runs().expect("glyph_runs"); + assert!(runs.len() > 0, "expected glyph runs"); + let baseline = runs.get(0).glyphs().expect("glyphs").get(0).baseline(); + let ay = atom.y(); + // Default arm: block top = baseline + 2 - block_h, so block bottom = baseline + 2. + let expected_top = baseline + 2.0 - 16.0; + assert!( + (ay - expected_top).abs() < 1.5, + "retired align=2 must anchor the block bottom 2px below the baseline \ + (top={expected_top}), got {ay}" + ); + // The retired centering arm would have put the top near baseline - + // line_height/2, about baseline - 8.3, some 5.7px away from the default + // arm. Asserting the default value alone discriminates, since centering + // would exceed the ±1.5 tolerance. + println!( + "[phase23_align2_retired] atom_y={ay:.3} baseline={baseline:.3} \ + expected_default_top={expected_top:.3}" + ); + } + + /// Review follow-up (yOffset consumption restored): param, the yOffset on the + /// serialization side fed by inlineYOffsetOverride, as on a real guide page + /// such as ``, takes part in the vertical + /// placement again in both the ink-box anchor arm and the default arm, with the + /// same sign convention as the retired align=2 centering arm + /// `top_off=(line_height-bh)/2+param`: a positive value moves the block top down. + /// The assertions compare block top minus baseline against the declared formulas + /// directly, which is immune to line growth and baseline drift: + /// ink-box arm: block top = baseline - ink_bottom_px + param; + /// default arm (leftover align=2 without an ink box): block top = + /// baseline + 2 - block_h + param. + /// A relative check follows: going from param 0 to 2 moves the block top exactly + /// 2px down relative to the baseline. + #[test] + fn phase23_atom_param_yoffset_consumed() { + let mut fs = GuideFontSystem::new(); + let ink = Some((2.0, 3.0, 10.0)); + // Ink-box arm: a param=0 control plus a param=2 case, the IconMetrics atom plus + // yOffset shape. + let out0 = compute_layout( + &build_atom_input_at(&[("A", false)], &[("B", false)], (16.0, 16.0), ink, 2, 500.0, 11.0, 0.0), + &mut fs, + ); + let res0 = flatbuffers::root::(&out0).expect("LayoutResult"); + let out2 = compute_layout( + &build_atom_input_at(&[("A", false)], &[("B", false)], (16.0, 16.0), ink, 2, 500.0, 11.0, 2.0), + &mut fs, + ); + let res2 = flatbuffers::root::(&out2).expect("LayoutResult"); + let g0 = sorted_glyphs(&res0); + let g2 = sorted_glyphs(&res2); + assert_eq!(g0.len(), 2, "expected A and B glyphs (param=0), got {}", g0.len()); + assert_eq!(g2.len(), 2, "expected A and B glyphs (param=2), got {}", g2.len()); + let baseline0 = g0[1].4; + let baseline2 = g2[1].4; + let ay0 = res0.nodes().expect("nodes").get(1).y(); + let ay2 = res2.nodes().expect("nodes").get(1).y(); + let ib = 10.0; // ink_bottom=10 × cell 16/16 → 10px + assert!( + (ay0 + ib - baseline0).abs() < 1.5, + "param=0 ink tier must anchor top = baseline − ink_bottom_px: \ + ay0({ay0:.3}) + {ib} vs baseline0({baseline0:.3})" + ); + assert!( + (ay2 + ib - 2.0 - baseline2).abs() < 1.5, + "param=2 ink tier must anchor top = baseline − ink_bottom_px + param: \ + ay2({ay2:.3}) + {ib} − 2 vs baseline2({baseline2:.3})" + ); + // Relative check, immune to line growth: from param 0 to 2 the block top moves + // 2px down relative to the baseline. + assert!( + (((ay2 - baseline2) - (ay0 - baseline0)) - 2.0).abs() < 1.0, + "ink tier param must shift the atom 2px DOWN relative to the baseline: \ + top−baseline {:.3} → {:.3}", + ay0 - baseline0, + ay2 - baseline2 + ); + + // Default arm (no ink box, leftover align=2 falls through): block top = + // baseline + 2 - bh + param. + let outn0 = compute_layout( + &build_atom_input_at(&[("A", false)], &[("B", false)], (16.0, 16.0), None, 2, 500.0, 11.0, 0.0), + &mut fs, + ); + let resn0 = flatbuffers::root::(&outn0).expect("LayoutResult"); + let outn2 = compute_layout( + &build_atom_input_at(&[("A", false)], &[("B", false)], (16.0, 16.0), None, 2, 500.0, 11.0, 2.0), + &mut fs, + ); + let resn2 = flatbuffers::root::(&outn2).expect("LayoutResult"); + let bn0 = sorted_glyphs(&resn0)[1].4; + let bn2 = sorted_glyphs(&resn2)[1].4; + let n0 = resn0.nodes().expect("nodes").get(1).y(); + let n2 = resn2.nodes().expect("nodes").get(1).y(); + assert!( + (n0 - (bn0 + 2.0 - 16.0)).abs() < 1.5, + "default tier param=0 must anchor top = baseline + 2 − bh: \ + n0({n0:.3}) vs bn0({bn0:.3}) + 2 − 16" + ); + assert!( + (n2 - (bn2 + 2.0 - 16.0 + 2.0)).abs() < 1.5, + "default tier param=2 must shift the atom DOWN by param: \ + n2({n2:.3}) vs bn2({bn2:.3}) + 2 − 16 + 2" + ); + println!( + "[phase23_atom_param] ink tier: top0={ay0:.3} top2={ay2:.3} \ + (top−baseline {:.3} → {:.3}); default tier: top0={n0:.3} top2={n2:.3}", + ay0 - baseline0, + ay2 - baseline2 + ); + } + + // List markers + + /// Build a LayoutInput with a list-item container (node 0, padding_left=10 = + /// LEVEL_MARGIN content indent, children=[para]) and one content paragraph + /// (node 1). `marker` = Some((text, argb)) declares ListMarkerData on the + /// container (the Java LytListItem write path); None leaves it undeclared. + fn build_list_marker_input( + marker: Option<(&str, u32)>, + para_text: &str, + avail_width: f32, + ) -> Vec { + use flatbuffers::FlatBufferBuilder; + let mut fbb = FlatBufferBuilder::with_capacity(1024); + + // node 1: content paragraph (relative flow, default white). + let ts = crate::fb::TextStyle::create( + &mut fbb, + &crate::fb::TextStyleArgs { + font_size: 11.0, + ..Default::default() + }, + ); + let text_off = fbb.create_string(para_text); + let td = crate::fb::TextData::create( + &mut fbb, + &crate::fb::TextDataArgs { + text: Some(text_off), + style: Some(ts), + ..Default::default() + }, + ); + let para_style = crate::fb::Style::create( + &mut fbb, + &crate::fb::StyleArgs { + position: 0, + ..Default::default() + }, + ); + let para = crate::fb::FlatNode::create( + &mut fbb, + &crate::fb::FlatNodeArgs { + style: Some(para_style), + node_type: 1, + text: Some(td), + ..Default::default() + }, + ); + + // node 0: list-item container with padding_left=10 (LEVEL_MARGIN). + let li_style = crate::fb::Style::create( + &mut fbb, + &crate::fb::StyleArgs { + padding_left: 10.0, + ..Default::default() + }, + ); + let lmd = marker.map(|(text, color)| { + let md_text = fbb.create_string(text); + let md_style = crate::fb::TextStyle::create( + &mut fbb, + &crate::fb::TextStyleArgs { + font_size: 11.0, + color, + ..Default::default() + }, + ); + crate::fb::ListMarkerData::create( + &mut fbb, + &crate::fb::ListMarkerDataArgs { + text: Some(md_text), + style: Some(md_style), + }, + ) + }); + let children = fbb.create_vector(&[1u32]); + let li = crate::fb::FlatNode::create( + &mut fbb, + &crate::fb::FlatNodeArgs { + style: Some(li_style), + node_type: 0, + children: Some(children), + list_marker: lmd, + ..Default::default() + }, + ); + let nodes = fbb.create_vector(&[li, para]); + let input = crate::fb::LayoutInput::create( + &mut fbb, + &crate::fb::LayoutInputArgs { + available_width: avail_width, + justify: 0, + nodes: Some(nodes), + ..Default::default() + }, + ); + fbb.finish(input, None); + fbb.finished_data().to_vec() + } + + /// Builder dedicated to the fallback case: a list item (node 0, + /// padding_left=10) holding a paragraph with inline atoms only (node 1, text = + /// U+FFFC and no text glyphs at all) plus the inline atom block (node 2, + /// position:absolute image 16×16, ink box (1,14,10) at realistic magnitude). + /// collectBlockChildren promotes the inline atom to a direct child of the list + /// item, level with the paragraph, so neither direct child (para, atom) has + /// glyphs. This is the real-corpus shape (AE2 getting-started items 13 and 22), + /// where the fallback must take the first-line baseline from the paragraph's own + /// markers. + fn build_list_marker_icon_input(marker: Option<(&str, u32)>, avail_width: f32) -> Vec { + use flatbuffers::FlatBufferBuilder; + let mut fbb = FlatBufferBuilder::with_capacity(1024); + + // node 1: icons-only paragraph (text = "\u{FFFC}" placeholder, no glyphs). + let ts = crate::fb::TextStyle::create( + &mut fbb, + &crate::fb::TextStyleArgs { + font_size: 11.0, + ..Default::default() + }, + ); + let text_off = fbb.create_string("\u{FFFC}"); + let ibr = crate::fb::InlineBlockRef::create( + &mut fbb, + &crate::fb::InlineBlockRefArgs { + node: 2, + align: 2, + param: 0.0, + ink_left: 1.0, + ink_right: 14.0, + ink_bottom: 10.0, + }, + ); + let ibr_vec = fbb.create_vector(&[ibr]); + let td = crate::fb::TextData::create( + &mut fbb, + &crate::fb::TextDataArgs { + text: Some(text_off), + style: Some(ts), + inline_blocks: Some(ibr_vec), + ..Default::default() + }, + ); + let para_style = crate::fb::Style::create( + &mut fbb, + &crate::fb::StyleArgs { + position: 0, + ..Default::default() + }, + ); + let para = crate::fb::FlatNode::create( + &mut fbb, + &crate::fb::FlatNodeArgs { + style: Some(para_style), + node_type: 1, + text: Some(td), + ..Default::default() + }, + ); + + // node 2: the inline atom (image, explicit 16×16 px, position:absolute). + let sz_w = crate::fb::Dimension::create( + &mut fbb, + &crate::fb::DimensionArgs { value: 16.0, unit: 1 }, + ); + let sz_h = crate::fb::Dimension::create( + &mut fbb, + &crate::fb::DimensionArgs { value: 16.0, unit: 1 }, + ); + let atom_style = crate::fb::Style::create( + &mut fbb, + &crate::fb::StyleArgs { + position: 1, // pos_abs → anchored by the inline post-pass + size_w: Some(sz_w), + size_h: Some(sz_h), + ..Default::default() + }, + ); + let img = crate::fb::ImageData::create( + &mut fbb, + &crate::fb::ImageDataArgs { + explicit_w: 16.0, + explicit_h: 16.0, + ..Default::default() + }, + ); + let atom = crate::fb::FlatNode::create( + &mut fbb, + &crate::fb::FlatNodeArgs { + style: Some(atom_style), + node_type: 2, + image: Some(img), + ..Default::default() + }, + ); + + // node 0: list-item container (padding_left=10), children = [para, atom]. + let li_style = crate::fb::Style::create( + &mut fbb, + &crate::fb::StyleArgs { + padding_left: 10.0, + ..Default::default() + }, + ); + let lmd = marker.map(|(text, color)| { + let md_text = fbb.create_string(text); + let md_style = crate::fb::TextStyle::create( + &mut fbb, + &crate::fb::TextStyleArgs { + font_size: 11.0, + color, + ..Default::default() + }, + ); + crate::fb::ListMarkerData::create( + &mut fbb, + &crate::fb::ListMarkerDataArgs { + text: Some(md_text), + style: Some(md_style), + }, + ) + }); + let children = fbb.create_vector(&[1u32, 2u32]); + let li = crate::fb::FlatNode::create( + &mut fbb, + &crate::fb::FlatNodeArgs { + style: Some(li_style), + node_type: 0, + children: Some(children), + list_marker: lmd, + ..Default::default() + }, + ); + let nodes = fbb.create_vector(&[li, para, atom]); + let input = crate::fb::LayoutInput::create( + &mut fbb, + &crate::fb::LayoutInputArgs { + available_width: avail_width, + justify: 0, + nodes: Some(nodes), + ..Default::default() + }, + ); + fbb.finish(input, None); + fbb.finished_data().to_vec() + } + + /// Builder for the cascade-order regression covering the nested case: a list + /// item (node 0, padding_left=10) holding a paragraph with inline atoms only + /// (node 1, text = U+FFFC and no text glyphs), the inline atom block (node 2, + /// position:absolute image 16×16, ink box (1,14,10)) and a nested list container + /// (node 3, children=[4]) whose nested text paragraph (node 4, "Nested item + /// text") does have glyphs. This is the real-corpus shape of AE2 + /// getting-started "Certus Quartz Ore", whose first line is a bare icon followed + /// by a nested sublist. + /// The old cascade went from direct-child glyphs (none on nodes 1/2/3) straight + /// to the DFS, which descended through node 3 and hit node 4's nested text line, + /// producing two markers on one row. In the corrected order, after step one finds + /// nothing, the inline-atom-only direct child (node 1, non-empty markers and + /// empty glyphs) is checked before any descent, so the marker anchors to the atom + /// line. + fn build_list_marker_icon_nested_input(marker: Option<(&str, u32)>, avail_width: f32) -> Vec { + use flatbuffers::FlatBufferBuilder; + let mut fbb = FlatBufferBuilder::with_capacity(2048); + + // node 1: icons-only paragraph (text = "\u{FFFC}" placeholder, no glyphs). + let ts = crate::fb::TextStyle::create( + &mut fbb, + &crate::fb::TextStyleArgs { + font_size: 11.0, + ..Default::default() + }, + ); + let text_off = fbb.create_string("\u{FFFC}"); + let ibr = crate::fb::InlineBlockRef::create( + &mut fbb, + &crate::fb::InlineBlockRefArgs { + node: 2, + align: 2, + param: 0.0, + ink_left: 1.0, + ink_right: 14.0, + ink_bottom: 10.0, + }, + ); + let ibr_vec = fbb.create_vector(&[ibr]); + let td = crate::fb::TextData::create( + &mut fbb, + &crate::fb::TextDataArgs { + text: Some(text_off), + style: Some(ts), + inline_blocks: Some(ibr_vec), + ..Default::default() + }, + ); + let para_style = crate::fb::Style::create( + &mut fbb, + &crate::fb::StyleArgs { + position: 0, + ..Default::default() + }, + ); + let para = crate::fb::FlatNode::create( + &mut fbb, + &crate::fb::FlatNodeArgs { + style: Some(para_style), + node_type: 1, + text: Some(td), + ..Default::default() + }, + ); + + // node 2: the inline atom (image, explicit 16×16 px, position:absolute). + let sz_w = crate::fb::Dimension::create( + &mut fbb, + &crate::fb::DimensionArgs { value: 16.0, unit: 1 }, + ); + let sz_h = crate::fb::Dimension::create( + &mut fbb, + &crate::fb::DimensionArgs { value: 16.0, unit: 1 }, + ); + let atom_style = crate::fb::Style::create( + &mut fbb, + &crate::fb::StyleArgs { + position: 1, // pos_abs → anchored by the inline post-pass + size_w: Some(sz_w), + size_h: Some(sz_h), + ..Default::default() + }, + ); + let img = crate::fb::ImageData::create( + &mut fbb, + &crate::fb::ImageDataArgs { + explicit_w: 16.0, + explicit_h: 16.0, + ..Default::default() + }, + ); + let atom = crate::fb::FlatNode::create( + &mut fbb, + &crate::fb::FlatNodeArgs { + style: Some(atom_style), + node_type: 2, + image: Some(img), + ..Default::default() + }, + ); + + // node 4: nested text paragraph (has real glyphs). + let nts = crate::fb::TextStyle::create( + &mut fbb, + &crate::fb::TextStyleArgs { + font_size: 11.0, + ..Default::default() + }, + ); + let ntext_off = fbb.create_string("Nested item text"); + let ntd = crate::fb::TextData::create( + &mut fbb, + &crate::fb::TextDataArgs { + text: Some(ntext_off), + style: Some(nts), + ..Default::default() + }, + ); + let npara_style = crate::fb::Style::create( + &mut fbb, + &crate::fb::StyleArgs { + position: 0, + ..Default::default() + }, + ); + let npara = crate::fb::FlatNode::create( + &mut fbb, + &crate::fb::FlatNodeArgs { + style: Some(npara_style), + node_type: 1, + text: Some(ntd), + ..Default::default() + }, + ); + + // node 3: nested list container wrapping the nested text paragraph. + let nlist_style = crate::fb::Style::create( + &mut fbb, + &crate::fb::StyleArgs { + padding_left: 10.0, + ..Default::default() + }, + ); + let nlist_children = fbb.create_vector(&[4u32]); + let nlist = crate::fb::FlatNode::create( + &mut fbb, + &crate::fb::FlatNodeArgs { + style: Some(nlist_style), + node_type: 0, + children: Some(nlist_children), + ..Default::default() + }, + ); + + // node 0: list-item container (padding_left=10), children = [para, atom, nested list]. + let li_style = crate::fb::Style::create( + &mut fbb, + &crate::fb::StyleArgs { + padding_left: 10.0, + ..Default::default() + }, + ); + let lmd = marker.map(|(text, color)| { + let md_text = fbb.create_string(text); + let md_style = crate::fb::TextStyle::create( + &mut fbb, + &crate::fb::TextStyleArgs { + font_size: 11.0, + color, + ..Default::default() + }, + ); + crate::fb::ListMarkerData::create( + &mut fbb, + &crate::fb::ListMarkerDataArgs { + text: Some(md_text), + style: Some(md_style), + }, + ) + }); + let children = fbb.create_vector(&[1u32, 2u32, 3u32]); + let li = crate::fb::FlatNode::create( + &mut fbb, + &crate::fb::FlatNodeArgs { + style: Some(li_style), + node_type: 0, + children: Some(children), + list_marker: lmd, + ..Default::default() + }, + ); + let nodes = fbb.create_vector(&[li, para, atom, nlist, npara]); + let input = crate::fb::LayoutInput::create( + &mut fbb, + &crate::fb::LayoutInputArgs { + available_width: avail_width, + justify: 0, + nodes: Some(nodes), + ..Default::default() + }, + ); + fbb.finish(input, None); + fbb.finished_data().to_vec() + } + + /// The marker GlyphRun of a result: (node_index, [(x, y, w, h)...]) for the + /// run whose argb equals the declared marker color. None = no such run + /// (marker missing from the quad stream). + fn marker_run( + result: &crate::fb::LayoutResult, + color: u32, + ) -> Option<(u32, Vec<(f32, f32, f32, f32)>)> { + let runs = result.glyph_runs()?; + for ri in 0..runs.len() { + let run = runs.get(ri); + if run.argb() != color { + continue; + } + let gs = run.glyphs()?; + let mut v = Vec::with_capacity(gs.len()); + for gi in 0..gs.len() { + let g = gs.get(gi); + v.push((g.x(), g.y(), g.w(), g.h())); + } + return Some((run.node_index(), v)); + } + None + } + + /// Acceptance case ①: marker glyphs attach to the content paragraph + /// (node_index == 1, the GlyphRunHolder) as an ordinary GlyphRun, and the ink + /// right edge lands exactly on the list item's own left edge, which is that + /// nesting level's document text line (L1 = 14 = CONTENT_PAD), so the whole box + /// hangs into the left margin and no gutter constant is involved. The marker + /// glyphs also carry the sentinel line_index (u32::MAX), keeping them out of the + /// body visual line so the content paragraph's firstLineBoundsFromGlyphs, which + /// filters on line 0, is not polluted by the marker. + #[test] + fn phase25_list_marker_right_edge_lands_on_gutter() { + const MARKER: u32 = 0xFFAAAAAA; // Java COL_MARKER gray (ColorUtils.MC_GRAY #AAA) + let mut fs = GuideFontSystem::new(); + let input = build_list_marker_input(Some(("1.", MARKER)), "Item text", 500.0); + let out = compute_layout(&input, &mut fs); + let result = flatbuffers::root::(&out).expect("LayoutResult"); + + let (node, glyphs) = marker_run(&result, MARKER).expect("marker glyph run"); assert_eq!( + node, 1, + "marker run must attach to the content paragraph (flat index 1, the GlyphRunHolder)" + ); + assert!(!glyphs.is_empty(), "marker run must carry glyph quads"); + + // The target is the list item's own left edge (node 0 x = CONTENT_PAD = 14); + // the marker's ink right edge lands exactly on that level's text line + // (L1 = 14), not on content-child x minus a gutter, which the old formula put + // at 24-5 = 19. + let item_x = result.nodes().expect("nodes").get(0).x(); + let target = item_x; + let max_right = glyphs + .iter() + .map(|(x, _, w, _)| x + w) + .fold(0.0f32, f32::max); + assert!( + (max_right - target).abs() < 1e-3, + "marker ink right edge must land exactly on the item left edge (L1 text line, {target}), \ + got {max_right} (marker glyphs: {glyphs:?})" + ); + // Sentinel line_index: the marker must not enter body line 0, which + // firstLineBoundsFromGlyphs filters on. + let runs = result.glyph_runs().expect("glyph_runs"); + let mut seen_sentinel = false; + for ri in 0..runs.len() { + let run = runs.get(ri); + if run.argb() != MARKER { + continue; + } + let gs = run.glyphs().expect("marker run glyphs"); + for gi in 0..gs.len() { + assert_eq!( + gs.get(gi).line_index(), + u32::MAX, + "marker glyphs must carry the sentinel line index (isolated from body visual lines)" + ); + seen_sentinel = true; + } + } + assert!(seen_sentinel, "sentinel line index must be observed"); + println!( + "[phase25_marker_right_edge] item_x={item_x:.3} target={target:.3} \ + max_right={max_right:.3} glyphs={}", + glyphs.len() + ); + } + + /// Acceptance case ②: a multi-digit marker "10." and a single-digit "1." both + /// right-align to the same list item left edge, so the ink right edges are + /// collinear (spread = 0). The wider "10." reaches further left, about 1.5 + /// logical px, the expected shape the owner accepted, with the whole box hanging + /// into the left margin. + #[test] + fn phase25_multidigit_marker_right_aligned() { + const MARKER: u32 = 0xFFAAAAAA; + let mut fs = GuideFontSystem::new(); + let in1 = build_list_marker_input(Some(("1.", MARKER)), "Item text", 500.0); + let in10 = build_list_marker_input(Some(("10.", MARKER)), "Item text", 500.0); + let out1 = compute_layout(&in1, &mut fs); + let r1 = flatbuffers::root::(&out1).expect("r1"); + let out10 = compute_layout(&in10, &mut fs); + let r10 = flatbuffers::root::(&out10).expect("r10"); + + let (n1, g1) = marker_run(&r1, MARKER).expect("1. run"); + let (n10, g10) = marker_run(&r10, MARKER).expect("10. run"); + assert_eq!(n1, 1); + assert_eq!(n10, 1); + let right = + |g: &Vec<(f32, f32, f32, f32)>| g.iter().map(|(x, _, w, _)| x + w).fold(0.0f32, f32::max); + let left = + |g: &Vec<(f32, f32, f32, f32)>| g.iter().map(|(x, _, _, _)| *x).fold(f32::MAX, f32::min); + let item_x = r1.nodes().expect("nodes").get(0).x(); + let target = item_x; + assert!( + (right(&g1) - target).abs() < 1e-3, + "1. must right-align to the item left edge: right={} target={target}", + right(&g1) + ); + assert!( + (right(&g10) - target).abs() < 1e-3, + "10. must right-align to the SAME item left edge (collinear, spread=0): \ + right={} target={target}", + right(&g10) + ); + assert!( + (right(&g1) - right(&g10)).abs() < 1e-3, + "right edges must be collinear (spread=0): right1={} right10={}", + right(&g1), + right(&g10) + ); + assert!( + left(&g10) < left(&g1) - 2.0, + "10. (wider) must extend further left than 1.: left1={} left10={}", + left(&g1), + left(&g10) + ); + assert!( + left(&g10) > -0.5 && left(&g10) < target, + "10. ink must hang into the left margin but not cross the page edge \ + (owner-accepted ≈1.5): left10={}", + left(&g10) + ); + println!( + "[phase25_multidigit] right1={:.3} right10={:.3} target={target:.3} \ + left1={:.3} left10={:.3}", + right(&g1), + right(&g10), + left(&g1), + left(&g10) + ); + } + + /// Acceptance case ③: an unordered bullet "•" travels the glyph path as an + /// ordinary GlyphRun attached to the content paragraph instead of Java drawing a + /// square, since the old FillRect logic has been deleted. + #[test] + fn phase25_bullet_renders_as_glyph_run() { + const MARKER: u32 = 0xFFAAAAAA; + let mut fs = GuideFontSystem::new(); + let input = build_list_marker_input(Some(("\u{2022}", MARKER)), "Item text", 500.0); + let out = compute_layout(&input, &mut fs); + let result = flatbuffers::root::(&out).expect("LayoutResult"); + + let (node, glyphs) = marker_run(&result, MARKER) + .expect("bullet must render through the glyph-quad path (Java FillRect removed)"); + assert_eq!(node, 1, "bullet run must attach to the content paragraph"); + assert!( + !glyphs.is_empty(), + "bullet run must carry at least one glyph quad (the '•' outline)" + ); + let item_x = result.nodes().expect("nodes").get(0).x(); + let target = item_x; + let max_right = glyphs + .iter() + .map(|(x, _, w, _)| x + w) + .fold(0.0f32, f32::max); + assert!( + (max_right - target).abs() < 1e-3, + "bullet ink right edge must land on the item left edge (B1): got {max_right}, target {target}" + ); + println!( + "[phase25_bullet_glyph] node={node} glyphs={} max_right={max_right:.3} target={target:.3}", + glyphs.len() + ); + } + + /// Acceptance case ④, the hard gate that content-block geometry is unchanged: a + /// marker declaration must not alter any content block's bounds. For the same + /// input with and without the list_marker declaration, every FlatLayout x/y/w/h + /// matches, because the marker hangs into the left margin, does not take part in + /// wrapping and writes neither sizes nor abs_positions. + #[test] + fn phase25_content_geometry_unchanged_by_marker_declaration() { + const MARKER: u32 = 0xFFAAAAAA; + let mut fs = GuideFontSystem::new(); + let with_marker = build_list_marker_input(Some(("1.", MARKER)), "Item text", 500.0); + let without_marker = build_list_marker_input(None, "Item text", 500.0); + let out_w = compute_layout(&with_marker, &mut fs); + let rw = flatbuffers::root::(&out_w).expect("rw"); + let out_wo = compute_layout(&without_marker, &mut fs); + let rwo = flatbuffers::root::(&out_wo).expect("rwo"); + + let na = rw.nodes().expect("nodes"); + let nb = rwo.nodes().expect("nodes"); + assert_eq!( + na.len(), + nb.len(), + "marker declaration must not add/remove layout blocks" + ); + for i in 0..na.len() { + let a = na.get(i); + let b = nb.get(i); + for (name, va, vb) in [ + ("x", a.x(), b.x()), + ("y", a.y(), b.y()), + ("w", a.w(), b.w()), + ("h", a.h(), b.h()), + ] { + assert!( + (va - vb).abs() < 1e-6, + "content block bounds must be byte-identical with/without the marker \ + declaration: node {i} {name} {va} vs {vb}" + ); + } + } + println!("[phase25_geometry] nodes={} byte-identical (marker-invariant)", na.len()); + } + + /// Rejected-then-fixed vertical anchoring: the marker baseline must land exactly + /// on the content first-line baseline, as a direct numeric equality within a + /// tolerance of ±1 logical px, which at the render_scale=1 used by this test is + /// ±1 device px. The first version added the marker's own baseline offset (about + /// one line height, measured at +12) on top of baseline_off, dropping the whole + /// marker by one line: marker_top - item_top measured +19 against a + /// baseline delta of +6.5. An additional hard gate requires the marker ink band + /// not to leave its own item band (list item y..y+h) and not to press into the + /// next line. + #[test] + fn phase25_marker_vertical_baseline_matches_content_first_line() { + const MARKER: u32 = 0xFFAAAAAA; + let mut fs = GuideFontSystem::new(); + let input = build_list_marker_input(Some(("1.", MARKER)), "Item text", 500.0); + let out = compute_layout(&input, &mut fs); + let result = flatbuffers::root::(&out).expect("LayoutResult"); + + let runs = result.glyph_runs().expect("glyph_runs"); + let mut marker_baseline: Option = None; + let mut content_baseline: Option = None; + let mut marker_min_y = f32::MAX; + let mut marker_max_y = f32::MIN; + for ri in 0..runs.len() { + let run = runs.get(ri); + if run.argb() == MARKER { + marker_baseline = Some(run.baseline()); + if let Some(gs) = run.glyphs() { + for gi in 0..gs.len() { + let g = gs.get(gi); + marker_min_y = marker_min_y.min(g.y()); + marker_max_y = marker_max_y.max(g.y() + g.h()); + } + } + } else if run.node_index() == 1 { + // Content paragraph run: one span means one run, whose baseline is + // the first-line baseline. + content_baseline = content_baseline.or(Some(run.baseline())); + } + } + let marker_baseline = marker_baseline.expect("marker run"); + let content_baseline = content_baseline.expect("content run"); + assert!( + (marker_baseline - content_baseline).abs() <= 1.0, + "marker baseline must EQUAL the content first-line baseline (direct value): \ + marker={marker_baseline} content={content_baseline} (2.5 first version had \ + marker−content = +12 ≈ one full line)" + ); + // Hard gate on the item band: the marker ink must not leave its own item + // (list item y..y+h). + let item = result.nodes().expect("nodes").get(0); + let item_y = item.y(); + let item_h = item.h(); + assert!( + marker_min_y >= item_y - 1.0 && marker_max_y <= item_y + item_h + 1.0, + "marker ink must stay inside its own item band [{item_y}, {}]: got \ + [{marker_min_y}, {marker_max_y}]", + item_y + item_h + ); + println!( + "[phase25_vertical_single] marker_baseline={marker_baseline:.3} \ + content_baseline={content_baseline:.3} delta={:.3} \ + ink=[{marker_min_y:.3},{marker_max_y:.3}] item=[{item_y:.3},{:.3}]", + marker_baseline - content_baseline, + item_y + item_h + ); + } + + /// Rejected-then-fixed vertical anchoring for a wrapped item: when the content + /// wraps onto two or more lines the marker still anchors to the first-line + /// baseline, not the second and certainly not the last. That baseline is the + /// minimum baseline among the content run's line_index==0 glyphs; the assertion + /// checks that the marker baseline equals it directly and sits clearly below the + /// second line's baseline. + #[test] + fn phase25_marker_vertical_anchors_first_line_when_content_wraps() { + const MARKER: u32 = 0xFFAAAAAA; + let mut fs = GuideFontSystem::new(); + // A narrow container forces the content to wrap. + let long = "First segment of a wrapped item text that certainly spills onto a second line."; + let input = build_list_marker_input(Some(("10.", MARKER)), long, 100.0); + let out = compute_layout(&input, &mut fs); + let result = flatbuffers::root::(&out).expect("LayoutResult"); + + let runs = result.glyph_runs().expect("glyph_runs"); + let mut marker_baseline: Option = None; + let mut line0_baseline: Option = None; + let mut line1_baseline: Option = None; + let mut lines: std::collections::BTreeSet = Default::default(); + for ri in 0..runs.len() { + let run = runs.get(ri); + if run.argb() == MARKER { + marker_baseline = Some(run.baseline()); + continue; + } + if run.node_index() != 1 { + continue; + } + if let Some(gs) = run.glyphs() { + for gi in 0..gs.len() { + let g = gs.get(gi); + lines.insert(g.line_index()); + if g.line_index() == 0 { + line0_baseline = + Some(line0_baseline.map_or(g.baseline(), |b| b.min(g.baseline()))); + } + if g.line_index() == 1 { + line1_baseline = + Some(line1_baseline.map_or(g.baseline(), |b| b.min(g.baseline()))); + } + } + } + } + let line_count = lines.len(); + assert!( + line_count >= 2, + "content must actually wrap to >=2 lines for this test, got {line_count}" + ); + let marker_baseline = marker_baseline.expect("marker run"); + let line0 = line0_baseline.expect("line-0 baseline"); + let line1 = line1_baseline.expect("line-1 baseline"); + assert!( + (marker_baseline - line0).abs() <= 1.0, + "wrapped item: marker baseline must equal the FIRST-line baseline (direct value): \ + marker={marker_baseline} line0={line0} line1={line1}" + ); + assert!( + marker_baseline < line1, + "marker must sit on the FIRST line, not the wrapped second line: \ + marker={marker_baseline} line1={line1}" + ); + println!( + "[phase25_vertical_wrap] lines={line_count} marker={marker_baseline:.3} \ + line0={line0:.3} line1={line1:.3} delta0={:.3}", + marker_baseline - line0 + ); + } + + /// Fallback case in the real-corpus shape of AE2 getting-started items 13 and 22: + /// the list item's first line holds inline atoms only (paragraph text = U+FFFC + /// with no text glyphs at all, the inline atom being promoted to a direct child + /// of the list item). The old anchor lookup only examined direct-child glyphs, + /// and neither direct child (para, atom) had any, so the marker was skipped + /// entirely. After the fix the lookup falls back to the paragraph's own markers, + /// the authoritative source of the first-line baseline, and the marker must be + /// generated with: + /// ① its right edge exactly on the list item's own left edge (item_x = + /// CONTENT_PAD = 14); + /// ② its baseline equal to the first-line baseline, which the inline atom's + /// ink bottom edge anchors: atom.y + ink_bottom_px, where + /// ink_bottom = 10 × 16/16 = 10. + #[test] + fn phase25_marker_survives_inline_atom_only_first_line() { + const MARKER: u32 = 0xFFAAAAAA; + let mut fs = GuideFontSystem::new(); + let input = build_list_marker_icon_input(Some(("1.", MARKER)), 500.0); + let out = compute_layout(&input, &mut fs); + let result = flatbuffers::root::(&out).expect("LayoutResult"); + + let (node, glyphs) = marker_run(&result, MARKER) + .expect("icons-only item must still get a marker (B2 subtree/marker fallback)"); + assert_eq!(node, 1, "marker run must attach to the icons-only paragraph (GlyphRunHolder)"); + assert!(!glyphs.is_empty(), "marker run must carry glyph quads"); + + // ① The ink right edge lands exactly on the list item's own left edge + // (node 0 x = CONTENT_PAD = 14). + let item_x = result.nodes().expect("nodes").get(0).x(); + let max_right = glyphs + .iter() + .map(|(x, _, w, _)| x + w) + .fold(0.0f32, f32::max); + assert!( + (max_right - item_x).abs() < 1e-3, + "marker ink right edge must land on the item left edge ({item_x}), got {max_right} \ + (marker glyphs: {glyphs:?})" + ); + + // ② Baseline = first-line baseline: the inline atom's ink bottom edge anchors + // it, atom.y + ink_bottom_px = 10 × 16/16 = 10. The marker run baseline + // comes from the paragraph's markers[0].baseline_y on the fallback path, + // and the atom y is derived from that same line baseline, so the two are + // directly numerically equal. + let runs = result.glyph_runs().expect("glyph_runs"); + let mut marker_baseline: Option = None; + let mut seen_sentinel = false; + for ri in 0..runs.len() { + let run = runs.get(ri); + if run.argb() != MARKER { + continue; + } + marker_baseline = Some(run.baseline()); + if let Some(gs) = run.glyphs() { + for gi in 0..gs.len() { + assert_eq!( + gs.get(gi).line_index(), + u32::MAX, + "marker glyphs must carry the sentinel line index" + ); + seen_sentinel = true; + } + } + } + assert!(seen_sentinel, "sentinel line index must be observed"); + let marker_baseline = marker_baseline.expect("marker run baseline"); + let atom_y = result.nodes().expect("nodes").get(2).y(); + let ink_bottom_px = 10.0; // 10 × 16/16 + assert!( + (marker_baseline - (atom_y + ink_bottom_px)).abs() < 1.5, + "icons-only item: marker baseline must equal the first-line baseline \ + (= atom ink-bottom anchor, atom.y({atom_y}) + {ink_bottom_px}): \ + marker={marker_baseline:.3}" + ); + println!( + "[phase25_b2_icons_only] item_x={item_x:.3} max_right={max_right:.3} \ + marker_baseline={marker_baseline:.3} atom_y={atom_y:.3} \ + atom_baseline={:.3}", + atom_y + ink_bottom_px + ); + } + + /// Cascade-order regression from the correction: when a list item's first line + /// holds inline atoms only and a nested list with text items follows, the marker + /// must anchor to the item's own first visible line, the atom-line baseline, and + /// must not be captured by the second cascade step's subtree DFS onto a nested + /// child's text line, which put two markers on one row: "• • We need to + /// obtain…" + /// The constructed item is [paragraph with inline atoms only, nested list with + /// text items]: + /// node 0: list-item, children = [para(1), atom(2), nested_list(3)] + /// node 1: icons-only para (U+FFFC, no glyphs, non-empty markers) + /// node 2: image atom 16×16 (ink box (1,14,10)) + /// node 3: nested list container, children = [4] + /// node 4: nested text paragraph (has glyphs) + /// The old cascade went from direct-child glyphs (none) to the DFS, which hit + /// node 4's nested text line and produced two markers on one row, while the atom + /// paragraph never got a turn. In the corrected order, after step one still finds + /// nothing, step two finds the inline-atom-only direct child at node 1. The + /// assertion requires the marker run to attach to the atom paragraph (node 1, the + /// GlyphRunHolder) with a baseline equal to the atom-line baseline, + /// atom.y + ink_bottom_px. + #[test] + fn phase25_b2_marker_anchors_atom_first_line_before_nested_list() { + const MARKER: u32 = 0xFFAAAAAA; + let mut fs = GuideFontSystem::new(); + let input = build_list_marker_icon_nested_input(Some(("1.", MARKER)), 500.0); + let out = compute_layout(&input, &mut fs); + let result = flatbuffers::root::(&out).expect("LayoutResult"); + + // Scenario validity: the nested text paragraph at node 4 must really produce + // glyphs, otherwise the test degenerates into a plain icon item without a + // nested list and cannot catch a cascade-order bug. + let runs = result.glyph_runs().expect("glyph_runs"); + let nested_glyphs = (0..runs.len()) + .map(|ri| runs.get(ri)) + .any(|run| run.argb() != MARKER && run.node_index() == 4 + && run.glyphs().map_or(false, |g| g.len() > 0)); + assert!( + nested_glyphs, + "nested text paragraph (node 4) must produce glyphs for this scenario" + ); + + // The marker run must attach to the atom paragraph (node 1), not to the nested + // text paragraph (node 4). + let (node, glyphs) = marker_run(&result, MARKER) + .expect("marker run must be generated (atom first line exists)"); + assert_eq!( + node, 1, + "marker must anchor to the item's OWN first visible line (icons-only \ + paragraph node 1), NOT to the nested list text line (node 4): got {node}" + ); + assert!(!glyphs.is_empty(), "marker run must carry glyph quads"); + + // Baseline = atom-line baseline: the atom's ink bottom edge anchors it + // (atom.y + ink_bottom_px). + let marker_baseline = (0..runs.len()) + .map(|ri| runs.get(ri)) + .find(|run| run.argb() == MARKER) + .map(|run| run.baseline()) + .expect("marker run baseline"); + let atom_y = result.nodes().expect("nodes").get(2).y(); + let ink_bottom_px = 10.0; // 10 × 16/16 + assert!( + (marker_baseline - (atom_y + ink_bottom_px)).abs() < 1.5, + "atom-first-line item WITH nested list: marker baseline must equal the \ + atom line baseline (= atom.y({atom_y}) + {ink_bottom_px}), NOT the nested \ + text line: marker={marker_baseline:.3}" + ); + println!( + "[phase25_b2_nested_after_atom] anchor_node={node} \ + marker_baseline={marker_baseline:.3} atom_y={atom_y:.3} \ + atom_baseline={:.3}", + atom_y + ink_bottom_px + ); + } +} diff --git a/src/rust/layout-engine/src/lib.rs b/src/rust/layout-engine/src/lib.rs new file mode 100644 index 00000000..1b7dd1ce --- /dev/null +++ b/src/rust/layout-engine/src/lib.rs @@ -0,0 +1,155 @@ +pub mod guidenh_layout_generated; +// Re-export generated types for convenience +pub use guidenh_layout_generated::com::hfstudio::guidenh::guide::layout::flatbuffers as fb; +pub mod jni_bridge; +pub mod layout; +pub mod measure; +pub mod parley_text; +pub mod style_convert; +pub mod text; + +use std::panic::{catch_unwind, AssertUnwindSafe}; + +use jni::JNIEnv; +use jni::objects::{JByteArray, JClass, JString}; +use jni::sys::{jbyteArray, jlong}; + +use crate::jni_bridge::{jbytearray_to_vec, vec_to_jbytearray}; +use crate::layout::compute_layout; +use crate::text::GuideFontSystem; + +/// Java: static native long init(byte[] fontTtfData, String locale); +#[no_mangle] +pub extern "system" fn Java_com_hfstudio_guidenh_guide_layout_LayoutBridge_init( + mut env: JNIEnv, + _class: JClass, + font_data: JByteArray, + locale: JString, +) -> jlong { + let result = catch_unwind(AssertUnwindSafe(|| { + let font_bytes = jbytearray_to_vec(&mut env, &font_data).unwrap_or_default(); + let _locale_str: String = env + .get_string(&locale) + .map(|s| s.into()) + .unwrap_or_default(); + + let mut font_system = GuideFontSystem::new(); + if !font_bytes.is_empty() { + font_system.load_font_data(font_bytes); + } + + let ptr = Box::into_raw(Box::new(font_system)); + ptr as jlong + })); + + match result { + Ok(h) => h, + Err(_) => 0, + } +} + +/// Java: static native void loadFallbackFont(long handle, byte[] fallbackData); +/// Best-effort: registers a symbol font and appends it to the Han fallback +/// key (see [`ParleyFonts::load_fallback_font_data`]). No-op on empty data +/// or any error; never affects the existing font system. +#[no_mangle] +pub extern "system" fn Java_com_hfstudio_guidenh_guide_layout_LayoutBridge_loadFallbackFont( + mut env: JNIEnv, + _class: JClass, + handle: jlong, + fallback_data: JByteArray, +) { + let result = catch_unwind(AssertUnwindSafe(|| { + if handle == 0 { + return; + } + let data = jbytearray_to_vec(&mut env, &fallback_data).unwrap_or_default(); + if data.is_empty() { + return; + } + let font_system = unsafe { &mut *(handle as *mut GuideFontSystem) }; + font_system.load_fallback_font_data(data); + })); + + let _ = result; +} + +/// Java: static native byte[] measureLayout(long handle, byte[] input); +#[no_mangle] +pub extern "system" fn Java_com_hfstudio_guidenh_guide_layout_LayoutBridge_measureLayout( + mut env: JNIEnv, + _class: JClass, + handle: jlong, + input: JByteArray, +) -> jbyteArray { + let result = catch_unwind(AssertUnwindSafe(|| { + if handle == 0 { + return vec_to_jbytearray(&mut env, &[]).unwrap_or(std::ptr::null_mut()); + } + let input_bytes = jbytearray_to_vec(&mut env, &input).unwrap_or_default(); + if input_bytes.is_empty() { + return vec_to_jbytearray(&mut env, &[]).unwrap_or(std::ptr::null_mut()); + } + + let font_system = unsafe { &mut *(handle as *mut GuideFontSystem) }; + let output = compute_layout(&input_bytes, font_system); + + vec_to_jbytearray(&mut env, &output).unwrap_or(std::ptr::null_mut()) + })); + + match result { + Ok(arr) => arr, + Err(_) => env + .new_byte_array(0) + .map(|a| a.into_raw()) + .unwrap_or(std::ptr::null_mut()), + } +} + +/// Java: static native byte[] shapeText(long handle, byte[] input); +/// Unified-text-pipeline entry: shape + rasterize one styled text into +/// atlas-keyed quads + metrics (ShapeTextResult FlatBuffer). +#[no_mangle] +pub extern "system" fn Java_com_hfstudio_guidenh_guide_layout_LayoutBridge_shapeText( + mut env: JNIEnv, + _class: JClass, + handle: jlong, + input: JByteArray, +) -> jbyteArray { + let result = catch_unwind(AssertUnwindSafe(|| { + if handle == 0 { + return vec_to_jbytearray(&mut env, &[]).unwrap_or(std::ptr::null_mut()); + } + let input_bytes = jbytearray_to_vec(&mut env, &input).unwrap_or_default(); + if input_bytes.is_empty() { + return vec_to_jbytearray(&mut env, &[]).unwrap_or(std::ptr::null_mut()); + } + + let font_system = unsafe { &mut *(handle as *mut GuideFontSystem) }; + let output = layout::shape_text_cmd(font_system, &input_bytes); + + vec_to_jbytearray(&mut env, &output).unwrap_or(std::ptr::null_mut()) + })); + + match result { + Ok(arr) => arr, + Err(_) => env + .new_byte_array(0) + .map(|a| a.into_raw()) + .unwrap_or(std::ptr::null_mut()), + } +} + +/// Java: static native void destroy(long handle); +#[no_mangle] +pub extern "system" fn Java_com_hfstudio_guidenh_guide_layout_LayoutBridge_destroy( + _env: JNIEnv, + _class: JClass, + handle: jlong, +) { + if handle != 0 { + unsafe { + drop(Box::from_raw(handle as *mut GuideFontSystem)); + } + } +} diff --git a/src/rust/layout-engine/src/measure.rs b/src/rust/layout-engine/src/measure.rs new file mode 100644 index 00000000..98b83c10 --- /dev/null +++ b/src/rust/layout-engine/src/measure.rs @@ -0,0 +1,1510 @@ +use std::collections::HashMap; + +use crate::fb::FlatNode; +use crate::text::GuideFontSystem; +use taffy::prelude::*; + +/// Context stored in Taffy leaf nodes for measure closure dispatch. +#[derive(Debug, Clone)] +pub struct NodeContext { + pub flat_index: usize, + pub node_type: u8, +} + +/// Accumulator for shaped glyphs during measure closure. +/// Inserted on each call; last call wins (Taffy may measure multiple times). +#[derive(Default)] +pub struct GlyphAccum { + /// Relative glyphs (paragraph-local coordinates, no node offset). + pub glyphs: Vec, + /// Inline-block anchors in shaping order, consumed by the inline + /// post-pass in layout.rs. + pub markers: Vec, + /// Float-aligned inline block anchors: (flat_node_index, paragraph-relative y). + pub float_anchors: Vec<(usize, f32)>, + /// (x_off, line_width) of the last line's full float-compressed window + /// (paragraph-relative). None for empty paragraphs. Used by separator + /// line (kind=3 DecorationRect) for heading paragraphs. + pub last_line_window: Option<(f32, f32)>, +} + +/// One inline-block anchor in paragraph-local coordinates: pen position on +/// the line's baseline plus the line metrics the anchor's block needs for +/// its vertical alignment. +#[derive(Clone, Debug)] +pub struct InlineMarker { + pub pen_x: f32, + pub baseline_y: f32, + pub line_top: f32, + pub line_height: f32, + pub line_index: usize, + pub advance: f32, +} + +/// Whether the atomic ink box is declared. The schema +/// default is 0, meaning "not declared", so legacy data is unaffected; any +/// non-zero field counts as declared. +pub(crate) fn ink_declared(r: &crate::fb::InlineBlockRef) -> bool { + r.ink_left() != 0.0 || r.ink_right() != 0.0 || r.ink_bottom() != 0.0 +} + +/// The atomic ink box advance in px, equal to +/// ink_left + ink width + right bearing. It replaces the cell width that the +/// legacy behavior used (the block's declared px width). Unit conversion: a +/// 16-cell icon-space float, the same unit as IconMetrics, becomes px through +/// the factor cell_h/16, where cell_h is the declared height of the icon block +/// on a square grid. Returns None when the box is not declared, and the caller +/// falls back to the cell width. +/// +/// Correction: the older formula treated the ink box's right edge +/// position ir, a 0-15 coordinate within the cell with inclusive endpoints, as +/// if it were the right side-bearing width. For real icons (il near 0..1, ir +/// near 13..15) that blew the advance up to 27-33px against a cell width of only +/// 16, leaving a wide gap after an ItemLinkDense icon. Under the +/// inclusive-endpoint IconMetrics convention (width = ir - il + 1 over cells +/// 0-15) the correct right bearing is the cell right edge (15) minus the ink +/// right edge (ir), so +/// advance in cell units = il + (ir - il + 1) + (15 - ir) = 16 +/// The il term cancels, so any declared ink box advances by exactly one full +/// 16-unit cell (il=0, ir=15 gives exactly 16; the typical ir=14 also gives 16), +/// which is px = 16 * cell_h/16 = cell_h. The optical gap between an icon and the +/// text that follows is then right bearing + text left bearing, roughly +/// (15 - ir) * cell_h/16 + 1 logical px, so 1-3 logical px for a typical ir=14 +/// icon instead of the swallowed 27-33px gap. +pub(crate) fn ink_box_advance(r: &crate::fb::InlineBlockRef, cell_h: f32) -> Option { + if !ink_declared(r) { + return None; + } + let il = r.ink_left(); + let ir = r.ink_right(); + // Ink width = inkRight - inkLeft + 1, the same inclusive-endpoint convention + // as IconMetrics.width; right bearing = 15 - ir, the cell right edge (15) + // minus the ink right edge position, so the ir position is no longer treated + // as a width. + Some((il + (ir - il + 1.0) + (15.0 - ir)) * cell_h / 16.0) +} + +/// Px distance from the bottom edge of the atomic ink box +/// to the top of the block, used for baseline anchoring. The 16-cell icon space +/// converts to px through the factor cell_h/16. Returns None when not declared. +pub(crate) fn ink_bottom_px(r: &crate::fb::InlineBlockRef, cell_h: f32) -> Option { + if !ink_declared(r) { + return None; + } + Some(r.ink_bottom() * cell_h / 16.0) +} + +/// Space an inline block needs above its line's baseline / below its line's +/// bottom, per alignment mode (see InlineBlockRef in the schema). Positive +/// values grow the line; the legacy layout grew line boxes the same way. +/// +/// Ink-box anchoring takes priority. When +/// `ink_bottom_px = Some(ib)` the ink bottom edge sits on the text baseline, so +/// the line grows by the ink box's height above the baseline plus its depth +/// below the line bottom. align is ignored in that branch, while param (yOffset) +/// still participates in the vertical placement (see below). +pub(crate) fn marker_needs( + m: &InlineMarker, + block_h: f32, + align: i8, + param: f32, + ink_bottom_px: Option, +) -> (f32, f32) { + let line_ascent = m.baseline_y - m.line_top; + let line_descent = (m.line_top + m.line_height) - m.baseline_y; + // The atomic ink bottom edge is aligned with the text + // baseline, so the block top = baseline - ink_bottom_px + param. param, the + // yOffset on the serialization side, takes part in the vertical placement + // again with the same sign convention as the retired align=2 centering arm + // `top_off = (line_height - block_h)/2 + param`: a positive value moves the + // block top down. + if let Some(ib) = ink_bottom_px { + return ( + (ib - param - line_ascent).max(0.0), + ((block_h - ib + param) - line_descent).max(0.0), + ); + } + match align { + // Baseline ascent: block top sits `param` above the baseline. + 1 => ( + (param - line_ascent).max(0.0), + ((block_h - param) - line_descent).max(0.0), + ), + // Retired: the align=2 centering hack is gone; icon atoms are + // now anchored through the ink box bottom edge (see above). Leftover + // align=2 values from older serialized data fall through to the default + // arm and are no longer centered. param (yOffset) takes part in this arm + // again: block top = baseline + 2 - block_h + param, a positive value + // moving the block down exactly as the retired align=2 arm did. The param + // sent by the serialization side for align=0 is always 0, so that behavior + // is unchanged. + // Default: block bottom sits 2px below the baseline, plus param. + _ => ( + (block_h - 2.0 - param - line_ascent).max(0.0), + (2.0 + param - line_descent).max(0.0), + ), + } +} + +/// Explicit pixel height of an inline block node (0 when not px-sized). +pub(crate) fn inline_block_height(nodes: &[FlatNode], idx: usize) -> f32 { + let Some(style) = nodes[idx].style() else { return 0.0 }; + let Some(d) = style.size_h() else { return 0.0 }; + if d.unit() == 1 { d.value() } else { 0.0 } +} + +/// Explicit pixel width of an inline block node (0 when not px-sized). +pub(crate) fn inline_block_width(nodes: &[FlatNode], idx: usize) -> f32 { + let Some(style) = nodes[idx].style() else { return 0.0 }; + let Some(d) = style.size_w() else { return 0.0 }; + if d.unit() == 1 { d.value() } else { 0.0 } +} + +/// Build the measure closure for compute_layout_with_measure. +/// Dispatches by node_type to the appropriate measurement function. +pub fn create_measure_closure<'a>( + font_system: &'a mut GuideFontSystem, + flat_nodes: &'a [FlatNode], + glyph_acc: &'a mut HashMap, + justify: bool, + visual_scale: f32, +) -> impl FnMut( + Size>, + Size, + NodeId, + Option<&mut NodeContext>, + &Style, +) -> Size + 'a { + move |known, available, _node_id, ctx, _style| -> Size { + let ctx = match ctx { + Some(c) => c, + None => return Size::ZERO, + }; + let index = ctx.flat_index; + + let (measured, _clear_floor) = match ctx.node_type { + 1 => { + // Inline paragraphs (position:absolute - the Java compiler + // lowers every paragraph-inline block to position 1, error / + // placeholder fallbacks included) shape at their NATURAL + // width: the declared px size is only the box size and the + // inline post-pass pen reservation, it must never become the + // wrap constraint. Taffy feeds a leaf's own declared width + // back as the measure available width (compute/leaf.rs + // `maybe_set(node_size.width)`), so a declared width a hair + // under the natural advance (Java's Math.round rounding + // down) would wrap the paragraph at its last space and + // overflow its own declared box - the wrapped second line + // lands below the box bottom. Shaping inline paragraphs at + // MaxContent keeps them on their natural single line no + // matter how the declared width was rounded. Relative-flow + // paragraphs (position 0) keep wrapping at the container's + // available width - their max_w semantics are untouched. + let inline = flat_nodes[index] + .style() + .map(|s| s.position() == 1) + .unwrap_or(false); + let avail = if inline { + Size { + width: AvailableSpace::MaxContent, + height: available.height, + } + } else { + available + }; + measure_text( + font_system, flat_nodes, index, glyph_acc, avail, justify, &[], 0.0, 0.0, &[], + ) + } + 2 => (measure_image(flat_nodes, index), None), + 3 => (measure_slot(flat_nodes, index), None), + 4 => (measure_thematic_break(flat_nodes, index, known, available), None), + 8 => (measure_latex(flat_nodes, index), None), + 20 => (measure_recipe_box(flat_nodes, index), None), + 21 => (measure_pie_chart(flat_nodes, index, known, available, visual_scale), None), + 22 | 23 | 24 | 25 => (measure_chart(flat_nodes, index, known, available, visual_scale), None), + 26 => (measure_structure_view(flat_nodes, index, known, available, visual_scale), None), + 27 => (measure_guidebook_scene(flat_nodes, index, known, available, visual_scale), None), + 28 => (measure_function_graph(flat_nodes, index, known, available, visual_scale), None), + 29 => (measure_mediawiki_generated_list(flat_nodes, index, known, available), None), + 30 => (measure_mediawiki_special_generated(flat_nodes, index, known, available), None), + _ => (Size::ZERO, None), + }; + // Explicit style sizes win over content measurement (CSS behavior): + // Taffy passes them in as known dimensions; honoring them is what lets + // opaque fixed-size leaves (buttons, sprites, px-pinned boxes) keep + // their declared size instead of collapsing to the measured ZERO. + Size { + width: known.width.unwrap_or(measured.width), + height: known.height.unwrap_or(measured.height), + } + } +} + +pub(crate) fn measure_text( + fs: &mut GuideFontSystem, + nodes: &[FlatNode], + idx: usize, + acc: &mut HashMap, + available: Size, + justify: bool, + floats: &[crate::parley_text::FloatRect], + para_abs_y: f32, + para_x: f32, + clears: &[(usize, u8)], +) -> (Size, Option) { + let node = &nodes[idx]; + let td = match node.text() { + Some(t) => t, + None => return (Size::ZERO, None), + }; + let text = td.text().unwrap_or(""); + let style = td.style().unwrap(); + let font_size = style.font_size(); + let font_scale = style.font_scale(); + // Read per-paragraph text alignment from FlatBuffer + let alignment = td.alignment_(); + // Read white_space from FlatBuffer (0=Normal 1=PreWrap 2=Pre/NoWrap). + // Value 2 disables wrapping for code blocks; the Rust shaping path was not + // reading this before and always used Wrap+BreakWord. + let white_space = td.white_space(); + + // Rich multi-style spans (TextData.spans) → builder ranges. Spans cover + // the full text in document order, so span byte boundaries index into it. + let mut span_styles: Vec = Vec::new(); + if let Some(v) = td.spans() { + if !v.is_empty() { + let mut pos = 0usize; + for s in v.iter() { + let t = s.text().unwrap_or(""); + let st = s.style().unwrap(); + span_styles.push(crate::parley_text::SpanStyle { + start: pos, + end: pos + t.len(), + bold: st.bold(), + italic: st.italic(), + baseline_shift: st.baseline_shift(), + // Span-level font_family; None means "not + // declared", so the paragraph default applies. + family: st.font_family().map(str::to_owned), + }); + pos += t.len(); + } + } + } + + // Inline blocks: anchor bytes are the U+FFFC placeholders in document + // order; each box's width comes from its node's explicit pixel size. + let mut inlines: Vec = Vec::new(); + if let Some(refs) = td.inline_blocks() { + if !refs.is_empty() { + let anchors: Vec = text + .char_indices() + .filter(|(_, ch)| *ch == '\u{FFFC}') + .map(|(i, _)| i) + .collect(); + for (k, r) in refs.iter().enumerate() { + if k >= anchors.len() { + break; + } + let align = r.align(); + let float_side = match align { + 3 => Some(1u8), + 4 => Some(2u8), + _ => None, + }; + // The atomic ink box advance replaces the + // cell width: advance = ink_left + ink width + ink_right, converted + // from the 16-cell icon space to px. Side-bearing and kerning come + // from the ink box; when it is not declared (all zero) the cell + // width is used instead. + let cell_h = inline_block_height(nodes, r.node() as usize); + let width = ink_box_advance(&r, cell_h) + .unwrap_or_else(|| inline_block_width(nodes, r.node() as usize)); + inlines.push(crate::parley_text::InlineSpec { + anchor_byte: anchors[k], + width, + height: inline_block_height(nodes, r.node() as usize), + float_side, + node: r.node() as usize, + }); + } + } + } + + // The buffer is already at the scaled font size (parley_text), so the + // wrap width is used as-is. + let max_w = match available.width { + AvailableSpace::Definite(w) => w as f32, + // Min-content probe: wrap at zero width so every breakable point is + // taken, so the measured width is then the longest unbreakable word, + // not the whole unwrapped line. + AvailableSpace::MinContent => 0.0, + _ => f32::MAX, + }; + + // Hard breaks (
): raw byte offsets (in the break-free text) at which the + // paragraph is split into independently shaped pieces. A
is a hard line + // break that must hold under ANY white-space mode, but parley 0.11 exposes no + // white-space control and its normal collapse would fold a literal '\n' into a + // space, so the break is realised structurally by shaping each piece on its + // own and stacking them vertically (piece k+1 starts at the accumulated height + // of the pieces before it). Pieces carry no inline boxes (a paragraph that has + // both inline boxes and hard breaks falls through to the single-shape path + // below, keeping inline geometry correct at the cost of the hard break). + let breaks: Vec = td + .breaks() + .map(|v| v.iter().map(|x| x as usize).collect()) + .unwrap_or_default(); + + let (shaped_glyphs, shaped_markers, shaped_h, shaped_max_x, shaped_floor, shaped_float_anchors, shaped_last_window) = + if !breaks.is_empty() && inlines.is_empty() { + let mut last_window: Option<(f32, f32)> = None; + let mut bounds: Vec = Vec::with_capacity(breaks.len() + 2); + bounds.push(0); + for &b in &breaks { + if b > *bounds.last().unwrap() && b <= text.len() { + bounds.push(b); + } + } + if *bounds.last().unwrap() != text.len() { + bounds.push(text.len()); + } + let mut all_glyphs: Vec = Vec::new(); + let mut all_markers: Vec = Vec::new(); + let mut acc_h: f32 = 0.0; + let mut max_x: f32 = 0.0; + let mut floor: Option = None; + for w in bounds.windows(2) { + let lo = w[0]; + let hi = w[1]; + if lo >= hi { + continue; + } + let sub_text = &text[lo..hi]; + let sub_spans: Vec = span_styles + .iter() + .filter_map(|sp| { + let ns = sp.start.max(lo); + let ne = sp.end.min(hi); + if ns < ne { + Some(crate::parley_text::SpanStyle { + start: ns - lo, + end: ne - lo, + bold: sp.bold, + italic: sp.italic, + baseline_shift: sp.baseline_shift, + family: sp.family.clone(), + }) + } else { + None + } + }) + .collect(); + let sub_clears: Vec<(usize, u8)> = clears + .iter() + .filter_map(|(o, s)| { + if *o >= lo && *o < hi { + Some((*o - lo, *s)) + } else { + None + } + }) + .collect(); + let seg_top = para_abs_y + acc_h; + let req = crate::parley_text::ShapeRequest { + text: sub_text, + spans: &sub_spans, + inlines: &[], + floats, + para_abs_y: seg_top, + para_x, + clears: &sub_clears, + font_size, + font_scale, + max_width: max_w, + justify, + alignment, + white_space, + // Paragraph default font_family (TextData.style). + family: style.font_family(), + }; + let shaped = crate::parley_text::shape_paragraph(&mut fs.parley, &req); + for mut g in shaped.glyphs { + g.y += acc_h; + g.line_top += acc_h; + all_glyphs.push(g); + } + for mut m in shaped.markers { + m.baseline_y += acc_h; + m.line_top += acc_h; + all_markers.push(m); + } + let sh = if shaped.content_height <= 0.0 { + font_size * font_scale * 1.55 + } else { + shaped.content_height + }; + acc_h += sh; + max_x = max_x.max(shaped.max_x); + floor = match (floor, shaped.clear_floor) { + (Some(a), Some(b)) => Some(a.max(b)), + (a, b) => a.or(b), + }; + // Track last segment's window for the separator-line mechanism + last_window = shaped.last_line_window; + } + (all_glyphs, all_markers, acc_h, max_x, floor, Vec::new(), last_window) + } else { + let req = crate::parley_text::ShapeRequest { + text, + spans: &span_styles, + inlines: &inlines, + floats, + para_abs_y, + para_x, + clears, + font_size, + font_scale, + max_width: max_w, + justify, + alignment, + white_space, + // Paragraph default font_family (TextData.style). + family: style.font_family(), + }; + let shaped = crate::parley_text::shape_paragraph(&mut fs.parley, &req); + let mut h = shaped.content_height; + if h <= 0.0 { + h = font_size * font_scale * 1.55; + } + if !shaped.markers.is_empty() { + h += inline_line_growth(nodes, idx, &shaped.markers); + } + (shaped.glyphs, shaped.markers, h, shaped.max_x, shaped.clear_floor, shaped.float_anchors, shaped.last_line_window) + }; + + acc.insert( + idx, + GlyphAccum { + glyphs: shaped_glyphs, + markers: shaped_markers, + float_anchors: shaped_float_anchors, + last_line_window: shaped_last_window, + }, + ); + + ( + Size { + width: match available.width { + AvailableSpace::Definite(max_w) => { + if alignment == 1 || alignment == 2 { + max_w.max(1.0) + } else if white_space == 2 { + // Pre/NoWrap code: the measured width is the + // shaped natural width of the longest line (NOT clamped + // to the container), so the narrow scroll container + // lays out one unbroken line and scrolls horizontally. + shaped_max_x.max(1.0) + } else { + (shaped_max_x.min(max_w)).max(1.0) + } + } + _ => shaped_max_x.max(1.0), + }, + height: shaped_h.max(1.0), + }, + shaped_floor, + ) +} + +/// Extra paragraph height from inline blocks, mirroring the legacy per-line +/// box growth: every line holding an anchor grows by the space its blocks +/// need above the baseline plus below the line, and later lines are pushed +/// down by the accumulated growth (applied in the inline post-pass). +fn inline_line_growth(nodes: &[FlatNode], idx: usize, markers: &[InlineMarker]) -> f32 { + let Some(refs) = nodes[idx].text().and_then(|t| t.inline_blocks()) else { + return 0.0; + }; + let mut by_line: std::collections::BTreeMap = Default::default(); + for (mi, m) in markers.iter().enumerate() { + if mi >= refs.len() { + break; + } + let r = refs.get(mi); + let bh = inline_block_height(nodes, r.node() as usize); + // Ink-box anchoring, with the ink bottom edge on the baseline, + // wins over the align mode. + let ink_bp = ink_bottom_px(&r, bh); + let (na, nb) = marker_needs(m, bh, r.align(), r.param(), ink_bp); + let e = by_line.entry(m.line_index).or_default(); + e.0 = e.0.max(na); + e.1 = e.1.max(nb); + } + by_line.values().map(|(a, b)| a + b).sum() +} + +fn measure_image(nodes: &[FlatNode], idx: usize) -> Size { + let node = &nodes[idx]; + let img = match node.image() { + Some(i) => i, + None => return Size::ZERO, + }; + let ew = img.explicit_w(); + let eh = img.explicit_h(); + let nw = img.natural_w(); + let nh = img.natural_h(); + // Single-parameter mode: exactly one explicit dimension is given + // (the other is <= 0) and the source image has a real natural size, infer + // the missing dimension from the natural aspect ratio: + // inferred = explicit × natural_other / natural_given + // The inferred axis does NOT apply its own scale (the inferred value is + // already the final display pixel size). Two explicit dimensions win as-is; + // when both are missing (or natural size is unavailable) fall back to the + // legacy natural × scale behaviour. + let (w, h) = if ew > 0.0 && eh > 0.0 { + (ew, eh) + } else if ew > 0.0 && nw > 0.0 && nh > 0.0 { + (ew, ew * (nh / nw)) + } else if eh > 0.0 && nw > 0.0 && nh > 0.0 { + (eh * (nw / nh), eh) + } else { + let w = if ew > 0.0 { ew } else { nw * img.scale_x() }; + let h = if eh > 0.0 { eh } else { nh * img.scale_y() }; + (w, h) + }; + Size { + width: w.max(1.0), + height: h.max(1.0), + } +} + +fn measure_slot(nodes: &[FlatNode], idx: usize) -> Size { + let node = &nodes[idx]; + let slot = match node.slot() { + Some(s) => s, + None => return Size::ZERO, + }; + let sz = slot.slot_size(); + Size { + width: sz, + height: sz, + } +} + +fn measure_thematic_break( + nodes: &[FlatNode], + idx: usize, + known: Size>, + available: Size, +) -> Size { + let node = &nodes[idx]; + let tb = match node.break_() { + Some(t) => t, + None => return Size::ZERO, + }; + let fallback_w = match available.width { + AvailableSpace::Definite(a) => a.max(0.0), + _ => 0.0, + }; + Size { + width: known.width.unwrap_or(fallback_w), + height: tb.height(), + } +} + +fn measure_latex(nodes: &[FlatNode], idx: usize) -> Size { + let node = &nodes[idx]; + let latex = match node.latex() { + Some(l) => l, + None => return Size::ZERO, + }; + Size { + width: latex.raw_w(), + height: latex.raw_h() + 8.0, + } +} + +/// Measure an NEI recipe box (node_type = 20). The formula mirrors +/// LytNeiRecipeBox.computeLayout term for term, with Java-computed +/// pixel values (title_text_width, title_height, body dimensions) +/// provided via RecipeBoxData. Constants replicate the Java class's +/// static fields. +fn measure_recipe_box(nodes: &[FlatNode], idx: usize) -> Size { + const FRAME_BORDER: f32 = 4.0; + const TITLE_GAP_AFTER_ICON: f32 = 3.0; + const TITLE_GAP_BEFORE_ACTION: f32 = 3.0; + const ACTION_BUTTON_SIZE: f32 = 12.0; + const BODY_MARGIN: f32 = 2.0; + + let node = &nodes[idx]; + let rb = match node.recipe_box() { + Some(d) => d, + None => return Size::ZERO, + }; + + // titleWidth = iconSize + (iconSize > 0 ? TITLE_GAP_AFTER_ICON : 0) + titleTextWidth + let mut title_width = rb.icon_size() + + (if rb.icon_size() > 0.0 { TITLE_GAP_AFTER_ICON } else { 0.0 }) + + rb.title_text_width(); + // if recipeJumpEnabled: titleWidth += TITLE_GAP_BEFORE_ACTION + ACTION_BUTTON_SIZE + if rb.recipe_jump_enabled() { + title_width += TITLE_GAP_BEFORE_ACTION + ACTION_BUTTON_SIZE; + } + // innerW = max(bodyWidth, titleWidth) + let inner_w = f32::max(rb.body_width(), title_width); + // w = FRAME_BORDER + innerW + FRAME_BORDER + let w = FRAME_BORDER + inner_w + FRAME_BORDER; + + // h = FRAME_BORDER + titleHeight + BODY_MARGIN + bodyTopInset + bodyHeight + bodyYShift + FRAME_BORDER + let h = FRAME_BORDER + + rb.title_height() + + BODY_MARGIN + + rb.body_top_inset() + + rb.body_height() + + rb.body_y_shift() + + FRAME_BORDER; + + Size { width: w, height: h } +} + +/// Shared chart measurement formula, extracted from LytChartBase.computeLayout. +/// Used by both PieChart (node_type=21) via PieChartData and Cartesian charts +/// (BarChart node_type=22, future Column/Line/Scatter) via ChartData. +/// +/// Chrome height is now computed in Rust from the final width w, eliminating +/// the discrepancy where the Java lazy getter used unscaled +/// preferredWidth for legend wrapping. +fn chart_measurement( + preferred_w: f32, + total_h: f32, + title_chrome: f32, + legend_position: i8, + legend_row_height: f32, + legend_label_widths: &[f32], + known: Size>, + available: Size, + visual_scale: f32, +) -> Size { + // Constants mirrored from LytChartBase: + const PADDING: f32 = 8.0; + const LEGEND_GAP: f32 = 6.0; + const LEGEND_ENTRY_GAP: f32 = 12.0; + const HORIZONTAL_ROW_GAP: f32 = 2.0; + const MIN_PLOT_HEIGHT: f32 = 72.0; + + // Width formula, mirroring LytChartBase.computeLayout: + // preferredWidth = (explicitW > 0 ? explicitW : DEFAULT_WIDTH) + extraPlotWidth + // scaledWidth = scaleWidth(preferredWidth, visualScale, 64) + // width = max(1, min(scaledWidth, availableWidth)) + // + // When explicitWidth > 0 (user-set via setExplicitSize), Taffy passes + // it as known.width and we use it directly. Otherwise known.width is + // None and we compute the width from the Java-precomputed preferred_width. + let w = match known.width { + Some(explicit) => explicit, + None => { + let scaled = scale_width(preferred_w, visual_scale, 64.0); + let avail = match available.width { + AvailableSpace::Definite(a) => a, + _ => f32::MAX, + }; + (scaled.min(avail)).max(1.0) + } + }; + + // Chrome height, computed in Rust from the final width w. + // Mirrors LytChartBase.estimateFixedChromeHeight: + // chrome = PADDING * 2 + // + title_chrome (Java-precomputed: lineHeight(titleStyle) + TITLE_GAP, 0 if no title) + // + legendHeight (only for TOP/BOTTOM legend) + // + LEGEND_GAP (if legend present and TOP/BOTTOM) + let content_w = (w - PADDING * 2.0).max(1.0); + let legend_h = chart_legend_height( + legend_row_height, + legend_label_widths, + content_w, + LEGEND_ENTRY_GAP, + HORIZONTAL_ROW_GAP, + ); + let mut chrome = PADDING * 2.0; + chrome += title_chrome; + // Chrome includes legend_h (may be 0 when entries empty) + LEGEND_GAP + // when legend is TOP/BOTTOM, matching LytChartBase.estimateFixedChromeHeight + // which always adds LEGEND_GAP for TOP/BOTTOM regardless of legend height. + let has_legend = legend_position == 1 || legend_position == 2; + if has_legend { + chrome += legend_h + LEGEND_GAP; + } + + // Height formula, mirroring LytChartBase.computeLayout: + // totalHeight = explicitH > 0 ? explicitH : DEFAULT_HEIGHT + // bodyHeight = max(1, totalHeight - clamp(chrome, 0, totalHeight - 1)) + // scaledBody = scaleHeightForWidth(preferredW, bodyHeight, width, MIN_PLOT_HEIGHT) + // height = chrome + scaledBody + let raw_h = total_h; + // Guard against raw_h < 1.0: clamp upper bound to at least 0 so the + // range is valid even when totalHeight is 0 or negative (legacy). + let body = (raw_h - chrome.clamp(0.0, (raw_h - 1.0).max(0.0))).max(1.0); + let scaled_body = scale_height_for_width(preferred_w, body, w, MIN_PLOT_HEIGHT); + let h = chrome + scaled_body; + + Size { width: w, height: h } +} + +/// Compute the total height of a horizontal (TOP/BOTTOM) legend given per-entry +/// widths, row height, and layout constants. Mirrors +/// ChartLegendRenderer.measureHorizontalLegendHeight term for term, including +/// the first-item-no-gap rule and the computation: +/// rows * rowHeight + max(0, rows-1) * rowGap +fn chart_legend_height( + row_height: f32, + item_widths: &[f32], + available_width: f32, + entry_gap: f32, + row_gap: f32, +) -> f32 { + if row_height <= 0.0 || item_widths.is_empty() { + return 0.0; + } + let mut rows: i32 = 1; + let mut row_w: f32 = 0.0; + for &item_w in item_widths { + if item_w <= 0.0 { + continue; + } + let needed = if row_w == 0.0 { + item_w + } else { + row_w + entry_gap + item_w + }; + if row_w > 0.0 && needed > available_width { + rows += 1; + row_w = item_w; + } else { + row_w = needed; + } + } + // If rows stayed at 1 but no items had width > 0, return 0 (matching Java). + if row_w == 0.0 { + return 0.0; + } + rows as f32 * row_height + (rows - 1) as f32 * row_gap +} + +/// Measure a pie chart (node_type = 21). The formula mirrors +/// LytChartBase.computeLayout term for term, with Java-computed +/// font-metric values (title_chrome, legend_row_height, +/// legend_label_widths) provided via PieChartData. Chrome height +/// is computed in Rust from the actual width, using the legend +/// wrapping algorithm transplanted from ChartLegendRenderer. +/// Pure-arithmetic helper functions (scale_width, scale_height_for_width) +/// replicate ResponsiveVisualSizing on the Rust side. +fn measure_pie_chart( + nodes: &[FlatNode], + idx: usize, + known: Size>, + available: Size, + visual_scale: f32, +) -> Size { + let node = &nodes[idx]; + let pd = match node.pie_chart() { + Some(d) => d, + None => return Size::ZERO, + }; + + // Collect legend label widths from FlatBuffer vector into a Vec. + // This is needed because chart_measurement operates on &[f32] and the + // flatbuffers Vector does not implement Deref. + let widths: Vec = pd + .legend_label_widths() + .map(|v| (0..v.len()).map(|i| v.get(i)).collect()) + .unwrap_or_default(); + + chart_measurement( + pd.preferred_width(), + pd.total_height(), + pd.title_chrome(), + pd.legend_position(), + pd.legend_row_height(), + &widths, + known, + available, + visual_scale, + ) +} + +/// Measure a Cartesian chart (node_type = 22: BarChart, future Column/Line/Scatter). +/// Uses the same LytChartBase.computeLayout formula as PieChart, reading sizing +/// data from the ChartData table instead. Chrome height is computed in Rust +/// from the final width w, using the legend wrapping algorithm transplanted +/// from ChartLegendRenderer. +fn measure_chart( + nodes: &[FlatNode], + idx: usize, + known: Size>, + available: Size, + visual_scale: f32, +) -> Size { + let node = &nodes[idx]; + let cd = match node.chart_data() { + Some(d) => d, + None => return Size::ZERO, + }; + + // Collect legend label widths from FlatBuffer vector into a Vec. + let widths: Vec = cd + .legend_label_widths() + .map(|v| (0..v.len()).map(|i| v.get(i)).collect()) + .unwrap_or_default(); + + chart_measurement( + cd.preferred_width(), + cd.total_height(), + cd.title_chrome(), + cd.legend_position(), + cd.legend_row_height(), + &widths, + known, + available, + visual_scale, + ) +} + +/// Measure an isometric structure view (node_type = 26). The formula mirrors +/// LytStructureView.computeLayout term for term, with Java-precomputed +/// view_width and view_height (setViewSize or DEFAULT_WIDTH/HEIGHT) provided +/// via StructureViewData. Uses the shared scale_width/scale_height_for_width +/// helpers that replicate ResponsiveVisualSizing on the Rust side. +fn measure_structure_view( + nodes: &[FlatNode], + idx: usize, + known: Size>, + available: Size, + visual_scale: f32, +) -> Size { + let node = &nodes[idx]; + let sv = match node.structure_view_data() { + Some(d) => d, + None => return Size::ZERO, + }; + + let view_w = sv.view_width(); + let view_h = sv.view_height(); + + // targetWidth = scaleWidth(viewWidth, visualScale, 32) + let target_w = scale_width(view_w, visual_scale, 32.0); + // width = clamp(targetWidth, 1, availableWidth) + let avail_w = match available.width { + AvailableSpace::Definite(a) => a, + _ => f32::MAX, + }; + let w = target_w.max(1.0).min(avail_w); + // height = scaleHeightForWidth(viewWidth, viewHeight, width, 32) + let h = scale_height_for_width(view_w, view_h, w, 32.0); + + // Explicit style sizes (known dimensions from Taffy) win over content + // measurement; the caller's known.unwrap_or already handles this for + // the general case, but we include the logic for clarity. + Size { + width: w, + height: h, + } +} + +/// Measure a guidebook scene (node_type = 27). The formula mirrors +/// LytGuidebookScene.computeLayout term for term, with Java-precomputed +/// dock sizes, button column reserve, button total height, and bottom +/// control area height provided via GuidebookSceneData. The responsive +/// scene sizing (scale_width, dock clamping, computeResponsiveSceneHeight) +/// is replicated in Rust using available_width and visual_scale. +fn measure_guidebook_scene( + nodes: &[FlatNode], + idx: usize, + known: Size>, + available: Size, + visual_scale: f32, +) -> Size { + const MIN_RESPONSIVE_SCENE_SIZE: f32 = 16.0; + const BLOCK_STATS_DOCK_GAP: f32 = 4.0; // only used in dock clamping logic + const BLOCK_STATS_MIN_WIDTH: f32 = 32.0; + + let node = &nodes[idx]; + let gs = match node.guidebook_scene_data() { + Some(d) => d, + None => return Size::ZERO, + }; + + let scene_w = gs.scene_width(); + let scene_h = gs.scene_height(); + let reserve = gs.button_column_reserve(); + let buttons_total_h = gs.buttons_total_height(); + let mut left_dock = gs.left_dock(); + let mut right_dock = gs.right_dock(); + let top_dock = gs.top_dock(); + let bottom_dock = gs.bottom_dock(); + let bottom_ctrl_h = gs.bottom_control_area_height(); + let reserve_bottom = gs.reserve_bottom_control(); + + // targetSceneWidth = scaleWidth(width, visualScale, MIN_RESPONSIVE_SCENE_SIZE) + let target_scene_w = scale_width(scene_w, visual_scale, MIN_RESPONSIVE_SCENE_SIZE); + + // totalDesired = targetSceneWidth + reserve + leftDock + rightDock + let total_desired = target_scene_w + reserve + left_dock + right_dock; + + // availableWidth from Taffy + let avail_w = match available.width { + AvailableSpace::Definite(a) => a, + _ => f32::MAX, + }; + + // w = min(totalDesired, max(reserve + MIN_RESPONSIVE_SCENE_SIZE, availableWidth)) + let w = total_desired.min((reserve + MIN_RESPONSIVE_SCENE_SIZE).max(avail_w)); + + // availableForDocks = max(0, w - reserve - targetSceneWidth) + let available_for_docks = (w - reserve - target_scene_w).max(0.0); + + // Dock clamping: if left+right > availableForDocks, shrink proportionally + if left_dock + right_dock > available_for_docks { + if left_dock > 0.0 && right_dock > 0.0 { + left_dock = left_dock.min(available_for_docks / 2.0); + right_dock = right_dock.min(available_for_docks - left_dock); + } else if left_dock > 0.0 { + left_dock = left_dock.min(available_for_docks); + } else { + right_dock = right_dock.min(available_for_docks); + } + } + + // minDockSpace = BLOCK_STATS_MIN_WIDTH + BLOCK_STATS_DOCK_GAP + let min_dock_space = BLOCK_STATS_MIN_WIDTH + BLOCK_STATS_DOCK_GAP; + if left_dock > 0.0 && left_dock < min_dock_space { + left_dock = 0.0; + } + if right_dock > 0.0 && right_dock < min_dock_space { + right_dock = 0.0; + } + + // sceneW = max(MIN_RESPONSIVE_SCENE_SIZE, w - reserve - leftDock - rightDock) + let scene_w_responsive = (w - reserve - left_dock - right_dock).max(MIN_RESPONSIVE_SCENE_SIZE); + + // computeResponsiveSceneHeight(sceneW, buttonsTotalH) + let scene_h_responsive = compute_responsive_scene_height(scene_w, scene_h, scene_w_responsive, buttons_total_h, MIN_RESPONSIVE_SCENE_SIZE); + + // h = topDock + sceneH + (reserveBottomControlArea ? bottomControlAreaHeight : 0) + bottomDock + let h = top_dock + scene_h_responsive + (if reserve_bottom { bottom_ctrl_h } else { 0.0 }) + bottom_dock; + + // known dimensions (explicit style sizes) win over measured + Size { + width: known.width.unwrap_or(w), + height: known.height.unwrap_or(h), + } +} + +/// Mirrors LytGuidebookScene.computeResponsiveSceneHeight. +/// scene_width / scene_height are the intrinsic dimensions (setSceneSize or +/// defaults); actual_width is the responsive scene width after dock clamping. +fn compute_responsive_scene_height( + base_width: f32, + base_height: f32, + actual_width: f32, + buttons_total_h: f32, + min_size: f32, +) -> f32 { + let base_w = base_width.max(1.0); + let base_h = base_height.max(1.0); + if actual_width >= base_w { + return base_h.max(buttons_total_h); + } + let scale = actual_width / base_w; + let scaled_h = (base_h * scale).round().max(1.0).max(min_size); + scaled_h.max(buttons_total_h) +} + +/// Measure a function graph (node_type = 28). The formula mirrors +/// LytFunctionGraph.computeLayout term for term, with Java-precomputed +/// title_chrome, legend_row_height, and per-plot label_item_widths +/// (via FunctionGraphData). Uses shared scale_width/scale_height_for_width +/// helpers that replicate ResponsiveVisualSizing on the Rust side. +fn measure_function_graph( + nodes: &[FlatNode], + idx: usize, + known: Size>, + available: Size, + visual_scale: f32, +) -> Size { + const PADDING: f32 = 8.0; + const AXIS_PAD_LEFT: f32 = 28.0; + const AXIS_PAD_BOTTOM: f32 = 14.0; + const LEGEND_GAP_ABOVE: f32 = 4.0; + const LEGEND_ITEM_GAP: f32 = 10.0; + const LEGEND_ROW_GAP: f32 = 2.0; + const MIN_PLOT_HEIGHT: f32 = 88.0; + + let node = &nodes[idx]; + let fgd = match node.function_graph_data() { + Some(d) => d, + None => return Size::ZERO, + }; + + let base_w = fgd.base_width(); + let base_h = fgd.base_height(); + + // width = scaleWidth(baseWidth, visualScale, 72), clamped to [1, availableWidth] + let target_w = scale_width(base_w, visual_scale, 72.0); + let avail_w = match available.width { + AvailableSpace::Definite(a) => a, + _ => f32::MAX, + }; + let w = target_w.max(1.0).min(avail_w); + + // plotWidth = max(0, width - PADDING * 2 - AXIS_PAD_LEFT) + let plot_w = (w - PADDING * 2.0 - AXIS_PAD_LEFT).max(0.0); + + // fixedChromeHeight = PADDING * 2 + AXIS_PAD_BOTTOM + let mut fixed_chrome = PADDING * 2.0 + AXIS_PAD_BOTTOM; + + // if (title != null && !title.isEmpty()) fixedChrome += titleChrome (precomputed) + fixed_chrome += fgd.title_chrome(); + + // legendHeight = measureLegendHeight(plotWidth) + // Precomputed row height and per-label item widths; wrapping algorithm + // replicates LytFunctionGraph.measureLegendHeight. + let legend_row_h = fgd.legend_row_height(); + let legend_h = if legend_row_h > 0.0 { + let mut rows: i32 = 1; + let mut row_w: f32 = 0.0; + if let Some(widths) = fgd.label_item_widths() { + for i in 0..widths.len() { + let item_w = widths.get(i); + if item_w <= 0.0 { + continue; + } + let needed = if row_w == 0.0 { + item_w + } else { + row_w + LEGEND_ITEM_GAP + item_w + }; + if row_w > 0.0 && needed > plot_w { + rows += 1; + row_w = item_w; + } else { + row_w = needed; + } + } + } + // If rows stays 1 but no items had width > 0, the loop never + // ran (all labels empty or no plots). The Java code returns 0 + // for this case. Since we start at rows=1, check: legend_h = 0 + // when no items have been processed (row_w == 0.0). + if row_w == 0.0 { + 0.0 + } else { + rows as f32 * legend_row_h + (rows - 1) as f32 * LEGEND_ROW_GAP + } + } else { + 0.0 + }; + if legend_h > 0.0 { + fixed_chrome += legend_h + LEGEND_GAP_ABOVE; + } + + // height = scaleBodyHeightForWidth(baseWidth, baseHeight, width, fixedChrome, MIN_PLOT_HEIGHT) + let safe_total_h = base_h.max(1.0); + let safe_fixed_h = fixed_chrome.clamp(0.0, (safe_total_h - 1.0).max(0.0)); + let body_h = (safe_total_h - safe_fixed_h).max(1.0); + let scaled_body = scale_height_for_width(base_w, body_h, w, MIN_PLOT_HEIGHT); + let h = safe_fixed_h + scaled_body; + + // Explicit style sizes (known dimensions from Taffy) win over content + // measurement; the caller's known.unwrap_or already handles this. + Size { + width: known.width.unwrap_or(w), + height: known.height.unwrap_or(h), + } +} + +/// Measure a MediaWiki generated list block (node_type = 29). The formula mirrors +/// MediaWikiGeneratedListBlock.computeLayout term for term. The column-planning +/// algorithm (planColumns) depends on Java object data (entry sort keys, titles, +/// section grouping) and cannot be replicated in Rust. Java precomputes the max +/// column content height via MediaWikiGeneratedListData.max_content_height. +/// Rust adds the padding constants to produce the total block height. +/// Width is always availableWidth, since the block fills the parent's content box. +fn measure_mediawiki_generated_list( + nodes: &[FlatNode], + idx: usize, + known: Size>, + available: Size, +) -> Size { + // Constants mirrored from MediaWikiGeneratedListBlock: + // private static final int TOP_PADDING = 6; + // private static final int BOTTOM_PADDING = 6; + const TOP_PADDING: f32 = 6.0; + const BOTTOM_PADDING: f32 = 6.0; + + let node = &nodes[idx]; + let data = match node.mediawiki_generated_list_data() { + Some(d) => d, + None => return Size::ZERO, + }; + + let max_content_h = data.max_content_height(); + + // Width: the block always fills available width (no intrinsic preference). + // Mirrors computeLayout: return new LytRect(x, y, availableWidth, ...). + let w = match available.width { + AvailableSpace::Definite(a) => a.max(0.0), + _ => 0.0, + }; + + // Height = TOP_PADDING + maxColumnContentHeight + BOTTOM_PADDING. + // Mirrors computeLayout: + // return new LytRect(x, y, availableWidth, TOP_PADDING + maxColumnHeight + BOTTOM_PADDING); + // and for empty entries: + // return new LytRect(x, y, availableWidth, TOP_PADDING + ROW_HEIGHT + BOTTOM_PADDING); + let h = TOP_PADDING + max_content_h + BOTTOM_PADDING; + + Size { + width: known.width.unwrap_or(w), + height: known.height.unwrap_or(h), + } +} + +/// Measure a MediaWiki special generated block (node_type = 30). +/// +/// Java passes width-independent font facts (title widths, subtitle word widths, +/// line-height constants, estimated heights). Rust computes columnWidth from +/// availableWidth, wraps subtitle words per entry, packs columns (shortest-column-first +/// for groups, even distribution for flat entries), and produces maxColumnHeight. +/// Width is always availableWidth, since the block fills the parent's content box. +/// +/// Mirrors MediaWikiSpecialGeneratedBlock.computeLayout term for term, with the +/// entry-height computation now owned by Rust using the pre-measured word-width +/// array. This eliminates the discrepancy between the lazy estimateEntryHeight +/// (hardcoded 9px, no wrapping) and computeEntryHeight (10px, wrapped by columnWidth). +fn measure_mediawiki_special_generated( + nodes: &[FlatNode], + idx: usize, + known: Size>, + available: Size, +) -> Size { + // Layout constants, mirrored from MediaWikiSpecialGeneratedBlock static fields. + const TOP_PADDING: f32 = 6.0; + const BOTTOM_PADDING: f32 = 6.0; + const SIDE_PADDING: f32 = 2.0; + const COLUMN_GAP: f32 = 10.0; + const GROUP_MARGIN: f32 = 6.0; + const HEADER_HEIGHT: f32 = 20.0; + const HEADER_MARGIN_TOP: f32 = 5.0; + const HEADER_MARGIN_BOTTOM: f32 = 5.0; + const ENTRY_HEIGHT: f32 = 20.0; + const ENTRY_GAP: f32 = 2.0; + const ICON_SIZE: f32 = 16.0; + const ICON_GAP: f32 = 4.0; + const LIST_MARKER_SIZE: f32 = 3.0; + const LIST_MARKER_GAP: f32 = 6.0; + const TITLE_SUBTITLE_GAP: f32 = 3.0; + const ENTRY_VERTICAL_PADDING_TOP: f32 = 3.0; + const ENTRY_VERTICAL_PADDING_BOTTOM: f32 = 3.0; + const LOAD_MORE_HEIGHT: f32 = 18.0; + const LOAD_MORE_MARGIN_TOP: f32 = 2.0; + // Line heights come from the serialized data: Java's + // GuideText.lineHeight(LINK_STYLE)/lineHeight(SUBTITLE_STYLE) (= 17 at + // scale 1). The fbs fields default to 10.0 for old serialized data. + // (Previously hardcoded 10.0 here, making the block ~1/3 too short.) + + let node = &nodes[idx]; + let data = match node.mediawiki_special_generated_data() { + Some(d) => d, + None => return Size::ZERO, + }; + + // Width: the block always fills available width (no intrinsic preference). + let w = match available.width { + AvailableSpace::Definite(a) => a.max(0.0), + _ => 0.0, + }; + + // Column width (mirrors Java computeLayout). + let inner_width = (w - SIDE_PADDING * 2.0).max(0.0); + let column_count = data.column_count().max(1) as usize; + let column_width = + ((inner_width - COLUMN_GAP * (column_count as f32 - 1.0)) / column_count as f32).max(1.0); + + // Read font-fact vectors. + let total_entries = data.total_entry_count() as usize; + let entry_title_widths = data.entry_title_widths(); + let entry_has_icon = data.entry_has_icon(); + let entry_subtitle_line_counts = data.entry_subtitle_line_counts(); + let subtitle_line_word_counts = data.subtitle_line_word_counts(); + let subtitle_word_widths = data.subtitle_word_widths(); + let subtitle_space_width = data.subtitle_space_width(); + + // Real line heights serialized from Java (GuideText.lineHeight), mirroring + // computeEntryHeight/rowContentHeight's getLineHeight(LINK_STYLE) and + // getLineHeight(SUBTITLE_STYLE). fbs defaults to 10.0 for old data. + let title_line_height = data.link_line_height(); + let subtitle_line_height = data.subtitle_line_height(); + + let group_cnt = data.group_count().max(0) as usize; + let group_title_widths = data.group_title_widths(); + let group_entry_counts = data.group_entry_counts(); + let group_estimated_heights = data.group_estimated_heights(); + + let has_more = data.has_more(); + + // Compute real entry height given columnWidth. + // Mirrors computeEntryHeight: wrapping + line-height constants. + let compute_entry_height = |entry_idx: usize| -> f32 { + let has_icon = entry_has_icon + .as_ref() + .and_then(|v| if entry_idx < v.len() { Some(v.get(entry_idx)) } else { None }) + .unwrap_or(0i8) + != 0; + + let text_max_width = { + let reserve = LIST_MARKER_SIZE + + LIST_MARKER_GAP + + if has_icon { ICON_SIZE + ICON_GAP } else { 0.0 }; + (column_width - reserve).max(1.0) + }; + + let line_count = entry_subtitle_line_counts + .as_ref() + .and_then(|v| { + if entry_idx < v.len() { + Some(v.get(entry_idx) as usize) + } else { + None + } + }) + .unwrap_or(0); + + if line_count == 0 { + return ENTRY_HEIGHT; + } + + // Word-level wrapping. + // Find the global line-index start for this entry within the flat arrays. + let line_start: usize = entry_subtitle_line_counts + .as_ref() + .map(|v| (0..entry_idx).map(|i| v.get(i) as usize).sum::()) + .unwrap_or(0); + + let mut total_wrapped: usize = 0; + if let (Some(wc_vec), Some(ww_vec)) = (subtitle_line_word_counts.as_ref(), subtitle_word_widths.as_ref()) { + // Compute word-index start: sum word counts of all lines before line_start. + let mut word_start: usize = 0; + for li in 0..line_start { + if li < wc_vec.len() { + word_start += wc_vec.get(li) as usize; + } + } + + for li in 0..line_count { + let global_line = line_start + li; + if global_line >= wc_vec.len() { + break; + } + let n_words = wc_vec.get(global_line) as usize; + if n_words == 0 { + continue; + } + + // Raw line width = word widths + spaces between words. + let raw_line_width: f32 = { + let mut sum = 0.0f32; + for wi in word_start..word_start + n_words { + if wi < ww_vec.len() { + sum += ww_vec.get(wi); + } + } + if n_words > 1 { + sum += subtitle_space_width * (n_words - 1) as f32; + } + sum + }; + + if raw_line_width <= text_max_width { + total_wrapped += 1; + } else { + // Word-wrap: mirrors Java appendWrappedLine. + let mut current_w: f32 = 0.0; + let mut sub_lines: usize = 0; + for wi in word_start..word_start + n_words { + if wi >= ww_vec.len() { + break; + } + let word_w = ww_vec.get(wi); + let needed = if current_w == 0.0 { + word_w + } else { + current_w + subtitle_space_width + word_w + }; + if current_w > 0.0 && needed > text_max_width { + sub_lines += 1; + current_w = word_w; + } else { + current_w = needed; + } + } + if current_w > 0.0 { + sub_lines += 1; + } + total_wrapped += sub_lines; + } + + word_start += n_words; + } + } + + if total_wrapped == 0 { + return ENTRY_HEIGHT; + } + + // Mirrors computeEntryHeight: + // contentHeight = max(ICON_SIZE, getLineHeight(LINK_STYLE) + TITLE_SUBTITLE_GAP + // + getLineHeight(SUBTITLE_STYLE) * subtitleLines.size()) + // height = max(ENTRY_HEIGHT, ENTRY_VERTICAL_PADDING_TOP + contentHeight + ENTRY_VERTICAL_PADDING_BOTTOM) + let content_height = (title_line_height + TITLE_SUBTITLE_GAP + + subtitle_line_height * total_wrapped as f32) + .max(ICON_SIZE); + (ENTRY_VERTICAL_PADDING_TOP + content_height + ENTRY_VERTICAL_PADDING_BOTTOM) + .max(ENTRY_HEIGHT) + }; + + // Packing and per-column height. + let max_column_height: f32; + + if total_entries == 0 { + // Empty result: one row at ENTRY_HEIGHT (mirrors isEmpty branch). + max_column_height = ENTRY_HEIGHT; + } else if group_cnt > 0 { + // Grouped: shortest-column-first packing. + // Mirrors layoutColumns: assign each group to the shortest column + // using estimated heights, then recompute real heights. + let mut col_heights_est: Vec = vec![0.0; column_count]; + // For tracking which groups go to which column. + let mut col_groups: Vec> = vec![Vec::new(); column_count]; + + for g in 0..group_cnt { + // Find shortest column. + let target = col_heights_est + .iter() + .enumerate() + .min_by(|(_, a), (_, b)| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal)) + .map(|(i, _)| i) + .unwrap_or(0); + col_groups[target].push(g); + let est_h = group_estimated_heights + .as_ref() + .and_then(|v| if g < v.len() { Some(v.get(g)) } else { None }) + .unwrap_or(0.0); + col_heights_est[target] += est_h; + } + + // Compute real column heights using actual entry heights. + // Build per-group entry-index ranges. + let mut group_entry_start: Vec = Vec::with_capacity(group_cnt); + let mut cur: usize = 0; + for g in 0..group_cnt { + group_entry_start.push(cur); + let cnt = group_entry_counts + .as_ref() + .and_then(|v| if g < v.len() { Some(v.get(g) as usize) } else { None }) + .unwrap_or(0); + cur += cnt; + } + + let mut real_max: f32 = 0.0; + for col_g in &col_groups { + if col_g.is_empty() { + // Empty column gets no height contribution but we need to + // still consider it (prev max will capture the tallest). + continue; + } + let mut col_y: f32 = 0.0; + for &g in col_g { + // Group header. + let has_title = group_title_widths + .as_ref() + .and_then(|v| if g < v.len() { Some(v.get(g)) } else { None }) + .unwrap_or(0.0) + > 0.0; + if has_title { + col_y += HEADER_MARGIN_TOP + HEADER_HEIGHT + HEADER_MARGIN_BOTTOM; + } + // Entries in this group. + let start = group_entry_start[g]; + let end = group_entry_start.get(g + 1).copied().unwrap_or(cur); + for e in start..end { + col_y += compute_entry_height(e); + col_y += ENTRY_GAP; + } + col_y += GROUP_MARGIN; + } + real_max = real_max.max(col_y); + } + max_column_height = real_max; + } else { + // Flat: even distribution across columns. + // Mirrors layoutFlatColumns: perColumn = ceil(N / columnCount). + let per_column = ((total_entries + column_count - 1) / column_count).max(1); + let mut real_max: f32 = 0.0; + for col in 0..column_count { + let start = col * per_column; + if start >= total_entries { + break; + } + let end = (start + per_column).min(total_entries); + let mut col_y: f32 = 0.0; + for e in start..end { + col_y += compute_entry_height(e); + col_y += ENTRY_GAP; + } + // Flat entries are wrapped in a single no-title group per column. + col_y += GROUP_MARGIN; + real_max = real_max.max(col_y); + } + max_column_height = real_max; + } + + // hasMore adds LOAD_MORE_HEIGHT after the column content (mirrors Java). + let max_h = if has_more { + max_column_height + LOAD_MORE_MARGIN_TOP + LOAD_MORE_HEIGHT + } else { + max_column_height + }; + + let h = TOP_PADDING + max_h + BOTTOM_PADDING; + + Size { + width: known.width.unwrap_or(w), + height: known.height.unwrap_or(h), + } +} + +/// Mirrors ResponsiveVisualSizing.scaleWidth: apply a visual-scale factor +/// to a base width, then clamp. +fn scale_width(base_width: f32, visual_scale: f32, min_width: f32) -> f32 { + let safe_base = base_width.max(1.0); + let clamped = visual_scale.clamp(0.1, 1.0); + if clamped >= 0.999 { + return safe_base; + } + (safe_base * clamped).round().max(1.0).max(min_width) +} + +/// Mirrors ResponsiveVisualSizing.scaleHeightForWidth: proportionally scale +/// a base height when the actual width is narrower than the base width. +fn scale_height_for_width(base_width: f32, base_height: f32, actual_width: f32, min_height: f32) -> f32 { + let safe_base_w = base_width.max(1.0); + let safe_base_h = base_height.max(1.0); + let safe_actual_w = actual_width.max(1.0); + if safe_actual_w >= safe_base_w { + return safe_base_h; + } + let scale = safe_actual_w / safe_base_w; + (safe_base_h * scale).round().max(1.0).max(min_height) +} diff --git a/src/rust/layout-engine/src/parley_text.rs b/src/rust/layout-engine/src/parley_text.rs new file mode 100644 index 00000000..dc86d58c --- /dev/null +++ b/src/rust/layout-engine/src/parley_text.rs @@ -0,0 +1,1084 @@ +//! Parley text layout: the guide's line-box layer and the migration target for +//! the cosmic shaping path. +//! +//! Owns the parley contexts (fontique FontContext + LayoutContext) and the +//! paragraph layout → positioned-glyph pipeline. Parley's `BreakLines` gives +//! per-line geometry control (`BreakerState.set_line_max_advance` / +//! `set_line_x`), so float wrapping is done by feeding each line its +//! available width from the clip query: no pre-split bands, no byte rebase, +//! no segmented re-shaping. Inline blocks are first-class `InlineBox`es +//! (width participates in wrapping; vertical growth stays with the Java-side +//! post-pass). Rasterization stays on the swash bridge. + +use std::sync::Arc; + +use parley::fontique; +use parley::{ + Alignment, AlignmentOptions, FontContext, FontData, FontFamily, FontFamilyName, FontWeight, + GenericFamily, InlineBox, InlineBoxKind, Layout, LayoutContext, LineHeight, OverflowWrap, + PositionedLayoutItem, StyleProperty, TextWrapMode, +}; + +/// Custom brush: carries the source span index (TextData.spans) and the +/// italic marker through shaping so each emitted glyph run can be +/// tinted/decorated per span and the boundary correction can read +/// which runs are italic (§o). +/// +/// The italic MARKER lives here, in the brush, and NOT in +/// `StyleProperty::FontStyle`. `FontStyle::Italic` would make fontique run +/// the CSS font-style algorithm with a true-italic request: on real systems +/// (system_fonts) the matcher picks a genuine italic face (Arial Italic) and +/// the Latin outlines get swapped (an observed visual regression). Shaping +/// therefore always runs with `FontStyle::Normal`, so every glyph comes from +/// the regular face; the marker survives for the boundary correction while shaping stays +/// pixel-identical to the upright case. +#[derive(Clone, Debug, Default, PartialEq)] +pub struct SpanBrush { + /// Source span index (TextData.spans); 0 for single-style paragraphs. + pub span_index: u32, + /// Italic boundary-detection marker: this run is italic (§o). + /// Carried in the brush so it never enters font matching. + pub italic: bool, + /// The font family this run requested, taken from TextData.style.font_family + /// or from the span's TextStyle.font_family. None means "not declared", which + /// falls back to the SansSerif generic exactly as behavior was before this + /// field existed. The value rides the brush until collect_layout reads it back, + /// so wire round-trip tests and later stages can observe it; a later stage + /// keys its font cache on the family. The actual font match is injected + /// through StyleProperty::FontFamily, and this field is only the readable + /// carrier of the request. + pub family: Option, +} + +/// Parley-side font state riding inside [`crate::text::GuideFontSystem`] +/// alongside the (legacy) cosmic one during the migration. +pub struct ParleyFonts { + pub font_cx: FontContext, + pub layout_cx: LayoutContext, +} + +impl ParleyFonts { + pub fn new() -> Self { + Self { + font_cx: FontContext::new(), + layout_cx: LayoutContext::new(), + } + } + + pub fn load_font_data(&mut self, data: Vec) { + self.font_cx + .collection + .register_fonts(fontique::Blob::new(Arc::new(data)), None); + } + + /// Register an additional font file (e.g. a symbol font like + /// seguisym.ttf) and append its families to the Han fallback key so the + /// fontique Hani-fallback hack (collection/query.rs `set_fallbacks`) can + /// land Common-script symbols (ⓘ ✦ ➤ ⚠ ☢, U+24D8/2726/27A4/26A0/2622) + /// that msyh.ttc lacks. + /// + /// Uses `append_fallbacks`, never `set_fallbacks`, so the system CJK + /// fallback list for the key is preserved and the symbol families land + /// AFTER it. Best-effort: empty data is a no-op. + pub fn load_fallback_font_data(&mut self, data: Vec) { + if data.is_empty() { + return; + } + let key = fontique::FallbackKey::new(fontique::Script::from_bytes(*b"Hani"), None); + let ids: Vec = self + .font_cx + .collection + .register_fonts(fontique::Blob::new(Arc::new(data)), None) + .into_iter() + .map(|(id, _)| id) + .collect(); + if ids.is_empty() { + return; + } + // Warm the system Hani fallback cache first so the appended symbol + // families are ordered after the system CJK fallbacks. + let _warm: Vec<_> = self.font_cx.collection.fallback_families(key).collect(); + for id in &ids { + self.font_cx.collection.append_fallbacks(key, core::iter::once(*id)); + } + // Register the symbol families as the leading generic Emoji + // family. ⚠ (U+26A0) / ☢ (U+2622) are classified emoji/pictograph by + // parley_data, so parley shapes them via `GenericFamily::Emoji` + // (parley shape/mod.rs:580-591): the Hani fallback key above is + // bypassed entirely and the system resolves Segoe UI Emoji (COLR + // composite glyph), which collapses to a 22×10 fragment at 22px. + // `append_generic_families` lands the ids in `ours`, iterated before + // the system generic families (fontique collection/mod.rs:350-362), + // so the emoji query hits seguisym (Outline path, consistent with the + // monochrome pipeline) and stops. + self.font_cx + .collection + .append_generic_families(fontique::GenericFamily::Emoji, ids.iter().copied()); + } +} + +// renderTextParley + +impl ParleyFonts { + /// Lay out one single-style paragraph at `scaled_size` wrapped to + /// `max_width` (None = unwrapped), with line height + /// `scaled_size × line_height_rel`. + /// + /// `italic` is carried as a brush MARKER (see [`SpanBrush`]), NOT as + /// `StyleProperty::FontStyle`. Forwarding `FontStyle::Italic` into + /// shaping made fontique run the CSS font-style algorithm with a + /// true-italic request: on real systems (system_fonts:true, Windows) the + /// matcher resolves a genuine italic face for Latin text (Arial Italic), + /// which swaps the glyph outlines, an observed visual regression. + /// Shaping therefore always matches `FontStyle::Normal`, so glyphs and + /// advances are byte-identical to the upright case; the italic marker + /// rides the brush and is restored into the output by `collect_layout` + /// (read as `OutGlyph.italic` by the boundary correction). The + /// synthetic draw-time slant stays on the Java side (MC §o parity): it + /// is NOT re-applied here, and since shaping always selects the upright + /// face there is no double-slant. + pub fn layout_styled( + &mut self, + text: &str, + scaled_size: f32, + line_height_rel: f32, + bold: bool, + italic: bool, + max_width: Option, + family: Option<&str>, + ) -> Layout { + let mut builder = self + .layout_cx + .ranged_builder(&mut self.font_cx, text, 1.0, true); + builder.push_default(StyleProperty::FontSize(scaled_size)); + builder.push_default(StyleProperty::LineHeight(LineHeight::FontSizeRelative( + line_height_rel, + ))); + // A non-empty font_family becomes FontFamily::named(...); the system + // fonts are already in the fontique collection, so the Named lookup hits + // directly (Consolas, for example). An empty or absent family falls back + // to the SansSerif generic, the hard-coded default from before this field + // existed, byte for byte unchanged. + builder.push_default(StyleProperty::FontFamily(resolve_family(family))); + // Emergency-break unbreakable runs (no-space CJK strings / + // overlong titles) at the line's advance limit instead of overflowing + // the content box. Mirrors CSS `overflow-wrap: break-word`: only + // lines with no fitting soft break point get intra-word breaks; normal + // text keeps breaking at spaces (NOT BreakAll, so Latin words do not + // fragment). + builder.push_default(StyleProperty::OverflowWrap(OverflowWrap::BreakWord)); + if bold { + builder.push_default(StyleProperty::FontWeight(FontWeight::BOLD)); + } + // Italic fix: the italic marker rides the brush; FontStyle is + // deliberately NOT pushed, so shaping always matches Normal (see + // SpanBrush docs). span_index stays 0 for a single-style paragraph. + builder.push_default(StyleProperty::Brush(SpanBrush { + span_index: 0, + italic, + family: requested_family(family), + })); + let mut layout = builder.build(text); + layout.break_all_lines(max_width); + layout.align(Alignment::Start, AlignmentOptions::default()); + layout + } + + /// Lay out one single-style paragraph at `font_size` wrapped to + /// `max_width` (None = unwrapped), with line height + /// `font_size × line_height_rel` (callers pass 10/9 for guide text, 1.5 + /// to A/B against the legacy renderText test window). + pub fn layout_paragraph( + &mut self, + text: &str, + font_size: f32, + line_height_rel: f32, + max_width: Option, + ) -> Layout { + self.layout_styled(text, font_size, line_height_rel, false, false, max_width, None) + } +} + +/// One rasterized glyph quad (RGBA bitmap + pixel position), matching the +/// RenderGlyph wire shape renderText emits. +pub struct RasterQuad { + pub x: i32, + pub y: i32, + pub w: u32, + pub h: u32, + pub rgba: Vec, +} + +/// Rasterize every glyph of a laid-out paragraph into RGBA quads (white text, + /// coverage as alpha: the guide text pipeline is monochrome, tinted at draw +/// time; color emoji bitmaps are out of scope). +pub fn rasterize_layout(layout: &Layout, font_size: f32) -> Vec { + let mut out = Vec::new(); + for line in layout.lines() { + for item in line.items() { + let PositionedLayoutItem::GlyphRun(gr) = item else { + continue; + }; + let fd = gr.run().font(); + let Some(font_ref) = swash::FontRef::from_index(fd.data.data(), fd.index as usize) + else { + continue; + }; + let mut ctx = swash::scale::ScaleContext::new(); + let mut scaler = ctx + .builder(font_ref) + .size(font_size) + .hint(true) + .build(); + for g in gr.positioned_glyphs() { + let Some(img) = swash::scale::Render::new(&[ + swash::scale::Source::ColorOutline(0), + swash::scale::Source::ColorBitmap(swash::scale::StrikeWith::BestFit), + swash::scale::Source::Outline, + ]) + .format(swash::zeno::Format::Alpha) + .render(&mut scaler, g.id as u16) else { + continue; + }; + let (w, h) = (img.placement.width as u32, img.placement.height as u32); + if w == 0 || h == 0 { + continue; + } + out.push(RasterQuad { + x: g.x as i32 + img.placement.left, + y: g.y as i32 - img.placement.top, + w, + h, + rgba: alpha_to_rgba(&img.data), + }); + } + } + } + out +} + +/// Alpha-mask bitmap → tightly packed white RGBA. +fn alpha_to_rgba(data: &[u8]) -> Vec { + let mut rgba = vec![0u8; data.len() * 4]; + for (i, &a) in data.iter().enumerate() { + rgba[i * 4] = 255; + rgba[i * 4 + 1] = 255; + rgba[i * 4 + 2] = 255; + rgba[i * 4 + 3] = a; + } + rgba +} + +// Paragraph shaping core + +/// One registered document-level float, in absolute document coordinates. +/// The pusher owns the float table and hands it to paragraph shaping so the +/// line breaker can query the free interval per line in real time: there is +/// no precomputed clip table and no cross-boundary geometry (the "bridge" is +/// this in-process query). `right` mirrors CSS float side. +#[derive(Clone, Copy)] +pub struct FloatRect { + pub x: f32, + pub y: f32, + pub w: f32, + pub h: f32, + pub right: bool, +} + +/// One styled span: byte range into the ORIGINAL (unstripped) text + bold +/// flag. Index = position in TextData.spans; glyphs shaped from the range +/// report it back as their brush. +pub struct SpanStyle { + pub start: usize, + pub end: usize, + pub bold: bool, + /// Italic marker: this span is italic (§o). Fed into the span brush + /// (never into `StyleProperty::FontStyle`) so it survives shaping + /// without affecting font matching. + pub italic: bool, + pub baseline_shift: f32, + /// This span's font family, taken from its TextStyle.font_family. None means + /// "not declared", so the paragraph default is inherited and no ranged family + /// is injected. When it is non-empty, push_spans injects a ranged + /// `FontFamily::named(...)` style. + pub family: Option, +} + +/// One inline block to place: byte index of its U+FFFC anchor in the +/// ORIGINAL text + its layout width (paragraph-relative units). +/// `float_side`: None = regular inline, Some(1) = float-left, Some(2) = float-right. +pub struct InlineSpec { + pub anchor_byte: usize, + pub width: f32, + pub height: f32, + pub float_side: Option, + pub node: usize, +} + +pub struct ShapeRequest<'a> { + pub text: &'a str, + /// Rich spans covering the whole text in document order (empty = + /// single-style paragraph). + pub spans: &'a [SpanStyle], + /// Inline blocks in document order (paired with U+FFFC anchors). + pub inlines: &'a [InlineSpec], + /// Document-level floats registered so far (absolute coords); empty = + /// uniform-width paragraph. Queried per line against the line's absolute y. + pub floats: &'a [FloatRect], + /// Absolute document y of this paragraph's top edge; line y for the float + /// query is `para_abs_y + line_relative_y`. + pub para_abs_y: f32, + /// Absolute document x of this paragraph's content origin; float edges are + /// converted to paragraph-relative by subtracting this. + pub para_x: f32, + /// In-paragraph clear breaks (raw UTF-8 byte offset into the original text + /// + side 1=left 2=right 3=both), in document order. Mapped to cleaned-text + /// offsets inside shape_paragraph. + pub clears: &'a [(usize, u8)], + pub font_size: f32, + pub font_scale: f32, + pub max_width: f32, + pub justify: bool, + /// Per-paragraph text alignment. 0=Start(Left) 1=Center 2=End(Right) + pub alignment: i8, + /// FlatBuffer TextData.white_space: 0=Normal 1=PreWrap 2=Pre/NoWrap + /// (code blocks). Value 2 disables soft wrapping AND emergency + /// (break-word) wrapping so a long unbreakable code line stays on one + /// line and overflows horizontally instead of wrapping inside the + /// container. + pub white_space: i8, + /// Paragraph default font family, taken from TextData.style.font_family. None + /// means "not declared" and falls back to the SansSerif generic, the same + /// hard-coded default push_defaults used before. A span-level family lives in + /// [`SpanStyle::family`] and overrides this default over its own range when it + /// is non-empty. + pub family: Option<&'a str>, +} + +/// A shaped glyph in paragraph-local coordinates: (x, y) is the pen position +/// on the baseline; w is the advance. The font bytes ride along so the swash +/// rasterizer needs no other font database. +pub struct OutGlyph { + pub glyph_id: u32, + pub x: f32, + pub y: f32, + pub w: f32, + pub line_top: f32, + pub line_height: f32, + pub line_index: usize, + pub span_index: u32, + /// Italic boundary-detection marker: this glyph's run is italic (§o). + /// Restored from the span brush by `collect_layout` (the brush carried + /// it through shaping without letting it into font matching). Read by + /// the boundary correction from the glyph-run output. + pub italic: bool, + /// The font family requested by the run this glyph belongs to, as declared on + /// the wire by its span or paragraph and normalized through clean_family. None + /// means "not declared" and falls back to the SansSerif generic. Restored from + /// the span brush, so it survives with the glyph and lets the full-pipeline test + /// assert that an inline_code span carries a monospace family while a body span + /// falls back to SansSerif. + pub family: Option, + pub font: FontData, + pub font_size: f32, +} + +pub struct ParleyShaped { + pub glyphs: Vec, + /// Inline-box anchors in document order (consumed by the post-pass). + pub markers: Vec, + pub content_height: f32, + pub max_x: f32, + /// Absolute document y that the flow AFTER this paragraph must not rise + /// above, when the paragraph ends with a `
` whose drop no later + /// line inside the paragraph can carry (the common, trailing form). The + /// paragraph's own `content_height` is NOT inflated by it; the pusher + /// advances its cursor to this floor after the paragraph so the following + /// block (e.g. a callout) starts below the cleared float while this + /// paragraph's box still hugs its text. `None` when no such clear applies. + pub clear_floor: Option, + /// (x_off, line_width) of the last line in the paragraph, in paragraph- + /// relative coordinates. Used by the separator-line mechanism (kind=3 + /// DecorationRect) so LytHeading draws a themed separator across the + /// float-compressed full line window. + pub last_line_window: Option<(f32, f32)>, + /// Float-aligned inline block anchors: (flat_node_index, paragraph-relative y). + /// The x coordinate is computed later in inline_post_pass from the block's + /// align mode and the paragraph's content width. + pub float_anchors: Vec<(usize, f32)>, +} + +/// Shape one paragraph: spans → ranged styles, inline blocks → InlineBox, +/// float clips → per-line widths via BreakerState, then collect positioned +/// glyphs and inline markers. +/// +/// Float-aligned inline blocks trigger a two-pass shaping: pass 1 (full +/// width, all inlines as boxes) determines each float's anchor line; pass 2 +/// shapes with paragraph-level floats constraining subsequent line widths. +/// Regular paragraphs (no float inlines) take the fast one-pass path. +pub fn shape_paragraph(parley: &mut ParleyFonts, req: &ShapeRequest) -> ParleyShaped { + let scaled = req.font_size * req.font_scale; + + // ── Pre-processing: strip U+FFFC, build clean text ── + let mut clean = String::with_capacity(req.text.len()); + let mut box_clean_idx: Vec = Vec::with_capacity(req.inlines.len()); + let mut fffc_orig: Vec = Vec::with_capacity(req.inlines.len()); + for (i, ch) in req.text.char_indices() { + if ch == '\u{FFFC}' { + box_clean_idx.push(clean.len()); + fffc_orig.push(i); + } else { + clean.push(ch); + } + } + let adjust = |pos: usize| -> usize { + let n = fffc_orig.iter().take_while(|&&p| p < pos).count(); + pos - 3 * n + }; + let clean_clears: Vec<(usize, u8)> = + req.clears.iter().map(|(k, s)| (adjust(*k), *s)).collect(); + + let has_float = req.inlines.iter().any(|s| s.float_side.is_some()); + + // ── One-pass: fast path when no float inlines ── + if !has_float { + let mut b = parley.layout_cx.ranged_builder(&mut parley.font_cx, &clean, 1.0, true); + push_defaults(&mut b, scaled, req.white_space, req.family); + push_spans(&mut b, req.spans, &adjust, &clean); + push_inlines(&mut b, req.inlines, &box_clean_idx, false); + let mut layout = b.build(&clean); + break_and_align(&mut layout, req); + let (mut glyphs, markers, max_x, h, floor, last_window) = collect_layout( + &layout, req.floats, req.para_abs_y, req.para_x, req.max_width, &clean_clears, + ); + // Apply per-span baseline shift to glyph Y coordinates + for g in &mut glyphs { + let bs = req.spans.get(g.span_index as usize).map_or(0.0, |s| s.baseline_shift); + g.y += bs * scaled; + } + return ParleyShaped { + glyphs, + markers, + content_height: h, + max_x, + clear_floor: floor, + last_line_window: last_window, + float_anchors: Vec::new(), + }; + } + + // ── Two-pass: paragraph-level float inlines ── + + // Pass 1: all boxes (including floats), full width. Float blocks + // participate as inline boxes so their anchor line positions are correct. + let layout1 = { + let mut b = parley.layout_cx.ranged_builder(&mut parley.font_cx, &clean, 1.0, true); + push_defaults(&mut b, scaled, req.white_space, req.family); + push_spans(&mut b, req.spans, &adjust, &clean); + push_inlines(&mut b, req.inlines, &box_clean_idx, false); + let mut layout = b.build(&clean); + break_and_align(&mut layout, req); + layout + }; + + // Find float anchor line Y positions from pass 1 layout. + let mut para_floats: Vec = Vec::new(); + let mut float_anchor_ys: Vec<(usize, f32)> = Vec::new(); + for line in layout1.lines() { + let tr = line.text_range(); + let y = line.metrics().block_min_coord; + for (k, spec) in req.inlines.iter().enumerate() { + if k >= box_clean_idx.len() { + break; + } + if spec.float_side.is_none() { + continue; + } + if tr.contains(&box_clean_idx[k]) { + let abs_y = req.para_abs_y + y; + let side = spec.float_side.unwrap_or(1); + let fl = FloatRect { + x: if side == 1 { + req.para_x + } else { + (req.para_x + req.max_width - spec.width).max(req.para_x) + }, + y: abs_y, + w: spec.width.max(1.0), + h: spec.height.max(scaled * 1.55), + right: side == 2, + }; + para_floats.push(fl); + float_anchor_ys.push((spec.node, y)); + break; + } + } + } + + // Merge document floats + paragraph-local floats for pass 2. + let mut merged_floats: Vec = req.floats.to_vec(); + merged_floats.extend(para_floats.iter().cloned()); + + // Pass 2: skip float inline boxes, shape with constrained widths. + let layout2 = { + let mut b = parley.layout_cx.ranged_builder(&mut parley.font_cx, &clean, 1.0, true); + push_defaults(&mut b, scaled, req.white_space, req.family); + push_spans(&mut b, req.spans, &adjust, &clean); + push_inlines(&mut b, req.inlines, &box_clean_idx, true); + let mut layout = b.build(&clean); + if req.white_space == 2 { + layout.break_all_lines(None); + } else { + break_with_floats(&mut layout, req, &merged_floats, scaled); + } + layout.align( + resolve_alignment(req.justify, req.alignment), + AlignmentOptions::default(), + ); + layout + }; + + let (mut glyphs, markers, max_x, content_height, clear_floor, last_window) = collect_layout( + &layout2, &merged_floats, req.para_abs_y, req.para_x, req.max_width, &clean_clears, + ); + + // Apply per-span baseline shift to glyph Y coordinates + for g in &mut glyphs { + let bs = req.spans.get(g.span_index as usize).map_or(0.0, |s| s.baseline_shift); + g.y += bs * scaled; + } + + ParleyShaped { + glyphs, + markers, + content_height, + max_x, + clear_floor, + last_line_window: last_window, + float_anchors: float_anchor_ys, + } +} + +/// Single normalization point for family strings read off the wire: trim, then +/// drop empty values. The trim/empty filtering used to be spread over three call +/// sites (resolve_family, requested_family and push_spans) and is now collected +/// here. None means "not declared", which falls back to the SansSerif generic as +/// before. +fn clean_family(family: Option<&str>) -> Option<&str> { + family.map(str::trim).filter(|f| !f.is_empty()) +} + +/// Single resolution point from a family name to a `FontFamily`, shared by +/// resolve_family and push_spans. +/// +/// The reserved name `"monospace"` maps to `Generic(Monospace)`, because every +/// fontique backend resolves that generic to the platform's monospace default +/// (dwrite to Consolas, fontconfig through its "monospace" substitution chain, +/// coretext to its monospace default). That hit is more deterministic across +/// platforms than a hard-coded `Named("Consolas")`, which simply misses on a +/// system without Consolas and would be asymmetric with the SansSerif generic +/// fallback used elsewhere in this module. +/// Any other non-empty name maps to `FontFamily::named(...)`, which is +/// `Single(Named(name))`; system fonts are already in the fontique collection, +/// so the Named lookup hits. +/// An empty, all-whitespace or absent name falls back to the SansSerif generic, +/// the hard-coded push_defaults default, byte for byte unchanged. +fn resolve_family(family: Option<&str>) -> FontFamily<'_> { + match clean_family(family) { + Some("monospace") => FontFamily::Single(FontFamilyName::Generic(GenericFamily::Monospace)), + Some(name) => FontFamily::named(name), + None => FontFamily::Single(FontFamilyName::Generic(GenericFamily::SansSerif)), + } +} + +/// Brush-readable carrier of the requested font family. The value is normalized +/// through clean_family, and an empty, all-whitespace or absent one stays None, +/// which means the SansSerif generic fallback and no injected named family. +fn requested_family(family: Option<&str>) -> Option { + clean_family(family).map(str::to_owned) +} + +fn push_defaults( + b: &mut parley::RangedBuilder, + scaled: f32, + white_space: i8, + family: Option<&str>, +) { + b.push_default(StyleProperty::FontSize(scaled)); + b.push_default(StyleProperty::LineHeight(LineHeight::FontSizeRelative(1.55))); + b.push_default(StyleProperty::FontFamily(resolve_family(family))); + if white_space == 2 { + // white_space=2 (Pre/NoWrap, code blocks). NoWrap disables soft + // wrapping and OverflowWrap::Normal disables the emergency break-word + // pass, so a long unbreakable code line keeps its natural single line + // and overflows horizontally (the narrow container scrolls) instead of + // being chopped at the container's advance limit. + b.push_default(StyleProperty::TextWrapMode(TextWrapMode::NoWrap)); + b.push_default(StyleProperty::OverflowWrap(OverflowWrap::Normal)); + } else { + // Emergency-break unbreakable runs (no-space CJK strings / + // overlong titles) at the line's advance limit instead of overflowing + // the content box. Mirrors CSS `overflow-wrap: break-word`: only + // lines with no fitting soft break point get intra-word breaks; normal + // text keeps breaking at spaces (NOT BreakAll, so Latin words do not + // fragment). + b.push_default(StyleProperty::OverflowWrap(OverflowWrap::BreakWord)); + } + b.push_default(StyleProperty::Brush(SpanBrush { + span_index: 0, + italic: false, + family: requested_family(family), + })); +} + +fn push_spans( + b: &mut parley::RangedBuilder, + spans: &[SpanStyle], + adjust: &dyn Fn(usize) -> usize, + text: &str, +) { + for (i, sp) in spans.iter().enumerate() { + let (s, e) = (adjust(sp.start), adjust(sp.end)); + if s >= e || e > text.len() { + continue; + } + // A span-level font_family is injected over the span's range and resolved + // through the single resolve_family point, so "monospace" becomes + // Generic(Monospace); the brush carries the normalized original name as + // well, so it can be read back. + let span_family = clean_family(sp.family.as_deref()); + b.push( + StyleProperty::Brush(SpanBrush { + span_index: i as u32, + // The span's italic flag rides the brush + // (NOT StyleProperty::FontStyle) so shaping keeps matching + // Normal; see the SpanBrush docs. + italic: sp.italic, + family: span_family.map(str::to_owned), + }), + s..e, + ); + if span_family.is_some() { + b.push(StyleProperty::FontFamily(resolve_family(span_family)), s..e); + } + if sp.bold { + b.push(StyleProperty::FontWeight(FontWeight::BOLD), s..e); + } + } +} + +fn push_inlines( + b: &mut parley::RangedBuilder, + inlines: &[InlineSpec], + clean_idx: &[usize], + skip_floats: bool, +) { + for (k, spec) in inlines.iter().enumerate() { + if k >= clean_idx.len() { + break; + } + if skip_floats && spec.float_side.is_some() { + continue; + } + b.push_inline_box(InlineBox { + id: k as u64, + kind: InlineBoxKind::InFlow, + index: clean_idx[k], + width: spec.width, + height: 0.0, + }); + } +} + +fn break_and_align(layout: &mut Layout, req: &ShapeRequest) { + if req.white_space == 2 { + // white_space=2 (Pre/NoWrap). Break at hard breaks only: + // break_all_lines(None) means max advance f32::MAX and NoWrap ignores + // the advance anyway, so every code line stays on one line. + layout.break_all_lines(None); + } else if req.floats.is_empty() { + layout.break_all_lines(Some(req.max_width)); + } else { + let est_h = req.font_size * req.font_scale * 1.55; + break_with_floats(layout, req, req.floats, est_h); + } + layout.align( + resolve_alignment(req.justify, req.alignment), + AlignmentOptions::default(), + ); +} + +/// Resolve text alignment from justify flag and per-paragraph alignment. +/// Paragraph-level alignment (1=Center, 2=End) takes precedence over justify. +/// Justify is applied only when alignment is default (0=Start/Left). +/// 0=Start(Left) 1=Center 2=End(Right) +fn resolve_alignment(justify: bool, alignment: i8) -> Alignment { + match alignment { + 1 => Alignment::Center, + 2 => Alignment::End, + _ if justify => Alignment::Justify, + _ => Alignment::Start, + } +} + +fn break_with_floats( + layout: &mut Layout, + req: &ShapeRequest, + floats: &[FloatRect], + est_h: f32, +) { + let mut breaker = layout.break_lines(); + breaker.state_mut().set_layout_max_advance(req.max_width.max(1.0)); + while !breaker.is_done() { + let rel_y = breaker.committed_y() as f32; + let (_, w) = + query_floats(req.para_abs_y + rel_y, est_h, req.para_x, req.max_width, floats); + breaker.state_mut().set_line_max_advance(w); + if breaker.break_next().is_none() { + break; + } + } + breaker.finish(); +} + +/// Collect positioned glyphs + inline markers from a laid-out paragraph, +/// applying per-line float x offsets and in-paragraph clear breaks. The +/// left-float indent lives ONLY here: the breaker sets just the per-line width +/// because under Justify, parley bakes a set_line_x origin into the glyph pens (line +/// fills [line_x, line_x+adv]), so setting it at break time would shift every +/// left-clipped line twice. +/// +/// `clears` are in-paragraph `
` breaks (cleaned-text byte offset + +/// side), in document order. After the line whose text range covers a break's +/// offset is laid out, every following line is dropped to the cleared floats' +/// bottom edge (`clear_floor`). This is exact for the only real-world form, a +/// clear at the paragraph's end (the break has no trailing text, so no line is +/// re-wrapped by the drop); a mid-paragraph clear drops later lines without +/// re-wrapping them (first-order: never mispositions into a float, only the +/// wrap of those later lines is approximate, and no real page uses that form). +/// Returns the paragraph content height including any clear-induced growth. +pub fn collect_layout( + layout: &Layout, + floats: &[FloatRect], + para_abs_y: f32, + para_x: f32, + max_width: f32, + clears: &[(usize, u8)], + ) -> (Vec, Vec, f32, f32, Option, Option<(f32, f32)>) { + let mut glyphs = Vec::new(); + let mut markers: Vec<(u64, crate::measure::InlineMarker)> = Vec::new(); + let mut max_x = 0.0f32; + let mut clear_floor: Option = None; // absolute y later lines must not rise above + let mut next_clear = 0usize; + let mut content_h = 0.0f32; + let mut last_window: Option<(f32, f32)> = None; + for (li, line) in layout.lines().enumerate() { + let m = line.metrics(); + let tr = line.text_range(); + let orig_top_abs = para_abs_y + m.block_min_coord; + let shift = clear_floor.map_or(0.0, |f| (f - orig_top_abs).max(0.0)); + let eff_top_abs = orig_top_abs + shift; + let (x_off, line_width) = if floats.is_empty() { + (0.0, max_width) + } else { + // Same query as the breaker, but at the (possibly clear-dropped) + // effective line top: a line pushed below the floats sees the full + // width, so its x-offset is 0 regardless of the exact dropped y. + query_floats(eff_top_abs, m.line_height, para_x, max_width, floats) + }; + for item in line.items() { + match item { + PositionedLayoutItem::GlyphRun(gr) => { + // Read-back point: the italic marker is + // restored from the brush (carried through shaping without + // affecting font matching; shaping always ran Normal). + let span_index = gr.style().brush.span_index; + let italic = gr.style().brush.italic; + let family = gr.style().brush.family.clone(); + let fd = gr.run().font().clone(); + let fs = gr.run().font_size(); + for g in gr.positioned_glyphs() { + max_x = max_x.max(g.x + x_off + g.advance); + glyphs.push(OutGlyph { + glyph_id: g.id, + x: g.x + x_off, + y: g.y + shift, + w: g.advance, + line_top: m.block_min_coord + shift, + line_height: m.line_height, + line_index: li, + span_index, + italic, + family: family.clone(), + font: fd.clone(), + font_size: fs, + }); + } + } + PositionedLayoutItem::InlineBox(bx) => { + max_x = max_x.max(bx.x + x_off + bx.width); + markers.push(( + bx.id, + crate::measure::InlineMarker { + pen_x: bx.x + x_off, + baseline_y: m.baseline + shift, + line_top: m.block_min_coord + shift, + line_height: m.line_height, + line_index: li, + advance: bx.width, + }, + )); + } + } + } + content_h = content_h.max(eff_top_abs + m.line_height - para_abs_y); + last_window = Some((x_off, line_width)); + // A clear takes effect after the line that covers its offset: the break + // sits at/after that line's text, so the line itself is not dropped but + // everything following it is. + while next_clear < clears.len() && clears[next_clear].0 <= tr.end { + let side = clears[next_clear].1; + let mut floor = 0.0f32; + for f in floats { + let side_match = + side == 3 || (side == 1 && !f.right) || (side == 2 && f.right); + if side_match { + floor = floor.max(f.y + f.h); + } + } + if floor > 0.0 { + clear_floor = Some(clear_floor.map_or(floor, |c| c.max(floor))); + } + next_clear += 1; + } + } + // A trailing clear (break after the last line's text, the only real-world + // form) has no following line inside this paragraph to carry its drop, so + // its floor is NOT added to this paragraph's height (that would stretch the + // box and leave a blank gap, the "callout not hugging" bug). Instead the + // floor is returned for the pusher to advance its cursor past it. A mid- + // paragraph clear needs no such hand-off: its dropped later lines already + // raised content_h via eff_top_abs, so the natural cursor advance already + // clears the floor. + markers.sort_by_key(|(id, _)| *id); + ( + glyphs, + markers + .into_iter() + .map(|(_, m)| m) + .collect(), + max_x, + content_h, + clear_floor, + last_window, + ) +} + +/// The free horizontal interval (paragraph-relative) for a line whose absolute +/// top is `abs_y` and height `h`: the node width minus every float intersecting +/// that absolute band, with float edges converted to paragraph-relative by +/// `para_x`. Left floats push the left edge right; right floats pull the right +/// edge left. +fn query_floats( + abs_y: f32, + h: f32, + para_x: f32, + node_w: f32, + floats: &[FloatRect], +) -> (f32, f32) { + let mut x0 = 0.0f32; + let mut x1 = node_w; + for f in floats { + if f.y + f.h <= abs_y || f.y >= abs_y + h { + continue; + } + if f.right { + x1 = x1.min(f.x - para_x); + } else { + x0 = x0.max(f.x + f.w - para_x); + } + } + (x0, (x1 - x0).max(1.0)) +} + +/// One rasterized glyph quad with its atlas key (same wire role as +/// layout.rs's cosmic RasterizedGlyph, produced from parley shaping). +pub struct ParleyRasterGlyph { + pub bitmap_key: u64, + pub x: f32, + pub y: f32, + pub w: f32, + pub h: f32, + pub line_index: u32, + pub span_index: u32, + /// Baseline read-back: baseline Y of the line this glyph belongs + /// to, in paragraph-relative coordinates, already reduced to the display pixel + /// grid by division by render_scale and truncation, the same precision + /// convention as x/y/w/h. The caller adds the node's absolute y to obtain + /// document coordinates before writing PlacedGlyph.baseline. + pub baseline: f32, +} + +/// Glyph rasterization size ceiling: the bleed-stop (defense-in-depth). +/// +/// Diagnosis (numerically closed): the live 23s/441-WARN flood comes from +/// glyphs rasterized at 1089-1452 ppem (bitmaps 966×1086 / 1402×1517), +/// traced back to `size = g.font_size × render_scale` below. The normal +/// live path tops out at 11 × 2.5(MAX_ZOOM) × 4 = 110 ppem; the +/// fontScale≈33 injection point that feeds this multiplier is NOT yet +/// located; every static path is value-bounded, so the oversized size must +/// arrive via an unidentified live path. +/// +/// THIS CLAMP IS THE TOURNIQUET, NOT THE FIX. The primary repair is +/// locating and fixing the injection point (the diagnostic log emitted on +/// the first clamp per key is the tracing payload for that hunt). This +/// ceiling only stops the symptom: oversized glyphs rasterize at 128 ppem +/// and, because the atlas key below is derived from the CLAMPED size, +/// all oversize injections collapse onto one shared 128ppem atlas entry +/// instead of flooding the glyph atlas with giant bitmaps. +/// +/// Normal glyphs (≤110 ppem, i.e. the entire legitimate range) take the +/// identical code path: `size` is untouched and the key is identical, so +/// rasterization output is byte-for-byte unchanged. +const MAX_RASTER_SIZE: f32 = 128.0; + +/// Fallback rasterization size for pathological font sizes: the +/// root-cause repair (runtime-closed): ALL +/// 186 giant glyphs arrived with fontScale=0.0. Java passes ShapeTextInput +/// fontScale=0 → `scaled = font_size × font_scale = 0` → parley shapes at +/// size 0 → `OutGlyph.font_size = 0` (or NaN on some paths). Then +/// `raw_size = font_size × render_scale` is 0/NaN and the `> MAX_RASTER_SIZE` +/// clamp above is vacuous (0>128=false; NaN>128=false), so swash renders at +/// size 0/NaN and emits the glyph's raw outline coordinates as a giant +/// bitmap (1371×1516 etc). +/// +/// This fallback is NOT a clamp: it replaces an invalid size with a +/// legitimate one. 22ppem = 11pt × 2, the standard body size at the +/// default 2× render scale, so these glyphs render at normal size instead +/// of raw outline coordinates. Because the atlas key is derived from the +/// final size, all fallback glyphs share one 22ppem atlas entry. +const DEFAULT_RASTER_SIZE: f32 = 22.0; + +/// Atlas keys whose oversize clamp has already been reported. Process-wide +/// so a flood spanning many layout calls still logs exactly once per key: +/// the diagnostic must not itself become a log flood. A poisoned lock (a +/// panic elsewhere in this thread) is skipped silently: reporting is +/// best-effort and must never alter rasterization. +static CLAMP_REPORTED: std::sync::LazyLock>> = + std::sync::LazyLock::new(|| std::sync::Mutex::new(std::collections::HashSet::new())); + +/// Rasterize shaped glyphs: pen positions → swash bitmaps, deduplicated by a +/// stable content key (font bytes ptr, face index, glyph id, size). Pen +/// positions snap to the integer grid; the MC pixel grid wants integer +/// placement anyway (subpixel bins deferred as a calibration item). +pub fn rasterize_out_glyphs( + glyphs: &[OutGlyph], + render_scale: f32, +) -> (Vec, Vec<(u64, u32, u32, Vec)>) { + use std::collections::hash_map::DefaultHasher; + use std::hash::{Hash, Hasher}; + + let mut quads = Vec::new(); + let mut bitmaps: Vec<(u64, u32, u32, Vec)> = Vec::new(); + let mut placements: std::collections::HashMap = Default::default(); + let mut ctx = swash::scale::ScaleContext::new(); + + for g in glyphs { + let Some(font_ref) = swash::FontRef::from_index(g.font.data.data(), g.font.index as usize) + else { + continue; + }; + let raw_size = g.font_size * render_scale; + // Bleed-stop: clamp the rasterization size before it reaches the + // swash scaler. Clamped glyphs share one key (derived below from the + // clamped size), so the atlas cannot be flooded by giant bitmaps. + let clamped = raw_size > MAX_RASTER_SIZE; + // Root-cause repair: fontScale=0 injection makes `font_size` 0 (or NaN), + // so raw_size is 0/NaN and the >MAX clamp above is vacuous + // (0>128=false; NaN>128=false), so swash would render at size 0/NaN and + // emit the glyph's raw outline coordinates as a giant bitmap. Fall + // back to DEFAULT_RASTER_SIZE instead. `clamped` keeps its exact + // meaning (`raw_size > MAX_RASTER_SIZE`, only ever true for finite + // positive raw_size); the two conditions are mutually exclusive. + let fallback = !raw_size.is_finite() || raw_size <= 0.0; + let size = if fallback { + DEFAULT_RASTER_SIZE + } else { + raw_size.min(MAX_RASTER_SIZE) + }; + let xi = (g.x * render_scale).trunc() as i32; + let yi = (g.y * render_scale).trunc() as i32; + + let mut h = DefaultHasher::new(); + (g.font.data.data().as_ptr(), g.font.index, g.glyph_id, size.to_bits()).hash(&mut h); + let key = h.finish(); + + let placement = match placements.get(&key) { + Some(p) => *p, + None => { + let mut scaler = ctx + .builder(font_ref) + .size(size) + .hint(true) + .build(); + let Some(img) = swash::scale::Render::new(&[ + swash::scale::Source::ColorOutline(0), + swash::scale::Source::ColorBitmap(swash::scale::StrikeWith::BestFit), + swash::scale::Source::Outline, + ]) + .format(swash::zeno::Format::Alpha) + .render(&mut scaler, g.glyph_id as u16) else { + continue; + }; + let (w, hh) = (img.placement.width as u32, img.placement.height as u32); + if w == 0 || hh == 0 { + continue; + } + // Bleed-stop diagnostic: first oversize clamp per key logs + // the injection shape (font_size, render_scale, raw size, + // bitmap w×h) once, process-wide, enough to pinpoint the + // live injection point on the next reproduction without the + // diagnostic itself flooding the log. + if clamped { + if let Ok(mut reported) = CLAMP_REPORTED.lock() { + if reported.insert(key) { + eprintln!( + "[guide_layout_engine][WARN] glyph rasterize size clamped \ + (F4 bleed-stop): font_size={} render_scale={} raw_size={} \ + bitmap={}x{} clamped_to={}ppem glyph_id={}", + g.font_size, + render_scale, + raw_size, + w, + hh, + MAX_RASTER_SIZE, + g.glyph_id + ); + } + } + } + let p = (img.placement.left, img.placement.top, w, hh); + bitmaps.push((key, w, hh, alpha_to_rgba(&img.data))); + placements.insert(key, p); + p + } + }; + let (left, top, w, hh) = placement; + // The baseline uses the same precision convention as x/y + // (divide by render_scale, then truncate), reusing the yi computed above + // as (g.y * render_scale).trunc(). The value is in paragraph-relative + // coordinates; the caller adds the node's absolute y for document + // coordinates. + quads.push(ParleyRasterGlyph { + bitmap_key: key, + x: (xi + left) as f32 / render_scale, + y: (yi - top) as f32 / render_scale, + w: w as f32 / render_scale, + h: hh as f32 / render_scale, + line_index: g.line_index as u32, + span_index: g.span_index, + baseline: yi as f32 / render_scale, + }); + } + (quads, bitmaps) +} diff --git a/src/rust/layout-engine/src/style_convert.rs b/src/rust/layout-engine/src/style_convert.rs new file mode 100644 index 00000000..ffce3d0e --- /dev/null +++ b/src/rust/layout-engine/src/style_convert.rs @@ -0,0 +1,174 @@ +use crate::fb::Style as FbStyle; +use taffy::prelude::*; +use taffy::style::{Clear, Float, Overflow}; + +/// Convert FlatBuffer Dimension → Taffy Dimension +fn fb_dim(unit: u8, value: f32) -> Dimension { + match unit { + 1 => Dimension::length(value), + 2 => Dimension::percent(value / 100.0), + _ => Dimension::AUTO, + } +} + +/// Convert FlatBuffer Dimension → LengthPercentageAuto (for margin) +fn fb_to_lpa(unit: u8, value: f32, auto: bool) -> LengthPercentageAuto { + if auto { + return LengthPercentageAuto::AUTO; + } + match unit { + 1 => LengthPercentageAuto::length(value), + 2 => LengthPercentageAuto::percent(value / 100.0), + _ => LengthPercentageAuto::AUTO, + } +} + +/// Convert FlatBuffer Dimension → LengthPercentage (for padding/border) +fn fb_to_lp(unit: u8, value: f32) -> LengthPercentage { + match unit { + 1 => LengthPercentage::length(value), + 2 => LengthPercentage::percent(value / 100.0), + _ => LengthPercentage::length(0.0), + } +} + +/// Convert a FlatBuffer byte to Dimension (for simple getters) +fn dim_from_opt(opt: Option) -> (u8, f32) { + match opt { + Some(d) => (d.unit() as u8, d.value()), + None => (0, 0.0), + } +} + +/// Full conversion: FlatBuffer Style → Taffy Style +pub fn flat_style_to_taffy(fb: &FbStyle) -> Style { + let (gw, gv) = dim_from_opt(fb.gap_w()); + let (gh, hv) = dim_from_opt(fb.gap_h()); + let (sw, sv) = dim_from_opt(fb.size_w()); + let (sh, shv) = dim_from_opt(fb.size_h()); + let (mnw, mnwv) = dim_from_opt(fb.min_w()); + let (mnh, mnhv) = dim_from_opt(fb.min_h()); + let (mxw, mxwv) = dim_from_opt(fb.max_w()); + let (mxh, mxhv) = dim_from_opt(fb.max_h()); + let (fbv, fbwv) = dim_from_opt(fb.flex_basis()); + let (it, itv) = dim_from_opt(fb.inset_top()); + let (ir, irv) = dim_from_opt(fb.inset_right()); + let (ib, ibv) = dim_from_opt(fb.inset_bottom()); + let (il, ilv) = dim_from_opt(fb.inset_left()); + + Style { + display: match fb.display() { + 1 => Display::Grid, + 2 => Display::Block, + 3 => Display::None, + _ => Display::Flex, + }, + flex_direction: match fb.flex_direction() { + 1 => FlexDirection::Column, + _ => FlexDirection::Row, + }, + flex_wrap: match fb.flex_wrap() { + 1 => FlexWrap::Wrap, + _ => FlexWrap::NoWrap, + }, + align_items: match fb.align_items() { + 1 => Some(AlignItems::CENTER), + 2 => Some(AlignItems::FLEX_END), + 3 => Some(AlignItems::STRETCH), + 4 => Some(AlignItems::BASELINE), + _ => Some(AlignItems::FLEX_START), + }, + align_self: match fb.align_self() { + 1 => Some(AlignSelf::FLEX_START), + 2 => Some(AlignSelf::CENTER), + 3 => Some(AlignSelf::FLEX_END), + 4 => Some(AlignSelf::STRETCH), + _ => None, + }, + justify_content: match fb.justify_content() { + 1 => Some(JustifyContent::CENTER), + 2 => Some(JustifyContent::FLEX_END), + 3 => Some(JustifyContent::SPACE_BETWEEN), + 4 => Some(JustifyContent::SPACE_AROUND), + 5 => Some(JustifyContent::SPACE_EVENLY), + _ => Some(JustifyContent::FLEX_START), + }, + gap: Size { width: fb_to_lp(gw, gv), height: fb_to_lp(gh, hv) }, + size: Size { + width: fb_dim(sw, sv), + height: fb_dim(sh, shv), + }, + min_size: Size { + width: fb_dim(mnw, mnwv), + height: fb_dim(mnh, mnhv), + }, + max_size: Size { + width: fb_dim(mxw, mxwv), + height: fb_dim(mxh, mxhv), + }, + aspect_ratio: if fb.aspect_ratio() > 0.0 { + Some(fb.aspect_ratio()) + } else { + None + }, + margin: Rect { + left: fb_to_lpa(1, fb.margin_left(), fb.margin_auto_left()), + right: fb_to_lpa(1, fb.margin_right(), fb.margin_auto_right()), + top: fb_to_lpa(1, fb.margin_top(), fb.margin_auto_top()), + bottom: fb_to_lpa(1, fb.margin_bottom(), fb.margin_auto_bottom()), + }, + padding: Rect { + left: fb_to_lp(1, fb.padding_left()), + right: fb_to_lp(1, fb.padding_right()), + top: fb_to_lp(1, fb.padding_top()), + bottom: fb_to_lp(1, fb.padding_bottom()), + }, + border: Rect { + left: fb_to_lp(1, fb.border_left()), + right: fb_to_lp(1, fb.border_right()), + top: fb_to_lp(1, fb.border_top()), + bottom: fb_to_lp(1, fb.border_bottom()), + }, + overflow: taffy::geometry::Point { + x: match fb.overflow() { + 1 => Overflow::Hidden, + 2 => Overflow::Scroll, + _ => Overflow::Visible, + }, + y: match fb.overflow() { + 1 => Overflow::Hidden, + 2 => Overflow::Scroll, + _ => Overflow::Visible, + }, + }, + flex_grow: fb.flex_grow(), + flex_shrink: fb.flex_shrink(), + flex_basis: fb_dim(fbv, fbwv), + position: match fb.position() { + 1 => Position::Absolute, + _ => Position::Relative, + }, + inset: Rect { + left: fb_to_lpa(il, ilv, false), + right: fb_to_lpa(ir, irv, false), + top: fb_to_lpa(it, itv, false), + bottom: fb_to_lpa(ib, ibv, false), + }, + // Float and Clear are feature-gated behind float_layout + // which is enabled in our Cargo.toml + float: if fb.float() == 1 { + Float::Left + } else if fb.float() == 2 { + Float::Right + } else { + Float::None + }, + clear: match fb.clear() { + 1 => Clear::Left, + 2 => Clear::Right, + 3 => Clear::Both, + _ => Clear::None, + }, + ..Default::default() + } +} diff --git a/src/rust/layout-engine/src/text.rs b/src/rust/layout-engine/src/text.rs new file mode 100644 index 00000000..3553f62d --- /dev/null +++ b/src/rust/layout-engine/src/text.rs @@ -0,0 +1,22 @@ +/// Persistent font state per LayoutBridge handle. Only the parley contexts +/// remain; the legacy cosmic-text shaping/rasterization path was removed once +/// parley took over measureLayout and shapeText. +pub struct GuideFontSystem { + pub parley: crate::parley_text::ParleyFonts, +} + +impl GuideFontSystem { + pub fn new() -> Self { + Self { + parley: crate::parley_text::ParleyFonts::new(), + } + } + + pub fn load_font_data(&mut self, data: Vec) { + self.parley.load_font_data(data); + } + + pub fn load_fallback_font_data(&mut self, data: Vec) { + self.parley.load_fallback_font_data(data); + } +} From b0b6d5030fc97f03b7e684f9ed242e728fb07ce0 Mon Sep 17 00:00:00 2001 From: Cheng Ming <8456918+Windorain@users.noreply.github.com> Date: Sun, 4 Oct 2026 04:55:31 +0800 Subject: [PATCH 3/6] test(visual): visual regression system under test/ Fixture resource pack, bounds assertion ratchet, and Python inspection tooling under test/visual/, plus the JUnit suites under src/test/. Fixture prose is English; CJK payload sections are intentional render test material. --- .../guide/compiler/SectionFormattingTest.java | 136 ++ .../compiler/TaskListCompilationTest.java | 274 +++ .../markdown/MarkdownLatexShorthandTest.java | 202 ++ .../markdown/MarkdownListSemanticsTest.java | 231 +++ .../GuideSettingsDocumentBuilderTest.java | 341 ++++ .../guide/layout/DetailsAlignDiag.java | 230 +++ .../guidenh/guide/layout/GlyphDiag.java | 240 +++ .../guide/layout/LayoutBridgeSmokeTest.java | 182 ++ .../guide/layout/LayoutPipelineHarness.java | 1634 +++++++++++++++++ .../layout/LayoutTreeSerializerSmokeTest.java | 370 ++++ .../OrderedListMarkerSerializeTest.java | 86 + .../render/GuideGlyphAtlasPagingTest.java | 189 ++ .../render/GuideRenderPrimitiveTest.java | 21 + .../GuideTextFontFamilyResolveTest.java | 163 ++ .../guide/render/PrimitiveCollectorTest.java | 195 ++ .../token/ThemeTokenSystemSmokeTest.java | 158 ++ test/visual/README.md | 65 + test/visual/ratchet/assertions.json | 509 +++++ .../_en_us/visualtest/assets/checker-128.png | Bin 0 -> 487 bytes .../_en_us/visualtest/assets/red-64.png | Bin 0 -> 208 bytes .../_en_us/visualtest/assets/tall-64x256.png | Bin 0 -> 382 bytes .../visualtest/assets/test-structure.snbt | 1 + .../_en_us/visualtest/assets/test-table.csv | 6 + .../_en_us/visualtest/assets/wide-256x64.png | Bin 0 -> 285 bytes .../_en_us/visualtest/charts/bar-column.md | 43 + .../_en_us/visualtest/charts/function.md | 48 + .../_en_us/visualtest/charts/line-scatter.md | 44 + .../_en_us/visualtest/charts/options.md | 82 + .../guidenh/_en_us/visualtest/charts/pie.md | 53 + .../guidenh/_en_us/visualtest/code/blocks.md | 151 ++ .../_en_us/visualtest/code/special-langs.md | 87 + .../_en_us/visualtest/floats/content-types.md | 83 + .../_en_us/visualtest/floats/in-tabs.md | 78 + .../guidenh/_en_us/visualtest/floats/multi.md | 87 + .../_en_us/visualtest/floats/wrap-modes.md | 67 + .../guidenh/_en_us/visualtest/images/basic.md | 45 + .../_en_us/visualtest/images/block-item.md | 100 + .../guidenh/_en_us/visualtest/images/float.md | 69 + .../visualtest/images/floating-image.md | 120 ++ .../_en_us/visualtest/images/fullwidth.md | 96 + .../_en_us/visualtest/images/single-param.md | 25 + .../guidenh/_en_us/visualtest/index.md | 15 + .../guidenh/_en_us/visualtest/latex/audit.md | 81 + .../_en_us/visualtest/latex/display.md | 55 + .../_en_us/visualtest/latex/inline-xheight.md | 21 + .../guidenh/_en_us/visualtest/latex/inline.md | 65 + .../guidenh/_en_us/visualtest/layout/align.md | 45 + .../visualtest/layout/baseline-align.md | 71 + .../visualtest/layout/callout-combos.md | 77 + .../_en_us/visualtest/layout/callouts.md | 100 + .../_en_us/visualtest/layout/content-tabs.md | 127 ++ .../_en_us/visualtest/layout/details.md | 86 + .../_en_us/visualtest/layout/row-column.md | 102 + .../guidenh/_en_us/visualtest/lists/basic.md | 58 + .../guidenh/_en_us/visualtest/lists/rich.md | 81 + .../guidenh/_en_us/visualtest/lists/tasks.md | 42 + .../_en_us/visualtest/mdx/file-tree.md | 57 + .../visualtest/mermaid/deep-subgraphs.md | 117 ++ .../_en_us/visualtest/mermaid/directions.md | 76 + .../_en_us/visualtest/mermaid/flowchart.md | 84 + .../_en_us/visualtest/mermaid/large.md | 107 ++ .../_en_us/visualtest/mermaid/links.md | 107 ++ .../visualtest/mermaid/mindmap-variants.md | 137 ++ .../_en_us/visualtest/mermaid/mindmap.md | 73 + .../_en_us/visualtest/mermaid/mixed-multi.md | 116 ++ .../visualtest/mermaid/node-content-tags.md | 84 + .../_en_us/visualtest/mermaid/node-content.md | 52 + .../_en_us/visualtest/mermaid/rich-text.md | 102 + .../_en_us/visualtest/mermaid/shapes.md | 76 + .../_en_us/visualtest/mermaid/stress-large.md | 382 ++++ .../_en_us/visualtest/mermaid/subgraphs.md | 85 + .../visualtest/mermaid/wrap-long-labels.md | 27 + .../_en_us/visualtest/meta/error-parse.md | 17 + .../_en_us/visualtest/meta/frontmatter.md | 55 + .../_en_us/visualtest/meta/indexes-sub-a.md | 17 + .../_en_us/visualtest/meta/indexes-sub-b.md | 17 + .../guidenh/_en_us/visualtest/meta/indexes.md | 26 + .../guidenh/_en_us/visualtest/meta/special.md | 32 + .../_en_us/visualtest/meta/zoom-large.md | 58 + .../_en_us/visualtest/meta/zoom-small.md | 58 + .../_en_us/visualtest/nei/item-grid.md | 59 + .../guidenh/_en_us/visualtest/nei/recipes.md | 59 + .../_en_us/visualtest/overflow/exact-fit.md | 55 + .../visualtest/overflow/scroll-containers.md | 66 + .../visualtest/overflow/tall-element.md | 133 ++ .../_en_us/visualtest/scenes/annotations.md | 92 + .../_en_us/visualtest/scenes/blocks.md | 55 + .../_en_us/visualtest/scenes/camera.md | 96 + .../_en_us/visualtest/scenes/effects.md | 61 + .../_en_us/visualtest/scenes/entities.md | 52 + .../_en_us/visualtest/scenes/import.md | 72 + .../_en_us/visualtest/scenes/ponder.md | 29 + .../guidenh/_en_us/visualtest/stress/mixed.md | 119 ++ .../_en_us/visualtest/stress/stress-glyphs.md | 224 +++ .../guidenh/_en_us/visualtest/tables/basic.md | 37 + .../guidenh/_en_us/visualtest/tables/cjk.md | 38 + .../guidenh/_en_us/visualtest/tables/csv.md | 59 + .../_en_us/visualtest/tables/metadata.md | 42 + .../guidenh/_en_us/visualtest/tables/wide.md | 39 + .../_en_us/visualtest/tags/block-stats.md | 42 + .../_en_us/visualtest/tags/quest-link.md | 29 + .../_en_us/visualtest/tags/recipes-usage.md | 29 + .../_en_us/visualtest/text/cjk-mixed.md | 51 + .../_en_us/visualtest/text/color-named.md | 39 + .../_en_us/visualtest/text/decorations.md | 53 + .../_en_us/visualtest/text/error-fallback.md | 47 + .../_en_us/visualtest/text/footnotes.md | 51 + .../_en_us/visualtest/text/headings.md | 49 + .../visualtest/text/inline-game-tags.md | 98 + .../_en_us/visualtest/text/inline-marks.md | 59 + .../_en_us/visualtest/text/item-link-dense.md | 47 + .../visualtest/text/itemlink-italic-probe.md | 57 + .../guidenh/_en_us/visualtest/text/links.md | 90 + .../guidenh/_en_us/visualtest/text/mark.md | 40 + .../_en_us/visualtest/text/paragraphs.md | 51 + .../_en_us/visualtest/text/section-codes.md | 66 + .../visualtest/text_italic-correction.md | 31 + test/visual/resourcepack/pack.mcmeta | 6 + test/visual/resourcepack/pack.png | Bin 0 -> 4834 bytes test/visual/tools/.env.example | 29 + test/visual/tools/README.md | 293 +++ test/visual/tools/assert_bounds.py | 597 ++++++ test/visual/tools/check_freshness.py | 298 +++ test/visual/tools/render_watchdog.py | 314 ++++ test/visual/tools/scan_logs.py | 221 +++ test/visual/tools/screen.py | 1209 ++++++++++++ 126 files changed, 14782 insertions(+) create mode 100644 src/test/java/com/hfstudio/guidenh/guide/compiler/SectionFormattingTest.java create mode 100644 src/test/java/com/hfstudio/guidenh/guide/compiler/TaskListCompilationTest.java create mode 100644 src/test/java/com/hfstudio/guidenh/guide/internal/markdown/MarkdownLatexShorthandTest.java create mode 100644 src/test/java/com/hfstudio/guidenh/guide/internal/markdown/MarkdownListSemanticsTest.java create mode 100644 src/test/java/com/hfstudio/guidenh/guide/internal/settings/GuideSettingsDocumentBuilderTest.java create mode 100644 src/test/java/com/hfstudio/guidenh/guide/layout/DetailsAlignDiag.java create mode 100644 src/test/java/com/hfstudio/guidenh/guide/layout/GlyphDiag.java create mode 100644 src/test/java/com/hfstudio/guidenh/guide/layout/LayoutBridgeSmokeTest.java create mode 100644 src/test/java/com/hfstudio/guidenh/guide/layout/LayoutPipelineHarness.java create mode 100644 src/test/java/com/hfstudio/guidenh/guide/layout/LayoutTreeSerializerSmokeTest.java create mode 100644 src/test/java/com/hfstudio/guidenh/guide/layout/OrderedListMarkerSerializeTest.java create mode 100644 src/test/java/com/hfstudio/guidenh/guide/render/GuideGlyphAtlasPagingTest.java create mode 100644 src/test/java/com/hfstudio/guidenh/guide/render/GuideRenderPrimitiveTest.java create mode 100644 src/test/java/com/hfstudio/guidenh/guide/render/GuideTextFontFamilyResolveTest.java create mode 100644 src/test/java/com/hfstudio/guidenh/guide/render/PrimitiveCollectorTest.java create mode 100644 src/test/java/com/hfstudio/guidenh/guide/style/token/ThemeTokenSystemSmokeTest.java create mode 100644 test/visual/README.md create mode 100644 test/visual/ratchet/assertions.json create mode 100644 test/visual/resourcepack/assets/guidenh/guidenh/_en_us/visualtest/assets/checker-128.png create mode 100644 test/visual/resourcepack/assets/guidenh/guidenh/_en_us/visualtest/assets/red-64.png create mode 100644 test/visual/resourcepack/assets/guidenh/guidenh/_en_us/visualtest/assets/tall-64x256.png create mode 100644 test/visual/resourcepack/assets/guidenh/guidenh/_en_us/visualtest/assets/test-structure.snbt create mode 100644 test/visual/resourcepack/assets/guidenh/guidenh/_en_us/visualtest/assets/test-table.csv create mode 100644 test/visual/resourcepack/assets/guidenh/guidenh/_en_us/visualtest/assets/wide-256x64.png create mode 100644 test/visual/resourcepack/assets/guidenh/guidenh/_en_us/visualtest/charts/bar-column.md create mode 100644 test/visual/resourcepack/assets/guidenh/guidenh/_en_us/visualtest/charts/function.md create mode 100644 test/visual/resourcepack/assets/guidenh/guidenh/_en_us/visualtest/charts/line-scatter.md create mode 100644 test/visual/resourcepack/assets/guidenh/guidenh/_en_us/visualtest/charts/options.md create mode 100644 test/visual/resourcepack/assets/guidenh/guidenh/_en_us/visualtest/charts/pie.md create mode 100644 test/visual/resourcepack/assets/guidenh/guidenh/_en_us/visualtest/code/blocks.md create mode 100644 test/visual/resourcepack/assets/guidenh/guidenh/_en_us/visualtest/code/special-langs.md create mode 100644 test/visual/resourcepack/assets/guidenh/guidenh/_en_us/visualtest/floats/content-types.md create mode 100644 test/visual/resourcepack/assets/guidenh/guidenh/_en_us/visualtest/floats/in-tabs.md create mode 100644 test/visual/resourcepack/assets/guidenh/guidenh/_en_us/visualtest/floats/multi.md create mode 100644 test/visual/resourcepack/assets/guidenh/guidenh/_en_us/visualtest/floats/wrap-modes.md create mode 100644 test/visual/resourcepack/assets/guidenh/guidenh/_en_us/visualtest/images/basic.md create mode 100644 test/visual/resourcepack/assets/guidenh/guidenh/_en_us/visualtest/images/block-item.md create mode 100644 test/visual/resourcepack/assets/guidenh/guidenh/_en_us/visualtest/images/float.md create mode 100644 test/visual/resourcepack/assets/guidenh/guidenh/_en_us/visualtest/images/floating-image.md create mode 100644 test/visual/resourcepack/assets/guidenh/guidenh/_en_us/visualtest/images/fullwidth.md create mode 100644 test/visual/resourcepack/assets/guidenh/guidenh/_en_us/visualtest/images/single-param.md create mode 100644 test/visual/resourcepack/assets/guidenh/guidenh/_en_us/visualtest/index.md create mode 100644 test/visual/resourcepack/assets/guidenh/guidenh/_en_us/visualtest/latex/audit.md create mode 100644 test/visual/resourcepack/assets/guidenh/guidenh/_en_us/visualtest/latex/display.md create mode 100644 test/visual/resourcepack/assets/guidenh/guidenh/_en_us/visualtest/latex/inline-xheight.md create mode 100644 test/visual/resourcepack/assets/guidenh/guidenh/_en_us/visualtest/latex/inline.md create mode 100644 test/visual/resourcepack/assets/guidenh/guidenh/_en_us/visualtest/layout/align.md create mode 100644 test/visual/resourcepack/assets/guidenh/guidenh/_en_us/visualtest/layout/baseline-align.md create mode 100644 test/visual/resourcepack/assets/guidenh/guidenh/_en_us/visualtest/layout/callout-combos.md create mode 100644 test/visual/resourcepack/assets/guidenh/guidenh/_en_us/visualtest/layout/callouts.md create mode 100644 test/visual/resourcepack/assets/guidenh/guidenh/_en_us/visualtest/layout/content-tabs.md create mode 100644 test/visual/resourcepack/assets/guidenh/guidenh/_en_us/visualtest/layout/details.md create mode 100644 test/visual/resourcepack/assets/guidenh/guidenh/_en_us/visualtest/layout/row-column.md create mode 100644 test/visual/resourcepack/assets/guidenh/guidenh/_en_us/visualtest/lists/basic.md create mode 100644 test/visual/resourcepack/assets/guidenh/guidenh/_en_us/visualtest/lists/rich.md create mode 100644 test/visual/resourcepack/assets/guidenh/guidenh/_en_us/visualtest/lists/tasks.md create mode 100644 test/visual/resourcepack/assets/guidenh/guidenh/_en_us/visualtest/mdx/file-tree.md create mode 100644 test/visual/resourcepack/assets/guidenh/guidenh/_en_us/visualtest/mermaid/deep-subgraphs.md create mode 100644 test/visual/resourcepack/assets/guidenh/guidenh/_en_us/visualtest/mermaid/directions.md create mode 100644 test/visual/resourcepack/assets/guidenh/guidenh/_en_us/visualtest/mermaid/flowchart.md create mode 100644 test/visual/resourcepack/assets/guidenh/guidenh/_en_us/visualtest/mermaid/large.md create mode 100644 test/visual/resourcepack/assets/guidenh/guidenh/_en_us/visualtest/mermaid/links.md create mode 100644 test/visual/resourcepack/assets/guidenh/guidenh/_en_us/visualtest/mermaid/mindmap-variants.md create mode 100644 test/visual/resourcepack/assets/guidenh/guidenh/_en_us/visualtest/mermaid/mindmap.md create mode 100644 test/visual/resourcepack/assets/guidenh/guidenh/_en_us/visualtest/mermaid/mixed-multi.md create mode 100644 test/visual/resourcepack/assets/guidenh/guidenh/_en_us/visualtest/mermaid/node-content-tags.md create mode 100644 test/visual/resourcepack/assets/guidenh/guidenh/_en_us/visualtest/mermaid/node-content.md create mode 100644 test/visual/resourcepack/assets/guidenh/guidenh/_en_us/visualtest/mermaid/rich-text.md create mode 100644 test/visual/resourcepack/assets/guidenh/guidenh/_en_us/visualtest/mermaid/shapes.md create mode 100644 test/visual/resourcepack/assets/guidenh/guidenh/_en_us/visualtest/mermaid/stress-large.md create mode 100644 test/visual/resourcepack/assets/guidenh/guidenh/_en_us/visualtest/mermaid/subgraphs.md create mode 100644 test/visual/resourcepack/assets/guidenh/guidenh/_en_us/visualtest/mermaid/wrap-long-labels.md create mode 100644 test/visual/resourcepack/assets/guidenh/guidenh/_en_us/visualtest/meta/error-parse.md create mode 100644 test/visual/resourcepack/assets/guidenh/guidenh/_en_us/visualtest/meta/frontmatter.md create mode 100644 test/visual/resourcepack/assets/guidenh/guidenh/_en_us/visualtest/meta/indexes-sub-a.md create mode 100644 test/visual/resourcepack/assets/guidenh/guidenh/_en_us/visualtest/meta/indexes-sub-b.md create mode 100644 test/visual/resourcepack/assets/guidenh/guidenh/_en_us/visualtest/meta/indexes.md create mode 100644 test/visual/resourcepack/assets/guidenh/guidenh/_en_us/visualtest/meta/special.md create mode 100644 test/visual/resourcepack/assets/guidenh/guidenh/_en_us/visualtest/meta/zoom-large.md create mode 100644 test/visual/resourcepack/assets/guidenh/guidenh/_en_us/visualtest/meta/zoom-small.md create mode 100644 test/visual/resourcepack/assets/guidenh/guidenh/_en_us/visualtest/nei/item-grid.md create mode 100644 test/visual/resourcepack/assets/guidenh/guidenh/_en_us/visualtest/nei/recipes.md create mode 100644 test/visual/resourcepack/assets/guidenh/guidenh/_en_us/visualtest/overflow/exact-fit.md create mode 100644 test/visual/resourcepack/assets/guidenh/guidenh/_en_us/visualtest/overflow/scroll-containers.md create mode 100644 test/visual/resourcepack/assets/guidenh/guidenh/_en_us/visualtest/overflow/tall-element.md create mode 100644 test/visual/resourcepack/assets/guidenh/guidenh/_en_us/visualtest/scenes/annotations.md create mode 100644 test/visual/resourcepack/assets/guidenh/guidenh/_en_us/visualtest/scenes/blocks.md create mode 100644 test/visual/resourcepack/assets/guidenh/guidenh/_en_us/visualtest/scenes/camera.md create mode 100644 test/visual/resourcepack/assets/guidenh/guidenh/_en_us/visualtest/scenes/effects.md create mode 100644 test/visual/resourcepack/assets/guidenh/guidenh/_en_us/visualtest/scenes/entities.md create mode 100644 test/visual/resourcepack/assets/guidenh/guidenh/_en_us/visualtest/scenes/import.md create mode 100644 test/visual/resourcepack/assets/guidenh/guidenh/_en_us/visualtest/scenes/ponder.md create mode 100644 test/visual/resourcepack/assets/guidenh/guidenh/_en_us/visualtest/stress/mixed.md create mode 100644 test/visual/resourcepack/assets/guidenh/guidenh/_en_us/visualtest/stress/stress-glyphs.md create mode 100644 test/visual/resourcepack/assets/guidenh/guidenh/_en_us/visualtest/tables/basic.md create mode 100644 test/visual/resourcepack/assets/guidenh/guidenh/_en_us/visualtest/tables/cjk.md create mode 100644 test/visual/resourcepack/assets/guidenh/guidenh/_en_us/visualtest/tables/csv.md create mode 100644 test/visual/resourcepack/assets/guidenh/guidenh/_en_us/visualtest/tables/metadata.md create mode 100644 test/visual/resourcepack/assets/guidenh/guidenh/_en_us/visualtest/tables/wide.md create mode 100644 test/visual/resourcepack/assets/guidenh/guidenh/_en_us/visualtest/tags/block-stats.md create mode 100644 test/visual/resourcepack/assets/guidenh/guidenh/_en_us/visualtest/tags/quest-link.md create mode 100644 test/visual/resourcepack/assets/guidenh/guidenh/_en_us/visualtest/tags/recipes-usage.md create mode 100644 test/visual/resourcepack/assets/guidenh/guidenh/_en_us/visualtest/text/cjk-mixed.md create mode 100644 test/visual/resourcepack/assets/guidenh/guidenh/_en_us/visualtest/text/color-named.md create mode 100644 test/visual/resourcepack/assets/guidenh/guidenh/_en_us/visualtest/text/decorations.md create mode 100644 test/visual/resourcepack/assets/guidenh/guidenh/_en_us/visualtest/text/error-fallback.md create mode 100644 test/visual/resourcepack/assets/guidenh/guidenh/_en_us/visualtest/text/footnotes.md create mode 100644 test/visual/resourcepack/assets/guidenh/guidenh/_en_us/visualtest/text/headings.md create mode 100644 test/visual/resourcepack/assets/guidenh/guidenh/_en_us/visualtest/text/inline-game-tags.md create mode 100644 test/visual/resourcepack/assets/guidenh/guidenh/_en_us/visualtest/text/inline-marks.md create mode 100644 test/visual/resourcepack/assets/guidenh/guidenh/_en_us/visualtest/text/item-link-dense.md create mode 100644 test/visual/resourcepack/assets/guidenh/guidenh/_en_us/visualtest/text/itemlink-italic-probe.md create mode 100644 test/visual/resourcepack/assets/guidenh/guidenh/_en_us/visualtest/text/links.md create mode 100644 test/visual/resourcepack/assets/guidenh/guidenh/_en_us/visualtest/text/mark.md create mode 100644 test/visual/resourcepack/assets/guidenh/guidenh/_en_us/visualtest/text/paragraphs.md create mode 100644 test/visual/resourcepack/assets/guidenh/guidenh/_en_us/visualtest/text/section-codes.md create mode 100644 test/visual/resourcepack/assets/guidenh/guidenh/_en_us/visualtest/text_italic-correction.md create mode 100644 test/visual/resourcepack/pack.mcmeta create mode 100644 test/visual/resourcepack/pack.png create mode 100644 test/visual/tools/.env.example create mode 100644 test/visual/tools/README.md create mode 100644 test/visual/tools/assert_bounds.py create mode 100644 test/visual/tools/check_freshness.py create mode 100644 test/visual/tools/render_watchdog.py create mode 100644 test/visual/tools/scan_logs.py create mode 100644 test/visual/tools/screen.py diff --git a/src/test/java/com/hfstudio/guidenh/guide/compiler/SectionFormattingTest.java b/src/test/java/com/hfstudio/guidenh/guide/compiler/SectionFormattingTest.java new file mode 100644 index 00000000..c3d2c8f4 --- /dev/null +++ b/src/test/java/com/hfstudio/guidenh/guide/compiler/SectionFormattingTest.java @@ -0,0 +1,136 @@ +package com.hfstudio.guidenh.guide.compiler; + +import static org.junit.jupiter.api.Assertions.*; + +import java.util.List; + +import org.junit.jupiter.api.Test; + +import com.hfstudio.guidenh.guide.color.ConstantColor; +import com.hfstudio.guidenh.guide.document.flow.LytFlowContent; +import com.hfstudio.guidenh.guide.document.flow.LytFlowSpan; +import com.hfstudio.guidenh.guide.document.flow.LytFlowText; +import com.hfstudio.guidenh.guide.style.TextStyle; + +class SectionFormattingTest { + + // Color code 'f' selects white (0xFFFFFFFF). + + @Test + void sectionFProducesWhiteSpan() { + List result = PageCompiler.parseSectionFormatting("§fWhite"); + assertEquals(1, result.size()); + + LytFlowSpan span = assertInstanceOf(LytFlowSpan.class, result.get(0)); + TextStyle style = span.getStyle(); + assertNotNull(style.color()); + assertEquals(new ConstantColor(0xFFFFFFFF), style.color()); + + assertEquals( + 1, + span.getChildren() + .size()); + LytFlowText text = assertInstanceOf( + LytFlowText.class, + span.getChildren() + .get(0)); + assertEquals("White", text.getText()); + } + + // Color code 'c' selects red (0xFFFF5555). + + @Test + void sectionCRegression() { + List result = PageCompiler.parseSectionFormatting("§cRed"); + assertEquals(1, result.size()); + + LytFlowSpan span = assertInstanceOf(LytFlowSpan.class, result.get(0)); + TextStyle style = span.getStyle(); + assertNotNull(style.color()); + assertEquals(new ConstantColor(0xFFFF5555), style.color()); + + assertEquals( + 1, + span.getChildren() + .size()); + LytFlowText text = assertInstanceOf( + LytFlowText.class, + span.getChildren() + .get(0)); + assertEquals("Red", text.getText()); + } + + // Color code 'l' sets bold and carries no color. + + @Test + void sectionLProducesBoldSpan() { + List result = PageCompiler.parseSectionFormatting("§lBold"); + assertEquals(1, result.size()); + + LytFlowSpan span = assertInstanceOf(LytFlowSpan.class, result.get(0)); + TextStyle style = span.getStyle(); + assertNull(style.color()); + assertTrue(style.bold()); + + assertEquals( + 1, + span.getChildren() + .size()); + LytFlowText text = assertInstanceOf( + LytFlowText.class, + span.getChildren() + .get(0)); + assertEquals("Bold", text.getText()); + } + + @Test + void plainTextReturnsLytFlowText() { + List result = PageCompiler.parseSectionFormatting("Hello"); + assertEquals(1, result.size()); + + LytFlowText text = assertInstanceOf(LytFlowText.class, result.get(0)); + assertEquals("Hello", text.getText()); + } + + // An unknown code after the section sign stays literal text. + + @Test + void illegalCodeKeptLiteral() { + List result = PageCompiler.parseSectionFormatting("§zFoo"); + assertEquals(1, result.size()); + + LytFlowText text = assertInstanceOf(LytFlowText.class, result.get(0)); + assertEquals("§zFoo", text.getText()); + } + + @Test + void mapSectionColorFWhite() { + assertEquals(0xFFFFFFFF, PageCompiler.mapSectionColor('f')); + } + + @Test + void mapSectionColorFUpperCase() { + assertEquals(0xFFFFFFFF, PageCompiler.mapSectionColor('F')); + } + + @Test + void mapSectionColorZSentinel() { + assertEquals(0, PageCompiler.mapSectionColor('z')); + } + + // A color code also resets formatting state set by earlier codes. + + @Test + void colorCodeResetsBold() { + // §l sets bold, then §f resets all formatting including bold + List result = PageCompiler.parseSectionFormatting("§l§fRest"); + assertEquals(1, result.size()); + + LytFlowSpan span = assertInstanceOf(LytFlowSpan.class, result.get(0)); + TextStyle style = span.getStyle(); + assertNotNull(style.color()); + assertEquals(new ConstantColor(0xFFFFFFFF), style.color()); + // bold is explicitly set to false by the color code + assertFalse(style.bold()); + } +} diff --git a/src/test/java/com/hfstudio/guidenh/guide/compiler/TaskListCompilationTest.java b/src/test/java/com/hfstudio/guidenh/guide/compiler/TaskListCompilationTest.java new file mode 100644 index 00000000..df9d8408 --- /dev/null +++ b/src/test/java/com/hfstudio/guidenh/guide/compiler/TaskListCompilationTest.java @@ -0,0 +1,274 @@ +package com.hfstudio.guidenh.guide.compiler; + +import static org.junit.jupiter.api.Assertions.*; + +import java.util.ArrayList; +import java.util.List; + +import net.minecraft.util.ResourceLocation; + +import org.junit.jupiter.api.Test; + +import com.hfstudio.guidenh.guide.GuidePage; +import com.hfstudio.guidenh.guide.PageCollection; +import com.hfstudio.guidenh.guide.document.block.LytDocument; +import com.hfstudio.guidenh.guide.document.block.LytListItem; +import com.hfstudio.guidenh.guide.document.block.LytNode; +import com.hfstudio.guidenh.guide.document.block.LytTaskListItem; +import com.hfstudio.guidenh.guide.extensions.ExtensionCollection; +import com.hfstudio.guidenh.guide.internal.extensions.DefaultExtensions; + +/** + * Test that the full page-compilation pipeline produces LytTaskListItem + * for markdown task-list syntax {@code - [x]} / {@code - [ ]}. + * + *

+ * Replicates the EXACT runtime path used by + * {@link com.hfstudio.guidenh.guide.internal.headless.RenderPageService}: + * {@code PageCompiler.parse() → PageCompiler.compile()}. + * + *

+ * Uses the first 17 items of {@code visualtest/lists/tasks.md} (excludes + * the last {@code } line which requires item-registry infrastructure). + */ +class TaskListCompilationTest { + + private static final String TASKS_MD = "## Mixed Checked and Unchecked\n" + "\n" + + "- [x] Completed task A\n" + + "- [ ] Pending task B\n" + + "- [x] Completed task C\n" + + "- [ ] Pending task D\n" + + "- [ ] Pending task E\n" + + "\n" + + "## Nested Task Lists\n" + + "\n" + + "- [x] Parent completed\n" + + " - [ ] Child pending A\n" + + " - [x] Child completed B\n" + + "- [ ] Parent pending\n" + + " - [ ] Sub-task one\n" + + " - [ ] Sub-task two\n" + + " - [x] Deep nested completed\n" + + " - [ ] Deep nested pending\n" + + "\n" + + "## Rich Text Labels\n" + + "\n" + + "- [x] **Bold completed** with *italic* suffix\n" + + "- [ ] `code inline` inside task label\n" + + "- [x] Task with [link](https://example.com) embedded\n" + + "- [ ] ~~Strikethrough~~ ++underline++ ^^wave^^ mixed\n"; + + /** + * THE CRITICAL TEST: follows the exact runtime compilation pipeline. + * + *

+ * Runtime path (headless rendering): + *

    + *
  1. {@link PageCompiler#parse(String, String, ResourceLocation, String)}
  2. + *
  3. {@link PageCompiler#compile(PageCollection, ExtensionCollection, ParsedGuidePage)}
  4. + *
+ */ + @Test + void runtimePipeline_producesLytTaskListItem() { + // 1. Parse, exactly as RenderPageService.compileMdFile does. + ResourceLocation pageId = new ResourceLocation("guidenh", "test/tasks"); + ParsedGuidePage parsed = PageCompiler.parse("test", "en_us", pageId, TASKS_MD); + + // 2. Build the ExtensionCollection with tag compilers, as DefaultExtensions does. + ExtensionCollection extensions = buildTagExtensions(); + + // 3. Compile, as RenderPageService.compileRegisteredPage / compileMdFile does. + // We need a PageCollection. Create a minimal one. + PageCollection pages = createMinimalPageCollection(); + + GuidePage compiledPage = PageCompiler.compile(pages, extensions, parsed); + LytDocument document = compiledPage.document(); + + // 4. Walk the document tree for LytTaskListItem instances. + List taskItems = new ArrayList<>(); + List plainItems = new ArrayList<>(); + walkForListItems(document, taskItems, plainItems); + + // Debug output + System.out.println( + "TaskListCompilationTest: found " + taskItems.size() + + " LytTaskListItem, " + + plainItems.size() + + " LytListItem"); + + // 5. Assert the collected items. + // All 17 items (without ItemImage line) are task items ("- [x]" or "- [ ]") + int total = taskItems.size() + plainItems.size(); + assertTrue(total > 0, "Expected at least some list items"); + assertEquals( + total, + taskItems.size(), + "All " + total + + " items should be LytTaskListItem, got " + + taskItems.size() + + " task items and " + + plainItems.size() + + " plain items"); + } + + /** + * Tests that COMPILATION IS IDEMPOTENT: calling {@code PageCompiler.compile()} + * twice on the same {@code ParsedGuidePage} must produce the same result. + * + *

+ * This guards against the real runtime bug: the CompileWorker pre-compiles + * all pages eagerly on the {@code guidenh-compile} thread. When + * {@code RenderPageService} compiles the same page again, the AST text nodes + * must not have been permanently mutated by the first compile (prefix stripped + * by {@code MarkdownListSemantics.extractTaskMarker}), otherwise the second + * compile would fail to detect task markers. + */ + @Test + void doubleCompile_stillProducesLytTaskListItem() { + ResourceLocation pageId = new ResourceLocation("guidenh", "test/tasks"); + ParsedGuidePage parsed = PageCompiler.parse("test", "en_us", pageId, TASKS_MD); + ExtensionCollection extensions = buildTagExtensions(); + PageCollection pages = createMinimalPageCollection(); + + // First compile (as CompileWorker would do) + GuidePage compiled1 = PageCompiler.compile(pages, extensions, parsed); + List taskItems1 = new ArrayList<>(); + List plainItems1 = new ArrayList<>(); + walkForListItems(compiled1.document(), taskItems1, plainItems1); + System.out.println( + "First compile: found " + taskItems1.size() + " LytTaskListItem, " + plainItems1.size() + " LytListItem"); + + // Second compile (as RenderPageService would do) + GuidePage compiled2 = PageCompiler.compile(pages, extensions, parsed); + List taskItems2 = new ArrayList<>(); + List plainItems2 = new ArrayList<>(); + walkForListItems(compiled2.document(), taskItems2, plainItems2); + System.out.println( + "Second compile: found " + taskItems2.size() + " LytTaskListItem, " + plainItems2.size() + " LytListItem"); + + // Both compiles must detect task markers + int total1 = taskItems1.size() + plainItems1.size(); + int total2 = taskItems2.size() + plainItems2.size(); + assertTrue(total1 > 0, "First compile must produce list items"); + assertEquals(total1, taskItems1.size(), "First compile: all " + total1 + " items must be LytTaskListItem"); + assertTrue(total2 > 0, "Second compile must produce list items"); + assertEquals( + total2, + taskItems2.size(), + "Second compile: all " + total2 + " items must be LytTaskListItem (idempotent)"); + } + + /** + * Tests the lazy-parse path used for registered pages at runtime: + * {@code PageCompiler.parseFrontmatterOnly()} → then + * {@code ParsedGuidePage.getAstRoot()} (lazy) → {@code PageCompiler.compile()}. + * + * This is the EXACT path used by + * {@link com.hfstudio.guidenh.guide.internal.headless.RenderPageService#compileRegisteredPage}. + */ + @Test + void runtimeLazyParsePath_producesLytTaskListItem() { + ResourceLocation pageId = new ResourceLocation("guidenh", "test/tasks"); + // Simulate parseFrontmatterOnly like GuideLightweightReloadService does + ParsedGuidePage parsed = PageCompiler.parseFrontmatterOnly("test", "en_us", pageId, TASKS_MD); + // At this point, astRoot is null + + ExtensionCollection extensions = buildTagExtensions(); + PageCollection pages = createMinimalPageCollection(); + + // This triggers getAstRoot() → lazy parse → PageCompiler.parse() + GuidePage compiledPage = PageCompiler.compile(pages, extensions, parsed); + LytDocument document = compiledPage.document(); + + // Walk and inspect + List taskItems = new ArrayList<>(); + List plainItems = new ArrayList<>(); + walkForListItems(document, taskItems, plainItems); + + System.out.println( + "TaskListCompilationTest (lazy-parse): found " + taskItems.size() + + " LytTaskListItem, " + + plainItems.size() + + " LytListItem"); + + int total2 = taskItems.size() + plainItems.size(); + assertTrue(total2 > 0, "Expected at least some list items"); + assertEquals( + total2, + taskItems.size(), + "All " + total2 + + " items should be LytTaskListItem (lazy-parse path), got " + + taskItems.size() + + " task items and " + + plainItems.size() + + " plain items"); + } + + /** + * Walk the document tree and collect LytTaskListItem vs LytListItem instances. + */ + private static void walkForListItems(LytNode node, List taskItems, List plainItems) { + if (node instanceof LytTaskListItem) { + taskItems.add(node); + } else if (node instanceof LytListItem) { + plainItems.add(node); + } + for (LytNode child : node.getChildren()) { + walkForListItems(child, taskItems, plainItems); + } + } + + /** + * Create a minimal PageCollection stub for testing. + */ + private static PageCollection createMinimalPageCollection() { + return new PageCollection() { + + @Override + public T getIndex(Class indexClass) { + throw new UnsupportedOperationException(); + } + + @Override + public java.util.Collection getPages() { + return List.of(); + } + + @Override + public ParsedGuidePage getParsedPage(ResourceLocation id) { + return null; + } + + @Override + public GuidePage getPage(ResourceLocation id) { + return null; + } + + @Override + public byte[] loadAsset(ResourceLocation id) { + return new byte[0]; + } + + @Override + public com.hfstudio.guidenh.guide.navigation.NavigationTree getNavigationTree() { + return null; + } + + @Override + public boolean pageExists(ResourceLocation pageId2) { + return false; + } + }; + } + + /** + * Build an ExtensionCollection with tag compilers matching {@code DefaultExtensions.tagCompilers()}. + */ + static ExtensionCollection buildTagExtensions() { + var builder = ExtensionCollection.builder(); + for (var compiler : DefaultExtensions.tagCompilers()) { + builder.add(com.hfstudio.guidenh.guide.compiler.TagCompiler.EXTENSION_POINT, compiler); + } + return builder.build(); + } +} diff --git a/src/test/java/com/hfstudio/guidenh/guide/internal/markdown/MarkdownLatexShorthandTest.java b/src/test/java/com/hfstudio/guidenh/guide/internal/markdown/MarkdownLatexShorthandTest.java new file mode 100644 index 00000000..3880bf6b --- /dev/null +++ b/src/test/java/com/hfstudio/guidenh/guide/internal/markdown/MarkdownLatexShorthandTest.java @@ -0,0 +1,202 @@ +package com.hfstudio.guidenh.guide.internal.markdown; + +import static org.junit.jupiter.api.Assertions.*; + +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.stream.Collectors; + +import org.junit.jupiter.api.Test; + +import com.hfstudio.guidenh.guide.internal.markdown.MarkdownLatexShorthand.MaskResult; +import com.hfstudio.guidenh.guide.internal.markdown.MarkdownLatexShorthand.Segment; + +class MarkdownLatexShorthandTest { + + /** Placeholder prefix used for formula entries (both $$ and single $). */ + private static final String FORMULA_PREFIX = "\uE000GUIDENH_LATEX_"; + + /** Placeholder prefix used for \$ escape entries. */ + private static final String ESCAPE_PREFIX = "\uE000GUIDENH_LATEXESC_"; + + /** Extract non-escape formula values from a MaskResult. */ + private static Set maskFormulas(MaskResult mr) { + return mr.formulas() + .entrySet() + .stream() + .filter( + e -> e.getKey() + .startsWith(FORMULA_PREFIX)) + .map(Map.Entry::getValue) + .collect(Collectors.toSet()); + } + + /** Extract formula values from a split result. */ + private static Set splitFormulas(String text) { + return MarkdownLatexShorthand.split(text) + .stream() + .filter(Segment::isFormula) + .map(Segment::getValue) + .collect(Collectors.toSet()); + } + + /** Simulate restore by replacing placeholders with their values, then split. */ + private static List maskRestoreSplit(String input) { + MaskResult mr = MarkdownLatexShorthand.mask(input); + String restored = mr.source(); + for (Map.Entry e : mr.formulas() + .entrySet()) { + restored = restored.replace(e.getKey(), e.getValue()); + } + return MarkdownLatexShorthand.split(restored); + } + + /** Verify mask formulas (non-escape) == split formulas for the same input. */ + private static void assertMaskSplitConsistent(String input) { + MaskResult mr = MarkdownLatexShorthand.mask(input); + Set mf = maskFormulas(mr); + Set sf = splitFormulas(input); + assertEquals(mf, sf, "mask/split formula mismatch for: " + input); + } + + /** Assert that split produces exactly the given formula values in order. */ + private static void assertSplitFormulas(String input, String... expectedFormulas) { + List segs = MarkdownLatexShorthand.split(input); + List actual = segs.stream() + .filter(Segment::isFormula) + .map(Segment::getValue) + .toList(); + assertEquals(List.of(expectedFormulas), actual, "split formulas mismatch for: " + input); + } + + /** Assert that mask/restore/split produces the same formulas as direct split. */ + private static void assertMaskRestoreSplitFormulas(String input, String... expectedFormulas) { + List segs = maskRestoreSplit(input); + List actual = segs.stream() + .filter(Segment::isFormula) + .map(Segment::getValue) + .toList(); + assertEquals(List.of(expectedFormulas), actual, "mask/restore/split formulas mismatch for: " + input); + } + + /** a. $E=mc^2$ → single formula segment. */ + @Test + void testSingleInlineFormula() { + String input = "$E=mc^2$"; + assertSplitFormulas(input, "E=mc^2"); + assertMaskSplitConsistent(input); + } + + /** b. Two inline formulas with text between. */ + @Test + void testTwoInlineFormulas() { + String input = "$\\frac{1}{2}$ and $\\frac{a+b}{c-d}$"; + assertSplitFormulas(input, "\\frac{1}{2}", "\\frac{a+b}{c-d}"); + assertMaskSplitConsistent(input); + } + + /** c. Currency $5 should NOT be detected as formula. */ + @Test + void testCurrencyNotFormula() { + String input = "Price is $5 and $10 today"; + assertSplitFormulas(input); + assertMaskSplitConsistent(input); + } + + /** d. $5-$10 → closing $ followed by digit → no formula. */ + @Test + void testDollarRangeNotFormula() { + String input = "$5-$10"; + assertSplitFormulas(input); + assertMaskSplitConsistent(input); + } + + /** e. Escaped \$: only $y$ should become a formula. */ + @Test + void testEscapedDollar() { + String input = "Use \\$x for $y$"; + // Direct split: $y$ is formula, \$x is just text + assertSplitFormulas(input, "y"); + assertMaskSplitConsistent(input); + + // After mask/restore, \$x → $x (literal), $y$ → formula y + assertMaskRestoreSplitFormulas(input, "y"); + } + + /** f. $$...$$ and $...$ mixed: $$ takes priority. */ + @Test + void testMixedDoubleAndSingleDollar() { + String input = "$$d$$ and $i$"; + // $$d$$ → formula "d", $i$ → formula "i" + assertSplitFormulas(input, "d", "i"); + assertMaskSplitConsistent(input); + + // Also verify sequence has correct ordering + List segs = MarkdownLatexShorthand.split(input); + assertEquals(3, segs.size(), "expected 3 segments: formula + text + formula"); + assertTrue( + segs.get(0) + .isFormula()); + assertEquals( + "d", + segs.get(0) + .getValue()); + assertFalse( + segs.get(1) + .isFormula()); + assertEquals( + " and ", + segs.get(1) + .getValue()); + assertTrue( + segs.get(2) + .isFormula()); + assertEquals( + "i", + segs.get(2) + .getValue()); + } + + /** g. Space-adjacent: $ x$ and $x $ → no formula. */ + @Test + void testSpaceAdjacentNoFormula() { + assertSplitFormulas("$ x$"); + assertMaskSplitConsistent("$ x$"); + + assertSplitFormulas("$x $"); + assertMaskSplitConsistent("$x $"); + } + + /** h. Multi-line $a\nb$ → no formula (single $ must be single-line). */ + @Test + void testMultilineNoFormula() { + String input = "$a\nb$"; + assertSplitFormulas(input); + assertMaskSplitConsistent(input); + } + + /** i. extractSoleDisplayFormula and mayContain for single $. */ + @Test + void testExtractSoleDisplayWithSingleDollar() { + // Single $ never produces display mode + assertNull( + MarkdownLatexShorthand.extractSoleDisplayFormula("$x$"), + "extractSoleDisplayFormula must return null for single $"); + // mayContain is a superset check: true for any $ + assertTrue(MarkdownLatexShorthand.mayContain("$x$"), "mayContain must return true for text containing $"); + // $$ still works for display mode + assertEquals("x", MarkdownLatexShorthand.extractSoleDisplayFormula("$$x$$")); + } + + /** Mask/split consistency for all test inputs a-h. */ + @Test + void testMaskSplitConsistencyAll() { + String[] cases = { "$E=mc^2$", "$\\frac{1}{2}$ and $\\frac{a+b}{c-d}$", "Price is $5 and $10 today", "$5-$10", + "Use \\$x for $y$", "$$d$$ and $i$", "$ x$", "$x $", "$a\nb$", }; + for (String tc : cases) { + assertMaskSplitConsistent(tc); + } + } + +} diff --git a/src/test/java/com/hfstudio/guidenh/guide/internal/markdown/MarkdownListSemanticsTest.java b/src/test/java/com/hfstudio/guidenh/guide/internal/markdown/MarkdownListSemanticsTest.java new file mode 100644 index 00000000..c4dcecf9 --- /dev/null +++ b/src/test/java/com/hfstudio/guidenh/guide/internal/markdown/MarkdownListSemanticsTest.java @@ -0,0 +1,231 @@ +package com.hfstudio.guidenh.guide.internal.markdown; + +import static org.junit.jupiter.api.Assertions.*; + +import java.util.List; +import java.util.Map; + +import org.junit.jupiter.api.Test; + +import com.hfstudio.guidenh.guide.compiler.GuideMarkdownOptions; +import com.hfstudio.guidenh.libs.mdast.MdAst; +import com.hfstudio.guidenh.libs.mdast.mdx.model.MdxJsxFlowElement; +import com.hfstudio.guidenh.libs.mdast.model.MdAstAnyContent; +import com.hfstudio.guidenh.libs.mdast.model.MdAstNode; +import com.hfstudio.guidenh.libs.mdast.model.MdAstText; + +class MarkdownListSemanticsTest { + + /** + * Parses markdown through the full pipeline (mdast + MDX conversion) and + * extracts the first + *

  • element's MdxJsxFlowElement. + */ + private MdxJsxFlowElement parseFirstLi(String markdown) { + var root = MdAst.fromMarkdown(markdown, GuideMarkdownOptions.runtime()); + // Convert mdast → MDX (same as PageCompiler) + MdAstToMdxConverter.convert(root, Map.of()); + + // Navigate: root →
      or
        →
      1. + var rootChildren = root.children(); + assertFalse(rootChildren.isEmpty(), "root must have children"); + // Skip frontmatter nodes + MdxJsxFlowElement listEl = null; + for (var child : rootChildren) { + if (child instanceof MdxJsxFlowElement flow && ("ul".equals(flow.name()) || "ol".equals(flow.name()))) { + listEl = flow; + break; + } + } + assertNotNull(listEl, "expected a list (ul/ol) in root children: " + debugTypes(rootChildren)); + + List liChildren = listEl.children(); + assertFalse(liChildren.isEmpty(), "list must have children"); + var li = liChildren.get(0); + assertInstanceOf( + MdxJsxFlowElement.class, + li, + "list child must be MdxJsxFlowElement, got: " + (li != null ? li.getClass() : "null")); + return (MdxJsxFlowElement) li; + } + + private static String debugTypes(List children) { + var sb = new StringBuilder("["); + for (int i = 0; i < children.size(); i++) { + if (i > 0) sb.append(", "); + Object c = children.get(i); + if (c == null) { + sb.append("null"); + } else { + sb.append( + c.getClass() + .getSimpleName()); + if (c instanceof MdxJsxFlowElement f) { + sb.append("(") + .append(f.name()) + .append(")"); + } else if (c instanceof MdAstNode n) { + sb.append("(") + .append(n.type()) + .append(")"); + } + } + } + sb.append("]"); + return sb.toString(); + } + + /** + * Dump the + *
      2. children tree safely (using raw Object iteration to avoid + * ClassCastException from raw-typed phrasing content inside flow elements). + */ + private static String dumpTreeSafely(MdxJsxFlowElement container) { + var sb = new StringBuilder(); + dumpObject(sb, container, 0); + return sb.toString(); + } + + @SuppressWarnings({ "rawtypes", "unchecked" }) + private static void dumpObject(StringBuilder sb, Object obj, int depth) { + if (obj == null) { + sb.append(" ".repeat(depth)) + .append("null\n"); + return; + } + String className = obj.getClass() + .getSimpleName(); + sb.append(" ".repeat(depth)) + .append(className); + if (obj instanceof MdAstText text) { + sb.append(" value='") + .append(text.value) + .append("'"); + } else if (obj instanceof MdxJsxFlowElement flow) { + sb.append(" name=") + .append(flow.name()); + } + sb.append("\n"); + // Recurse into children if this is a parent node, using raw iteration + if (obj instanceof MdxJsxFlowElement flow) { + for (Object child : (List) flow.children()) { + dumpObject(sb, child, depth + 1); + } + } + } + + // Simple task items. + + @Test + void testSimpleChecked() { + String md = "- [x] Completed task A\n"; + var li = parseFirstLi(md); + System.out.println("=== AST dump for '" + md.trim() + "' ==="); + System.out.println(dumpTreeSafely(li)); + System.out.println("li children types: " + debugTypes(li.children())); + System.out.println( + "li children size: " + li.children() + .size()); + + // Manually dump the first child's type + var firstP = li.children() + .get(0); + System.out.println( + "first child type: " + firstP.getClass() + .getName()); + + var marker = MarkdownListSemantics.extractTaskMarker(li.children()); + assertNotNull(marker, "extractTaskMarker must detect checked task"); + assertTrue(marker.checked(), "must be checked"); + assertEquals("Completed task A", marker.remainingText(), "remaining text must match"); + } + + @Test + void testSimpleUnchecked() { + String md = "- [ ] Incomplete task\n"; + var li = parseFirstLi(md); + System.out.println("=== AST dump for '" + md.trim() + "' ==="); + System.out.println(dumpTreeSafely(li)); + + var marker = MarkdownListSemantics.extractTaskMarker(li.children()); + assertNotNull(marker, "extractTaskMarker must detect unchecked task"); + assertFalse(marker.checked(), "must be unchecked"); + assertEquals("Incomplete task", marker.remainingText(), "remaining text must match"); + } + + // Nested task items. + + @Test + void testNestedChecked() { + String md = "- [x] Outer task\n - [ ] Inner task\n"; + var root = MdAst.fromMarkdown(md, GuideMarkdownOptions.runtime()); + MdAstToMdxConverter.convert(root, Map.of()); + + // Find the outer
      3. (first child of the
          ) + MdxJsxFlowElement outerLi = null; + for (var child : root.children()) { + if (child instanceof MdxJsxFlowElement flow && "ul".equals(flow.name())) { + var listChildren = flow.children(); + if (!listChildren.isEmpty() && listChildren.get(0) instanceof MdxJsxFlowElement li) { + outerLi = li; + } + break; + } + } + assertNotNull(outerLi, "outer
        • must be found"); + System.out.println("=== AST dump for nested li ==="); + System.out.println(dumpTreeSafely(outerLi)); + System.out.println("outer li children types: " + debugTypes(outerLi.children())); + System.out.println( + "outer li children size: " + outerLi.children() + .size()); + + var marker = MarkdownListSemantics.extractTaskMarker(outerLi.children()); + assertNotNull(marker, "extractTaskMarker must detect nested checked task"); + assertTrue(marker.checked(), "outer task must be checked"); + assertEquals("Outer task", marker.remainingText(), "remaining text must match"); + } + + // Rich text task items. + + @Test + void testRichTextChecked() { + String md = "- [x] **Bold** task\n"; + var li = parseFirstLi(md); + System.out.println("=== AST dump for '" + md.trim() + "' ==="); + System.out.println(dumpTreeSafely(li)); + + var marker = MarkdownListSemantics.extractTaskMarker(li.children()); + assertNotNull(marker, "extractTaskMarker must detect rich-text checked task"); + assertTrue(marker.checked(), "must be checked"); + // extractTaskMarker is now NON-mutating: remainingText is the regex-computed + // full remaining text (from toText, which concatenates leaf values, so + // the strong markers around "Bold" are dropped), NOT the first text + // node's stripped value. + // The prefix-stripping for display is done in ListItemCompiler with save/restore. + assertEquals("Bold task", marker.remainingText(), "remaining text must be full text after prefix"); + } + + @Test + void testRichTextUnchecked() { + String md = "- [ ] *italic* task\n"; + var li = parseFirstLi(md); + System.out.println("=== AST dump for '" + md.trim() + "' ==="); + System.out.println(dumpTreeSafely(li)); + + var marker = MarkdownListSemantics.extractTaskMarker(li.children()); + assertNotNull(marker, "extractTaskMarker must detect rich-text unchecked task"); + assertFalse(marker.checked(), "must be unchecked"); + // Same as checked: remainingText is the regex-computed full remaining text. + assertEquals("italic task", marker.remainingText(), "remaining text must be full text after prefix"); + } + + // Negative case: a regular list item is not a task. + + @Test + void testRegularListItem_notATask() { + var li = parseFirstLi("- Regular item\n"); + var marker = MarkdownListSemantics.extractTaskMarker(li.children()); + assertNull(marker, "regular list item must not be detected as task"); + } +} diff --git a/src/test/java/com/hfstudio/guidenh/guide/internal/settings/GuideSettingsDocumentBuilderTest.java b/src/test/java/com/hfstudio/guidenh/guide/internal/settings/GuideSettingsDocumentBuilderTest.java new file mode 100644 index 00000000..79f44f6b --- /dev/null +++ b/src/test/java/com/hfstudio/guidenh/guide/internal/settings/GuideSettingsDocumentBuilderTest.java @@ -0,0 +1,341 @@ +package com.hfstudio.guidenh.guide.internal.settings; + +import static org.junit.jupiter.api.Assertions.*; + +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Test; + +import com.hfstudio.guidenh.config.ModConfig; +import com.hfstudio.guidenh.guide.document.block.LytNode; +import com.hfstudio.guidenh.guide.document.block.LytVBox; + +/** + * Structural tests for the settings document builder. Builds + * documents headlessly (the builder never touches GL / Minecraft singletons) + * and walks the tree with {@link LytNode#getTextContent()}. + */ +class GuideSettingsDocumentBuilderTest { + + private static final int[] PRESETS = GuideSettingsDocumentBuilder.FONT_SIZE_PRESETS; + private static final String[] BODY_FAMILY_PRESETS = GuideSettingsDocumentBuilder.BODY_FONT_FAMILY_PRESETS; + private static final String[] CODE_FAMILY_PRESETS = GuideSettingsDocumentBuilder.CODE_FONT_FAMILY_PRESETS; + + @AfterEach + void restoreReaderConfig() { + ModConfig.reader.readerFontSize = 0; + ModConfig.reader.readerFontFamily = "system"; + ModConfig.reader.readerCodeFontFamily = "mono"; + } + + @Test + void buildDocumentContainsReaderCategoryItem() { + var document = GuideSettingsDocumentBuilder.buildDocument(); + String text = document.getTextContent(); + assertTrue(text.contains("\u9605\u8BFB"), "sidebar must contain the Reading category label"); + assertTrue(text.contains("Reader"), "sidebar must contain the Reader category"); + } + + @Test + void buildDocumentContainsFontSizeRowLabel() { + var document = GuideSettingsDocumentBuilder.buildDocument(); + assertTrue( + document.getTextContent() + .contains("\u5B57\u53F7 / Font size"), + "font size row must carry the bilingual label"); + } + + @Test + void valueEchoesConfiguredReaderFontSize() { + ModConfig.reader.readerFontSize = 13; + var document = GuideSettingsDocumentBuilder.buildDocument(); + String text = document.getTextContent(); + assertTrue(text.contains("13"), "current value must echo the configured size"); + assertFalse(text.contains("\u8DDF\u968F\u7CFB\u7EDF"), "non-zero size must not render the follow-system label"); + } + + @Test + void valueShowsFollowSystemWhenZero() { + ModConfig.reader.readerFontSize = 0; + var document = GuideSettingsDocumentBuilder.buildDocument(); + assertTrue( + document.getTextContent() + .contains("\u8DDF\u968F\u7CFB\u7EDF"), + "zero must render the follow-system label"); + } + + @Test + void presetsAreStrictlyAscendingAndStartAtFollowSystem() { + assertEquals(0, PRESETS[0], "first preset must be 0 (follow system)"); + for (int i = 1; i < PRESETS.length; i++) { + assertTrue(PRESETS[i] > PRESETS[i - 1], "presets must be strictly ascending at index " + i); + } + } + + @Test + void nextWalksAllPresetsAndClampsAtTop() { + for (int i = 1; i < PRESETS.length; i++) { + assertEquals( + PRESETS[i], + GuideSettingsDocumentBuilder.FontSizePresets.next(PRESETS[i - 1]), + "next must step onto the following preset from " + PRESETS[i - 1]); + } + assertEquals(20, GuideSettingsDocumentBuilder.FontSizePresets.next(20), "top endpoint must clamp"); + assertEquals(20, GuideSettingsDocumentBuilder.FontSizePresets.next(21), "above-range must clamp at max"); + } + + @Test + void prevWalksAllPresetsAndClampsAtBottom() { + for (int i = 1; i < PRESETS.length; i++) { + assertEquals( + PRESETS[i - 1], + GuideSettingsDocumentBuilder.FontSizePresets.prev(PRESETS[i]), + "prev must step onto the preceding preset from " + PRESETS[i]); + } + assertEquals(0, GuideSettingsDocumentBuilder.FontSizePresets.prev(0), "bottom endpoint must clamp"); + assertEquals(0, GuideSettingsDocumentBuilder.FontSizePresets.prev(-1), "below-range must clamp at min"); + } + + @Test + void buildDocumentContainsPlusMinusStepperButtons() { + var document = GuideSettingsDocumentBuilder.buildDocument(); + assertNotNull(findTextButton(document, "\u2212"), "document must contain the minus step button"); + assertNotNull(findTextButton(document, "+"), "document must contain the plus step button"); + } + + @Test + void applyFontSizeStepPersistsConfigValue() { + ModConfig.reader.readerFontSize = 12; + GuideSettingsDocumentBuilder.applyFontSizeStep(true); + assertEquals(13, ModConfig.reader.readerFontSize, "increase must move to the next preset"); + + GuideSettingsDocumentBuilder.applyFontSizeStep(false); + assertEquals(12, ModConfig.reader.readerFontSize, "decrease must move back to the previous preset"); + } + + @Test + void applyFontSizeStepClampsAtPresetEndpoints() { + ModConfig.reader.readerFontSize = 20; + GuideSettingsDocumentBuilder.applyFontSizeStep(true); + assertEquals(20, ModConfig.reader.readerFontSize, "increase at max preset must not change the value"); + + ModConfig.reader.readerFontSize = 0; + GuideSettingsDocumentBuilder.applyFontSizeStep(false); + assertEquals(0, ModConfig.reader.readerFontSize, "decrease at follow-system preset must not change the value"); + } + + @Test + void rowsAppearInDeclaredOrder() { + var document = GuideSettingsDocumentBuilder.buildDocument(); + var rowsVBox = (LytVBox) document.getChildren() + .get(0) + .getChildren() + .get(1); + var rows = rowsVBox.getChildren(); + assertEquals(3, rows.size(), "settings page must carry exactly three rows"); + assertTrue( + rows.get(0) + .getTextContent() + .contains("\u5B57\u53F7 / Font size"), + "row 1 must be the font-size row"); + assertTrue( + rows.get(1) + .getTextContent() + .contains("\u6B63\u6587\u5B57\u4F53 / Font family"), + "row 2 must be the body font-family row"); + assertTrue( + rows.get(2) + .getTextContent() + .contains("\u4EE3\u7801\u5B57\u4F53 / Code font"), + "row 3 must be the code font-family row"); + } + + @Test + void settingsPageCarriesSixStepperButtons() { + var document = GuideSettingsDocumentBuilder.buildDocument(); + assertEquals(6, countTextButtons(document), "three rows x 2 stepper buttons must yield 6 TextButtons"); + } + + @Test + void familyPresetsMatchDeclaredSpec() { + assertArrayEquals( + new String[] { "system", "serif", "monospace" }, + BODY_FAMILY_PRESETS, + "body font-family presets must match the executor-fixed stepping order"); + assertArrayEquals( + new String[] { "mono", "Consolas", "Courier New" }, + CODE_FAMILY_PRESETS, + "code font-family presets must match the executor-fixed stepping order"); + } + + @Test + void bodyFamilyNextWalksAllPresetsAndClampsAtTop() { + for (int i = 1; i < BODY_FAMILY_PRESETS.length; i++) { + assertEquals( + BODY_FAMILY_PRESETS[i], + GuideSettingsDocumentBuilder.StringPresets.next(BODY_FAMILY_PRESETS, BODY_FAMILY_PRESETS[i - 1]), + "next must step onto the following body-family preset from " + BODY_FAMILY_PRESETS[i - 1]); + } + String top = BODY_FAMILY_PRESETS[BODY_FAMILY_PRESETS.length - 1]; + assertEquals( + top, + GuideSettingsDocumentBuilder.StringPresets.next(BODY_FAMILY_PRESETS, top), + "body-family top endpoint must clamp"); + assertEquals( + top, + GuideSettingsDocumentBuilder.StringPresets.next(BODY_FAMILY_PRESETS, "unknown"), + "body-family out-of-preset value must clamp at max"); + } + + @Test + void bodyFamilyPrevWalksAllPresetsAndClampsAtBottom() { + for (int i = 1; i < BODY_FAMILY_PRESETS.length; i++) { + assertEquals( + BODY_FAMILY_PRESETS[i - 1], + GuideSettingsDocumentBuilder.StringPresets.prev(BODY_FAMILY_PRESETS, BODY_FAMILY_PRESETS[i]), + "prev must step onto the preceding body-family preset from " + BODY_FAMILY_PRESETS[i]); + } + String bottom = BODY_FAMILY_PRESETS[0]; + assertEquals( + bottom, + GuideSettingsDocumentBuilder.StringPresets.prev(BODY_FAMILY_PRESETS, bottom), + "body-family bottom endpoint must clamp"); + assertEquals( + bottom, + GuideSettingsDocumentBuilder.StringPresets.prev(BODY_FAMILY_PRESETS, "unknown"), + "body-family out-of-preset value must clamp at min"); + } + + @Test + void codeFamilyNextWalksAllPresetsAndClampsAtTop() { + for (int i = 1; i < CODE_FAMILY_PRESETS.length; i++) { + assertEquals( + CODE_FAMILY_PRESETS[i], + GuideSettingsDocumentBuilder.StringPresets.next(CODE_FAMILY_PRESETS, CODE_FAMILY_PRESETS[i - 1]), + "next must step onto the following code-family preset from " + CODE_FAMILY_PRESETS[i - 1]); + } + String top = CODE_FAMILY_PRESETS[CODE_FAMILY_PRESETS.length - 1]; + assertEquals( + top, + GuideSettingsDocumentBuilder.StringPresets.next(CODE_FAMILY_PRESETS, top), + "code-family top endpoint must clamp"); + } + + @Test + void codeFamilyPrevWalksAllPresetsAndClampsAtBottom() { + for (int i = 1; i < CODE_FAMILY_PRESETS.length; i++) { + assertEquals( + CODE_FAMILY_PRESETS[i - 1], + GuideSettingsDocumentBuilder.StringPresets.prev(CODE_FAMILY_PRESETS, CODE_FAMILY_PRESETS[i]), + "prev must step onto the preceding code-family preset from " + CODE_FAMILY_PRESETS[i]); + } + String bottom = CODE_FAMILY_PRESETS[0]; + assertEquals( + bottom, + GuideSettingsDocumentBuilder.StringPresets.prev(CODE_FAMILY_PRESETS, bottom), + "code-family bottom endpoint must clamp"); + } + + @Test + void valueEchoesConfiguredBodyFontFamily() { + ModConfig.reader.readerFontFamily = "serif"; + var document = GuideSettingsDocumentBuilder.buildDocument(); + assertTrue( + document.getTextContent() + .contains("serif"), + "body font-family value must echo the configured raw string"); + } + + @Test + void valueEchoesConfiguredCodeFontFamily() { + ModConfig.reader.readerCodeFontFamily = "Consolas"; + var document = GuideSettingsDocumentBuilder.buildDocument(); + assertTrue( + document.getTextContent() + .contains("Consolas"), + "code font-family value must echo the configured raw string"); + } + + @Test + void applyBodyFontFamilyStepPersistsConfigValue() { + ModConfig.reader.readerFontFamily = "system"; + GuideSettingsDocumentBuilder.applyBodyFontFamilyStep(true); + assertEquals("serif", ModConfig.reader.readerFontFamily, "increase must move to the next body-family preset"); + + GuideSettingsDocumentBuilder.applyBodyFontFamilyStep(false); + assertEquals( + "system", + ModConfig.reader.readerFontFamily, + "decrease must move back to the previous body-family preset"); + } + + @Test + void applyBodyFontFamilyStepClampsAtPresetEndpoints() { + ModConfig.reader.readerFontFamily = "monospace"; + GuideSettingsDocumentBuilder.applyBodyFontFamilyStep(true); + assertEquals( + "monospace", + ModConfig.reader.readerFontFamily, + "increase at the top preset must not change the value"); + + ModConfig.reader.readerFontFamily = "system"; + GuideSettingsDocumentBuilder.applyBodyFontFamilyStep(false); + assertEquals( + "system", + ModConfig.reader.readerFontFamily, + "decrease at the bottom preset must not change the value"); + } + + @Test + void applyCodeFontFamilyStepPersistsConfigValue() { + ModConfig.reader.readerCodeFontFamily = "mono"; + GuideSettingsDocumentBuilder.applyCodeFontFamilyStep(true); + assertEquals( + "Consolas", + ModConfig.reader.readerCodeFontFamily, + "increase must move to the next code-family preset"); + + GuideSettingsDocumentBuilder.applyCodeFontFamilyStep(false); + assertEquals( + "mono", + ModConfig.reader.readerCodeFontFamily, + "decrease must move back to the previous code-family preset"); + } + + @Test + void applyCodeFontFamilyStepClampsAtPresetEndpoints() { + ModConfig.reader.readerCodeFontFamily = "Courier New"; + GuideSettingsDocumentBuilder.applyCodeFontFamilyStep(true); + assertEquals( + "Courier New", + ModConfig.reader.readerCodeFontFamily, + "increase at the top preset must not change the value"); + + ModConfig.reader.readerCodeFontFamily = "mono"; + GuideSettingsDocumentBuilder.applyCodeFontFamilyStep(false); + assertEquals( + "mono", + ModConfig.reader.readerCodeFontFamily, + "decrease at the bottom preset must not change the value"); + } + + private static int countTextButtons(LytNode node) { + int count = node instanceof GuideSettingsDocumentBuilder.TextButton ? 1 : 0; + for (var child : node.getChildren()) { + count += countTextButtons(child); + } + return count; + } + + private static GuideSettingsDocumentBuilder.TextButton findTextButton(LytNode node, String label) { + if (node instanceof GuideSettingsDocumentBuilder.TextButton button && label.equals(button.getLabel())) { + return button; + } + for (var child : node.getChildren()) { + GuideSettingsDocumentBuilder.TextButton found = findTextButton(child, label); + if (found != null) { + return found; + } + } + return null; + } +} diff --git a/src/test/java/com/hfstudio/guidenh/guide/layout/DetailsAlignDiag.java b/src/test/java/com/hfstudio/guidenh/guide/layout/DetailsAlignDiag.java new file mode 100644 index 00000000..392bfdb9 --- /dev/null +++ b/src/test/java/com/hfstudio/guidenh/guide/layout/DetailsAlignDiag.java @@ -0,0 +1,230 @@ +package com.hfstudio.guidenh.guide.layout; + +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.util.List; + +import com.hfstudio.guidenh.guide.color.ConstantColor; +import com.hfstudio.guidenh.guide.document.LytRect; +import com.hfstudio.guidenh.guide.document.block.AlignItems; +import com.hfstudio.guidenh.guide.document.block.LytBlock; +import com.hfstudio.guidenh.guide.document.block.LytDetailsBlock; +import com.hfstudio.guidenh.guide.document.block.LytDocument; +import com.hfstudio.guidenh.guide.document.block.LytHBox; +import com.hfstudio.guidenh.guide.document.block.LytNode; +import com.hfstudio.guidenh.guide.document.block.LytParagraph; +import com.hfstudio.guidenh.guide.document.flow.LytFlowSpan; +import com.hfstudio.guidenh.guide.document.flow.LytFlowText; +import com.hfstudio.guidenh.guide.render.GlyphRunHolder; +import com.hfstudio.guidenh.guide.render.GuideGlyphAtlas; +import com.hfstudio.guidenh.guide.style.ResolvedTextStyle; +import com.hfstudio.guidenh.guide.style.TextStyle; + +/** + * TEMPORARY diagnostic (will be deleted): span-based baselineShift experiment + * for the details summary marker glyph. + */ +public final class DetailsAlignDiag { + + private static com.hfstudio.guidenh.guide.layout.FontMetrics MOCK_METRICS; + + private static com.hfstudio.guidenh.guide.layout.FontMetrics createFontMetrics(byte[] fontData) { + try { + var awtFont = java.awt.Font + .createFont(java.awt.Font.TRUETYPE_FONT, new java.io.ByteArrayInputStream(fontData)) + .deriveFont(9f); + var img = new java.awt.image.BufferedImage(1, 1, java.awt.image.BufferedImage.TYPE_INT_ARGB); + var g = img.createGraphics(); + g.setRenderingHint( + java.awt.RenderingHints.KEY_FRACTIONALMETRICS, + java.awt.RenderingHints.VALUE_FRACTIONALMETRICS_ON); + g.setFont(awtFont); + var fm = g.getFontMetrics(); + return new com.hfstudio.guidenh.guide.layout.FontMetrics() { + + @Override + public float getAdvance(int codePoint, ResolvedTextStyle style) { + return (float) fm.getStringBounds(new String(Character.toChars(codePoint)), g) + .getWidth(); + } + + @Override + public int getLineHeight(ResolvedTextStyle style) { + return 10; + } + }; + } catch (Exception e) { + throw new IllegalStateException("diag font not usable by AWT", e); + } + } + + private static Path resolveFontPath() { + String windir = System.getenv("WINDIR"); + if (windir == null) windir = "C:\\Windows"; + Path base = Paths.get(windir, "Fonts"); + Path[] cands = { base.resolve("msyh.ttc"), base.resolve("msyh.ttf"), base.resolve("segoeui.ttf"), + base.resolve("arial.ttf"), base.resolve("consola.ttf"), base.resolve("cour.ttf") }; + for (Path c : cands) { + if (Files.exists(c)) return c; + } + return null; + } + + private static void dumpPara(String label, LytParagraph p) { + LytRect r = p.getBounds(); + var gd = ((GlyphRunHolder) p).getGlyphData(); + if (gd == null || gd.runs() + .isEmpty()) { + System.out.printf("PARA[%s] bounds=%s no glyph data%n", label, r); + return; + } + float minY = Float.MAX_VALUE, maxY = 0; + int count = 0; + for (var group : gd.runs()) { + for (var g : group.glyphs()) { + minY = Math.min(minY, g.y()); + maxY = Math.max(maxY, g.y() + g.h()); + count++; + } + } + float inkCenterY = (minY + maxY) / 2f; + float boxCenterY = (r.y() + (r.y() + r.height())) / 2f; + System.out.printf( + "PARA[%s] bounds=%s glyphs=%d inkY=[%.2f..%.2f] inkCenter=%.2f boxCenter=%.2f delta=%.2f runs=%d%n", + label, + r, + count, + minY, + maxY, + inkCenterY, + boxCenterY, + inkCenterY - boxCenterY, + gd.runs() + .size()); + for (var group : gd.runs()) { + for (var g : group.glyphs()) { + System.out.printf(" g x=%.2f y=%.2f w=%.2f h=%.2f%n", g.x(), g.y(), g.w(), g.h()); + } + } + } + + private static void runSpanCase(String label, float bs) { + LytDocument doc = new LytDocument(); + var row = new LytHBox(); + row.setGap(4); + row.setWrap(false); + row.setFullWidth(true); + row.setAlignItems(AlignItems.CENTER); + + var marker = new LytParagraph(); + marker.setMarginTop(0); + marker.setMarginBottom(0); + marker.modifyStyle( + style -> style.bold(true) + .color(new ConstantColor(0xFFE2E6ED))); + var s1 = new LytFlowSpan(); + s1.setStyle( + TextStyle.builder() + .bold(true) + .color(new ConstantColor(0xFFE2E6ED)) + .baselineShift(bs) + .build()); + s1.append(LytFlowText.of(">")); + marker.append(s1); + var s2 = new LytFlowSpan(); + s2.setStyle( + TextStyle.builder() + .bold(true) + .color(new ConstantColor(0xFFE2E6ED)) + .baselineShift(0f) + .build()); + s2.append(LytFlowText.of("\u200B")); + marker.append(s2); + + var text = new LytParagraph(); + text.setMarginTop(0); + text.setMarginBottom(0); + text.modifyStyle( + style -> style.bold(true) + .color(new ConstantColor(0xFFE2E6ED))); + text.appendText("Closed Details (click to expand)"); + + row.append(marker); + row.append(text); + doc.append(row); + doc.updateLayout(new LayoutContext(MOCK_METRICS), 557); + System.out.println("\n==== spanCase bs=" + bs + " ===="); + for (LytNode c : row.getChildren()) { + if (c instanceof LytParagraph p) { + dumpPara( + ((GlyphRunHolder) p).getGlyphData() != null && !((GlyphRunHolder) p).getGlyphData() + .runs() + .isEmpty() + && ((GlyphRunHolder) p).getGlyphData() + .runs() + .getFirst() + .glyphs() + .size() == 1 ? "> marker" : "text", + p); + } + } + } + + private static void runRealDetails(String state) { + LytDocument doc = new LytDocument(); + LytDetailsBlock d = new LytDetailsBlock(); + d.setMarginTop(0); + d.setMarginBottom(0); + boolean open = "open".equals(state); + d.setOpen(open); + d.getSummaryBox() + .clearContent(); + d.getSummaryBox() + .appendText(open ? "Open Details (click to toggle)" : "Closed Details (click to expand)"); + doc.append(d); + doc.updateLayout(new LayoutContext(MOCK_METRICS), 557); + System.out.println("\n==== REAL details " + state + " ===="); + List summaryChildren = d.getChildren() + .getFirst() instanceof LytBlock sb ? sb.getChildren() : List.of(); + for (LytNode c : summaryChildren) { + if (c instanceof LytParagraph p) { + int n = ((GlyphRunHolder) p).getGlyphData() != null && !((GlyphRunHolder) p).getGlyphData() + .runs() + .isEmpty() ? ((GlyphRunHolder) p).getGlyphData() + .runs() + .getFirst() + .glyphs() + .size() : 0; + dumpPara(n == 1 ? "> marker" : "text", p); + } + } + } + + public static void main(String[] args) throws Exception { + String libPath = System.getProperty("guide.native.lib.path"); + System.out.println("libPath=" + libPath); + if (libPath == null || libPath.isEmpty()) { + System.err.println("SKIP: guide.native.lib.path not set"); + return; + } + System.load(libPath); + Path fontPath = resolveFontPath(); + byte[] fontData = Files.readAllBytes(fontPath); + long handle = LayoutBridge.init(fontData, "en-US"); + LayoutBridge.setFontHandle(handle); + GuideGlyphAtlas.instance() + .setHeadless(true); + MOCK_METRICS = createFontMetrics(fontData); + System.out.println("font=" + fontPath); + + runRealDetails("closed"); + runRealDetails("open"); + runSpanCase("control_bs0", 0f); + runSpanCase("bs_plus0_07", 0.07f); + runSpanCase("bs_plus0_09", 0.09f); + + LayoutBridge.destroy(handle); + System.out.println("DONE"); + } +} diff --git a/src/test/java/com/hfstudio/guidenh/guide/layout/GlyphDiag.java b/src/test/java/com/hfstudio/guidenh/guide/layout/GlyphDiag.java new file mode 100644 index 00000000..0f0c4113 --- /dev/null +++ b/src/test/java/com/hfstudio/guidenh/guide/layout/GlyphDiag.java @@ -0,0 +1,240 @@ +package com.hfstudio.guidenh.guide.layout; + +import java.nio.ByteBuffer; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.util.HashMap; +import java.util.Map; + +import com.google.flatbuffers.FlatBufferBuilder; +import com.hfstudio.guidenh.guide.layout.flatbuffers.FlatNode; +import com.hfstudio.guidenh.guide.layout.flatbuffers.GlyphBitmap; +import com.hfstudio.guidenh.guide.layout.flatbuffers.GlyphRun; +import com.hfstudio.guidenh.guide.layout.flatbuffers.LayoutInput; +import com.hfstudio.guidenh.guide.layout.flatbuffers.LayoutResult; +import com.hfstudio.guidenh.guide.layout.flatbuffers.PlacedGlyph; +import com.hfstudio.guidenh.guide.layout.flatbuffers.Style; +import com.hfstudio.guidenh.guide.layout.flatbuffers.TextData; +import com.hfstudio.guidenh.guide.layout.flatbuffers.TextStyle; + +/** + * Headless diagnostic: prints glyph data from the Rust layout pipeline to console. + * Bitmaps arrive inside LayoutResult (rasterized at render_scale); quads carry + * document-space top-left positions. No window: runs and exits. + * + * Run: ./gradlew runGlyphDiag + */ +public class GlyphDiag { + + public static void main(String[] args) throws Exception { + byte[] fontData = loadFont(); + long handle = LayoutBridge.init(fontData, "en-US"); + if (handle == 0) { + System.err.println("FATAL: init returned 0"); + System.exit(1); + } + System.out.println("font: " + fontPath + " bytes=" + fontData.length); + + String text = "Hello World! The quick brown fox jumps over the lazy dog. ABCDEFGH abcdefgh 0123456789"; + + diag(text, 24f, 1.0f, handle); + diag(text, 24f, 2.0f, handle); + + LayoutBridge.destroy(handle); + System.out.println("DONE"); + } + + static void diag(String text, float fontSize, float renderScale, long handle) { + System.out.println("\n=== fontSize=" + fontSize + " renderScale=" + renderScale + " text=\"" + text + "\""); + + byte[] lo = LayoutBridge.measureLayout(handle, buildLayoutInput(text, fontSize, renderScale)); + if (lo.length == 0) { + System.out.println(" measureLayout: EMPTY"); + return; + } + LayoutResult lr = LayoutResult.getRootAsLayoutResult(ByteBuffer.wrap(lo)); + int totalGlyphs = 0; + for (int r = 0; r < lr.glyphRunsLength(); r++) totalGlyphs += lr.glyphRuns(r) + .glyphsLength(); + System.out.println(" layout: " + lr.debugInfo()); + System.out.println( + " glyphRuns=" + lr.glyphRunsLength() + " totalGlyphs=" + totalGlyphs + " bitmaps=" + lr.bitmapsLength()); + + // Bitmap stats keyed by bitmap key + Map bmpMap = new HashMap<>(); + for (int i = 0; i < lr.bitmapsLength(); i++) { + GlyphBitmap bmp = lr.bitmaps(i); + int w = (int) bmp.w(); + int h = (int) bmp.h(); + ByteBuffer rgba = bmp.rgbaAsByteBuffer(); + int baseOff = rgba.position(); + int nonZero = 0; + boolean allWhite = true; + for (int p = 0; p < w * h; p++) { + int off = baseOff + p * 4; + if ((rgba.get(off) & 0xFF) != 255 || (rgba.get(off + 1) & 0xFF) != 255 + || (rgba.get(off + 2) & 0xFF) != 255) { + allWhite = false; + } + if ((rgba.get(off + 3) & 0xFF) > 0) nonZero++; + } + bmpMap.put(bmp.key(), new BmpEntry(w, h, nonZero, allWhite)); + } + + // Print glyph table: quads in run order + System.out.printf( + " %-4s %-20s %-10s %-10s %-8s %-8s %-8s %-8s %-6s %-6s%n", + "#", + "bitmapKey", + "docX", + "docY", + "docW", + "docH", + "bmpW", + "bmpH", + "ink%", + "ok?"); + int printed = 0; + int missing = 0; + for (int r = 0; r < lr.glyphRunsLength() && printed < 30; r++) { + GlyphRun run = lr.glyphRuns(r); + for (int g = 0; g < run.glyphsLength() && printed < 30; g++) { + PlacedGlyph pg = run.glyphs(g); + BmpEntry be = bmpMap.get(pg.bitmapKey()); + if (be == null) missing++; + System.out.printf( + " %-4d %-20d %-10.2f %-10.2f %-8.2f %-8.2f %-8d %-8d %-6d %-6s%n", + printed, + pg.bitmapKey(), + pg.x(), + pg.y(), + pg.w(), + pg.h(), + be != null ? be.w : 0, + be != null ? be.h : 0, + be != null ? be.nonZero * 100 / Math.max(1, be.w * be.h) : 0, + be == null ? "MISS" : be.allWhite ? "OK" : "!RGB"); + printed++; + } + } + System.out.println( + " SUMMARY: unique bitmaps=" + bmpMap.size() + + " quadsMissingBitmap=" + + missing + + " (renderScale=" + + renderScale + + ", bitmap px should scale with it)"); + } + + static byte[] buildLayoutInput(String text, float fontSize, float renderScale) { + FlatBufferBuilder fbb = new FlatBufferBuilder(4096); + int to = fbb.createString(text); + int tso = TextStyle.createTextStyle( + fbb, + fontSize, + false, + false, + 1.0f, + 0xFFFFFFFFL, + 0, + false, + false, + 0L, + false, + 0.0f, + false, + false); + int tdo = TextData.createTextData(fbb, to, tso, (byte) 1, 0, 0, 0, 0, 0, 0, false, (byte) 0); + int so = Style.createStyle( + fbb, + (byte) 0, + (byte) 1, + (byte) 0, + (byte) 0, + (byte) 0, + (byte) 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0f, + 0f, + 0f, + 0f, + 0f, + false, + false, + false, + false, + 0f, + 0f, + 0f, + 0f, + 0f, + 0f, + 0f, + 0f, + (byte) 0, + 0f, + 1f, + 0, + (byte) 0, + (byte) 0, + (byte) 0, + 0, + 0, + 0, + 0); + int cv = createIntVec(fbb, new int[0]); + int no = FlatNode + .createFlatNode(fbb, so, (byte) 1, tdo, 0, 0, 0, 0, 0, (byte) 0, cv, 0, 0, 0, 0, 0, 0, 0, 0, 0); + int nv = fbb.createVectorOfTables(new int[] { no }); + fbb.finish(LayoutInput.createLayoutInput(fbb, 800f, 1.0f, renderScale, (byte) 1, nv, 0.25f)); // Shear factor, + // formerly + // LayoutTreeSerializer.SHEAR_K; + // the Rust engine + // owns SHEAR_K + // now. + return fbb.sizedByteArray(); + } + + static int createIntVec(FlatBufferBuilder fbb, int[] data) { + fbb.startVector(4, data.length, 4); + for (int i = data.length - 1; i >= 0; i--) fbb.addInt(data[i]); + return fbb.endVector(); + } + + static String fontPath = "none"; + + static byte[] loadFont() { + Path p = resolveFontPath(); + if (p == null) return new byte[0]; + try { + fontPath = p.toAbsolutePath() + .toString(); + return Files.readAllBytes(p); + } catch (Exception e) { + return new byte[0]; + } + } + + static Path resolveFontPath() { + String windir = System.getenv("WINDIR"); + if (windir == null) windir = "C:\\Windows"; + Path base = Paths.get(windir, "Fonts"); + Path[] cands = { base.resolve("msyh.ttc"), base.resolve("msyh.ttf"), base.resolve("simsun.ttc"), + base.resolve("segoeui.ttf"), base.resolve("arial.ttf"), base.resolve("consola.ttf"), + base.resolve("times.ttf"), base.resolve("cour.ttf"), }; + for (Path c : cands) { + if (Files.exists(c)) return c; + } + return null; + } + + record BmpEntry(int w, int h, int nonZero, boolean allWhite) {} +} diff --git a/src/test/java/com/hfstudio/guidenh/guide/layout/LayoutBridgeSmokeTest.java b/src/test/java/com/hfstudio/guidenh/guide/layout/LayoutBridgeSmokeTest.java new file mode 100644 index 00000000..f3af7972 --- /dev/null +++ b/src/test/java/com/hfstudio/guidenh/guide/layout/LayoutBridgeSmokeTest.java @@ -0,0 +1,182 @@ +package com.hfstudio.guidenh.guide.layout; + +import static org.junit.jupiter.api.Assertions.*; + +import java.nio.ByteBuffer; + +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.condition.EnabledIfSystemProperty; + +import com.google.flatbuffers.FlatBufferBuilder; +import com.hfstudio.guidenh.guide.layout.flatbuffers.FlatLayout; +import com.hfstudio.guidenh.guide.layout.flatbuffers.FlatNode; +import com.hfstudio.guidenh.guide.layout.flatbuffers.LayoutInput; +import com.hfstudio.guidenh.guide.layout.flatbuffers.LayoutResult; +import com.hfstudio.guidenh.guide.layout.flatbuffers.Style; + +/** + * Smoke test for the Rust JNI bridge (LayoutBridge). + *

          + * Run with: + * {@code ./gradlew test --tests "*LayoutBridgeSmokeTest" -Dguide.native.lib.path=src/rust/layout-engine/target/release/guide_layout_engine.dll} + */ +@EnabledIfSystemProperty(named = "guide.native.lib.path", matches = ".+") +class LayoutBridgeSmokeTest { + + @BeforeAll + static void checkLib() { + String p = System.getProperty("guide.native.lib.path"); + assertNotNull(p); + assertTrue(new java.io.File(p).exists(), "Native library not found: " + p); + } + + @Test + void testInitReturnsNonZero() { + long handle = LayoutBridge.init(new byte[0], "en-US"); + assertTrue(handle != 0, "init must return non-zero handle"); + LayoutBridge.destroy(handle); + } + + @Test + void testDestroyZeroIsNoop() { + LayoutBridge.destroy(0); // should not crash + } + + @Test + void testInitDestroyRoundTrip() { + long handle = LayoutBridge.init(new byte[0], "en-US"); + assertTrue(handle != 0); + LayoutBridge.destroy(handle); + } + + @Test + void testMeasureLayoutEmptyInput() { + long handle = LayoutBridge.init(new byte[0], "en-US"); + byte[] result = LayoutBridge.measureLayout(handle, new byte[0]); + assertNotNull(result); + assertEquals(0, result.length, "empty input should produce empty output"); + LayoutBridge.destroy(handle); + } + + @Test + void testMeasureLayoutWithRootNode() { + long handle = LayoutBridge.init(new byte[0], "en-US"); + + byte[] input = buildMinimalLayoutInput(800f); + byte[] output = LayoutBridge.measureLayout(handle, input); + assertNotNull(output); + assertTrue(output.length > 0, "valid FlatBuffer input should produce non-empty output"); + + // Parse the result + LayoutResult result = LayoutResult.getRootAsLayoutResult(ByteBuffer.wrap(output)); + assertTrue(result.nodesLength() == 1, "should have exactly 1 layout node"); + + // Container node with no children → (0,0,0,0), content_height=0 + // (Taffy computes no intrinsic size for an empty flex container) + FlatLayout fl = result.nodes(0); + assertTrue(fl.x() >= 0); + assertTrue(fl.y() >= 0); + assertTrue(fl.w() >= 0); + assertTrue(fl.h() >= 0); + + LayoutBridge.destroy(handle); + } + + /** Build a FlatBuffer int vector. */ + private static int createIntVec(FlatBufferBuilder fbb, int[] data) { + fbb.startVector(4, data.length, 4); + for (int i = data.length - 1; i >= 0; i--) { + fbb.addInt(data[i]); + } + return fbb.endVector(); + } + + private byte[] buildMinimalLayoutInput(float availableWidth) { + FlatBufferBuilder fbb = new FlatBufferBuilder(256); + + int styleOff = Style.createStyle( + fbb, + (byte) 0, // display Flex + (byte) 1, // flex_direction Column + (byte) 0, // flex_wrap + (byte) 0, // align_items + (byte) 0, // align_self + (byte) 0, // justify_content + 0, + 0, // gap_w, gap_h + 0, + 0, // size_w, size_h + 0, + 0, // min_w, min_h + 0, + 0, // max_w, max_h + 0f, // aspect_ratio + 0f, + 0f, + 0f, + 0f, // margin + false, + false, + false, + false, // margin_auto + 0f, + 0f, + 0f, + 0f, // padding + 0f, + 0f, + 0f, + 0f, // border + (byte) 0, // overflow + 0f, // flex_grow + 1f, // flex_shrink + 0, // flex_basis + (byte) 0, // float + (byte) 0, // clear + (byte) 0, // position + 0, + 0, + 0, + 0 // inset + ); + + int childrenVec = createIntVec(fbb, new int[0]); + int nodeOff = FlatNode.createFlatNode( + fbb, + styleOff, + (byte) 0, + 0, + 0, + 0, + 0, + 0, + 0, + (byte) 0, + childrenVec, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0); + + int nodesVec = fbb.createVectorOfTables(new int[] { nodeOff }); + int inputOff = LayoutInput.createLayoutInput(fbb, availableWidth, 1.0f, 1.0f, (byte) 1, nodesVec, 0.25f); // Shear + // factor, + // formerly + // LayoutTreeSerializer.SHEAR_K; + // the + // Rust + // engine + // owns + // SHEAR_K + // now. + fbb.finish(inputOff); + + return fbb.sizedByteArray(); + } +} diff --git a/src/test/java/com/hfstudio/guidenh/guide/layout/LayoutPipelineHarness.java b/src/test/java/com/hfstudio/guidenh/guide/layout/LayoutPipelineHarness.java new file mode 100644 index 00000000..b7c2fb3b --- /dev/null +++ b/src/test/java/com/hfstudio/guidenh/guide/layout/LayoutPipelineHarness.java @@ -0,0 +1,1634 @@ +package com.hfstudio.guidenh.guide.layout; + +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.util.ArrayList; +import java.util.HashSet; +import java.util.List; +import java.util.Set; + +import com.hfstudio.guidenh.guide.document.LytRect; +import com.hfstudio.guidenh.guide.document.block.LytBlock; +import com.hfstudio.guidenh.guide.document.block.LytBox; +import com.hfstudio.guidenh.guide.document.block.LytCodeBlock; +import com.hfstudio.guidenh.guide.document.block.LytDocument; +import com.hfstudio.guidenh.guide.document.block.LytDocumentFloat; +import com.hfstudio.guidenh.guide.document.block.LytNode; +import com.hfstudio.guidenh.guide.document.block.LytParagraph; +import com.hfstudio.guidenh.guide.render.GlyphRunHolder; +import com.hfstudio.guidenh.guide.render.GuideGlyphAtlas; +import com.hfstudio.guidenh.guide.style.ResolvedTextStyle; + +/** + * Headless pipeline test bench: drives the full layout pipeline (Java layout → + * compiler serialization → Rust measureLayout via JNI → writeback) on synthetic + * documents that mirror the problem pages, then checks structural invariants + * (no zero-sized content, no unexpected sibling overlap, glyph runs present + * where expected) and dumps the resulting tree for inspection. + *

          + * Run via {@code ./gradlew runLayoutDump}. Not a JUnit test: it needs the + * native DLL and a real TTF font. + */ +public final class LayoutPipelineHarness { + + // + // AWT-backed advances from the SAME TTF the Rust engine shapes with, so the + // Java layout pass (LineBuilder) breaks lines essentially identically to + // parley: the float clip/lane geometry replayed from Java bounds then lines + // up with the Rust line positions. A fixed mock advance (6px/char) makes + // Java line counts diverge from Rust's by whole lines, which breaks that + // replay exactly the way it breaks in-game when the two engines' metrics + // disagree. + + private static FontMetrics MOCK_METRICS; + + private static FontMetrics createFontMetrics(byte[] fontData) { + try { + var awtFont = java.awt.Font + .createFont(java.awt.Font.TRUETYPE_FONT, new java.io.ByteArrayInputStream(fontData)) + .deriveFont(9f); + var img = new java.awt.image.BufferedImage(1, 1, java.awt.image.BufferedImage.TYPE_INT_ARGB); + var g = img.createGraphics(); + // Fractional metrics: advances come straight from the font's hmtx + // (unhinted, float): the same numbers swash reports to parley. + g.setRenderingHint( + java.awt.RenderingHints.KEY_FRACTIONALMETRICS, + java.awt.RenderingHints.VALUE_FRACTIONALMETRICS_ON); + g.setFont(awtFont); + var fm = g.getFontMetrics(); + return new FontMetrics() { + + @Override + public float getAdvance(int codePoint, ResolvedTextStyle style) { + return (float) fm.getStringBounds(new String(Character.toChars(codePoint)), g) + .getWidth(); + } + + @Override + public int getLineHeight(ResolvedTextStyle style) { + return 10; + } + }; + } catch (Exception e) { + throw new IllegalStateException("harness font not usable by AWT", e); + } + } + + /** Opaque fixed-size leaf (stand-in for graphs / NEI boxes / scenes). */ + private static class FixedLeaf extends LytBox { + + private final int w; + private final int h; + + FixedLeaf(int w, int h) { + this.w = w; + this.h = h; + } + + @Override + public int getExplicitWidth() { + return w; + } + + @Override + public int getExplicitHeight() { + return h; + } + + @Override + protected LytRect computeBoxLayout(LayoutContext context, int x, int y, int availableWidth) { + return new LytRect(x, y, w, h); + } + } + + private static LytParagraph paragraph(String text) { + var p = new LytParagraph(); + p.appendText(text); + return p; + } + + private static boolean ready; + + private static synchronized boolean setup() { + if (ready) return true; + String libPath = System.getProperty("guide.native.lib.path"); + if (libPath == null || libPath.isEmpty()) { + System.err.println("SKIP: guide.native.lib.path not set"); + return false; + } + System.load(libPath); + Path fontPath = resolveFontPath(); + byte[] fontData = new byte[0]; + try { + if (fontPath != null) fontData = Files.readAllBytes(fontPath); + } catch (Exception e) { + System.err.println("WARN: font read failed: " + e); + } + long handle = LayoutBridge.init(fontData, "en-US"); + if (handle == 0) { + System.err.println("SKIP: LayoutBridge.init returned 0"); + return false; + } + LayoutBridge.setFontHandle(handle); + GuideGlyphAtlas.instance() + .setHeadless(true); + MOCK_METRICS = createFontMetrics(fontData); + ready = true; + System.out.println("[harness] ready; font=" + fontPath + " bytes=" + fontData.length); + return true; + } + + private static Path resolveFontPath() { + String windir = System.getenv("WINDIR"); + if (windir == null) windir = "C:\\Windows"; + Path base = Paths.get(windir, "Fonts"); + Path[] cands = { base.resolve("msyh.ttc"), base.resolve("msyh.ttf"), base.resolve("segoeui.ttf"), + base.resolve("arial.ttf"), base.resolve("consola.ttf"), base.resolve("cour.ttf") }; + for (Path c : cands) { + if (Files.exists(c)) return c; + } + return null; + } + + private record Issue(String text) {} + + // Baseline evidence: the serialized Rust glyph runs must carry a non-zero + // document-space baseline satisfying the Rust invariant + // baseline - y == placement.top: the diff is + // pixel/0.5px-aligned and the baseline sits in or just around the glyph + // quad's vertical span, NOT literally within [0, h], because a narrow + // punctuation glyph's ink bitmap (h) is smaller than its placement.top + // (ascent measured from the glyph quad top). Mirrors the Rust-side bounds + // `bl >= pg.y() - 1.0 && bl <= pg.y() + pg.h() + 1.0` plus a sane ascent + // ceiling so a broken pipeline (baseline decoupled from y) still trips. + private static long BS_TOTAL; + private static long BS_NONZERO; + private static long BS_VALID; + private static long BS_HALF_PX; // diff is a multiple of 0.5 (pixel/subpixel aligned) + private static String BS_SAMPLE = ""; + + private static void checkTree(LytNode node, List issues, String path) { + if (node instanceof LytBlock b) { + LytRect r = b.getBounds(); + String here = path + "/" + + b.getClass() + .getSimpleName(); + if (r == null) { + issues.add(new Issue(here + ": null bounds")); + } else { + if (r.width() < 0 || r.height() < 0) { + issues.add(new Issue(here + ": negative size " + r)); + } + // Serialized opaque leaves and text paragraphs must not collapse + // to zero area: that is the "empty frame" failure mode. + boolean expectContent = b instanceof FixedLeaf || (b instanceof LytParagraph p && !p.isEmpty()); + if (expectContent && r.width() * r.height() == 0) { + issues.add(new Issue(here + ": zero-area bounds " + r + " (empty frame!)")); + } + } + // Every injected Rust glyph run must carry its baseline (nonzero) + // with baseline - y == placement.top (Rust invariant; the diff is + // 0.5px-aligned and within [-1, 24]; outside that the baseline is + // decoupled from its glyph quad). This is the evidence the Java + // consumer actually reads fbg.baseline() at both deserialization + // points and that onLayoutMoved keeps the pair (y, baseline) coherent + // (both translate by deltaY, so baseline - y is invariant under moves). + if (b instanceof GlyphRunHolder h && h.getGlyphData() != null) { + for (var group : h.getGlyphData() + .runs()) { + for (var g : group.glyphs()) { + BS_TOTAL++; + float top = g.baseline() - g.y(); + boolean nz = g.baseline() != 0f; + boolean valid = top >= -1.0f && top <= 24.0f; + boolean halfPx = Math.abs((top * 2f) - Math.rint(top * 2f)) < 0.01f; + if (nz) BS_NONZERO++; + if (valid) BS_VALID++; + if (halfPx) BS_HALF_PX++; + if (BS_SAMPLE.isEmpty() && nz && valid) { + BS_SAMPLE = String + .format(" baseline=%.2f y=%.2f h=%.2f top=%.2f", g.baseline(), g.y(), g.h(), top); + } + if (!nz) { + issues.add(new Issue(here + ": glyph baseline=0 (Rust baseline not wired) y=" + g.y())); + } else if (!valid) { + issues.add( + new Issue( + here + ": glyph top=" + + top + + " outside [-1,24] (baseline=" + + g.baseline() + + " y=" + + g.y() + + " h=" + + g.h() + + ")")); + } + } + } + } + } + for (LytNode child : node.getChildren()) { + checkTree( + child, + issues, + path + "/" + + (node instanceof LytBlock b ? b.getClass() + .getSimpleName() : "doc")); + } + } + + private static void checkSiblingOverlap(LytNode node, List issues, String path) { + List rects = new ArrayList<>(); + List names = new ArrayList<>(); + for (LytNode child : node.getChildren()) { + if (child instanceof LytBlock b && b.getBounds() != null) { + rects.add(b.getBounds()); + names.add( + b.getClass() + .getSimpleName()); + } + } + for (int i = 0; i < rects.size(); i++) { + for (int j = i + 1; j < rects.size(); j++) { + // Floats legitimately overlap in-flow siblings by design + // (earlier content is not pushed aside). + if (names.get(i) + .equals("LytDocumentFloat") + || names.get(j) + .equals("LytDocumentFloat")) { + continue; + } + if (rects.get(i) + .intersects(rects.get(j))) { + issues.add( + new Issue( + path + ": siblings overlap: " + + names.get(i) + + rects.get(i) + + " vs " + + names.get(j) + + rects.get(j))); + } + } + } + for (LytNode child : node.getChildren()) { + checkSiblingOverlap( + child, + issues, + path + "/" + + (child instanceof LytBlock b ? b.getClass() + .getSimpleName() : "?")); + } + } + + private static void dump(LytNode node, int depth, StringBuilder out) { + String indent = " ".repeat(depth); + if (node instanceof LytBlock b) { + out.append(indent) + .append( + b.getClass() + .getSimpleName()) + .append(" ") + .append(b.getBounds()) + .append( + b instanceof GlyphRunHolder h && h.getGlyphData() != null + && !h.getGlyphData() + .runs() + .isEmpty() ? " [glyphs:" + h.getGlyphData() + .runs() + .stream() + .mapToInt( + g -> g.glyphs() + .size()) + .sum() + "]" : "") + .append('\n'); + } + for (LytNode child : node.getChildren()) { + dump(child, depth + 1, out); + } + } + + private static LytDocument floatPage() { + var doc = new LytDocument(); + doc.append(paragraph("Intro to function graphs. Floats should sit right, text wraps around them.")); + doc.append(new LytDocumentFloat(new FixedLeaf(200, 150), true)); // right-floating graph + doc.append( + paragraph( + "This paragraph is long enough to wrap around the floating graph on the right side." + + " It must be lane-narrowed instead of overlapping the float.")); + doc.append(paragraph("Another wrapping paragraph beside the float.")); + var code = new LytCodeBlock(); + code.setCodeText("\n \n"); + doc.append(code); + doc.append(paragraph("Tail paragraph after the code block.")); + return doc; + } + + private static LytDocument recipeGalleryPage() { + var doc = new LytDocument(); + doc.append(paragraph("Recipes should form a multi-column gallery:")); + for (int i = 0; i < 6; i++) { + doc.append( + com.hfstudio.guidenh.guide.document.block.recipes.LytStandardRecipeBox + .shaped3x3(java.util.Collections.emptyList(), null)); + } + doc.append(paragraph("After the gallery.")); + return doc; + } + + private static LytDocument fileTreePage() { + var doc = new LytDocument(); + doc.append(paragraph("File tree:")); + var tree = new com.hfstudio.guidenh.guide.document.block.LytFileTree(); + // Row with icon block: a short icon paragraph + text payload → exercises + // icon+paragraph twin-row layout inside the LytHBox row container. + { + var icon = new LytParagraph(); + icon.setMarginTop(0); + icon.setMarginBottom(0); + icon.appendText("\uD83D\uDCC1"); + tree.appendRow( + java.util.List.of(com.hfstudio.guidenh.guide.internal.markdown.FileTreeParser.SlotKind.BRANCH), + icon, + paragraph("config/")); + } + tree.appendRow( + java.util.List.of( + com.hfstudio.guidenh.guide.internal.markdown.FileTreeParser.SlotKind.VERTICAL, + com.hfstudio.guidenh.guide.internal.markdown.FileTreeParser.SlotKind.BRANCH), + null, + paragraph("GuideNH/")); + tree.appendRow( + java.util.List.of( + com.hfstudio.guidenh.guide.internal.markdown.FileTreeParser.SlotKind.EMPTY, + com.hfstudio.guidenh.guide.internal.markdown.FileTreeParser.SlotKind.VERTICAL, + com.hfstudio.guidenh.guide.internal.markdown.FileTreeParser.SlotKind.LAST_BRANCH), + null, + paragraph("themes/default.cfg")); + doc.append(tree); + doc.append(paragraph("After the tree.")); + return doc; + } + + private static LytDocument inlinePage() { + var doc = new LytDocument(); + var p = new LytParagraph(); + p.appendText("before "); + var inlineImg = new com.hfstudio.guidenh.guide.document.block.LytItemImage(null); + p.append(com.hfstudio.guidenh.guide.document.flow.LytFlowInlineBlock.of(inlineImg)); + p.appendText(" after the icon"); + doc.append(p); + doc.append(paragraph("Tail paragraph.")); + return doc; + } + + /** Table with pinned cell layout (absolute lowering) + glyph-run paragraphs inside cells. */ + private static LytDocument tablePage() { + var doc = new LytDocument(); + doc.append(paragraph("Table below:")); + var table = new com.hfstudio.guidenh.guide.document.block.table.LytTable(); + for (int r = 0; r < 2; r++) { + var row = table.appendRow(); + for (int c = 0; c < 3; c++) { + var cell = row.appendCell(); + cell.append(paragraph("cell " + r + "," + c)); + } + } + // Third row: one long description that must wrap and GROW its row + // (regression guard for the "description text overlaps next row" bug). + var longRow = table.appendRow(); + for (int c = 0; c < 3; c++) { + var cell = longRow.appendCell(); + cell.append( + c == 2 ? paragraph("Scene viewport width in pixels, a long description that wraps to multiple lines.") + : paragraph("cell 2," + c)); + } + doc.append(table); + doc.append(paragraph("After table.")); + return doc; + } + + /** Real FunctionGraph through the primitive pipeline (curves, axes, points, title). */ + private static LytDocument functionGraphPage() { + var doc = new LytDocument(); + doc.append(paragraph("Function graph below:")); + var graph = new com.hfstudio.guidenh.guide.document.block.functiongraph.LytFunctionGraph(); + graph.setTitle("sin(x)"); + graph.addPlot( + new com.hfstudio.guidenh.guide.document.block.functiongraph.FunctionPlot( + "sin(x)", + (x, y) -> Math.sin(x), + false, + null, + 0xFF66CCFF, + "sin(x)")); + graph.addPoint( + new com.hfstudio.guidenh.guide.document.block.functiongraph.MarkedPoint( + com.hfstudio.guidenh.guide.document.block.functiongraph.MarkedPoint.MODE_EXPLICIT, + -1, + 1.0, + 1.0, + 0xFFFFCC66, + false, + "P")); + doc.append(graph); + doc.append(paragraph("After the graph.")); + return doc; + } + + /** + * Replicates function-graph.md's two on-page examples (fenced + container), + * the reported "labels scattered across each other's panel" page. + */ + private static LytDocument twoGraphsPage() { + var doc = new LytDocument(); + doc.append(paragraph("Fenced Block")); + // Graph 1 goes through the real fence parser (attr parsing incl. + // "xRange=-pi..pi"; negative constants broke before the parser fix). + var g1 = com.hfstudio.guidenh.guide.compiler.tags.functiongraph.FunctionGraphFenceParser.parse( + "width=360 height=220 xRange=-pi..pi yRange=-2..2 quadrants=all cornerLegend=topRight\n" + + "sin(x) | color=#ff5566 label=\"sin\" pointEveryX=1 autoPointLabel=x\n" + + "cos(x) | color=#3399ff label=\"cos\" pointEveryY=1\n" + + "x/2 | color=#88cc77 domain=-pi..pi\n" + + ":0,0\n" + + "@plot=0 atX=1.5708\n"); + doc.append(g1); + doc.append(paragraph(" Container")); + var g2 = new com.hfstudio.guidenh.guide.document.block.functiongraph.LytFunctionGraph(); + g2.setExplicitSize(360, 220); + g2.setExplicitXRange(-6, 6); + g2.setExplicitYRange(-3, 3); + g2.setQuadrantMask(0xF); + g2.setCornerLegendPosition(com.hfstudio.guidenh.guide.document.block.chart.CornerLegendPosition.TOP_RIGHT); + g2.addPlot( + new com.hfstudio.guidenh.guide.document.block.functiongraph.FunctionPlot( + "sin(x)", + (x, y) -> Math.sin(x), + false, + null, + 0xFFFF5566, + "sin x", + new com.hfstudio.guidenh.guide.document.block.functiongraph.AutoPointSpec( + 1.0, + Double.NaN, + com.hfstudio.guidenh.guide.document.block.functiongraph.AutoPointLabelMode.X, + 0xFFFFFFFF, + true))); + g2.addPlot( + new com.hfstudio.guidenh.guide.document.block.functiongraph.FunctionPlot( + "x^2/4", + (x, y) -> x * x / 4, + false, + null, + 0xFF3399FF, + "x² / 4")); + g2.addPlot( + new com.hfstudio.guidenh.guide.document.block.functiongraph.FunctionPlot( + "|x|-1", + (x, y) -> Math.abs(x) - 1, + false, + null, + 0xFF88CC77, + "|x| - 1", + new com.hfstudio.guidenh.guide.document.block.functiongraph.AutoPointSpec( + Double.NaN, + 1.0, + com.hfstudio.guidenh.guide.document.block.functiongraph.AutoPointLabelMode.NONE, + 0xFFFFFFFF, + true))); + doc.append(g2); + return doc; + } + + /** Heading with fontScale ≠ 1: regression guard for the double-scale bug. */ + private static LytDocument fontScalePage() { + var doc = new LytDocument(); + var heading = new com.hfstudio.guidenh.guide.document.block.LytHeading(); + heading.setDepth(1); + heading.appendText("Scaled Heading (H1 style, fontScale > 1)"); + doc.append(heading); + doc.append(paragraph("Normal body line right after, width should not jump.")); + return doc; + } + + /** ContentTabs: only the ACTIVE tab body may enter the serialized tree. */ + private static LytDocument contentTabsPage() { + var doc = new LytDocument(); + doc.append(paragraph("Content tabs below:")); + var entries = java.util.List.of( + new com.hfstudio.guidenh.guide.compiler.tags.ContentTabsSpec.TabEntry( + "First", + paragraph("First tab body."), + null), + new com.hfstudio.guidenh.guide.compiler.tags.ContentTabsSpec.TabEntry( + "Second", + paragraph("Second tab body (hidden)."), + null), + new com.hfstudio.guidenh.guide.compiler.tags.ContentTabsSpec.TabEntry( + "Third", + paragraph("Third tab body (hidden too)."), + null)); + doc.append( + new com.hfstudio.guidenh.guide.document.block.LytContentTabsBlock("Demo Tabs", null, 0, null, entries)); + doc.append(paragraph("After tabs.")); + return doc; + } + + /** + * Right float + a long paragraph crossing its bottom edge: band splitting must + * keep lines narrow beside the float and recover full width below it. + */ + private static LytDocument floatWrapPage() { + var doc = new LytDocument(); + doc.append(paragraph("Intro line before the float.")); + doc.append(new LytDocumentFloat(new FixedLeaf(200, 60), true)); + doc.append( + paragraph( + "A long paragraph that wraps beside the floating block on the right and must recover " + + "full width below it. It carries enough text to cross the float's bottom edge, " + + "so band splitting produces a narrow band beside the float and a full-width band " + + "below it. More filler text to make sure several lines sit below the edge. " + + "Even more text so the paragraph definitely grows past the float's bottom: " + + "the quick brown fox jumps over the lazy dog, pack my box with five dozen liquor jugs, " + + "and still this paragraph continues with additional words to fill the remaining lines, " + + "then several more sentences so the full-width band below the float spans multiple " + + "lines and its first line demonstrably reaches well beyond the narrow lane width.")); + doc.append(paragraph("After the wrap.")); + return doc; + } + + /** + * Left float + plain-text paragraph (left-side clip path) followed by a + * right float + paragraph: mirrors content-embed.md's multi-float + * structure: both floats must attach to their flow slots (zero-height + * anchors), plain-text lines must clip on BOTH sides, and glyph pens must + * never intrude into either float's content rect. + */ + private static LytDocument multiFloatPage() { + var doc = new LytDocument(); + doc.append(paragraph("Intro line before the floats.")); + doc.append(new LytDocumentFloat(new FixedLeaf(120, 80), false)); // left float + doc.append( + paragraph( + "A long paragraph that wraps to the right of the left-floating block, staying clear of " + + "its lane until the float's bottom edge passes, then recovering the full content " + + "width below it. Filler text to carry the paragraph well past the float's bottom: " + + "the quick brown fox jumps over the lazy dog, pack my box with five dozen liquor " + + "jugs, and still more words so several lines land below the float's zone.")); + doc.append(new LytDocumentFloat(new FixedLeaf(140, 70), true)); // right float + doc.append( + paragraph( + "Another long paragraph, this one wrapping to the left of the right-floating block. " + + "It must keep its lines clear of the right lane until that float clears, while " + + "the earlier left float is already gone from the registry. More filler text so " + + "the paragraph crosses the right float's bottom edge and recovers full width.")); + doc.append(paragraph("After the floats.")); + return doc; + } + + /** Latin paragraph with short lines: justification stretches spaces to the node width. */ + private static LytDocument justifyPage() { + var doc = new LytDocument(); + doc.append( + paragraph( + "Short words spread out across the full width when justification stretches the spaces " + + "between them evenly so each line reaches the right edge of the paragraph box.")); + doc.append(paragraph("\u77ED\u884C\u7ED3\u675F\u540E\u6062\u590D\u81EA\u7136\u5BBD\u5EA6\u3002")); + return doc; + } + + private static LytDocument itemImagePage() { + var doc = new LytDocument(); + doc.append(paragraph("Item images (null stacks, layout-only):")); + doc.append(new com.hfstudio.guidenh.guide.document.block.LytItemImage(null)); + var scaled = new com.hfstudio.guidenh.guide.document.block.LytItemImage(null); + scaled.setScale(2f); + doc.append(scaled); + doc.append(paragraph("After items.")); + return doc; + } + + /** Mirrors markdown.md's inline-LaTeX section (the reported regression page). */ + private static LytDocument latexInlinePage() { + var doc = new LytDocument(); + var p1 = new LytParagraph(); + p1.appendText("Einstein's mass-energy equivalence: "); + p1.append(com.hfstudio.guidenh.guide.document.flow.LytFlowInlineBlock.of(latex("E=mc^2"))); + p1.appendText(" and Pythagoras: "); + p1.append(com.hfstudio.guidenh.guide.document.flow.LytFlowInlineBlock.of(latex("a^2+b^2=c^2"))); + doc.append(p1); + var p2 = new LytParagraph(); + p2.appendText("A formula with a fraction expands the line height automatically: contains "); + p2.append(com.hfstudio.guidenh.guide.document.flow.LytFlowInlineBlock.of(latex("\\frac{1}{2}"))); + p2.appendText(" and also "); + p2.append(com.hfstudio.guidenh.guide.document.flow.LytFlowInlineBlock.of(latex("\\frac{a+b}{c-d}"))); + p2.appendText(" in the same line."); + doc.append(p2); + return doc; + } + + /** + * Multi-color PRE_WRAP paragraphs (a highlighted code block body plus a + * directly built two-color paragraph): the static multi-style span pipeline + * must render them per-span via GuideText. + */ + private static LytDocument codeBlockPage() { + var doc = new LytDocument(); + var code = new LytCodeBlock(); + code.setCodeText("\n \n"); + doc.append(code); + // Deterministic multi-style input: two spans with distinct colors. + var pre = new LytParagraph(); + pre.modifyStyle(s -> s.whiteSpace(com.hfstudio.guidenh.guide.style.WhiteSpaceMode.PRE_WRAP)); + var red = new com.hfstudio.guidenh.guide.document.flow.LytFlowSpan(); + red.setStyle( + com.hfstudio.guidenh.guide.style.TextStyle.builder() + .color(new com.hfstudio.guidenh.guide.color.ConstantColor(0xFFFF5555)) + .build()); + red.append(com.hfstudio.guidenh.guide.document.flow.LytFlowText.of("first span ")); + var blue = new com.hfstudio.guidenh.guide.document.flow.LytFlowSpan(); + blue.setStyle( + com.hfstudio.guidenh.guide.style.TextStyle.builder() + .color(new com.hfstudio.guidenh.guide.color.ConstantColor(0xFF5555FF)) + .build()); + blue.append(com.hfstudio.guidenh.guide.document.flow.LytFlowText.of("second span")); + pre.append(red); + pre.append(blue); + doc.append(pre); + return doc; + } + + /** + * Span-pipeline check: the multi-color PRE_WRAP document must collect at + * least two differently-tinted DrawGlyphRuns (per-span GuideText emission) + * and zero HostDraw fallbacks. + */ + private static int runCodeBlockScenario() { + System.out.println("\n=== codeBlockPage (width=557) ==="); + LytDocument doc = codeBlockPage(); + doc.updateLayout(new LayoutContext(MOCK_METRICS), 557); + List issues = new ArrayList<>(); + checkTree(doc, issues, ""); + checkSiblingOverlap(doc, issues, "doc"); + StringBuilder sb = new StringBuilder(); + dump(doc, 0, sb); + System.out.print(sb); + + var pc = new com.hfstudio.guidenh.guide.render.PrimitiveCollector(new LytRect(0, 0, 557, 2000), null); + for (var block : doc.getBlocks()) { + pc.collectFrom(block); + } + long hostDraws = 0; + long glyphRuns = 0; + Set tints = new HashSet<>(); + for (var p : pc.result()) { + if (p instanceof com.hfstudio.guidenh.guide.render.GuideRenderPrimitive.HostDraw) { + hostDraws++; + } else + if (p instanceof com.hfstudio.guidenh.guide.render.GuideRenderPrimitive.DrawGlyphRun dg && !dg.glyphs() + .isEmpty()) { + glyphRuns++; + tints.add(dg.argb()); + } + } + System.out.println("-- glyphRuns=" + glyphRuns + " tints=" + tints + " hostDraws=" + hostDraws); + if (glyphRuns < 2 || tints.size() < 2) { + issues.add( + new Issue( + "expected >= 2 DrawGlyphRuns with distinct tints, got runs=" + glyphRuns + " tints=" + tints)); + } + if (hostDraws > 0) { + issues.add(new Issue("expected no HostDraw primitives, got " + hostDraws)); + } + if (issues.isEmpty()) { + System.out.println("OK: no issues"); + return 0; + } + for (Issue i : issues) { + System.out.println("ISSUE: " + i.text()); + } + return issues.size(); + } + + /** One instance of each chart type, all rendered through the primitive pipeline. */ + private static LytDocument chartsPage() { + var doc = new LytDocument(); + doc.append(paragraph("Charts below:")); + var column = new com.hfstudio.guidenh.guide.document.block.chart.LytColumnChart(); + column.setCategories(new String[] { "A", "B", "C" }); + column.setSeries( + java.util.List.of(chartSeries("s1", 0xFF66CCFF, 3, 7, 5), chartSeries("s2", 0xFFFFCC66, 5, 2, 6))); + doc.append(column); + var bar = new com.hfstudio.guidenh.guide.document.block.chart.LytBarChart(); + bar.setCategories(new String[] { "A", "B", "C" }); + bar.setSeries(java.util.List.of(chartSeries("bars", 0xFF88CC77, 4, 6, 3))); + doc.append(bar); + var line = new com.hfstudio.guidenh.guide.document.block.chart.LytLineChart(); + line.setCategories(new String[] { "A", "B", "C", "D" }); + line.setSeries(java.util.List.of(chartSeries("line", 0xFFFF8899, 1, 4, 2, 5))); + doc.append(line); + var scatter = new com.hfstudio.guidenh.guide.document.block.chart.LytScatterChart(); + scatter.setSeries( + java.util.List.of( + new com.hfstudio.guidenh.guide.document.block.chart.ChartSeries( + "scatter", + 0xFFCC99FF, + new double[] { 1, 2, 3, 4 }, + new double[] { 2, 5, 3, 6 }))); + doc.append(scatter); + var pie = new com.hfstudio.guidenh.guide.document.block.chart.LytPieChart(); + pie.setSlices( + java.util.List.of( + new com.hfstudio.guidenh.guide.document.block.chart.PieSlice("alpha", 5, 0xFF66CCFF), + new com.hfstudio.guidenh.guide.document.block.chart.PieSlice("beta", 3, 0xFFFFCC66), + new com.hfstudio.guidenh.guide.document.block.chart.PieSlice("gamma", 2, 0xFF88CC77))); + doc.append(pie); + return doc; + } + + private static com.hfstudio.guidenh.guide.document.block.chart.ChartSeries chartSeries(String name, int color, + double... ys) { + return com.hfstudio.guidenh.guide.document.block.chart.ChartSeries.fromValues(name, color, ys); + } + + /** + * Charts check: every chart type has non-zero bounds and emits primitives + * through the pipeline: zero HostDraw fallbacks. + */ + private static int runChartsScenario() { + System.out.println("\n=== chartsPage (width=557) ==="); + LytDocument doc = chartsPage(); + doc.updateLayout(new LayoutContext(MOCK_METRICS), 557); + List issues = new ArrayList<>(); + checkTree(doc, issues, ""); + checkSiblingOverlap(doc, issues, "doc"); + StringBuilder sb = new StringBuilder(); + dump(doc, 0, sb); + System.out.print(sb); + + int chartBlocks = 0; + long hostDraws = 0; + var viewport = new LytRect(0, 0, 557, 4000); + for (var block : doc.getBlocks()) { + if (!(block instanceof com.hfstudio.guidenh.guide.document.block.chart.LytChartBase chart)) { + continue; + } + chartBlocks++; + LytRect b = chart.getBounds(); + if (b == null || b.width() * b.height() == 0) { + issues.add( + new Issue( + chart.getClass() + .getSimpleName() + ": zero-area bounds " + + b)); + continue; + } + var pc = new com.hfstudio.guidenh.guide.render.PrimitiveCollector(viewport, null); + pc.collectFrom(chart); + long own = 0; + for (var p : pc.result()) { + if (p instanceof com.hfstudio.guidenh.guide.render.GuideRenderPrimitive.HostDraw) { + hostDraws++; + } else { + own++; + } + } + System.out.println( + "-- " + chart.getClass() + .getSimpleName() + " bounds=" + b + " primitives=" + own); + if (own == 0) { + issues.add( + new Issue( + chart.getClass() + .getSimpleName() + ": emitted no primitives")); + } + } + if (chartBlocks != 5) { + issues.add(new Issue("expected 5 chart blocks, got " + chartBlocks)); + } + if (hostDraws > 0) { + issues.add(new Issue("expected no HostDraw primitives, got " + hostDraws)); + } + if (issues.isEmpty()) { + System.out.println("OK: no issues"); + return 0; + } + for (Issue i : issues) { + System.out.println("ISSUE: " + i.text()); + } + return issues.size(); + } + + /** + * Rich multi-style paragraph: bold/colored/italic/struck/highlighted spans + * in one paragraph, shaped once by Rust into per-span glyph runs. + */ + private static LytDocument richTextPage() { + var doc = new LytDocument(); + var p = new LytParagraph(); + p.appendText("plain "); + var boldRed = new com.hfstudio.guidenh.guide.document.flow.LytFlowSpan(); + boldRed.setStyle( + com.hfstudio.guidenh.guide.style.TextStyle.builder() + .bold(true) + .color(new com.hfstudio.guidenh.guide.color.ConstantColor(0xFFFF5555)) + .build()); + boldRed.append(com.hfstudio.guidenh.guide.document.flow.LytFlowText.of("bold-red ")); + var italic = new com.hfstudio.guidenh.guide.document.flow.LytFlowSpan(); + italic.setStyle( + com.hfstudio.guidenh.guide.style.TextStyle.builder() + .italic(true) + .color(new com.hfstudio.guidenh.guide.color.ConstantColor(0xFF55FF55)) + .build()); + italic.append(com.hfstudio.guidenh.guide.document.flow.LytFlowText.of("italic ")); + var struck = new com.hfstudio.guidenh.guide.document.flow.LytFlowSpan(); + struck.setStyle( + com.hfstudio.guidenh.guide.style.TextStyle.builder() + .strikethrough(true) + .backgroundColor(new com.hfstudio.guidenh.guide.color.ConstantColor(0x66FFFF00)) + .build()); + struck.append(com.hfstudio.guidenh.guide.document.flow.LytFlowText.of("struck")); + p.append(boldRed); + p.append(italic); + p.append(struck); + doc.append(p); + var p2 = new LytParagraph(); + p2.appendText("underlined "); + var ul = new com.hfstudio.guidenh.guide.document.flow.LytFlowSpan(); + ul.setStyle( + com.hfstudio.guidenh.guide.style.TextStyle.builder() + .underlined(true) + .color(new com.hfstudio.guidenh.guide.color.ConstantColor(0xFF55CCFF)) + .build()); + ul.append(com.hfstudio.guidenh.guide.document.flow.LytFlowText.of("link-like")); + p2.append(ul); + doc.append(p2); + return doc; + } + + /** + * Rich-text check: >= 2 differently-tinted DrawGlyphRuns, a sheared (italic) + * run, decoration FillRects (highlight/strikethrough/underline), no HostDraw. + */ + private static int runRichTextScenario() { + System.out.println("\n=== richTextPage (width=557) ==="); + LytDocument doc = richTextPage(); + doc.updateLayout(new LayoutContext(MOCK_METRICS), 557); + List issues = new ArrayList<>(); + checkTree(doc, issues, ""); + checkSiblingOverlap(doc, issues, "doc"); + StringBuilder sb = new StringBuilder(); + dump(doc, 0, sb); + System.out.print(sb); + + var pc = new com.hfstudio.guidenh.guide.render.PrimitiveCollector(new LytRect(0, 0, 557, 2000), null); + for (var block : doc.getBlocks()) { + pc.collectFrom(block); + } + long hostDraws = 0; + long glyphRuns = 0; + long sheared = 0; + long decorations = 0; + Set tints = new HashSet<>(); + for (var p : pc.result()) { + if (p instanceof com.hfstudio.guidenh.guide.render.GuideRenderPrimitive.HostDraw) { + hostDraws++; + } else + if (p instanceof com.hfstudio.guidenh.guide.render.GuideRenderPrimitive.DrawGlyphRun dg && !dg.glyphs() + .isEmpty()) { + glyphRuns++; + tints.add(dg.argb()); + if (dg.shear()) { + sheared++; + } + } else if (p instanceof com.hfstudio.guidenh.guide.render.GuideRenderPrimitive.FillRect) { + decorations++; + } + } + System.out.println( + "-- glyphRuns=" + glyphRuns + + " tints=" + + tints + + " sheared=" + + sheared + + " decorations=" + + decorations + + " hostDraws=" + + hostDraws); + if (glyphRuns < 2 || tints.size() < 2) { + issues.add( + new Issue( + "expected >= 2 DrawGlyphRuns with distinct tints, got runs=" + glyphRuns + " tints=" + tints)); + } + if (sheared < 1) { + issues.add(new Issue("expected a sheared (italic) DrawGlyphRun")); + } + if (decorations < 3) { + issues.add( + new Issue( + "expected >= 3 decoration FillRects (highlight/strikethrough/underline), got " + decorations)); + } + if (hostDraws > 0) { + issues.add(new Issue("expected no HostDraw primitives, got " + hostDraws)); + } + if (issues.isEmpty()) { + System.out.println("OK: no issues"); + return 0; + } + for (Issue i : issues) { + System.out.println("ISSUE: " + i.text()); + } + return issues.size(); + } + + private static com.hfstudio.guidenh.guide.document.block.LytLatexBlock latex(String formula) { + return new com.hfstudio.guidenh.guide.document.block.LytLatexBlock( + formula, + com.hfstudio.guidenh.guide.document.block.LatexRenderOptions.builder() + .build()); + } + + /** + * Geometry audit for inline blocks: prints each paragraph's glyph baseline + * rows and each LaTeX block's layout box vs. actual blit rect, so vertical + * anchoring errors show up as numbers instead of screenshots. + */ + private static void dumpInlineDiagnostics(LytNode node) { + if (node instanceof LytParagraph par && par.getGlyphData() != null + && !par.getGlyphData() + .runs() + .isEmpty()) { + java.util.TreeSet rows = new java.util.TreeSet<>(); + for (var group : par.getGlyphData() + .runs()) { + for (var g : group.glyphs()) { + rows.add(g.y()); + } + } + System.out.println( + " PARA bounds=" + par.getBounds() + + " baselines=" + + rows.stream() + .map(String::valueOf) + .reduce((a, b) -> a + "," + b) + .orElse("")); + } + if (node instanceof com.hfstudio.guidenh.guide.document.block.LytLatexBlock lb) { + System.out.println( + " LATEX \"" + lb.getFormula() + "\" bounds=" + lb.getBounds() + " visual=" + lb.getVisualBounds()); + } + if (node instanceof LytParagraph par && par.getGlyphData() != null + && !par.getGlyphData() + .runs() + .isEmpty()) { + // Per-line extents from Rust glyph runs, keyed by the REAL line_index + // (float-wrap band verification + the Rust side of the line-geometry + // oracle that will replace the Java LineBuilder). + java.util.TreeMap byLine = new java.util.TreeMap<>(); + for (var group : par.getGlyphData() + .runs()) { + for (var g : group.glyphs()) { + float[] ext = byLine + .computeIfAbsent(g.lineIndex(), k -> new float[] { Float.MAX_VALUE, 0, Float.MAX_VALUE, 0 }); + ext[0] = Math.min(ext[0], g.x()); + ext[1] = Math.max(ext[1], g.x() + g.w()); + ext[2] = Math.min(ext[2], g.y()); + ext[3] = Math.max(ext[3], g.y() + g.h()); + } + } + StringBuilder sb = new StringBuilder(" RUST-LINE:"); + byLine.forEach( + (line, ext) -> sb.append( + " [" + line + + " x=" + + (int) ext[0] + + ".." + + (int) ext[1] + + " y=" + + (int) ext[2] + + ".." + + (int) ext[3] + + "]")); + System.out.println(sb); + System.out.println(" JAVA-LINE:"); + } + for (LytNode child : node.getChildren()) { + dumpInlineDiagnostics(child); + } + } + + private static int runScenario(String name, LytDocument doc, int width) { + return runScenario(name, doc, width, false); + } + + private static int runScenario(String name, LytDocument doc, int width, boolean collectPrimitives) { + System.out.println("\n=== " + name + " (width=" + width + ") ==="); + doc.updateLayout(new LayoutContext(MOCK_METRICS), width); + // Serialize a second time AFTER the Java pass so the dump reflects the + // real (post-Java-layout) styles and Rust output. + dumpFlatTree(doc); + if (collectPrimitives) { + var pc = new com.hfstudio.guidenh.guide.render.PrimitiveCollector(new LytRect(0, 0, width, 2000), null); + for (var block : doc.getBlocks()) { + pc.collectFrom(block); + } + var prims = pc.result(); + long fills = prims.stream() + .filter(com.hfstudio.guidenh.guide.render.GuideRenderPrimitive.FillRect.class::isInstance) + .count(); + long hostDraws = prims.stream() + .filter(com.hfstudio.guidenh.guide.render.GuideRenderPrimitive.HostDraw.class::isInstance) + .count(); + long glyphs = prims.stream() + .filter(com.hfstudio.guidenh.guide.render.GuideRenderPrimitive.DrawGlyphRun.class::isInstance) + .count(); + long lines = prims.stream() + .filter(com.hfstudio.guidenh.guide.render.GuideRenderPrimitive.DrawLine.class::isInstance) + .count(); + long circles = prims.stream() + .filter(com.hfstudio.guidenh.guide.render.GuideRenderPrimitive.DrawCircle.class::isInstance) + .count(); + long texts = prims.stream() + .filter(com.hfstudio.guidenh.guide.render.GuideRenderPrimitive.DrawText.class::isInstance) + .count(); + System.out.println( + "-- primitives: total=" + prims.size() + + " FillRect=" + + fills + + " HostDraw=" + + hostDraws + + " GlyphRun=" + + glyphs + + " Line=" + + lines + + " Circle=" + + circles + + " Text=" + + texts); + for (var p : prims) { + if (p instanceof com.hfstudio.guidenh.guide.render.GuideRenderPrimitive.PushScissor ps) { + System.out.println(" PushScissor " + ps); + } + } + } + List issues = new ArrayList<>(); + checkTree(doc, issues, ""); + checkSiblingOverlap(doc, issues, "doc"); + checkFloatOverlap(doc, issues); + checkLineGeometry(doc, width, issues); + StringBuilder sb = new StringBuilder(); + dump(doc, 0, sb); + System.out.print(sb); + dumpInlineDiagnostics(doc); + if (issues.isEmpty()) { + System.out.println("OK: no issues"); + return 0; + } + for (Issue i : issues) { + System.out.println("ISSUE: " + i.text()); + } + return issues.size(); + } + + /** Dump the serialized flat tree (index, class, children) for structure debugging. */ + private static void dumpFlatTree(LytNode root) { + var serializer = new LayoutTreeSerializer(); + byte[] bytes = serializer.serialize(root, 557f, 1.0f, 1.0f); + var input = com.hfstudio.guidenh.guide.layout.flatbuffers.LayoutInput + .getRootAsLayoutInput(java.nio.ByteBuffer.wrap(bytes)); + System.out.println("-- flat tree --"); + for (int i = 0;; i++) { + LytNode n = serializer.getNodeByFlatIndex(i); + if (n == null) break; + List kids = new ArrayList<>(); + for (LytNode c : n.getChildren()) { + int idx = serializer.getFlatIndex(c); + if (idx >= 0) kids.add(idx); + } + var st = input.nodes(i) + .style(); + var textData = input.nodes(i) + .text(); + System.out.println( + " [" + i + + "] " + + n.getClass() + .getSimpleName() + + " children=" + + kids + + " sizeW=" + + (st.sizeW() == null ? "auto" + : st.sizeW() + .value()) + + " sizeH=" + + (st.sizeH() == null ? "auto" + : st.sizeH() + .value()) + + " grow=" + + st.flexGrow() + + " shrink=" + + st.flexShrink() + + " pos=" + + st.position() + + " dir=" + + st.flexDirection() + + (textData == null ? "" : " bands=" + textData.bandsLength()) + + (textData == null ? "" : " clips=" + textData.floatClipsLength())); + if (textData != null && textData.floatClipsLength() > 0) { + for (int ci = 0; ci < textData.floatClipsLength(); ci++) { + var fc = textData.floatClips(ci); + System.out.println( + " clip[" + ci + + "] y=" + + fc.yTop() + + ".." + + fc.yBottom() + + " x=" + + fc.x() + + " w=" + + fc.width()); + } + } + if (textData != null && textData.bandsLength() > 0) { + for (int bi = 0; bi < textData.bandsLength(); bi++) { + var tb = textData.bands(bi); + System.out.println( + " band[" + bi + + "] split=" + + tb.splitByte() + + " w=" + + tb.width() + + " ml=" + + tb.marginLeft()); + } + } + } + if (LayoutBridge.getFontHandle() != 0) { + byte[] result = LayoutBridge.measureLayout(LayoutBridge.getFontHandle(), bytes); + var lr = com.hfstudio.guidenh.guide.layout.flatbuffers.LayoutResult + .getRootAsLayoutResult(java.nio.ByteBuffer.wrap(result)); + System.out.println("-- rust debug_info --"); + System.out.println(lr.debugInfo()); + } + } + + /** + * Replicates beamcrafter.md's structure: a tall right float, a short intro + * paragraph carrying a {@code
          + * }, then the following + * paragraph. The clear break extends the intro's Java height below the + * float (LineBuilder jumps lineBoxY) but contributes no text; without the + * serializer's min-height bridge, Rust stacks the follower at the intro's + * TEXT height, pulling it up into the float's zone (the "Construction + * paragraph invades the scene panel" bug). + */ + private static LytDocument floatClearPage() { + var doc = new LytDocument(); + doc.append(new LytDocumentFloat(new FixedLeaf(200, 120), true)); + var intro = new LytParagraph(); + intro.appendText("Short intro beside the float."); + var br = new com.hfstudio.guidenh.guide.document.flow.LytFlowBreak(); + br.setClearRight(true); + intro.append(br); + doc.append(intro); + doc.append(paragraph("Following paragraph must start below the float's bottom edge, not inside it.")); + return doc; + } + + /** + * Hard-break check: a paragraph with an in-line {@code
          + * } (no clear, no + * float) must lay out as two lines. Before the break-splitting migration the + * {@code
          + * } was dropped from the shaped text, collapsing the two lines + * into one (height ~10); the Rust pusher now splits at the break and stacks + * the pieces, so the box must be at least two lines tall (~20). + */ + private static int runBreakScenario() { + System.out.println("\n=== breakWrapPage (width=557) ==="); + var doc = new LytDocument(); + var p = new LytParagraph(); + p.appendText("line one"); + p.appendBreak(); + p.appendText("line two"); + doc.append(p); + doc.updateLayout(new LayoutContext(MOCK_METRICS), 557); + List issues = new ArrayList<>(); + StringBuilder sb = new StringBuilder(); + dump(doc, 0, sb); + System.out.print(sb); + int h = p.getBounds() + .height(); + System.out.println("-- break paragraph height=" + h); + if (h < 18) { + issues.add( + new Issue( + "hard
          not honoured: paragraph height=" + h + + " (expected >= 2 lines; the
          was dropped so the two lines collapsed into one)")); + } + if (issues.isEmpty()) { + System.out.println("OK: no issues"); + return 0; + } + for (Issue i : issues) { + System.out.println("ISSUE: " + i.text()); + } + return issues.size(); + } + + /** + * Clear-break check: the paragraph after a clear="right" break must start + * at or below the float's bottom edge. + */ + private static int runFloatClearScenario() { + System.out.println("\n=== floatClearPage (width=557) ==="); + LytDocument doc = floatClearPage(); + doc.updateLayout(new LayoutContext(MOCK_METRICS), 557); + List issues = new ArrayList<>(); + checkTree(doc, issues, ""); + checkSiblingOverlap(doc, issues, "doc"); + checkFloatOverlap(doc, issues); + StringBuilder sb = new StringBuilder(); + dump(doc, 0, sb); + System.out.print(sb); + + var serializer = new LayoutTreeSerializer(); + serializer.serialize(doc, 557f, 1.0f, 1.0f); + var floats = serializer.getFloatRects(); + int floatBottom = floats.isEmpty() ? -1 + : floats.get(0) + .rect() + .bottom() - LytDocumentFloat.FLOAT_GAP; + + LytParagraph intro = null; + LytParagraph follower = null; + for (LytNode child : doc.getChildren()) { + if (child instanceof LytParagraph p) { + if (intro == null) intro = p; + follower = p; + } + } + // The clear break lives at the END of the intro paragraph. CSS clear + // moves the flow AFTER the break below the float; it must NOT inflate + // the intro paragraph's own box (its text still wraps beside the float + // at the top). Inflating it leaves a blank gap inside the intro: the + // exact "callout not hugging" symptom seen in-game. + if (intro != null && intro.getBounds() != null + && intro.getBounds() + .height() > 30) { + issues.add( + new Issue( + "intro paragraph inflated to height=" + intro.getBounds() + .height() + + " by trailing clear (should hug its text, ~10px/line; the clear must push the FOLLOWING flow, not stretch this box)")); + } + if (follower == null || follower.getBounds() == null) { + issues.add(new Issue("floatClearPage: following paragraph missing")); + } else { + System.out.println( + "-- follower y=" + follower.getBounds() + .y() + " floatBottom=" + floatBottom); + if (follower.getBounds() + .y() < floatBottom - 2) { + issues.add( + new Issue( + "following paragraph starts at y=" + follower.getBounds() + .y() + " inside the float's zone (bottom=" + floatBottom + "); clear break lost")); + } + } + if (issues.isEmpty()) { + System.out.println("OK: no issues"); + return 0; + } + for (Issue i : issues) { + System.out.println("ISSUE: " + i.text()); + } + return issues.size(); + } + + /** + * Float-wrap invariant: glyph runs of blocks OUTSIDE a float's subtree must + * never intersect the float's content rect (the registered rect minus + * FLOAT_GAP on the gap side and the bottom). Guards the whole "text invades + * the float zone" failure class across every scenario. + */ + private static void checkFloatOverlap(LytDocument doc, List issues) { + var serializer = new LayoutTreeSerializer(); + serializer.serialize(doc, 557f, 1.0f, 1.0f); + List floats = serializer.getFloatRects(); + if (floats.isEmpty()) return; + checkFloatOverlap(doc, floats, issues, false, "doc"); + } + + private static void checkFloatOverlap(LytNode node, List floats, List issues, + boolean insideFloat, String path) { + boolean inFloat = insideFloat || node instanceof LytDocumentFloat; + String here = path + "/" + + (node instanceof LytBlock b ? b.getClass() + .getSimpleName() : "?"); + if (!inFloat && node instanceof GlyphRunHolder h && h.getGlyphData() != null) { + int reported = 0; + outer: for (var group : h.getGlyphData() + .runs()) { + for (var g : group.glyphs()) { + for (var fr : floats) { + int gap = LytDocumentFloat.FLOAT_GAP; + LytRect f = fr.rect(); + // Content rect: right floats carry the gap on the left, + // left floats on the right; both carry it on the bottom. + float cx = fr.right() ? f.x() + gap : f.x(); + float cr = fr.right() ? f.right() : f.right() - gap; + float cb = f.bottom() - gap; + // A glyph intrudes only when its pen start passes the + // float's content edge by more than a few px; the lane + // edge sits FLOAT_GAP px clear of the content edge and + // shaped advances can overshoot the lane by ~2px, so + // legitimate pens never come near it. The vertical + // check carries the clear/clip semantics. + boolean xIntrude = fr.right() ? g.x() > cx + 4 : g.x() < cr - 4; + boolean overlap = xIntrude && g.y() + g.h() > f.y() + 2 && g.y() < cb - 2; + if (overlap) { + issues.add( + new Issue( + here + ": glyph @(" + + (int) g.x() + + "," + + (int) g.y() + + ") inside float rect " + + f)); + if (++reported >= 3) break outer; + } + } + } + } + } + for (LytNode child : node.getChildren()) { + checkFloatOverlap(child, floats, issues, inFloat, here); + } + } + + /** + * Line-geometry oracle backing the interaction retirement: prove Rust glyph + * runs reproduce the Java LineBuilder's per-line geometry for PLAIN + * paragraphs, so line rects can be rebuilt from glyph runs (via line_index) + * and the Java LineBuilder deleted. + *

          + * Two regimes, decided independently of the Rust/Java comparison (so it is + * not a circular "assert equal when equal"): + *

            + *
          • Plain (no float vertically overlaps, no inline blocks, unit + * fontScale): the Java LineBuilder is a valid oracle: Rust must match its + * line count, left edge and line-top y. Right edge is NOT compared: AWT + * (Java) and swash (Rust) per-glyph advances accumulate differently, so the + * right extent drifts with line length even when wrapping agrees.
          • + *
          • Float/inline: the Java LineBuilder is NOT a valid oracle: it + * skips the float clip the Rust pusher applies (measured: left-float text at + * x=5 in Java vs the correct x=129 in Rust) and its line count diverges in + * wrap bands. There we assert Rust internal consistency only; glyph-vs-lane + * correctness is checkFloatOverlap's job.
          • + *
          + */ + private static void checkLineGeometry(LytDocument doc, int width, List issues) { + var serializer = new LayoutTreeSerializer(); + serializer.serialize(doc, width, 1.0f, 1.0f); + List floats = serializer.getFloatRects(); + checkLineGeometry(doc, floats, issues, false, "doc"); + } + + private static void checkLineGeometry(LytNode node, List floats, List issues, + boolean insideFloat, String path) { + boolean inFloat = insideFloat || node instanceof LytDocumentFloat; + String here = path + "/" + + (node instanceof LytBlock b ? b.getClass() + .getSimpleName() : "?"); + if (!inFloat && node instanceof LytParagraph par + && par.getGlyphData() != null + && !par.getGlyphData() + .runs() + .isEmpty() + && par.getBounds() != null) { + checkParagraphLines(par, floats, issues, here); + } + for (LytNode child : node.getChildren()) { + checkLineGeometry(child, floats, issues, inFloat, here); + } + } + + private static void checkParagraphLines(LytParagraph par, List floats, + List issues, String here) { + LytRect pb = par.getBounds(); + // Rust per-line extents {minX, maxX, minY, maxY}, keyed by line_index. + java.util.TreeMap rust = new java.util.TreeMap<>(); + for (var group : par.getGlyphData() + .runs()) { + for (var g : group.glyphs()) { + float[] ext = rust + .computeIfAbsent(g.lineIndex(), k -> new float[] { Float.MAX_VALUE, 0, Float.MAX_VALUE, 0 }); + ext[0] = Math.min(ext[0], g.x()); + ext[1] = Math.max(ext[1], g.x() + g.w()); + ext[2] = Math.min(ext[2], g.y()); + ext[3] = Math.max(ext[3], g.y() + g.h()); + } + } + if (rust.isEmpty()) { + return; + } + // The Java LineBuilder is gone: Rust is the sole layout authority. + // No Java oracle to compare against; skip the line-level comparison. + } + + /** + * Coordinate-level check for the two-graph page: each graph's text/circle + * primitives must land inside its own panel (labels leaking into the other + * graph's panel is the reported bug), and curve segments must reach the + * plot area (curves drawn fully outside = the "invisible curves" bug). + */ + private static int runTwoGraphsScenario() { + System.out.println("\n=== twoGraphsPage (width=557) ==="); + LytDocument doc = twoGraphsPage(); + doc.updateLayout(new LayoutContext(MOCK_METRICS), 557); + List issues = new ArrayList<>(); + checkTree(doc, issues, ""); + checkSiblingOverlap(doc, issues, "doc"); + StringBuilder sb = new StringBuilder(); + dump(doc, 0, sb); + System.out.print(sb); + + var viewport = new LytRect(0, 0, 557, 4000); + for (var block : doc.getBlocks()) { + if (!(block instanceof com.hfstudio.guidenh.guide.document.block.functiongraph.LytFunctionGraph graph)) { + continue; + } + var pc = new com.hfstudio.guidenh.guide.render.PrimitiveCollector(viewport, null); + pc.collectFrom(graph); + LytRect b = graph.getBounds(); + System.out.println("-- graph bounds=" + b); + int texts = 0, circles = 0, lines = 0, glyphQuads = 0, outsideTexts = 0, outsideCircles = 0, + outsideGlyphs = 0, linesOffPanel = 0; + int printed = 0; + for (var p : pc.result()) { + if (p instanceof com.hfstudio.guidenh.guide.render.GuideRenderPrimitive.DrawText dt) { + texts++; + boolean in = dt.x() >= b.x() - 2 && dt.x() <= b.right() + 2 + && dt.y() >= b.y() - 2 + && dt.y() <= b.bottom() + 2; + if (!in) { + outsideTexts++; + if (printed++ < 10) { + System.out + .println(" OUTSIDE-TEXT \"" + dt.text() + "\" @(" + dt.x() + "," + dt.y() + ")"); + } + } else if (texts <= 16) { + System.out.println(" text \"" + dt.text() + "\" @(" + dt.x() + "," + dt.y() + ")"); + } + } else if (p instanceof com.hfstudio.guidenh.guide.render.GuideRenderPrimitive.DrawGlyphRun dg) { + // Unified-pipeline text (GuideText.emitText): every glyph quad + // must stay inside the emitting graph's panel. + for (var g : dg.glyphs()) { + glyphQuads++; + boolean in = g.x() >= b.x() - 3 && g.x() <= b.right() + 3 + && g.y() >= b.y() - 3 + && g.y() <= b.bottom() + 3; + if (!in) { + outsideGlyphs++; + if (printed++ < 10) { + System.out.println(" OUTSIDE-GLYPH @(" + g.x() + "," + g.y() + ")"); + } + } + } + } else if (p instanceof com.hfstudio.guidenh.guide.render.GuideRenderPrimitive.DrawCircle dc) { + circles++; + boolean in = dc.cx() >= b.x() - 4 && dc.cx() <= b.right() + 4 + && dc.cy() >= b.y() - 4 + && dc.cy() <= b.bottom() + 4; + if (!in) { + outsideCircles++; + if (printed++ < 10) { + System.out.println(" OUTSIDE-CIRCLE @(" + dc.cx() + "," + dc.cy() + ")"); + } + } + } else if (p instanceof com.hfstudio.guidenh.guide.render.GuideRenderPrimitive.DrawLine dl) { + lines++; + // Segment bounding box must intersect the (slightly inflated) panel. + float minX = Math.min(dl.x1(), dl.x2()), maxX = Math.max(dl.x1(), dl.x2()); + float minY = Math.min(dl.y1(), dl.y2()), maxY = Math.max(dl.y1(), dl.y2()); + boolean touches = maxX >= b.x() - 8 && minX <= b.right() + 8 + && maxY >= b.y() - 8 + && minY <= b.bottom() + 8; + if (!touches) linesOffPanel++; + } + } + System.out.println( + " texts=" + texts + + " glyphQuads=" + + glyphQuads + + " circles=" + + circles + + " lines=" + + lines + + " | outsideTexts=" + + outsideTexts + + " outsideGlyphs=" + + outsideGlyphs + + " outsideCircles=" + + outsideCircles + + " linesOffPanel=" + + linesOffPanel); + if (outsideTexts > 0 || outsideCircles > 0 || outsideGlyphs > 0) { + issues.add( + new Issue( + "graph " + b + + ": " + + (outsideTexts + outsideGlyphs + outsideCircles) + + " primitives outside bounds")); + } + if (lines > 0 && linesOffPanel == lines) { + issues.add(new Issue("graph " + b + ": ALL " + lines + " line segments off panel (invisible curves)")); + } + } + if (issues.isEmpty()) { + System.out.println("OK: no issues"); + return 0; + } + for (Issue i : issues) { + System.out.println("ISSUE: " + i.text()); + } + return issues.size(); + } + + /** + * Reproduces the in-game "callout not hugging the intro" case: a right + * float, a short intro paragraph, then a callout (LytQuoteBox) wrapped in + * LytFloatAwareBlock: exactly what PageCompiler emits for a blockquote + * beside a float. The wrapper must stay adjacent to the intro (a block box + * is not pushed below a float in CSS); only its width may shrink to the + * lane, and only the text inside wraps. + */ + private static LytDocument floatAwareBlockPage() { + var doc = new LytDocument(); + doc.append(new LytDocumentFloat(new FixedLeaf(120, 200), true)); + doc.append( + paragraph( + "The Large Sifter is an HV tier multiblock for sifting gems and minerals. The Large Sifter is a direct upgrade from the singleblock sifter because it runs at 500% speed, only uses 75% of the EU/t normally required, and offers 4 parallels per voltage tier. It is recommended to passively sift dusts and liquids with no other use.")); + var q = new com.hfstudio.guidenh.guide.document.block.LytQuoteBox(); + q.setQuoteStyle(null, "Note", null); + q.append(paragraph("Only the structure of this multiblock has changed, the mechanics stay the same.")); + doc.append(new com.hfstudio.guidenh.guide.document.block.LytFloatAwareBlock(q)); + return doc; + } + + public static void main(String[] args) { + if (!setup()) { + System.exit(2); + return; + } + int failures = 0; + failures += runScenario("floatPage", floatPage(), 557, true); + failures += runScenario("recipeGalleryPage", recipeGalleryPage(), 557); + failures += runScenario("fileTreePage", fileTreePage(), 557, true); + failures += runScenario("itemImagePage", itemImagePage(), 557); + failures += runScenario("inlinePage", inlinePage(), 557); + failures += runScenario("justifyPage", justifyPage(), 557); + failures += runScenario("floatWrapPage", floatWrapPage(), 557, true); + failures += runScenario("multiFloatPage", multiFloatPage(), 557, true); + failures += runFloatClearScenario(); + failures += runBreakScenario(); + failures += runScenario("floatAwareBlockPage", floatAwareBlockPage(), 920, true); + failures += runScenario("contentTabsPage", contentTabsPage(), 557, true); + failures += runScenario("fontScalePage", fontScalePage(), 557); + failures += runScenario("tablePage", tablePage(), 557); + failures += runScenario("functionGraphPage", functionGraphPage(), 557, true); + failures += runScenario("latexInlinePage", latexInlinePage(), 557); + // Narrow width forces the fraction paragraph to wrap like in-game, + // exercising the per-line growth path (markers on line 2+). + failures += runScenario("latexInlinePageNarrow", latexInlinePage(), 300); + failures += runTwoGraphsScenario(); + failures += runCodeBlockScenario(); + failures += runChartsScenario(); + failures += runRichTextScenario(); + System.out.println("\n==== TOTAL ISSUES: " + failures + " ===="); + // Baseline evidence: real text glyphs carry a non-zero Rust baseline with + // baseline - y == placement.top (Rust invariant; 0.5px-aligned). Fail the + // run when no such evidence exists. + System.out.println( + "==== BASELINE-EVIDENCE: total=" + BS_TOTAL + + " nonzero=" + + BS_NONZERO + + " valid(" + + -1.0 + + "..24)=" + + BS_VALID + + " halfPxAligned=" + + BS_HALF_PX + + " sample" + + BS_SAMPLE + + " ===="); + if (BS_TOTAL == 0 || BS_NONZERO == 0) { + failures++; + System.out.println("BASELINE-EVIDENCE-FAIL: no non-zero baseline observed"); + } + System.exit(failures == 0 ? 0 : 1); + } +} diff --git a/src/test/java/com/hfstudio/guidenh/guide/layout/LayoutTreeSerializerSmokeTest.java b/src/test/java/com/hfstudio/guidenh/guide/layout/LayoutTreeSerializerSmokeTest.java new file mode 100644 index 00000000..fb84656d --- /dev/null +++ b/src/test/java/com/hfstudio/guidenh/guide/layout/LayoutTreeSerializerSmokeTest.java @@ -0,0 +1,370 @@ +package com.hfstudio.guidenh.guide.layout; + +import static org.junit.jupiter.api.Assertions.*; + +import java.nio.ByteBuffer; + +import org.junit.jupiter.api.Test; + +import com.hfstudio.guidenh.guide.document.LytRect; +import com.hfstudio.guidenh.guide.document.block.LytBox; +import com.hfstudio.guidenh.guide.document.block.LytDocumentFloat; +import com.hfstudio.guidenh.guide.document.block.LytHBox; +import com.hfstudio.guidenh.guide.document.block.LytParagraph; +import com.hfstudio.guidenh.guide.document.block.LytSlotGrid; +import com.hfstudio.guidenh.guide.document.block.LytThematicBreak; +import com.hfstudio.guidenh.guide.document.block.LytVBox; +import com.hfstudio.guidenh.guide.layout.flatbuffers.FlatLayout; +import com.hfstudio.guidenh.guide.layout.flatbuffers.FlatNode; +import com.hfstudio.guidenh.guide.layout.flatbuffers.LayoutInput; +import com.hfstudio.guidenh.guide.layout.flatbuffers.LayoutResult; +import com.hfstudio.guidenh.guide.style.WhiteSpaceMode; + +/** + * Contract tests for the layout compiler's lowering rules (no JNI dependency): + * how high-level Lyt blocks are translated into Taffy-visible semantics. + */ +class LayoutTreeSerializerSmokeTest { + + /** Plain childless container with preset bounds. */ + private static class PlainLeafBox extends LytBox { + + PlainLeafBox(int x, int y, int w, int h) { + this.bounds = new LytRect(x, y, w, h); + } + + @Override + protected LytRect computeBoxLayout(LayoutContext context, int x, int y, int availableWidth) { + return bounds; + } + } + + /** Child fixture that honors the layout position, with fixed size. */ + private static class FixedChild extends LytBox { + + FixedChild() { + this(0, 0, 60, 6); + } + + FixedChild(int x, int y, int w, int h) { + this.bounds = new LytRect(x, y, w, h); + } + + @Override + protected LytRect computeBoxLayout(LayoutContext context, int x, int y, int availableWidth) { + return new LytRect(x, y, 60, 6); + } + } + + @Test + void childlessContainerNoLongerGetsJavaBoundsSize() { + // SIZE_FROM_JAVA_BOUNDS was removed with the Java layout pre-pass. + // Childless opaque containers no longer receive an explicit size from + // Java-computed bounds; size is now declared through explicit dimensions + // or Rust-side measure functions. + var leaf = new PlainLeafBox(0, 0, 77, 33); + + byte[] output = new LayoutTreeSerializer().serialize(leaf, 800f, 1.0f, 1.0f); + LayoutInput input = LayoutInput.getRootAsLayoutInput(ByteBuffer.wrap(output)); + + assertEquals(1, input.nodesLength()); + FlatNode node = input.nodes(0); + assertEquals(0, node.nodeType(), "fixture must be a Container node"); + assertNull( + node.style() + .sizeW(), + "childless container no longer gets an explicit width from Java bounds"); + assertNull( + node.style() + .sizeH(), + "childless container no longer gets an explicit height from Java bounds"); + } + + @Test + void normalBlockChildrenAreStillSerialized() { + var root = new LytVBox(); + root.append(new LytThematicBreak()); + root.append(new LytThematicBreak()); + + byte[] output = new LayoutTreeSerializer().serialize(root, 800f, 1.0f, 1.0f); + LayoutInput input = LayoutInput.getRootAsLayoutInput(ByteBuffer.wrap(output)); + + assertEquals(3, input.nodesLength(), "normal container keeps children in the flat tree"); + assertEquals( + 2, + input.nodes(0) + .childrenLength()); + } + + @Test + void paragraphSizeIsNotForcedFromBounds() { + var par = new LytParagraph(); + par.appendText("hello"); + // Simulate bounds from the Java layout pass: Rust text measurement + // must stay authoritative for text nodes. + par.applyExternalLayout(new LytRect(0, 0, 200, 20)); + + byte[] output = new LayoutTreeSerializer().serialize(par, 800f, 1.0f, 1.0f); + LayoutInput input = LayoutInput.getRootAsLayoutInput(ByteBuffer.wrap(output)); + + FlatNode node = input.nodes(0); + assertEquals(1, node.nodeType(), "paragraph must serialize as Text"); + assertNull( + node.style() + .sizeW(), + "text nodes must not get a px width from Java bounds"); + assertNull( + node.style() + .sizeH(), + "text nodes must not get a px height from Java bounds"); + } + + @Test + void hboxWrapSerializesAsFlexWrap() { + var row = new LytHBox(); // wrap defaults to true + row.append(new LytThematicBreak()); + + byte[] output = new LayoutTreeSerializer().serialize(row, 800f, 1.0f, 1.0f); + LayoutInput input = LayoutInput.getRootAsLayoutInput(ByteBuffer.wrap(output)); + + assertEquals( + 0, + input.nodes(0) + .style() + .flexDirection(), + "HBox must serialize as Row"); + assertEquals( + 1, + input.nodes(0) + .style() + .flexWrap(), + "wrapping HBox must serialize flex_wrap=Wrap"); + + row.setWrap(false); + output = new LayoutTreeSerializer().serialize(row, 800f, 1.0f, 1.0f); + input = LayoutInput.getRootAsLayoutInput(ByteBuffer.wrap(output)); + assertEquals( + 0, + input.nodes(0) + .style() + .flexWrap(), + "non-wrapping HBox must serialize flex_wrap=NoWrap"); + } + + @Test + void slotGridLowersToWrappingRowWithExplicitWidth() { + var grid = new LytSlotGrid(3, 3); + + byte[] output = new LayoutTreeSerializer().serialize(grid, 800f, 1.0f, 1.0f); + LayoutInput input = LayoutInput.getRootAsLayoutInput(ByteBuffer.wrap(output)); + + FlatNode node = input.nodes(0); + assertEquals( + 0, + node.style() + .flexDirection(), + "slot grid must lower to a Row container"); + assertEquals( + 1, + node.style() + .flexWrap(), + "slot grid must lower to a wrapping row"); + assertNotNull( + node.style() + .sizeW(), + "fixed grid must carry an explicit width"); + assertEquals( + 54f, + node.style() + .sizeW() + .value(), + 0.001f, + "3 columns x 18px"); + } + + @Test + void documentFloatEliminatedInnerCarriesRealFloat() { + var root = new LytVBox(); + root.applyExternalLayout(new LytRect(0, 0, 800, 600)); // give the root flow bounds + var floatWrapper = new LytDocumentFloat(new FixedChild(), true); + // Lay out so the inner has bounds; right float in 200px at x=5 → inner at (145,10). + floatWrapper.layout(new LayoutContext(null), 5, 10, 200); + root.append(floatWrapper); + root.append(new LytThematicBreak()); + + byte[] output = new LayoutTreeSerializer().serialize(root, 800f, 1.0f, 1.0f); + LayoutInput input = LayoutInput.getRootAsLayoutInput(ByteBuffer.wrap(output)); + + assertEquals( + 3, + input.nodesLength(), + "wrapper is eliminated: root + floated inner + sibling (no zero-height anchor node)"); + FlatNode inner = input.nodes(1); + assertEquals( + 2, + inner.style() + .float_(), + "the inner carries a real right-float for the Rust pusher"); + assertEquals( + 0, + inner.style() + .position(), + "the inner is in-flow (a float), not position:absolute"); + assertEquals( + LytDocumentFloat.FLOAT_GAP, + inner.style() + .marginLeft(), + 0.001f, + "right-float gap rides the inner's left margin (text-facing side)"); + // SIZE_FROM_JAVA_BOUNDS was removed with the Java layout pre-pass; + // the float inner (a plain LytBox fixture with no explicit dimensions) + // no longer carries a serialized size: Rust sizes it from its content. + assertNull( + inner.style() + .sizeW(), + "float inner no longer gets explicit width from Java bounds"); + assertNull( + inner.style() + .sizeH(), + "float inner no longer gets explicit height from Java bounds"); + } + + @Test + void floatAdjacentBlockIsNotLanePinned() { + var root = new LytVBox(); + root.applyExternalLayout(new LytRect(0, 0, 800, 600)); // give the root flow bounds + var floatWrapper = new LytDocumentFloat(new FixedChild(), false); // left float at (5,10,60,6) + floatWrapper.layout(new LayoutContext(null), 5, 10, 200); + root.append(floatWrapper); + // Non-text block beside the float: no longer lane-pinned (CSS: a block + // box is not narrowed by a float; only line boxes wrap). + root.append(new FixedChild(70, 12, 100, 6)); + + byte[] output = new LayoutTreeSerializer().serialize(root, 800f, 1.0f, 1.0f); + LayoutInput input = LayoutInput.getRootAsLayoutInput(ByteBuffer.wrap(output)); + + assertEquals(3, input.nodesLength()); + FlatNode inner = input.nodes(1); + assertEquals( + 1, + inner.style() + .float_(), + "the inner carries a real left-float"); + FlatNode adjacent = input.nodes(2); + assertEquals( + 0f, + adjacent.style() + .marginLeft(), + 0.001f, + "no lane displacement on margin-left"); + } + + @Test + void textParagraphEmitsNoFloatClipReplay() { + var root = new LytVBox(); + root.applyExternalLayout(new LytRect(0, 0, 800, 600)); + var floatWrapper = new LytDocumentFloat(new FixedChild(), true); // right float: inner at (145,10,60,6) + floatWrapper.layout(new LayoutContext(null), 5, 10, 200); + root.append(floatWrapper); + // A text paragraph in the float's band: no precomputed clip is emitted; + // the Rust pusher wraps it against its own live float table. + var par = new LytParagraph(); + par.appendText("hello"); + par.applyExternalLayout(new LytRect(5, 12, 100, 6)); + root.append(par); + + byte[] output = new LayoutTreeSerializer().serialize(root, 800f, 1.0f, 1.0f); + LayoutInput input = LayoutInput.getRootAsLayoutInput(ByteBuffer.wrap(output)); + + assertEquals(3, input.nodesLength()); + FlatNode para = input.nodes(2); + var text = para.text(); + assertNotNull(text, "paragraph must carry text data"); + assertEquals( + 0, + text.floatClipsLength(), + "no clip replay: wrapping is owned by the Rust pusher's live float table"); + } + + @Test + void preWrapParagraphIsTextLeaf() { + var par = new LytParagraph(); + par.modifyStyle(style -> style.whiteSpace(WhiteSpaceMode.PRE_WRAP)); + par.appendText("code"); + + byte[] output = new LayoutTreeSerializer().serialize(par, 800f, 1.0f, 1.0f); + LayoutInput input = LayoutInput.getRootAsLayoutInput(ByteBuffer.wrap(output)); + + assertEquals( + 1, + input.nodes(0) + .nodeType(), + "PRE_WRAP paragraphs serialize as Text (parley preserves spaces and \n natively)"); + } + + @Test + void testDimHelpers() { + // Verify dimension helpers produce valid offsets + com.google.flatbuffers.FlatBufferBuilder fbb = new com.google.flatbuffers.FlatBufferBuilder(64); + int auto = LayoutStyleExtractor.dimAuto(fbb); + assertEquals(0, auto, "dimAuto should return 0"); + } + + @Test + void testStyleBuilderProducesValidStyle() { + // Minimal LytBlock: LayoutStyleExtractor must handle it. + LytThematicBreak tb = new LytThematicBreak(); + com.google.flatbuffers.FlatBufferBuilder fbb = new com.google.flatbuffers.FlatBufferBuilder(256); + int styleOff = LayoutStyleExtractor + .build(fbb, tb, LayoutStyleExtractor.Flags.NONE, LayoutStyleExtractor.NodeAdjustments.ZERO); + assertTrue(styleOff != 0, "build() should return non-zero Style offset"); + } + + @Test + void testSerializerProducesValidFlatBuffer() { + // Serialize a minimal tree through LayoutTreeSerializer. + LytThematicBreak tb = new LytThematicBreak(); + LayoutTreeSerializer serializer = new LayoutTreeSerializer(); + + byte[] output = serializer.serialize(tb, 800f, 1.0f, 1.0f); + assertNotNull(output); + assertTrue(output.length > 0, "serializer should produce non-empty output"); + + // Verify we can parse the output as a LayoutInput + LayoutInput input = LayoutInput.getRootAsLayoutInput(ByteBuffer.wrap(output)); + assertTrue(input.availableWidth() > 0); + assertTrue(input.nodesLength() >= 1, "should have at least 1 node"); + } + + @Test + void testFullPipelineWithJni() { + // Full pipeline: Java LytBlock → Serializer → JNI → Rust → result + String libPath = System.getProperty("guide.native.lib.path"); + if (libPath == null || libPath.isEmpty()) { + return; // skip if JNI not available + } + System.load(libPath); + + LytThematicBreak tb = new LytThematicBreak(); + LayoutTreeSerializer serializer = new LayoutTreeSerializer(); + byte[] input = serializer.serialize(tb, 800f, 1.0f, 1.0f); + + long handle = LayoutBridge.init(new byte[0], "en-US"); + assertTrue(handle != 0); + + byte[] output = LayoutBridge.measureLayout(handle, input); + assertNotNull(output); + assertTrue(output.length > 0, "middle layer + JNI should produce non-empty result"); + + LayoutResult result = LayoutResult.getRootAsLayoutResult(ByteBuffer.wrap(output)); + assertTrue(result.nodesLength() >= 1, "LayoutResult should have at least 1 node"); + + FlatLayout fl = result.nodes(0); + assertTrue(fl.x() >= 0); + assertTrue(fl.y() >= 0); + assertTrue(fl.w() >= 0); + assertTrue(fl.h() >= 0, "ThematicBreak should have non-negative height"); + + LayoutBridge.destroy(handle); + } +} diff --git a/src/test/java/com/hfstudio/guidenh/guide/layout/OrderedListMarkerSerializeTest.java b/src/test/java/com/hfstudio/guidenh/guide/layout/OrderedListMarkerSerializeTest.java new file mode 100644 index 00000000..f51320f5 --- /dev/null +++ b/src/test/java/com/hfstudio/guidenh/guide/layout/OrderedListMarkerSerializeTest.java @@ -0,0 +1,86 @@ +package com.hfstudio.guidenh.guide.layout; + +import static org.junit.jupiter.api.Assertions.*; + +import java.nio.ByteBuffer; + +import org.junit.jupiter.api.Test; + +import com.hfstudio.guidenh.guide.document.block.LytList; +import com.hfstudio.guidenh.guide.document.block.LytListItem; +import com.hfstudio.guidenh.guide.layout.flatbuffers.LayoutInput; + +/** + * Ordered-list numbering is serialized on the first layout pass. + * + *

          + * Serialization runs before {@code afterExternalLayout()} inside + * {@code LytDocument.createLayout}, so on the first pass + * {@code LytListItem.cachedOrderedNumber} is still -1. Marker serialization + * must not depend on that cache: if it did, every ordered list would render + * as a bullet on its first pass. This test serializes a tree that has not + * been through {@code afterExternalLayout} and asserts that the serialized + * {@code ListMarkerData.text} already carries "N." with the list start + * semantics applied. + */ +class OrderedListMarkerSerializeTest { + + private static LayoutInput serialize(LytList root) { + byte[] output = new LayoutTreeSerializer().serialize(root, 800f, 1.0f, 1.0f); + return LayoutInput.getRootAsLayoutInput(ByteBuffer.wrap(output)); + } + + @Test + void orderedListItemsSerializeLiveNumbersOnFirstPass() { + var list = new LytList(true, 3); + list.append(new LytListItem()); + list.append(new LytListItem()); + list.append(new LytListItem()); + // NOTE: no applyExternalLayout / afterExternalLayout; serialize IS the + // first pass here. + LayoutInput input = serialize(list); + assertEquals(4, input.nodesLength(), "list + 3 items"); + assertEquals( + "3.", + input.nodes(1) + .listMarker() + .text(), + "first item must start at list start"); + assertEquals( + "4.", + input.nodes(2) + .listMarker() + .text(), + "second item must count up"); + assertEquals( + "5.", + input.nodes(3) + .listMarker() + .text(), + "third item must count up"); + } + + @Test + void orderedListStartOneSerializesFromOne() { + var list = new LytList(true, 1); + list.append(new LytListItem()); + LayoutInput input = serialize(list); + assertEquals( + "1.", + input.nodes(1) + .listMarker() + .text()); + } + + @Test + void unorderedListItemsSerializeBulletOnFirstPass() { + var list = new LytList(false, 1); + list.append(new LytListItem()); + LayoutInput input = serialize(list); + assertEquals( + "\u2022", + input.nodes(1) + .listMarker() + .text()); + } +} diff --git a/src/test/java/com/hfstudio/guidenh/guide/render/GuideGlyphAtlasPagingTest.java b/src/test/java/com/hfstudio/guidenh/guide/render/GuideGlyphAtlasPagingTest.java new file mode 100644 index 00000000..2da9ff96 --- /dev/null +++ b/src/test/java/com/hfstudio/guidenh/guide/render/GuideGlyphAtlasPagingTest.java @@ -0,0 +1,189 @@ +package com.hfstudio.guidenh.guide.render; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +/** + * Headless contract tests for {@link GuideGlyphAtlas} multi-page packing, LRU + * per-page eviction and the failure set. Pure Java: no GL, no Minecraft (the + * atlas runs in headless bookkeeping mode via {@code setHeadless(true)}). + *

          + * Geometry: pages are 2048² with 1px padding. A 400x400 glyph packs 5 per row + * (5×401 = 2005 ≤ 2048) and 5 rows (rows at y=1,402,803,1204,1605) → exactly + * 25 glyphs per page. + */ +class GuideGlyphAtlasPagingTest { + + private static final int GLYPH = 400; + private static final int PER_ROW = 5; + private static final int PER_PAGE = 25; + private static final int FULL_PAGES = GuideGlyphAtlas.MAX_PAGES; + /** Mirrors the production page dimension (2048² RGBA). */ + private static final int PAGE_SIZE = 2048; + + private GuideGlyphAtlas atlas; + + @BeforeEach + void setUp() { + atlas = new GuideGlyphAtlas(); + atlas.setHeadless(true); + } + + private static byte[] rgba(int w, int h) { + return new byte[w * h * 4]; + } + + private GuideGlyphAtlas.GlyphSlot upload(int key) { + return atlas.upload(key, rgba(GLYPH, GLYPH), GLYPH, GLYPH); + } + + @Test + void packingSpillsIntoSecondPageWhenFirstPageFills() { + // First page holds exactly PER_PAGE glyphs; the next glyph opens page 1. + for (int i = 0; i < PER_PAGE; i++) { + assertEquals(0, upload(i).pageId(), "glyph " + i + " must land on page 0"); + } + assertEquals(1, atlas.pageCount(), "page 1 not opened until page 0 is full"); + assertEquals(1, upload(PER_PAGE).pageId(), "overflow glyph must open page 1"); + assertEquals(2, atlas.pageCount()); + } + + @Test + void lookupReturnsPageAndRefreshesRecency() { + assertEquals(0, upload(7).pageId()); + GuideGlyphAtlas.GlyphSlot slot = atlas.lookup(7); + assertNotNull(slot); + assertEquals(0, slot.pageId()); + assertNotNull(slot.uv()); + } + + @Test + void lruEvictionReusesOldestPageAfterAllFourPagesFill() { + // Fill the full page budget (FULL_PAGES=8 pages, keys 0..199 → 25 glyphs each). + for (int i = 0; i < PER_PAGE * FULL_PAGES; i++) { + int page = upload(i).pageId(); + assertTrue(page < FULL_PAGES, "page " + page + " beyond budget"); + } + assertEquals(FULL_PAGES, atlas.pageCount()); + + // Refresh page 0's recency → page 1 (oldest untouched) becomes the LRU victim. + assertNotNull(atlas.lookup(0)); + GuideGlyphAtlas.GlyphSlot overflow = upload(PER_PAGE * FULL_PAGES); + assertNotNull(overflow, "new glyph must be packable after eviction"); + assertEquals(1, overflow.pageId(), "LRU victim (page 1) must host the overflow glyph"); + assertEquals(FULL_PAGES, atlas.pageCount(), "page budget must stay capped"); + + // Glyphs on surviving pages remain resolvable. + assertNotNull(atlas.lookup(50), "page 2 glyph survives"); + assertNotNull(atlas.lookup(75), "page 3 glyph survives"); + } + + @Test + void evictedKeysMissButAreNotInFailureSet() { + for (int i = 0; i < PER_PAGE * FULL_PAGES; i++) { + upload(i); + } + assertNotNull(atlas.lookup(0)); + assertNotNull(upload(PER_PAGE * FULL_PAGES)); // triggers eviction of page 1 + + assertNull(atlas.lookup(25), "glyph on the evicted page must miss"); + assertFalse(atlas.isFailed(25), "evicted keys must NOT enter the failure set"); + + // Re-uploading an evicted key repacks it (contents no longer cached). + GuideGlyphAtlas.GlyphSlot repacked = upload(25); + assertNotNull(repacked); + assertEquals(1, repacked.pageId(), "evicted key repacks onto the freed page"); + } + + @Test + void structurallyOversizedGlyphFailsOnceThenStaysSilent() { + int w = PAGE_SIZE - 1; // 2047 > 2046 → structurally unfittable + long key = 5000L; + assertNull(atlas.upload(key, rgba(w, 16), w, 16), "oversized glyph must be dropped"); + assertTrue(atlas.isFailed(key)); + assertEquals(0, atlas.pageCount(), "a failed upload must not allocate a page"); + + // Same key again: silently blocked by the failure set (no re-WARN). + assertNull(atlas.upload(key, rgba(w, 16), w, 16)); + assertTrue(atlas.isFailed(key)); + } + + @Test + void evictionClearsFailureSetAllowingRetry() { + long key = 5000L; + int w = PAGE_SIZE - 1; + assertNull(atlas.upload(key, rgba(w, 16), w, 16)); + assertTrue(atlas.isFailed(key)); + + // Fill the atlas past the budget → first eviction clears the failure set. + for (int i = 0; i < PER_PAGE * FULL_PAGES; i++) { + upload(i); + } + assertNotNull(atlas.lookup(0)); + assertNotNull(upload(PER_PAGE * FULL_PAGES)); // eviction happens here + assertFalse(atlas.isFailed(key), "page eviction must clear the failure set"); + + // Retry after capacity was released: still structurally unfittable, but + // now it is a fresh failure (re-recorded), not a stale-set block. + assertNull(atlas.upload(key, rgba(w, 16), w, 16)); + assertTrue(atlas.isFailed(key)); + } + + @Test + void clearAndDeleteAreNoopsPerPageInHeadless() { + assertEquals(-1, atlas.getTextureId(0), "headless pages expose no GL texture"); + assertEquals(0, upload(1).pageId()); + assertNotNull(atlas.lookup(1)); + + atlas.clear(); + assertNull(atlas.lookup(1), "clear() drops all cached glyphs"); + assertEquals(0, upload(2).pageId(), "packing resumes after clear"); + + atlas.delete(); + assertNull(atlas.lookup(2), "delete() drops all cached glyphs"); + } + + @Test + void singlePagePackingMatchesLegacyRowLayout() { + // The first glyph must sit at the legacy (PADDING, PADDING) origin and + // each row must wrap exactly like the pre-pagination single-atlas cursor. + float pad = 1f / PAGE_SIZE; + GuideGlyphAtlas.GlyphSlot first = upload(1); + assertEquals( + pad, + first.uv() + .u(), + 1e-6f, + "first glyph u must be PADDING/ATLAS_SIZE"); + assertEquals( + pad, + first.uv() + .v(), + 1e-6f, + "first glyph v must be PADDING/ATLAS_SIZE"); + + // Glyph PER_ROW is the first of row 2 → v advances one full glyph row. + for (int i = 1; i < PER_ROW; i++) { + upload(1 + i); + } + GuideGlyphAtlas.GlyphSlot rowTwo = upload(PER_ROW + 1); + assertEquals( + pad, + rowTwo.uv() + .u(), + 1e-6f, + "row 2 starts at u=PADDING/ATLAS_SIZE"); + assertEquals( + (float) (GLYPH + 1 + 1) / PAGE_SIZE, + rowTwo.uv() + .v(), + 1e-6f, + "row 2 v must skip the first row + padding"); + } +} diff --git a/src/test/java/com/hfstudio/guidenh/guide/render/GuideRenderPrimitiveTest.java b/src/test/java/com/hfstudio/guidenh/guide/render/GuideRenderPrimitiveTest.java new file mode 100644 index 00000000..59996bdd --- /dev/null +++ b/src/test/java/com/hfstudio/guidenh/guide/render/GuideRenderPrimitiveTest.java @@ -0,0 +1,21 @@ +package com.hfstudio.guidenh.guide.render; + +import static org.junit.jupiter.api.Assertions.*; + +import org.junit.jupiter.api.Test; + +/** Contract tests for {@link GuideRenderPrimitive} record shapes. */ +class GuideRenderPrimitiveTest { + + @Test + void blitTextureNineArgConstructorDefaultsToWhiteTint() { + var bt = new GuideRenderPrimitive.BlitTexture(1, 2, 3, 4, 5, 0f, 0f, 1f, 1f); + assertEquals(0xFFFFFFFF, bt.argb(), "9-arg form must default to untinted (white)"); + } + + @Test + void blitTextureTintIsStored() { + var bt = new GuideRenderPrimitive.BlitTexture(1, 2, 3, 4, 5, 0f, 0f, 1f, 1f, 0x80FF0000); + assertEquals(0x80FF0000, bt.argb()); + } +} diff --git a/src/test/java/com/hfstudio/guidenh/guide/render/GuideTextFontFamilyResolveTest.java b/src/test/java/com/hfstudio/guidenh/guide/render/GuideTextFontFamilyResolveTest.java new file mode 100644 index 00000000..6470df4a --- /dev/null +++ b/src/test/java/com/hfstudio/guidenh/guide/render/GuideTextFontFamilyResolveTest.java @@ -0,0 +1,163 @@ +package com.hfstudio.guidenh.guide.render; + +import static org.junit.jupiter.api.Assertions.*; + +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Test; + +import com.hfstudio.guidenh.config.ModConfig; +import com.hfstudio.guidenh.guide.style.ResolvedTextStyle; + +/** + * Unit tests for the base/code font-family mapping tables: the single point + * where config values become wire literals ("system" → null, "mono" → + * "monospace", empty/null → default, anything else passed through). Pure + * Java, no JNI dependency. + */ +class GuideTextFontFamilyResolveTest { + + @AfterEach + void restoreReaderFamilyConfig() { + ModConfig.reader.readerFontFamily = "system"; + ModConfig.reader.readerCodeFontFamily = "mono"; + } + + @Test + void defaultConfigBaseFamilyResolvesNull() { + ModConfig.reader.readerFontFamily = "system"; + assertNull(GuideText.resolveBaseFontFamily(), "default \"system\" must write no family (SansSerif generic)"); + } + + @Test + void systemKeywordResolvesNullExactMatchOnly() { + ModConfig.reader.readerFontFamily = "system"; + assertNull(GuideText.resolveBaseFontFamily()); + ModConfig.reader.readerFontFamily = " SYSTEM "; + assertEquals( + " SYSTEM ", + GuideText.resolveBaseFontFamily(), + "whitespace-padded string is a named family, not the keyword; passthrough"); + } + + @Test + void defaultConfigCodeFamilyResolvesMonospace() { + ModConfig.reader.readerCodeFontFamily = "mono"; + assertEquals( + GuideText.MONO_FONT_FAMILY, + GuideText.resolveCodeFontFamily(), + "default \"mono\" must map to the Rust-recognized \"monospace\" wire literal"); + } + + @Test + void monoKeywordResolvesMonospaceWire() { + ModConfig.reader.readerCodeFontFamily = "mono"; + assertEquals("monospace", GuideText.resolveCodeFontFamily()); + } + + @Test + void nullAndBlankResolveToRespectiveDefaults() { + // Base family default = "system" → null (no wire family). + ModConfig.reader.readerFontFamily = null; + assertNull(GuideText.resolveBaseFontFamily()); + ModConfig.reader.readerFontFamily = ""; + assertNull(GuideText.resolveBaseFontFamily()); + ModConfig.reader.readerFontFamily = " "; + assertNull(GuideText.resolveBaseFontFamily()); + // Code family default = "mono" → "monospace". + ModConfig.reader.readerCodeFontFamily = null; + assertEquals("monospace", GuideText.resolveCodeFontFamily()); + ModConfig.reader.readerCodeFontFamily = ""; + assertEquals("monospace", GuideText.resolveCodeFontFamily()); + ModConfig.reader.readerCodeFontFamily = " "; + assertEquals("monospace", GuideText.resolveCodeFontFamily()); + } + + @Test + void namedFamiliesPassThroughUnchanged() { + ModConfig.reader.readerFontFamily = "DejaVu Sans"; + assertEquals("DejaVu Sans", GuideText.resolveBaseFontFamily(), "named base family passes through as-is"); + ModConfig.reader.readerFontFamily = "Microsoft YaHei"; + assertEquals("Microsoft YaHei", GuideText.resolveBaseFontFamily()); + ModConfig.reader.readerCodeFontFamily = "Consolas"; + assertEquals("Consolas", GuideText.resolveCodeFontFamily(), "named code family passes through as-is"); + // Case-sensitive passthrough: only exact lowercase keywords map. + ModConfig.reader.readerFontFamily = "System"; + assertEquals("System", GuideText.resolveBaseFontFamily(), "\"System\" is a named family, not the keyword"); + ModConfig.reader.readerCodeFontFamily = "Mono"; + assertEquals("Mono", GuideText.resolveCodeFontFamily(), "\"Mono\" is a named family, not the keyword"); + } + + @Test + void fontFamilyOfFollowsResolversForNullFontStyles() { + // Non-code, null-font style → resolveBaseFontFamily (null under default). + ModConfig.reader.readerFontFamily = "system"; + assertNull(GuideText.fontFamilyOf(bodyStyle(null)), "base run with null font follows the base resolver"); + ModConfig.reader.readerFontFamily = "Microsoft YaHei"; + assertEquals( + "Microsoft YaHei", + GuideText.fontFamilyOf(bodyStyle(null)), + "base run wire family = resolved base family when the style carries no font"); + // Null style → null. + assertNull(GuideText.fontFamilyOf(null)); + } + + @Test + void fontFamilyOfPrefersExplicitStyleFont() { + ModConfig.reader.readerFontFamily = "Microsoft YaHei"; + assertEquals( + "Times New Roman", + GuideText.fontFamilyOf(bodyStyle("Times New Roman")), + "a style-level named font stays authoritative over the config"); + } + + @Test + void fontFamilyOfInlineCodeUsesCodeResolver() { + ModConfig.reader.readerCodeFontFamily = "mono"; + assertEquals("monospace", GuideText.fontFamilyOf(codeStyle(null))); + ModConfig.reader.readerCodeFontFamily = "Consolas"; + assertEquals( + "Consolas", + GuideText.fontFamilyOf(codeStyle(null)), + "inline code wire family = resolved code family"); + } + + private static ResolvedTextStyle bodyStyle(String font) { + return new ResolvedTextStyle( + 1f, + false, + false, + false, + false, + false, + false, + false, + font, + null, + null, + null, + false, + null, + false, + 0f); + } + + private static ResolvedTextStyle codeStyle(String font) { + return new ResolvedTextStyle( + 1f, + false, + false, + false, + false, + false, + false, + false, + font, + null, + null, + null, + false, + null, + true, + 0f); + } +} diff --git a/src/test/java/com/hfstudio/guidenh/guide/render/PrimitiveCollectorTest.java b/src/test/java/com/hfstudio/guidenh/guide/render/PrimitiveCollectorTest.java new file mode 100644 index 00000000..0ef82db7 --- /dev/null +++ b/src/test/java/com/hfstudio/guidenh/guide/render/PrimitiveCollectorTest.java @@ -0,0 +1,195 @@ +package com.hfstudio.guidenh.guide.render; + +import static org.junit.jupiter.api.Assertions.*; + +import java.util.List; + +import org.junit.jupiter.api.Test; + +import com.hfstudio.guidenh.guide.document.LytRect; +import com.hfstudio.guidenh.guide.document.block.LytBox; +import com.hfstudio.guidenh.guide.layout.LayoutContext; + +/** + * Contract tests for {@link PrimitiveCollector}: traversal order, culling, + * legacy fallback, scissor pairing and transform-stack symmetry. Pure Java: + * no GL, no Minecraft. + */ +class PrimitiveCollectorTest { + + /** + * Minimal block: fixed bounds, can hold children, configurable + * primitive/legacy path and children clip rect. + */ + private static class TestBlock extends LytBox { + + boolean usePrimitives = true; + LytRect childrenClip; + + TestBlock(int x, int y, int w, int h) { + this.bounds = new LytRect(x, y, w, h); + } + + @Override + public boolean usePrimitives() { + return usePrimitives; + } + + @Override + public void computePrimitives(PrimitiveCollector c) { + c.emit( + new GuideRenderPrimitive.FillRect(bounds.x(), bounds.y(), bounds.width(), bounds.height(), 0xFF112233)); + } + + @Override + public LytRect getChildrenClipRect() { + return childrenClip; + } + + @Override + public void emitDecorations(PrimitiveCollector c) { + c.emit( + new GuideRenderPrimitive.DrawBorder( + bounds.x(), + bounds.y(), + bounds.width(), + bounds.height(), + 1, + 1, + 1, + 1, + 0xFF445566)); + } + + @Override + protected LytRect computeBoxLayout(LayoutContext context, int x, int y, int availableWidth) { + return bounds; + } + + @Override + protected void onLayoutMoved(int deltaX, int deltaY) {} + } + + private static long count(List prims, Class type) { + return prims.stream() + .filter(type::isInstance) + .count(); + } + + private static int indexOf(List prims, Class type, int fromIndex) { + for (int i = fromIndex; i < prims.size(); i++) { + if (type.isInstance(prims.get(i))) return i; + } + return -1; + } + + @Test + void culledSubtreeEmitsNothing() { + var pc = new PrimitiveCollector(new LytRect(0, 0, 200, 200), null); + pc.pushTransform(0, -100, 1.0f); // scroll 100: doc y=500 -> screen y=400, outside + + TestBlock parent = new TestBlock(0, 500, 50, 50); + parent.append(new TestBlock(0, 510, 10, 10)); + pc.collectFrom(parent); + + var prims = pc.result(); + assertEquals(0, count(prims, GuideRenderPrimitive.FillRect.class), "culled subtree emits no draws"); + assertEquals(0, count(prims, GuideRenderPrimitive.HostDraw.class), "culled legacy subtree emits no HostDraw"); + assertEquals(0, count(prims, GuideRenderPrimitive.DrawBorder.class), "culled subtree emits no decorations"); + } + + @Test + void partiallyVisibleLegacyBlockEmitsOneHostDrawAndDoesNotRecurse() { + var pc = new PrimitiveCollector(new LytRect(0, 0, 200, 200), null); + pc.pushTransform(0, -100, 1.0f); + + TestBlock legacy = new TestBlock(0, 190, 50, 50); // screen y=90..140: visible + legacy.usePrimitives = false; + legacy.append(new TestBlock(0, 200, 10, 10)); // must NOT be visited + + pc.collectFrom(legacy); + + var prims = pc.result(); + assertEquals(1, count(prims, GuideRenderPrimitive.HostDraw.class), "legacy block must emit one HostDraw"); + assertEquals(0, count(prims, GuideRenderPrimitive.FillRect.class), "legacy subtree must not be recursed into"); + assertEquals(0, count(prims, GuideRenderPrimitive.DrawBorder.class), "legacy decorations must not be emitted"); + } + + @Test + void clipRectWrapsChildrenAndDecorationsStayOutside() { + var pc = new PrimitiveCollector(new LytRect(0, 0, 500, 500), null); + + TestBlock clipper = new TestBlock(0, 0, 100, 100); + clipper.childrenClip = new LytRect(0, 0, 50, 50); + clipper.append(new TestBlock(10, 10, 10, 10)); + + pc.collectFrom(clipper); + var prims = pc.result(); + + int ownFill = indexOf(prims, GuideRenderPrimitive.FillRect.class, 0); + int pushSc = indexOf(prims, GuideRenderPrimitive.PushScissor.class, 0); + int childFill = indexOf(prims, GuideRenderPrimitive.FillRect.class, ownFill + 1); + int popSc = indexOf(prims, GuideRenderPrimitive.PopScissor.class, 0); + int ownBorder = indexOf(prims, GuideRenderPrimitive.DrawBorder.class, popSc); + + assertTrue(ownFill >= 0 && ownFill < pushSc, "own primitives before PushScissor"); + assertTrue(pushSc >= 0 && pushSc < childFill, "PushScissor before child primitives"); + assertTrue(childFill > 0 && childFill < popSc, "child primitives before PopScissor"); + assertTrue(ownBorder > popSc, "decorations after PopScissor (outside clip)"); + } + + @Test + void transformPushPopStaysSymmetric() { + var pc = new PrimitiveCollector(new LytRect(0, 0, 100, 100), null); + pc.pushTransform(10, 20, 2.0f); + pc.pushTransform(5, 5, 1.5f); + pc.popTransform(); + pc.popTransform(); + pc.popTransform(); // no-op: root frame must be kept + pc.popTransform(); // no-op + + var prims = pc.result(); + assertEquals(2, count(prims, GuideRenderPrimitive.PushTransform.class)); + assertEquals( + 2, + count(prims, GuideRenderPrimitive.PopTransform.class), + "pops beyond the root frame must not be emitted"); + } + + @Test + void cullAccountsForTransformScale() { + // zoom 2 + scroll: doc y=90 -> screen y = 90*2 - 150 = 30 (visible); + // without the transform it would look outside the 200px viewport. + var pc = new PrimitiveCollector(new LytRect(0, 0, 200, 200), null); + pc.pushTransform(0, -150, 2.0f); + + pc.collectFrom(new TestBlock(0, 90, 10, 10)); + + assertFalse( + pc.result() + .isEmpty(), + "block visible under scale transform must be collected"); + } + + @Test + void overflowingChildKeepsCollapsedParentVisible() { + // Regression: a container whose own rect leaves the viewport must not take + // an overflowing child (e.g. a floated graph) with it. The collector culls + // by subtree-union bounds (LytBlock.getCullBounds). + var pc = new PrimitiveCollector(new LytRect(0, 0, 200, 200), null); + pc.pushTransform(0, -100, 1.0f); + + TestBlock parent = new TestBlock(0, 95, 50, 5); // own rect: screen y=-5..0, outside + TestBlock child = new TestBlock(0, 95, 50, 150); // overflows to screen y=145, visible + parent.append(child); + // LytDocument.computeCullBounds computes this union after each layout pass. + parent.setCullBounds(new LytRect(0, 95, 50, 150)); + + pc.collectFrom(parent); + + assertEquals( + 2, + count(pc.result(), GuideRenderPrimitive.FillRect.class), + "parent and overflowing child must both be collected"); + } +} diff --git a/src/test/java/com/hfstudio/guidenh/guide/style/token/ThemeTokenSystemSmokeTest.java b/src/test/java/com/hfstudio/guidenh/guide/style/token/ThemeTokenSystemSmokeTest.java new file mode 100644 index 00000000..60132486 --- /dev/null +++ b/src/test/java/com/hfstudio/guidenh/guide/style/token/ThemeTokenSystemSmokeTest.java @@ -0,0 +1,158 @@ +package com.hfstudio.guidenh.guide.style.token; + +import static org.junit.jupiter.api.Assertions.*; + +import org.junit.jupiter.api.Test; + +class ThemeTokenSystemSmokeTest { + + static final TokenKey BREAK_COLOR = TokenKey + .define("--thematic-break-color", TokenType.COLOR, new ColorValue(0xFF373737)); + static final TokenKey BREAK_HEIGHT = TokenKey + .define("--thematic-break-height", TokenType.DIMENSION, new DimensionValue(6, DimensionValue.DimUnit.PX)); + static final TokenKey ZOOM_STEP = TokenKey.define("--zoom-step", TokenType.FLOAT, new FloatValue(1.1f)); + static final TokenKey TAB_COUNT = TokenKey.define("--tab-count", TokenType.INT, new IntValue(3)); + + @Test + void testIdAssignment() { + assertTrue(BREAK_COLOR.id() >= 0); + assertTrue(BREAK_HEIGHT.id() > BREAK_COLOR.id(), "ids should be sequential"); + assertEquals("--thematic-break-color", BREAK_COLOR.name()); + } + + @Test + void testDefaultsOnBootstrap() { + GuideThemeManager.instance(); + Theme theme = GuideThemeManager.instance() + .active(); + + assertEquals( + 0xFF373737, + theme.color(BREAK_COLOR) + .argb()); + assertEquals( + 6f, + theme.dim(BREAK_HEIGHT) + .value()); + assertEquals( + 1.1f, + theme.flt(ZOOM_STEP) + .value(), + 0.001f); + assertEquals( + 3, + theme.int_(TAB_COUNT) + .value()); + } + + @Test + void testHotReloadFromCfgString() { + // Simulate a .cfg file with overrides + java.util.Map overrides = new java.util.LinkedHashMap<>(); + overrides.put("--thematic-break-color", "0xFFFF0000"); + overrides.put("--thematic-break-height", "12px"); + overrides.put("--zoom-step", "2.5"); + + java.util.List> keys = ThemeRegistry.snapshot(); + Theme custom = Theme.build("test", keys, overrides); + + // Overridden values + assertEquals( + 0xFFFF0000, + custom.color(BREAK_COLOR) + .argb()); + assertEquals( + 12f, + custom.dim(BREAK_HEIGHT) + .value()); + assertEquals( + 2.5f, + custom.flt(ZOOM_STEP) + .value(), + 0.001f); + + // Not overridden → default + assertEquals( + 3, + custom.int_(TAB_COUNT) + .value()); + } + + @Test + void testParseErrorThrows() { + // Invalid values should throw: parse functions reject garbage + assertThrows(Exception.class, () -> ColorValue.parse("garbage_value")); + assertThrows(Exception.class, () -> DimensionValue.parse("not_a_number")); + } + + @Test + void testNameLookup() { + // Build from the registry snapshot directly: the GuideThemeManager + // singleton snapshots the registry at first-init time, so depending on + // suite order it may have been initialized before this class's static + // TokenKeys were registered (flaky in full-suite runs). + Theme theme = Theme.build("test", ThemeRegistry.snapshot(), java.util.Collections.emptyMap()); + + ColorValue c = theme.get("--thematic-break-color", ColorValue.class); + assertNotNull(c); + assertEquals(0xFF373737, c.argb()); + } + + @Test + void testColorValueParsing() { + assertEquals( + 0xFFFFFFFF, + ColorValue.parse("0xFFFFFFFF") + .argb()); + assertEquals( + 0xFFFF0000, + ColorValue.parse("0xFFFF0000") + .argb()); + assertEquals( + 0xFF000000, + ColorValue.parse("0xFF000000") + .argb()); + assertEquals( + 0xFF373737, + ColorValue.parse("#373737") + .argb()); + assertEquals( + 0xFF00D5FF, + ColorValue.parse("0, 213, 255") + .argb()); + assertThrows(Exception.class, () -> ColorValue.parse("garbage")); + } + + @Test + void testDimensionValueParsing() { + assertEquals( + 6f, + DimensionValue.parse("6px") + .value()); + assertEquals( + DimensionValue.DimUnit.PX, + DimensionValue.parse("6px") + .unit()); + assertEquals( + 100f, + DimensionValue.parse("100%") + .value()); + assertEquals( + DimensionValue.DimUnit.PCT, + DimensionValue.parse("100%") + .unit()); + assertEquals( + 1.5f, + DimensionValue.parse("1.5em") + .value()); + assertThrows(Exception.class, () -> DimensionValue.parse("garbage")); + } + + @Test + void testTokenTypeParse() { + assertEquals(0xFF0000FF, ((ColorValue) TokenType.COLOR.parse("0xFF0000FF")).argb()); + assertEquals(6f, ((DimensionValue) TokenType.DIMENSION.parse("6px")).value()); + assertEquals(1.5f, ((FloatValue) TokenType.FLOAT.parse("1.5")).value()); + assertEquals(42, ((IntValue) TokenType.INT.parse("42")).value()); + } +} diff --git a/test/visual/README.md b/test/visual/README.md new file mode 100644 index 00000000..0662c0b0 --- /dev/null +++ b/test/visual/README.md @@ -0,0 +1,65 @@ +# GuideNH Visual Test System + +A fixture-driven visual regression system for the GuideNH guide engine. A curated corpus of guide +pages is rendered headlessly into screenshots and layout bounds, the results are inspected, and an +executable assertion ratchet locks the accepted geometry in place, so a later layout change cannot +silently move a page. + +## Contents + +| Path | Responsibility | +|---|---| +| `resourcepack/` | The fixture resource pack. Its pages are the render corpus, grouped by feature area: text, layout, floats, overflow, mermaid, latex, tables, charts, images, code, scenes, meta, nei, and stress. Pages live under `assets/guidenh/guidenh/_en_us/visualtest/`. | +| `ratchet/assertions.json` | The assertion ratchet. Each entry is keyed by page id and asserts class counts, node existence, or bounds read from the render bounds JSON. The file is append-only. | +| `tools/` | The Python toolset: the render watchdog, the bounds ratchet runner, the screenshot freshness check, the client log scanner, and optional image screening helpers. | + +The pages under `resourcepack/` double as the corpus and as examples of the guide format. Fixture +payloads are intentionally allowed to contain non-English text, including CJK, because glyph +coverage and mixed-script line breaking are part of what the corpus verifies. + +Detailed working documents that describe the internal visual loop, its ledgers, and its +adjudication history are maintained locally as research material and are not distributed with this +repository. + +## Quick Start + +1. Render the corpus headlessly. A render batch takes a list file with one page id per line, in the + form `guidenh:visualtest/.md`. Keep each batch to at most 40 pages, and always wrap it in + the render watchdog so a hung client cannot block the run: + +```powershell +py -3 test/visual/tools/render_watchdog.py --timeout 2400 --log -- cmd /c "gradlew.bat runClient25 -Dguidenh.guide.sources=/test/visual/resourcepack -Dguidenh.headlessRender=true -Dguidenh.renderpage.guide=guidenh:guidenh -Dguidenh.renderpage.list= -Dguidenh.renderpage.out= -Dguidenh.renderpage.width=900 -Dguidenh.renderpage.scale=2 -Dguidenh.renderpage.bounds=true" +``` + +2. Run the layout harness as a verification gate. It must report a total issue count of 0: + +```powershell +./gradlew compileJava compileTestJava test runLayoutDump +``` + +3. Check the rendered bounds against the ratchet. It must exit 0: + +```powershell +py -3 test/visual/tools/assert_bounds.py --shots run/client_new/ --assertions test/visual/ratchet/assertions.json +``` + +`runClient25` is used because the render driver runs on the JDK 25 toolchain. Screenshots, bounds +JSON, and overlay images are written under `run/client_new/`, and client logs under +`run/client_new/logs/`. `py -3` is the Windows launcher for Python 3; on other platforms use +`python3`. + +## Environment + +- Python 3. The bounds ratchet runner, the freshness check, the log scanner, and the watchdog use + the standard library only. +- Pillow, needed only by the screening subcommands of `tools/screen.py`. +- An API key for the screening helpers, copied from `tools/.env.example` to `tools/.env`. The + `tools/.env` file holds credentials and is never tracked. + +## Tracked And Untracked Material + +Tracked in this directory: the fixture resource pack, `ratchet/assertions.json`, the toolset under +`tools/`, and this file. + +Not tracked: render output and logs under `run/`, local scratch space under `test/visual/local/`, +screening credentials in `tools/.env`, and the local working documents mentioned above. diff --git a/test/visual/ratchet/assertions.json b/test/visual/ratchet/assertions.json new file mode 100644 index 00000000..2f285531 --- /dev/null +++ b/test/visual/ratchet/assertions.json @@ -0,0 +1,509 @@ +{ + "_readme": "append-only: add assertions for newly verified geometry, and adjust an existing assertion only when the geometry change is intentional and reviewed", + "pages": { + "visualtest_lists_tasks.md": [ + { + "count": { + "cls": "LytTaskListItem", + "eq": 18 + }, + "msg": "task list items render as dedicated class (A12/A16 ratchet)" + }, + { + "exists": { + "cls": "LytList" + }, + "msg": "task list has list container" + } + ], + "visualtest_lists_basic.md": [ + { + "count": { + "cls": "LytListItem", + "ge": 20 + }, + "msg": "basic list items count >= 20 (A12 ratchet)" + } + ], + "visualtest_latex_display.md": [ + { + "exists": { + "cls": "LytLatexDisplayBlock" + }, + "msg": "display LaTeX blocks exist (A6 ratchet)" + }, + { + "centered": { + "cls": "LytLatexDisplayBlock", + "tol": 3 + }, + "msg": "display LaTeX blocks centered in parent within 3px (A6 ratchet)" + } + ], + "visualtest_mermaid_flowchart.md": [ + { + "exists": { + "cls": "LytMermaidFlowchartCanvas" + }, + "msg": "mermaid flowchart canvas exists (A1 ratchet)" + } + ], + "visualtest_mermaid_mindmap.md": [ + { + "exists": { + "cls": "LytMermaidMindmapCanvas" + }, + "msg": "mermaid mindmap canvas exists (A1 ratchet)" + } + ], + "visualtest_mermaid_subgraphs.md": [ + { + "exists": { + "cls": "LytMermaidFlowchartCanvas" + }, + "msg": "mermaid subgraphs flowchart canvas exists (A1 ratchet)" + } + ], + "visualtest_mermaid_large.md": [ + { + "max_height": { + "cls": "LytMermaidFlowchartCanvas", + "le": 400 + }, + "msg": "large flowchart canvas height capped (A1 no-infinite-stretch ratchet)" + }, + { + "max_height": { + "cls": "LytMermaidMindmapCanvas", + "le": 400 + }, + "msg": "large mindmap canvas height capped (A1 no-infinite-stretch ratchet)" + } + ], + "visualtest_mermaid_wrap-long-labels.md": [ + { + "exists": { + "cls": "LytMermaidFlowchartCanvas" + }, + "msg": "mermaid wrap long-label canvas exists (T5 wrap-to-node-box ratchet)" + }, + { + "max_height": { + "cls": "LytMermaidFlowchartCanvas", + "le": 400 + }, + "msg": "wrapped-label canvas height capped (T5 no-infinite-stretch ratchet)" + } + ], + "visualtest_code_blocks.md": [ + { + "width_ratio": { + "cls": "LytCodeBlock", + "of": "parent", + "ge": 0.95 + }, + "msg": "code blocks fill parent width (K4/A11 ratchet)" + } + ], + "visualtest_images_fullwidth.md": [ + { + "width_ratio": { + "cls": "LytVBox", + "of": "parent", + "ge": 0.95 + }, + "msg": "full-width image boxes fill parent width (A11 ratchet)" + } + ], + "visualtest_tables_metadata.md": [ + { + "exists": { + "cls": "LytTable" + }, + "msg": "metadata table renders (A14 ratchet)" + }, + { + "count": { + "cls": "LytTableCell", + "ge": 6 + }, + "msg": "R4-4: metadata tables have cells (column widths respected)" + } + ], + "visualtest_tables_csv.md": [ + { + "count": { + "cls": "LytTableCell", + "ge": 10 + }, + "msg": "R4-4: CSV tables have cells (column widths respected)" + } + ], + "visualtest_code_special-langs.md": [ + { + "count": { + "cls": "LytItemImage", + "ge": 3 + }, + "msg": "filetree {:iconItem=} icons exist (R2-3 ratchet)" + }, + { + "attr": { + "cls": "LytItemImage", + "field": "w", + "gt": 0 + }, + "msg": "filetree item icons reserve real width (R2-3 ratchet)" + }, + { + "attr": { + "cls": "LytItemImage", + "field": "h", + "gt": 0 + }, + "msg": "filetree item icons reserve real height (R2-3 ratchet)" + }, + { + "count": { + "cls": "LytTableCell", + "ge": 4 + }, + "msg": "R4-4: special-langs csv has cells (column widths respected)" + } + ], + "visualtest_nei_recipes.md": [ + { + "count": { + "cls": "LytNeiRecipeBox", + "ge": 6 + }, + "msg": "NEI recipe boxes count >= 6 (A10 ratchet)" + } + ], + "visualtest_latex_inline.md": [ + { + "exists": { + "cls": "LytLatexBlock" + }, + "msg": "inline LaTeX blocks exist (A7/A8a ratchet)" + }, + { + "attr": { + "cls": "LytLatexBlock", + "field": "h", + "ge": 12 + }, + "msg": "inline formula heights stay >= 12px (P1b2 true-inset fix: measured min 12px, was 6-13px pre-fix)" + } + ], + "visualtest_text_inline-game-tags.md": [ + { + "count": { + "cls": "LytItemImage", + "ge": 8 + }, + "msg": "inline item images exist (R2-2 ratchet)" + }, + { + "attr": { + "cls": "LytItemImage", + "field": "w", + "gt": 0 + }, + "msg": "every LytItemImage reserves real width, w>0 (R2-2/R2-3 ratchet, was 0 before fix)" + }, + { + "attr": { + "cls": "LytItemImage", + "field": "h", + "gt": 0 + }, + "msg": "every LytItemImage reserves real height, h>0 (R2-2/R2-3 ratchet, was 0 before fix)" + }, + { + "count": { + "cls": "LytItemImage", + "ge": 15 + }, + "msg": "inline-label ItemImage section stays in the ratchet corpus (review note: 7 inline label icons, label baseline anchored to body baseline via align=1 baseline ascent; mechanism gap: assert_bounds cannot express cross-node baseline relations, so this page is locked in by count only)" + } + ], + "visualtest_layout_details.md": [ + { + "inside_parent": { + "cls": "LytViewportBox", + "pad": 6 + }, + "msg": "A2: details content viewport stays inside details block (N-A1 7px right-overflow regression guard, was +7px before fix)" + } + ], + "visualtest_layout_callouts.md": [ + { + "inside_parent": { + "cls": "LytAlertBox", + "pad": 6 + }, + "msg": "A2: callout (alert) box stays inside its parent block (F8 callout overflow guard)" + } + ], + "visualtest_layout_content-tabs.md": [ + { + "inside_parent": { + "cls": "LytContentTabsBlock", + "pad": 6 + }, + "msg": "A2: tab content block stays inside its parent (tab container overflow guard)" + } + ], + "visualtest_layout_row-column.md": [ + { + "inside_parent": { + "cls": "LytHBox", + "pad": 6 + }, + "msg": "A2: row layout box stays inside its parent (row/column container overflow guard)" + } + ], + "visualtest_text_decorations.md": [ + { + "exists": { + "cls": "LytParagraph" + }, + "msg": "decorations fixture locked into corpus (T1 underline/wavy/dots/strike ratchet)" + } + ], + "visualtest_latex_inline-xheight.md": [ + { + "exists": { + "cls": "LytLatexBlock" + }, + "msg": "inline LaTeX blocks exist on x-height calibration page (T4 x_height ratchet)" + }, + { + "attr": { + "cls": "LytLatexBlock", + "field": "h", + "ge": 8 + }, + "msg": "x-height calibration page formula heights stay >= 8px (P1b2 true-inset fix: measured min 8px post-fix, was 6-12px pre-fix)" + } + ], + "visualtest_layout_baseline-align.md": [ + { + "exists": { + "cls": "LytHBox" + }, + "msg": "baseline-align fixture locked into corpus to lock the true-baseline placement" + }, + { + "attr": { + "cls": "LytParagraph", + "field": "y", + "ge": 111 + }, + "msg": "EVERY LytParagraph on the page must satisfy y>=111 (attr = every-node universal). The baseline row's text children sit at y=114 post-layout (measured; first-line baselines 125.9, within tolerance of the BlockImage bottom edge 126, collinear within divided-by-scale plus truncation precision); all Expected/body paragraphs are further down. y>=111 proves the text was shifted DOWN onto the image baseline by the post-layout: pre-fix bottom-degradation leaves text at y=109, center around 85, start around 67, all FAIL. NOTE: adding any LytParagraph above y=111 (e.g. page-top intro) legitimately FAILS this rule, so recalibrate then. EXEMPTION protocol: second-pass geometry intentionally deviates from the raw taffy writeback for this BASELINE container (taffy 0.12.1 first_baselines always NONE); non-BASELINE containers are byte-identical. Tolerance 3px per measured evidence (bug margin 5px)." + } + ], + "visualtest_images_single-param.md": [ + { + "exists": { + "cls": "LytImage" + }, + "msg": "F-N1: single-param FloatingImage renders as LytImage (no compile-time red error)" + }, + { + "attr": { + "cls": "LytImage", + "field": "w", + "ge": 32 + }, + "msg": "F-N1: width-only(128) and height-only(inferred 32) images both reserve width >= 32px" + }, + { + "attr": { + "cls": "LytImage", + "field": "h", + "ge": 32 + }, + "msg": "F-N1: width-only(inferred 32) and height-only(128) images both reserve height >= 32px" + } + ], + "visualtest_tags_block-stats.md": [ + { + "exists": { + "cls": "LytGuidebookScene" + }, + "msg": "P0: BlockStats scenes render as LytGuidebookScene blocks (no compile error)" + }, + { + "attr": { + "cls": "LytGuidebookScene", + "field": "w", + "ge": 200 + }, + "msg": "P0: BlockStats scenes reserve real width >= 200px" + } + ], + "visualtest_tags_recipes-usage.md": [ + { + "count": { + "cls": "LytNeiRecipeBox", + "ge": 3 + }, + "msg": "P0: RecipesUsage usage-query renders >= 3 LytNeiRecipeBox recipe boxes" + }, + { + "attr": { + "cls": "LytNeiRecipeBox", + "field": "w", + "ge": 100 + }, + "msg": "P0: RecipesUsage recipe boxes reserve real width >= 100px" + } + ], + "visualtest_tags_quest-link.md": [ + { + "count": { + "cls": "LytParagraph", + "ge": 8 + }, + "msg": "P0: QuestLink page renders >= 8 paragraphs (quest links + prose, no compile error)" + } + ], + "visualtest_mdx_file-tree.md": [ + { + "exists": { + "cls": "LytFileTree" + }, + "msg": "P0: MDX renders as LytFileTree block" + }, + { + "count": { + "cls": "LytItemImage", + "ge": 2 + }, + "msg": "P0: MDX FileTree {:iconItem=} rows render item icons" + }, + { + "attr": { + "cls": "LytItemImage", + "field": "w", + "gt": 0 + }, + "msg": "P0: MDX FileTree item icons reserve real width (R2-3 family ratchet)" + } + ], + "visualtest_text_mark.md": [ + { + "count": { + "cls": "LytParagraph", + "ge": 12 + }, + "msg": "P0: page renders its paragraphs (mark highlight spans in flow)" + }, + { + "exists": { + "cls": "LytListItem" + }, + "msg": "P0: inside list items renders (list + highlight composition locked)" + } + ], + "visualtest_text_item-link-dense.md": [ + { + "count": { + "cls": "LytItemImage", + "ge": 20 + }, + "msg": "P1: dense ItemLink page renders >= 20 item icons (827-usage density ratchet)" + }, + { + "attr": { + "cls": "LytItemImage", + "field": "w", + "gt": 0 + }, + "msg": "P1: every dense ItemLink icon reserves real width (R2-2 family ratchet)" + } + ], + "visualtest_text_color-named.md": [ + { + "count": { + "cls": "LytParagraph", + "ge": 12 + }, + "msg": "P1: named page renders its 16-colour rows" + } + ], + "visualtest_layout_callout-combos.md": [ + { + "count": { + "cls": "LytAlertBox", + "ge": 5 + }, + "msg": "P1: multiple alert types render as LytAlertBox on one page (info/warn/danger combo)" + }, + { + "exists": { + "cls": "LytVBox" + }, + "msg": "P1: custom quotes / plain blockquotes render as quote boxes on the combos page" + } + ], + "visualtest_text_error-fallback.md": [ + { + "attr": { + "cls": "LytParagraph", + "field": "w", + "ge": 200 + }, + "msg": "F-N2: error fallback paragraphs reserve real width >= 200px (no fallback-width collapse)" + }, + { + "count": { + "cls": "LytParagraph", + "ge": 12 + }, + "msg": "F-N2: error-fallback page renders all error + trailing-prose paragraphs" + } + ], + "settings": [ + { + "count": { + "cls": "TextButton", + "ge": 6 + }, + "msg": "P4-3b: settings route renders 6 TextButton stepper buttons (3 rows x 2 = font-size, body font-family, code font-family; bounds cls = getSimpleName of the TextButton subclass; a missing family row would drop it to 4)" + }, + { + "attr": { + "cls": "TextButton", + "field": "w", + "eq": 18 + }, + "msg": "P4-4: every stepper button keeps STEP_BUTTON_WIDTH = 18px" + }, + { + "attr": { + "cls": "TextButton", + "field": "h", + "eq": 16 + }, + "msg": "P4-4: every stepper button keeps STEP_BUTTON_HEIGHT = 16px" + }, + { + "count": { + "cls": "LytParagraph", + "ge": 7 + }, + "msg": "P4-3b: settings route renders sidebar category (1) + 3 rows x (label + value) = 7 LytParagraphs; a missing family row would drop it to 5" + }, + { + "count": { + "cls": "LytHBox", + "ge": 4 + }, + "msg": "P4-3b: settings route renders root HBox + one LytHBox per setting row = 4 (rows present ⇒ 4, one missing ⇒ 3; bottom divider is a border, not a block in bounds JSON)" + } + ] + } +} diff --git a/test/visual/resourcepack/assets/guidenh/guidenh/_en_us/visualtest/assets/checker-128.png b/test/visual/resourcepack/assets/guidenh/guidenh/_en_us/visualtest/assets/checker-128.png new file mode 100644 index 0000000000000000000000000000000000000000..41ffbb75eb7a390651ef5ffa4bdd917b56f54b2f GIT binary patch literal 487 zcmeAS@N?(olHy`uVBq!ia0vp^4Is?H1|$#LC7xzrU|i?v;uumf=k2Y7oD7NrEC+j! z{Ml^N{9&U^Y}5Oo7thapuQ+GvKYjn}^WT_%?B2)lh)DGB&tK+0+m#!3=o5|d1%$q{ z3Xp(!orrY})X|O}cf=h?K;|5sznMBnKx~SI@eCxO`wA-WxD`l1@t*uEzaJ=V{tt{m N22WQ%mvv4FO#uHe&T;?% literal 0 HcmV?d00001 diff --git a/test/visual/resourcepack/assets/guidenh/guidenh/_en_us/visualtest/assets/red-64.png b/test/visual/resourcepack/assets/guidenh/guidenh/_en_us/visualtest/assets/red-64.png new file mode 100644 index 0000000000000000000000000000000000000000..24a01e2c8cacf6135affa721e6e97faf321564ed GIT binary patch literal 208 zcmeAS@N?(olHy`uVBq!ia0vp^4j|0I1|(Ny7TyC=(>+}rLn`LHy zwiS$$_7fg{k9qdy-uzy^3sMW1x3IlnSj%YTkljEhZnfK+jsJsKYBPaOWbkzLb6Mw< G&;$T}`9}r- literal 0 HcmV?d00001 diff --git a/test/visual/resourcepack/assets/guidenh/guidenh/_en_us/visualtest/assets/tall-64x256.png b/test/visual/resourcepack/assets/guidenh/guidenh/_en_us/visualtest/assets/tall-64x256.png new file mode 100644 index 0000000000000000000000000000000000000000..7420826e6130d5b6a303608904fa155fe2943bd3 GIT binary patch literal 382 zcmeAS@N?(olHy`uVBq!ia0vp^4h#&83><7g7PDIckYaT8ba4!+nDh36AtQqV2ZMo= zL{mqb0ZU;i+eT)t>XoGI7*{pvdg+U;VX6PI9<$WIg?XoI7 PAm4ks`njxgN@xNAwD?P1 literal 0 HcmV?d00001 diff --git a/test/visual/resourcepack/assets/guidenh/guidenh/_en_us/visualtest/charts/bar-column.md b/test/visual/resourcepack/assets/guidenh/guidenh/_en_us/visualtest/charts/bar-column.md new file mode 100644 index 00000000..8d8e6c31 --- /dev/null +++ b/test/visual/resourcepack/assets/guidenh/guidenh/_en_us/visualtest/charts/bar-column.md @@ -0,0 +1,43 @@ +--- +navigation: + title: Bar and Column Charts + position: 8260 +--- + +TEST GOAL: BarChart + ColumnChart basics, Series data/color/icon, PieInset embedded pie chart + +INVARIANTS: bar/column charts render correctly, the legend does not overflow, the embedded pie chart does not obscure the main chart + +## BarChart with Series data/color/icon + +Expected: Horizontal bars for three mod categories; each bar colored per `Series` color with an item icon in the legend. + + + + + + + +## ColumnChart with Series data/color + +Expected: Clustered columns across four categories; each series has distinct color; y-axis auto-scales to data range. + + + + + + + +## ColumnChart with PieInset + +Expected: Column chart with a `LineSeries` overlay and a `PieInset` positioned on the right side; the pie chart shows total share of the two `Series`. + + + + + + + + + + diff --git a/test/visual/resourcepack/assets/guidenh/guidenh/_en_us/visualtest/charts/function.md b/test/visual/resourcepack/assets/guidenh/guidenh/_en_us/visualtest/charts/function.md new file mode 100644 index 00000000..36fded57 --- /dev/null +++ b/test/visual/resourcepack/assets/guidenh/guidenh/_en_us/visualtest/charts/function.md @@ -0,0 +1,48 @@ +--- +navigation: + title: Function Graphs + position: 8230 +--- + +TEST GOAL: FunctionGraph + Plot + Point, Function shorthand, xRange/quadrants + +INVARIANTS: the curve is smooth and continuous, markers are positioned correctly, the quadrant restriction takes effect, labels do not overlap + +## FunctionGraph with Plots and Points + +Expected: Three curves (sin, parabola, absolute value) plotted on all quadrants; explicit `Point` at origin and a plot-anchored `Point` at `atX=1.5708` on plot index 0. + + + + + + + + + +## Function Shorthand + +Expected: Single-curve graph rendered from the `` short tag with expression, range, and color. + + + +## Quadrant Restriction + +Expected: Only quadrants 1 and 4 visible; the curve is clipped to the upper-right and lower-right regions. + + + + + +## Fenced FunctionGraph Block + +Expected: Function graph rendered from a `funcgraph` fenced code block with same style as the container version. + +```funcgraph +width=360 height=220 xRange=-pi..pi yRange=-2..2 quadrants=all cornerLegend=topRight +sin(x) | color=#ff5566 label="sin" pointEveryX=1 autoPointLabel=x +cos(x) | color=#3399ff label="cos" pointEveryY=1 +x/2 | color=#88cc77 domain=-pi..pi +:0,0 +@plot=0 atX=1.5708 +``` diff --git a/test/visual/resourcepack/assets/guidenh/guidenh/_en_us/visualtest/charts/line-scatter.md b/test/visual/resourcepack/assets/guidenh/guidenh/_en_us/visualtest/charts/line-scatter.md new file mode 100644 index 00000000..7ad4f8b9 --- /dev/null +++ b/test/visual/resourcepack/assets/guidenh/guidenh/_en_us/visualtest/charts/line-scatter.md @@ -0,0 +1,44 @@ +--- +navigation: + title: Line and Scatter Charts + position: 8250 +--- + +TEST GOAL: LineChart with numericX + ScatterChart + LineSeries + +INVARIANTS: the polyline is continuous, scatter points do not overlap text, the combined-chart LineSeries overlay is correct + +## LineChart with Categorical X + +Expected: Lines across five day categories; corner legend in top-right corner; hover tooltip shown. + + + + + + +## LineChart with Numeric X + +Expected: Line chart with `numericX={true}`; X axis labelled "Distance (m)", Y axis labelled "Strength (dB)"; series uses `points` string. + + + + + +## ScatterChart + +Expected: Scatter plot showing two sample groups with distinct colors; corner legend at bottom-right. + + + + + + +## ColumnChart with LineSeries Overlay + +Expected: Column chart with a `LineSeries` overlay sharing the same value axis; bars and line both visible. + + + + + diff --git a/test/visual/resourcepack/assets/guidenh/guidenh/_en_us/visualtest/charts/options.md b/test/visual/resourcepack/assets/guidenh/guidenh/_en_us/visualtest/charts/options.md new file mode 100644 index 00000000..877ddcbf --- /dev/null +++ b/test/visual/resourcepack/assets/guidenh/guidenh/_en_us/visualtest/charts/options.md @@ -0,0 +1,82 @@ +--- +navigation: + title: Chart Options (Legend, Axes, Grid) + position: 8220 +--- + +TEST GOAL: all five legend values, cornerLegend, axis label/min/max/step/unit/tickFormat, grid toggle and color + +INVARIANTS: legend position renders per attribute, axis range/step is correct, grid visibility and color are distinguishable + +## Legend Positions + +Expected: Five `ColumnChart` blocks each showing legend at `none`, `top`, `bottom`, `left`, `right`. + + + + + + + + + + + + + + + + + + + + + + + + + + +## Corner Legend + +Expected: In-plot compact legend at `topRight`, `topLeft`, `bottomRight`, `bottomLeft` positions in a `LineChart`. + + + + + + + + + + + + + + + + + + + + + +## Axis Labels, Min, Max, Step, Unit, TickFormat + +Expected: Bar chart with explicit axis range (`yAxisMin`/`yAxisMax`), step (`yAxisStep`), unit label (`yAxisUnit`), and tick format (`yAxisTickFormat`); X and Y axis labels visible. + + + + + +## Grid Toggle and Colors + +Expected: Chart with both grids disabled (`showXGrid={false}` `showYGrid={false}`); chart with custom grid colors (`xGridColor="#ff0000"` `yGridColor="#00ff00"`). + + + + + + + + diff --git a/test/visual/resourcepack/assets/guidenh/guidenh/_en_us/visualtest/charts/pie.md b/test/visual/resourcepack/assets/guidenh/guidenh/_en_us/visualtest/charts/pie.md new file mode 100644 index 00000000..4308afd5 --- /dev/null +++ b/test/visual/resourcepack/assets/guidenh/guidenh/_en_us/visualtest/charts/pie.md @@ -0,0 +1,53 @@ +--- +navigation: + title: Pie Charts + position: 8240 +--- + +TEST GOAL: PieChart + Slice (startAngle/clockwise/labelPosition) + +INVARIANTS: pie slice proportions are correct, labels do not overlap, direction/start angle render per attribute + +## Default PieChart + +Expected: Five slices with automatic color palette; labels positioned outside; legend on the right side. + + + + + + + + + +## Custom Start Angle + +Expected: Pie chart starting from 45 degrees (1:30 position) instead of default -90 degrees (12 o'clock). + + + + + + + +## Counterclockwise Direction + +Expected: Slices arranged counterclockwise (`clockwise={false}`) instead of the default clockwise order. + + + + + + + + +## Label Position Inside + +Expected: Slice labels rendered inside the pie segments; no labels outside the pie boundary. + + + + + + + diff --git a/test/visual/resourcepack/assets/guidenh/guidenh/_en_us/visualtest/code/blocks.md b/test/visual/resourcepack/assets/guidenh/guidenh/_en_us/visualtest/code/blocks.md new file mode 100644 index 00000000..e6ad9476 --- /dev/null +++ b/test/visual/resourcepack/assets/guidenh/guidenh/_en_us/visualtest/code/blocks.md @@ -0,0 +1,151 @@ +--- +navigation: + title: Code Block Variants + position: 8700 +--- + +TEST GOAL: every code block variant — multiple languages + long lines + blank lines + single line + special characters + fixed width/height viewport + indented code block + +INVARIANTS: the long-line strategy matches the spec; the background frame wraps the full line; a fixed viewport shows a scrollbar rather than page overflow + +## Multi-Language: XML + +Expected: XML syntax highlighting; angle brackets and tag names colored correctly. + +```xml + + 4.0.0 + com.example + demo + 1.0.0 + +``` + +## Multi-Language: Java + +Expected: Java syntax highlighting; keywords, strings, and annotations colored. + +```java +@EventHandler +public void onPlayerJoin(PlayerJoinEvent event) { + Player player = event.getPlayer(); + player.sendMessage("Welcome to the server!"); + player.getInventory().addItem(new ItemStack(Material.DIAMOND, 64)); +} +``` + +## Multi-Language: JSON + +Expected: JSON syntax highlighting; keys, strings, numbers, and braces colored. + +```json +{ + "name": "GuideNH", + "version": "1.0.0", + "mcVersion": "1.7.10", + "dependencies": { + "GTNH": ">=2.0.0", + "CodeChickenLib": "1.1.3" + } +} +``` + +## Multi-Language: Python + +Expected: Python syntax highlighting; def, print, and string literals colored. + +```python +def fibonacci(n): + if n <= 1: + return n + return fibonacci(n - 1) + fibonacci(n - 2) + +for i in range(10): + print(f"fib({i}) = {fibonacci(i)}") +``` + +## No Language (Plain Text) + +Expected: No syntax highlighting applied; rendered as plain monospace text without language label. + +```text +This code block has no explicit language fence. +The engine should render it as plain text. +No highlighting tokens are expected. +``` + +## 80+ Column Long Line + +Expected: Long single line does not overflow the page right margin; engine applies horizontal scroll or line-wrapping per its configured long-line strategy; the background box wraps around the entire line. + +``` +ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789!@#$%^&*()_+-=[]{}|;':\",./<>?`~ ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789 +``` + +## Empty Lines in Code Block + +Expected: Multiple consecutive empty lines preserved within the code block; line numbering (if present) remains consistent; no collapsed blank lines. + +```java +public class EmptyLines { + + public void methodA() { + System.out.println("line 1"); + } + + + public void methodB() { + System.out.println("line 5 — two blank lines above"); + } + +} +``` + +## Single-Line Code Block + +Expected: A code block with exactly one line of content renders with single-line height; toolbar with language label visible; no extraneous vertical padding. + +```json +{ "key": "value", "enabled": true } +``` + +## Special Characters <>&"§ + +Expected: Angle brackets, ampersand, double quotes, and section sign render literally as code content; no HTML/XML entity interpretation inside the fence. + +```xml + + content with "quotes" & amps §ect + +``` + +## Fixed Viewport width=180 height=80 (Overflow Sentinel) + +Expected: Code block renders with a fixed body viewport (180px wide, 80px tall); content exceeding the viewport shows a vertical scrollbar; the page itself does not grow in height; this is the overflow scroll container sentinel. + +```java width=180 height=80 +public class ScrollTest { + public static void main(String[] args) { + System.out.println("line 1"); + System.out.println("line 2"); + System.out.println("line 3"); + System.out.println("line 4"); + System.out.println("line 5"); + System.out.println("line 6"); + System.out.println("line 7"); + System.out.println("line 8"); + System.out.println("line 9"); + System.out.println("line 10"); + } +} +``` + +## Indented Code Block (4 spaces) + +Expected: Leading four-space indentation causes the content to be rendered as a code block (no language label, no toolbar, plain monospace). + + print("indented code block — no toolbar/language label") + for i in range(5): + print(f"line {i}") + print("done") diff --git a/test/visual/resourcepack/assets/guidenh/guidenh/_en_us/visualtest/code/special-langs.md b/test/visual/resourcepack/assets/guidenh/guidenh/_en_us/visualtest/code/special-langs.md new file mode 100644 index 00000000..2a29f0d8 --- /dev/null +++ b/test/visual/resourcepack/assets/guidenh/guidenh/_en_us/visualtest/code/special-langs.md @@ -0,0 +1,87 @@ +--- +navigation: + title: Special Language Renderers + position: 8690 +--- + +TEST GOAL: tree/filetree (including the {:icon=} suffix) + csv (compare tables/csv.md) render as special-lang blocks rather than plain-text code blocks + +INVARIANTS: tree/filetree render as a directory tree rather than plain text; csv renders as a runtime table rather than plain text + +## Tree (Unicode Box-Drawing) + +Expected: Root rendered at top with children indented below; Unicode box-drawing characters (│ ├ └ ─) produce real connector lines; leaf text supports inline markdown; no toolbar/language label shown. + +```tree +project +├── src +│ ├── main +│ │ ├── java +│ │ │ └── [App.java](./index.md) +│ │ └── resources +│ │ └── config.xml +│ └── test +│ └── java +│ └── AppTest.java +├── README.md +└── build.gradle +``` + +## FileTree with {:icon=} Text Icons + +Expected: ASCII-style (| +-- \--) connectors render as real tree lines; `{:icon=}` suffix renders as short text label beside the row; icons do not break the alignment of sibling entries. + +```filetree +world +|-- {:icon=Oak} oak forest +| |-- {:icon=Tree} tall oak +| \-- {:icon=Hill} rolling hills +|-- {:icon=Des} desert oasis +\-- {:icon=Cave} deep cave +``` + +## FileTree with {:iconItem=} and {:iconPng=} + +Expected: `{:iconItem=}` renders as a small item icon; `{:iconPng=}` renders as a small image icon; both icons appear before the row text; fallback text displayed if asset not found. + +```filetree +inventory +|-- {:iconItem=minecraft:grass} grass block +|-- {:iconItem=minecraft:wool:14} red wool +|-- {:iconPng=guidenh:textures/gui/logo.png} sample asset +\-- {:iconItem=minecraft:diamond} diamond +``` + +## CSV Basic Table + +Expected: Fenced `csv` block renders as a runtime table with header row highlighted; first row treated as header by default; cells separated by commas; no pure-text code block fallback. + +```csv +name,value,count +iron_ingot,42,128 +gold_ingot,17,64 +diamond,9,32 +emerald,3,16 +``` + +## CSV with widths=120,80 + +Expected: Column widths applied via `widths=` hint as space-separated pixel values; header row present; table layout respects specified column widths. + +```csv widths=120,80 +item,amount +Iron Ingot,42 +Gold Ingot,17 +Diamond,9 +``` + +## CSV with widths="120,80" header=false + +Expected: `header=false` suppresses header row styling; all rows rendered as data rows; column widths applied; quoted widths value correctly parsed. + +```csv widths="120,80" header=false +Iron Ingot,42 +Gold Ingot,17 +Diamond,9 +Emerald,3 +``` diff --git a/test/visual/resourcepack/assets/guidenh/guidenh/_en_us/visualtest/floats/content-types.md b/test/visual/resourcepack/assets/guidenh/guidenh/_en_us/visualtest/floats/content-types.md new file mode 100644 index 00000000..4acbded6 --- /dev/null +++ b/test/visual/resourcepack/assets/guidenh/guidenh/_en_us/visualtest/floats/content-types.md @@ -0,0 +1,83 @@ +--- +navigation: + title: Floating Content Types + position: 8390 +--- + +TEST GOAL: any block can float — tables, code blocks, GameScene, Recipe, charts and Column containers each shown as a float + +INVARIANTS: the available-width reduction for non-paragraph blocks (the LytFloatAwareBlock path) is correct; content does not overlap the float + +## Floating Table (CsvTable) + +Expected: `CsvTable` floats left with `wrap="square"`; text wraps to the right of the table. + + + +This paragraph flows to the right of the CSV table. The table registers as a left-side document float and subsequent paragraphs shrink their available width to avoid it, just like a CSS `float: left`. Multiple lines of text here demonstrate the wrapping effect around the floated table. + +
          + +## Floating Code Block + +Expected: Fenced code block floats left with `wrap="square"` applied on the fence line; text wraps to the right. + +```java wrap=square align=left +public class FloatTest { + public static void main(String[] args) { + System.out.println("floating code block"); + } +} +``` + +This paragraph flows to the right of the floated code block. The code block registers as a left-side document float and the text fills the remaining width. The code block background and syntax highlighting should render correctly within the float region. + +
          + +## Floating GameScene + +Expected: `GameScene` floats left with `wrap="square"`; text wraps to the right of the scene viewport. + + + + + +This paragraph flows to the right of the scene viewport. The `wrap="square" align="left"` combination registers the scene as a left-side document float. All subsequent paragraphs narrow their available width until the float clears. This demonstrates that block-level 3D scenes participate in the float system. + +
          + +## Floating Recipe + +Expected: `Recipe` floats left with `wrap="square"`; text wraps to the right. NEI may be unavailable in test environment — rendered fallback text is acceptable. + + + +This paragraph flows to the right of the recipe box. The recipe registers as a left-side document float. In a real GTNH environment the recipe grid renders; in headless mode the fallback text is shown. The float behavior (text wrapping) should work identically in both cases. + +
          + +## Floating Chart (BarChart) + +Expected: `BarChart` floats left with `wrap="square"`; text wraps to the right of the chart. + + + + + +This paragraph flows to the right of the bar chart. The chart registers as a left-side document float. Text fills the available space beside the chart, demonstrating that chart components also participate in the floating layout system. + +
          + +## Floating Column Container + +Expected: `Column` with `wrap="square"` floats left; text wraps to the right of the column. + + + +**Floated Column** + +This column contains structured content and floats left like any other block-level tag. + + + +This paragraph flows to the right of the column container. The `` block carrying `wrap="square" align="left"` floats left and subsequent paragraph text fills the space beside it. This demonstrates that flex containers also participate in the float system via the `LytFloatAwareBlock` path. diff --git a/test/visual/resourcepack/assets/guidenh/guidenh/_en_us/visualtest/floats/in-tabs.md b/test/visual/resourcepack/assets/guidenh/guidenh/_en_us/visualtest/floats/in-tabs.md new file mode 100644 index 00000000..d5d1953e --- /dev/null +++ b/test/visual/resourcepack/assets/guidenh/guidenh/_en_us/visualtest/floats/in-tabs.md @@ -0,0 +1,78 @@ +--- +navigation: + title: Floats in ContentTabs + position: 8370 +--- + +TEST GOAL: floats inside ContentTabs + floats before and after a tabs block — floats inside a tab do not leak; the tabs block is not compressed by external floats + +INVARIANTS: floats inside a tab do not leak outside it; the tabs block itself (the fullWidth LytFloatAwareBlock path) is not abnormally compressed by external floats + +## Floating Image Inside Tab + +Expected: A `ContentTabs` with three tabs. The second tab contains a floating image with text wrapping. The float is confined within the tab content area and does not leak outside. + + + +This tab contains only text. No float is present here. + + + + +This text wraps around the floating image inside the tab. The float must be contained within the tab content bounds — it must not leak into adjacent tabs or outside the `ContentTabs` container. When switching to other tabs the float should not be visible. + + +This tab has no float content — verifying that tab switching works correctly. + + + +## GameScene Inside Tab + +Expected: A `ContentTabs` where a tab contains a floating `GameScene`. The scene float is contained within the tab area. + + + +Regular paragraph text in the first tab. + + + + + + +This text wraps to the left of the floating scene inside the tab. The scene is registered as a right-side document float confined to the tab content area. Switching tabs must not show this scene outside its tab. + + + +## External Float Before ContentTabs + +Expected: A left float appears above the `ContentTabs` block. The `ContentTabs` block itself is not compressed by the external float — it renders at full available width. + + + +This paragraph wraps around the external float above the tabs. The float precedes the `ContentTabs` block. + + + +The `ContentTabs` block appears below the external float. The tabs container should not be horizontally compressed by the active left float — it should render at its normal full width and push past the float vertical extent. + + +Additional tab content to verify tab switching still works correctly after an external float. + + + +## External Float After ContentTabs + +Expected: A right float appears below the `ContentTabs` block. Text after the tabs wraps around the float normally. + + + +Content inside the tabs before the external float. + + +More content inside the tabs. + + + + + +This paragraph wraps to the left of the external float that follows the `ContentTabs` block. The float appears after the tabs and should not affect the internal layout of the tabs themselves. diff --git a/test/visual/resourcepack/assets/guidenh/guidenh/_en_us/visualtest/floats/multi.md b/test/visual/resourcepack/assets/guidenh/guidenh/_en_us/visualtest/floats/multi.md new file mode 100644 index 00000000..08438904 --- /dev/null +++ b/test/visual/resourcepack/assets/guidenh/guidenh/_en_us/visualtest/floats/multi.md @@ -0,0 +1,87 @@ +--- +navigation: + title: Multiple Floats and Clear + position: 8380 +--- + +TEST GOAL: multiple floats + clearing — three consecutive left floats, paired left/right floats, a float immediately after a float, three br clear variants + +INVARIANTS: floats do not overlap each other; after clear, text resumes below the bottom edge of the floats + +## Three Consecutive Left Floats + +Expected: Three images stacked vertically on the left side; text flows to the right of the stack. Floats do not overlap one another. + + + + + + + +This paragraph flows to the right of three consecutive left floats. Each float occupies its own vertical position on the left margin without overlapping the previous float. The layout engine tracks the cumulative float region and adjusts the text lane accordingly. The third float sits below the second, and text continues on the right of all three. + +
          + +## Left-Right Paired Floats + +Expected: One image floats left and another floats right simultaneously; text fills the space between them. + + + + + +This paragraph flows in the space between the left and right floats. The left float claims space on the left side and the right float claims space on the right side. The text occupies the reduced horizontal lane between them. Multiple lines of text demonstrate the centred text column created by opposing floats. + +
          + +## Float Immediately Followed by Float + +Expected: A right float appears directly below a left float with no text between; both floats occupy their respective sides without overlapping. + + + + + +Text after the pair of adjacent floats. The first float occupies the left side and the second float occupies the right side. There is no intervening text or clear between them — the engine handles float stacking at the same vertical level. + +
          + +## br clear="left" + +Expected: Text after `
          ` starts below the cleared left float; right float remains active. + + + + + +Text between the floats to show the wrapping lane. +
          +This line starts below the left float after `clear="left"` but the right float is still active and text wraps around it. + +
          + +## br clear="right" + +Expected: Text after `
          ` starts below the cleared right float; left float remains active. + + + + + +Text in the lane between floats. +
          +This line starts below the right float after `clear="right"` but the left float is still active and text wraps around it. + +
          + +## br clear="all" + +Expected: Text after `
          ` starts below both cleared floats; full width is restored. + + + + + +Text in the lane. +
          +This line starts below both floats after `clear="all"`. The full page width is restored and text flows normally from margin to margin without any active float constraint. diff --git a/test/visual/resourcepack/assets/guidenh/guidenh/_en_us/visualtest/floats/wrap-modes.md b/test/visual/resourcepack/assets/guidenh/guidenh/_en_us/visualtest/floats/wrap-modes.md new file mode 100644 index 00000000..4dcbf9b3 --- /dev/null +++ b/test/visual/resourcepack/assets/guidenh/guidenh/_en_us/visualtest/floats/wrap-modes.md @@ -0,0 +1,67 @@ +--- +navigation: + title: Wrap Modes + position: 8400 +--- + +TEST GOAL: one example of each of the six ContentWrapMode modes (square/tight/through/top-bottom/behind/front) + text wrapping + +INVARIANTS: the wrapping behavior of each mode matches ContentWrapMode semantics; behind and front layering (z-order) is correct + +## Square Wrap + +Expected: Image floats left; text wraps around its rectangular bounding box; no text intrudes into the image area. + + + +This paragraph demonstrates square wrapping. The image floats to the left of the text and the text flows to the right of it, occupying the remaining horizontal space. The wrap rectangle follows the image bounding box, creating a clean rectangular cutout in the text block. Multiple sentences here show how the layout engine reduces the available line width for each line by the float width. + +
          + +## Tight Wrap + +Expected: Image floats right; text wraps around it. In this layout system `tight` is functionally equivalent to `square`. + + + +This paragraph demonstrates tight wrapping. The image floats to the right and text flows to the left of the image. In the GuideNH layout engine `tight` is functionally equivalent to `square` — both use the rectangular bounding box for text flow. The text occupies the reduced line width on the left side of the float. + +
          + +## Through Wrap + +Expected: Image floats left; text wraps around it. In this layout system `through` is functionally equivalent to `square`. + + + +This paragraph demonstrates through wrapping. The image floats to the left and the text fills the space on the right. In the GuideNH layout engine `through` is functionally equivalent to `square`. The wide horizontal aspect ratio of this float creates a broad left-side float region that reduces available text width significantly. + +
          + +## Top-Bottom Wrap + +Expected: Image in top-bottom wrap; text only appears above and below, never beside it. (Engine note: FloatingImage align accepts only left/right — center is rejected with an error, so this case uses left.) + + + +This paragraph appears below the centred image. With `top-bottom` wrap, text never flows beside the block — it only appears above and below. The image occupies a full horizontal slot within the content area and text resumes only after the image vertical extent ends. This is equivalent to a non-floating block with explicit horizontal alignment. + +
          + +## Behind Wrap + +Expected: Image renders behind (below) surrounding text; text overlays the image area with no layout shift. + + + +This paragraph demonstrates behind wrapping. The image is rendered behind the text — the text is drawn on top of the image with full opacity. The image does not alter text layout: the paragraph occupies its normal full-width position. The image serves as a background decoration that the text overlays. + +
          + +## Front Wrap + +Expected: Image renders in front of (above) surrounding text; image overlays the text content. + + + +This paragraph demonstrates front wrapping. The image is rendered in front of the text, overlaying the paragraph content. The text layout is unaffected by the image — it flows at full width. The image appears as an overlay on top of the paragraph, potentially obscuring the text beneath it. diff --git a/test/visual/resourcepack/assets/guidenh/guidenh/_en_us/visualtest/images/basic.md b/test/visual/resourcepack/assets/guidenh/guidenh/_en_us/visualtest/images/basic.md new file mode 100644 index 00000000..e4ee2445 --- /dev/null +++ b/test/visual/resourcepack/assets/guidenh/guidenh/_en_us/visualtest/images/basic.md @@ -0,0 +1,45 @@ +--- +navigation: + title: Basic Images + position: 8500 +--- + +TEST GOAL: `![alt](src)` standard markdown images at various sizes, center alignment, image caption via title attribute + +INVARIANTS: Images render at their native pixel dimensions; no distortion, cropping, or stretching; title text appears as a tooltip on hover; the center-aligned image is horizontally centered in the available width + +## Small Image (64×64) + +Expected: The red 64×64 pixel square renders at its native size without distortion. + +![Red 64×64](../assets/red-64.png) + +## Medium Image (128×128) + +Expected: The checkerboard 128×128 pixel square renders at its native size; all squares are evenly spaced. + +![Checker 128×128](../assets/checker-128.png) + +## Wide Image (256×64) + +Expected: The 256×64 pixel wide banner (blue top half, green bottom half) renders at its native size; wider than tall. + +![Wide 256×64](../assets/wide-256x64.png) + +## Tall Image (64×256) + +Expected: The 64×256 pixel tall bar (orange left half, purple right half) renders at its native size; taller than wide. + +![Tall 64×256](../assets/tall-64x256.png) + +## Image with Title (Caption / Tooltip) + +Expected: Hovering the image displays the title text "A red square tile" as a tooltip; the image renders identically to the Small Image case above. + +![Red 64×64](../assets/red-64.png "A red square tile") + +## Center-Aligned Image + +Expected: The image is horizontally centered within the available content width; equal empty space on both left and right sides. + +![Red 64×64 centered](../assets/red-64.png){align=center} diff --git a/test/visual/resourcepack/assets/guidenh/guidenh/_en_us/visualtest/images/block-item.md b/test/visual/resourcepack/assets/guidenh/guidenh/_en_us/visualtest/images/block-item.md new file mode 100644 index 00000000..5286ea80 --- /dev/null +++ b/test/visual/resourcepack/assets/guidenh/guidenh/_en_us/visualtest/images/block-item.md @@ -0,0 +1,100 @@ +--- +navigation: + title: BlockImage and ItemImage + position: 8460 +--- + +TEST GOAL: `` (scale/wrap/align/float) + block-level `` comparison + +INVARIANTS: BlockImage renders a 3D block preview; scale does not distort; wrap/align/float control text wrap positioning; ItemImage renders in item form and at a large scale serves as the block-level comparison + +## BlockImage Scale Variants + +Expected: Five BlockImages at scale 1 through 6. Each renders a 3D diamond block preview at progressively larger size; no distortion. + + + + + + + + + +## BlockImage with Different Blocks + +Expected: A row of different blocks at the same scale (3). Each BlockImage renders its respective 3D block preview. + + + + + + + + +## BlockImage wrap="square" align="left" + +Expected: BlockImage floats to the left with square wrap; text wraps around its right side. + + + +Surrounding paragraph text that wraps around the left-floated BlockImage. The block renders as a 3D diamond block preview at scale 4. Text fills the available space to the right and continues below the image. Additional filler ensures multiple wrapping lines are visible. + +
          + +## BlockImage wrap="square" align="right" + +Expected: BlockImage floats to the right with square wrap; text wraps around its left side. + + + +Surrounding paragraph text that wraps around the right-floated BlockImage. The block renders as a 3D stone block preview at scale 4. Text fills the space to the left and continues below. Additional filler ensures visible wrapping. + +
          + +## BlockImage align="center" + +Expected: BlockImage is horizontally centered on the page; no text wrapping (default wrap is inline). Equal margin on left and right. + + + +## BlockImage align="right" + +Expected: BlockImage is right-aligned on the page; no text wrapping. Flush against the right margin. + + + +## BlockImage float="left" (Flow Context) + +Expected: BlockImage floats left within a paragraph or inline flow; text wraps around its right side. + +Text before the floated BlockImage. This text appears after the inline BlockImage and wraps around its right side. The float attribute is the legacy inline floating mechanism for flow context. Additional filler ensures wrapping behavior is visible across multiple lines. + +
          + +## BlockImage float="right" (Flow Context) + +Expected: BlockImage floats right within a paragraph or inline flow; text wraps around its left side. + +Text before the right-floated BlockImage. This text appears before the floated image and wraps around its left side. Additional filler ensures wrapping behavior is visible across multiple lines. + +
          + +## ItemImage Comparison (Block-Style at Large Scale) + +Expected: ItemImages rendered at large scale with labels behave as block-like inline elements. This contrasts with the 3D BlockImage previews above. + + + + + + + +## ItemImage with Labels (Block Comparison) + +Expected: Each ItemImage shows its item icon with a text label to the right (default label position), demonstrating block-like layout alongside BlockImage elements above. + + + + + + diff --git a/test/visual/resourcepack/assets/guidenh/guidenh/_en_us/visualtest/images/float.md b/test/visual/resourcepack/assets/guidenh/guidenh/_en_us/visualtest/images/float.md new file mode 100644 index 00000000..204a76a6 --- /dev/null +++ b/test/visual/resourcepack/assets/guidenh/guidenh/_en_us/visualtest/images/float.md @@ -0,0 +1,69 @@ +--- +navigation: + title: Floating Images (Wrap × Align) + position: 8490 +--- + +TEST GOAL: `` wrap modes (square, tight, through) × align (left, right) matrix with surrounding text wrapping and `
          ` + +INVARIANTS: Text wraps around the floated image on the opposite side; no overlap between image and text; `
          ` clears all floats and resumes normal flow below the tallest float + +## wrap="square" align="left" + +Expected: Image floats to the left; text wraps around its right side in a rectangular boundary; image does not overlap text. + + + +Surrounding text that wraps around the left-floated image. This paragraph should fill the space to the right of the image, then continue below it once the image height is passed. The engine should maintain the rectangular wrap boundary described by the image bounding box. Additional filler text ensures the wrapping behavior is visible across multiple lines. + +
          + +## wrap="square" align="right" + +Expected: Image floats to the right; text wraps around its left side in a rectangular boundary. + + + +Surrounding text that wraps around the right-floated image. This paragraph fills the space to the left of the image and then continues below it. The wrap boundary follows the right edge of the text area. Additional filler text ensures the wrapping behavior is visible across multiple lines of content. + +
          + +## wrap="tight" align="left" + +Expected: Image floats to the left; text wraps around its right side. The tight mode should produce the same visual result as square in this engine (functionally equivalent). + + + +Surrounding text that wraps around the left-floated wide image. The wide aspect ratio means the text column to the right is narrower. The text should still flow cleanly without overlapping the image boundaries. Additional filler text here ensures that multiple lines of wrapping are clearly visible. + +
          + +## wrap="tight" align="right" + +Expected: Image floats to the right; text wraps around its left side. + + + +Surrounding text that wraps around the right-floated tall image. The tall aspect ratio means the text column to the left is wide. Multiple short lines of text should fill the space beside the tall image before continuing below it. Additional filler ensures wrapping is visible across the full height of the image. + +
          + +## wrap="through" align="left" + +Expected: Image floats to the left; text wraps around its right side. Through mode should produce the same visual result as square in this engine (functionally equivalent). + + + +Surrounding text wraps around the left-floated image. The through mode is documented as equivalent to square in this layout system. The text should fill the available space to the right and resume below the image without any gap or overlap. + +
          + +## wrap="through" align="right" + +Expected: Image floats to the right; text wraps around its left side. After the last float, `
          ` resets flow to below all floated content. + + + +Surrounding text wraps around the right-floated image. This is the last wrap test case. The text to the left should fill the available space and then resume below. After this paragraph the clear marker resets the float context entirely for subsequent content. + +
          diff --git a/test/visual/resourcepack/assets/guidenh/guidenh/_en_us/visualtest/images/floating-image.md b/test/visual/resourcepack/assets/guidenh/guidenh/_en_us/visualtest/images/floating-image.md new file mode 100644 index 00000000..461eb125 --- /dev/null +++ b/test/visual/resourcepack/assets/guidenh/guidenh/_en_us/visualtest/images/floating-image.md @@ -0,0 +1,120 @@ +--- +navigation: + title: FloatingImage Features + position: 8470 +--- + +TEST GOAL: the full `` feature set: cropping (x/y/w/h), scaling (scaleX/scaleY), `` hotspots, `` (render check only, no audio) + +INVARIANTS: the crop region correctly extracts the source sub-rectangle; scaling does not clip content; hotspot hover tooltips show the correct text; SoundArea regions render without crashing + +## Basic Crop (x, y, width, height) + +Expected: The full 64×64 red image is displayed at native size. Crop matches the original image bounds exactly. + + + +
          + +## Cropped Top-Left Quadrant + +Expected: Only the top-left 32×32 pixels of the red image are displayed. The crop rectangle selects (0,0)-(32,32). + + + +
          + +## Cropped Center with w and h Aliases + +Expected: The center 32×32 pixels of the checker image are displayed using the `w`/`h` attribute aliases. The crop rectangle selects (48,48)-(80,80). + + + +
          + +## Scale Stretch (scaleX) + +Expected: The 32×32 cropped region of the wide image is stretched horizontally (scaleX=3) while height remains at native scale (scaleY=1). Result is 96×32 pixels. + + + +
          + +## Scale Stretch (scaleY) + +Expected: The 32×32 cropped region of the tall image is stretched vertically (scaleY=3) while width remains at native scale (scaleX=1). Result is 32×96 pixels. + + + +
          + +## Whole-Image Annotation + +Expected: Hovering anywhere over the image displays the tooltip text "This annotation covers the entire image area." + + + + This annotation covers the entire image area. + + + +
          + +## Region Annotation with Border + +Expected: The annotated region (10,10)-(50,30) has a visible red border (borderColor="#FFFF4444", borderThickness=2). Hovering the region shows the tooltip "Red-bordered region tooltip." + + + + Red-bordered region tooltip. + + + +
          + +## Multiple Annotations + +Expected: Two annotation regions on the same image. The left half (0,0)-(64,128) shows tooltip "Left region" with a green border. The right half (64,0)-(64,128) shows tooltip "Right region" with a blue border. + + + + Left region + + + Right region + + + +
          + +## Annotation on Scaled Crop + +Expected: The cropped region (0,0)-(64,64) is stretched (scaleX=2, scaleY=1.5). The annotation covers the entire cropped region. The border follows the stretch. + + + + Annotation follows scale transform + + + +
          + +## SoundArea (Click Region) + +Expected: The left half (0,0)-(32,64) is a clickable SoundArea that would play a sound in a real environment. No audio is verified in this test; only rendering of the annotation region is checked. + + + + + +
          + +## SoundArea (Hover Trigger) + +Expected: The right half (32,0)-(32,64) is a SoundArea with trigger="hover". Hovering the region would activate the sound. Only rendering is checked. + + + + + +
          diff --git a/test/visual/resourcepack/assets/guidenh/guidenh/_en_us/visualtest/images/fullwidth.md b/test/visual/resourcepack/assets/guidenh/guidenh/_en_us/visualtest/images/fullwidth.md new file mode 100644 index 00000000..1002c06f --- /dev/null +++ b/test/visual/resourcepack/assets/guidenh/guidenh/_en_us/visualtest/images/fullwidth.md @@ -0,0 +1,96 @@ +--- +navigation: + title: FullWidth Comparison (K4) + position: 8480 +--- + +TEST GOAL: fullWidth image/table/code-block comparison; backlog K4 reproduction (the LytFloatAwareBlock wrapper layer loses fullWidth) + +INVARIANTS: the rendered width of a fullWidth block equals the available content width (assertable from bounds); ordinary blocks keep their natural width; in the K4 scenario a fullWidth block wrapped by `LytFloatAwareBlock` should fill the full page width and must not be narrowed by floats + +## FullWidth Image via Container + +Expected: The Column with fullWidth spans the full available width. The image inside appears left-aligned within the full-width container. + + +**Image inside fullWidth Column** + +![Red 64×64](../assets/red-64.png) + + +## FullWidth Table via Container + +Expected: The Column with fullWidth spans the full available width. The table inside uses the full column width; column widths are evenly distributed. + + +**Table inside fullWidth Column** + +| Material | Density | Hardness | +|----------|---------|----------| +| Iron | 7.87 | 4 | +| Gold | 19.32 | 2 | +| Diamond | 3.51 | 10 | + + +## FullWidth Code Block via Container + +Expected: The Column with fullWidth spans the full available width. The code block inside occupies the full width of the container. + + +**Code block inside fullWidth Column** + +```java +public class Hello { + public static void main(String[] args) { + System.out.println("FullWidth test"); + } +} +``` + + +## Natural Width (No fullWidth) for Comparison + +Expected: Without fullWidth, the Column shrinks to content width. The image, table, and code block appear at their natural widths, not stretching to fill the page. + + +**Image at natural width** + +![Checker 128×128](../assets/checker-128.png) + + + +**Table at natural width** + +| A | B | +|---|---| +| Short | Table | + + + +## K4 Replication: Float + FullWidth Table + +Expected: The floating image narrows the text lane. The table below is wrapped in `LytFloatAwareBlock` (all non-paragraph blocks are). If fullWidth is correctly propagated through the wrapper, the table fills the full page width. If the K4 bug is present, the table only fills the narrowed lane (width clipped to the space beside the float). + + + +Wrapping paragraph text beside the floated image. This text fills the space to the right of the image to demonstrate the narrowed lane. After the image clears, the paragraph continues below. + +| K4 Test | Expected | Actual | +|---------|----------|--------| +| fullWidth table | Page width | Lane width if bug present | +| Table wrapped in FloatAwareBlock | Full width | Check with bounds JSON | + +
          + +## K4 Replication: Float + FullWidth Code Block + +Expected: Similar to the table case above. The code block is non-paragraph content and receives `LytFloatAwareBlock` wrapping. Its rendering width determines whether fullWidth propagates correctly. + + + +Wrapping paragraph beside the right-floated image. The text fills the space to the left. After the image there is a code block that should fill the full width. + +``` +K4 fullWidth code block +``` +
          diff --git a/test/visual/resourcepack/assets/guidenh/guidenh/_en_us/visualtest/images/single-param.md b/test/visual/resourcepack/assets/guidenh/guidenh/_en_us/visualtest/images/single-param.md new file mode 100644 index 00000000..803b79a9 --- /dev/null +++ b/test/visual/resourcepack/assets/guidenh/guidenh/_en_us/visualtest/images/single-param.md @@ -0,0 +1,25 @@ +--- +navigation: + title: FloatingImage Single-Param + position: 8475 +--- + +TEST GOAL: `` single-parameter modes (width-only / height-only) — the missing dimension is inferred from the image's natural aspect ratio, the whole image is shown (no cropping), and no red error is reported anymore + +INVARIANTS: with width-only, height = width × natural_h/natural_w; with height-only, width = height × natural_w/natural_h; no red error text; the image actually renders (not an error placeholder) + +## Width-Only + +Expected: The wide 256×64 image is displayed whole at width=128. Height is inferred as 128 × 64/256 = 32 px. + + + +
          + +## Height-Only + +Expected: The tall 64×256 image is displayed whole at height=128. Width is inferred as 128 × 64/256 = 32 px. + + + +
          diff --git a/test/visual/resourcepack/assets/guidenh/guidenh/_en_us/visualtest/index.md b/test/visual/resourcepack/assets/guidenh/guidenh/_en_us/visualtest/index.md new file mode 100644 index 00000000..81a06c84 --- /dev/null +++ b/test/visual/resourcepack/assets/guidenh/guidenh/_en_us/visualtest/index.md @@ -0,0 +1,15 @@ +--- +navigation: + title: VisualTest Index + position: 9999 +--- + +# VisualTest Fixture Corpus + +This guide is the visual regression fixture corpus for the GuideNH layout/render engine. +Each page targets exactly one feature class; expectations are written inline above each case +("Expected: ..."). See `test/visual/README.md` and `.local/docs/visualtest/FIXTURES.md` in the repository for the authoritative spec. + +Folders (15, all complete): text / lists / tables / code / latex / images / floats / layout / +charts / mermaid / nei / scenes / meta / overflow / stress. Shared assets (test PNGs, CSV, SNBT) +live in `assets/`. In-game: `/guidenhc open guidenh:guidenh` and use the nav sidebar root list. diff --git a/test/visual/resourcepack/assets/guidenh/guidenh/_en_us/visualtest/latex/audit.md b/test/visual/resourcepack/assets/guidenh/guidenh/_en_us/visualtest/latex/audit.md new file mode 100644 index 00000000..4b71146d --- /dev/null +++ b/test/visual/resourcepack/assets/guidenh/guidenh/_en_us/visualtest/latex/audit.md @@ -0,0 +1,81 @@ +--- +navigation: + title: LaTeX Industrial Audit + position: 8580 +--- + +TEST GOAL: LaTeX industry-standard audit (quantitative diagnosis of font size/baseline/spacing/texture) + +## A. x-height parity + +Expected: formula body font size = body text font size (unified standard from task A; x-height follows each font's ratio, measured formula ≈ 0.92× body text, not the old inline target of 1.2×); baselines coincide exactly. + +Calib text: x xg E Eg H qi baseline reference. + +A $x$ B $xg$ C $E$ D $Eg$ E $H$ F $qi$ G end. + +## B. Baseline precision + +Expected: each formula baseline coincides with the text baseline at pixel level; the fraction bar sits on the math axis (slightly above the middle of the x-height). + +Base $x$ $a$ $g$ $y$ $E$ $f$ $\frac{x}{x}$ $\sqrt{x}$ $\frac{g}{y}$ end. + +## C. Scripts and fraction sizing + +Expected: superscripts and subscripts are about 0.7× the body size; textstyle fractions shrink internally. + +Text $x^2$ $x_i$ $e^{-x^2}$ $\frac{a+b}{c-d}$ $2^{2^2}$ done. + +## D. Display body-size parity + +Expected: display formula body glyph size = body text size (browser standard: display math at body font size). + +Body ref: E x H mc. + +$$E=mc^2$$ + +$$x^2+y^2=z^2$$ + +## E. Display vertical spacing + +Expected: vertical spacing above and below display blocks is consistent and generous; adjacent display blocks are clearly separated. + +$$\Delta G = \Delta H - T\Delta S$$ + +$$\oint_C \mathbf{E} \cdot d\mathbf{l} = -\frac{d}{dt}\iint_S \mathbf{B} \cdot d\mathbf{S}$$ + +Following paragraph after displays. + +## F. Display fraction interior + +Expected: displaystyle fraction numerators and denominators are close to the body size (the displaystyle convention), clearly larger than the interior of textstyle fractions. + +$$\frac{a+b}{c-d}$$ + +Inline version for contrast: $\frac{a+b}{c-d}$. + +## G. Big operators and limits + +Expected: display uses large operators with limits above and below; inline uses small operators with limits at the side. + +$$\int_0^\infty e^{-x^2}\,dx = \frac{\sqrt{\pi}}{2} \qquad \sum_{n=1}^{\infty} \frac{1}{n^2} = \frac{\pi^2}{6}$$ + +Inline: $\int_0^\infty e^{-x^2}\,dx$ and $\sum_{n=1}^{\infty} \frac{1}{n^2}$. + +## H. Matrix and delimiters + +$$\begin{pmatrix} a & b \\ c & d \\ \end{pmatrix} \begin{pmatrix} x \\ y \\ \end{pmatrix} = \begin{pmatrix} ax+by \\ cx+dy \\ \end{pmatrix}$$ + +## I. Stroke sharpness + +Expected: formula strokes are as sharp as text strokes (scaling-blur check). + +Text strokes: I I I l l l H |. + +$I$ $l$ $H$ $\int$ $\sum$ $\pi$ + +## J. Mixed-size line growth + +Expected: when a line contains a tall formula the line height expands and the lines above and below are not intruded upon; horizontal spacing around the formula is natural. + +Before line $\frac{a}{b}$ after $\sqrt{x^2+y^2}$ tail text continues here for wrap test and more words to fill the line so wrapping occurs naturally in this paragraph. diff --git a/test/visual/resourcepack/assets/guidenh/guidenh/_en_us/visualtest/latex/display.md b/test/visual/resourcepack/assets/guidenh/guidenh/_en_us/visualtest/latex/display.md new file mode 100644 index 00000000..f2592529 --- /dev/null +++ b/test/visual/resourcepack/assets/guidenh/guidenh/_en_us/visualtest/latex/display.md @@ -0,0 +1,55 @@ +--- +navigation: + title: Display LaTeX Formulas + position: 8590 +--- + +TEST GOAL: display LaTeX formulas render centered, plus scaling, color and tooltip + +INVARIANTS: horizontally centered; scaling does not overflow; paragraph spacing is consistent + +## Dollar Double-Dollar Shorthand + +Expected: Formula rendered as centered block with vertical margins above and below. + +$$\int_0^\infty e^{-x^2}\,dx = \frac{\sqrt{\pi}}{2}$$ + +$$\begin{pmatrix} a & b \\ c & d \end{pmatrix} \begin{pmatrix} x \\ y \end{pmatrix} = \begin{pmatrix} ax+by \\ cx+dy \end{pmatrix}$$ + +## Tag Syntax + +Expected: Block-level `` tag renders centered formula with vertical spacing. + + + + + +## Scale + +Expected: Formula scaled up by userScale=1.5 renders larger but still centered; does not overflow container. + + + +## Color + +Expected: Formula glyph colour overridden by color attribute. + + + + + +## Tooltip + +Expected: Hovering over display formula shows tooltip. + + + + + +## Vertical Alignment (display context) + +Expected: valign attribute is silently ignored in display context; formula renders at default centered position. + + + + diff --git a/test/visual/resourcepack/assets/guidenh/guidenh/_en_us/visualtest/latex/inline-xheight.md b/test/visual/resourcepack/assets/guidenh/guidenh/_en_us/visualtest/latex/inline-xheight.md new file mode 100644 index 00000000..37d589d6 --- /dev/null +++ b/test/visual/resourcepack/assets/guidenh/guidenh/_en_us/visualtest/latex/inline-xheight.md @@ -0,0 +1,21 @@ +--- +navigation: + title: Inline LaTeX x-height Calibration + position: 8610 +--- + +TEST GOAL: inline formula x-height matches the x-height of lowercase body text (T4 real x_height measurement, replacing the 0.625 approximation; under 0.625 inline formulas were oversized, F5-3) + +INVARIANTS: the `$x$` formula renders at about the height of a body-text x; fractions/radicals/summations render normally; no compile errors + +## Control: Formula x vs Body x + +Expected: The inline formula x renders at the same height as the lowercase body x, because both derive from the same real x_height metric (T4). + +Body x x x followed by the inline formula: x x x $x$. + +## Magnification: Fractions, Radicals, Sums + +Expected: Tall inline formulas magnify any x_height ratio error into a visible pixel mismatch while rendering inline without breaking or overlapping. + +Mixing $\frac{1}{2}$, $\sqrt{x}$, and $\sum_{i=1}^{n} i$ into the same line as regular body text amplifies a mis-scaled inline factor into a visible size difference; the fraction must expand the line without crushing adjacent text. diff --git a/test/visual/resourcepack/assets/guidenh/guidenh/_en_us/visualtest/latex/inline.md b/test/visual/resourcepack/assets/guidenh/guidenh/_en_us/visualtest/latex/inline.md new file mode 100644 index 00000000..9b278194 --- /dev/null +++ b/test/visual/resourcepack/assets/guidenh/guidenh/_en_us/visualtest/latex/inline.md @@ -0,0 +1,65 @@ +--- +navigation: + title: Inline LaTeX Formulas + position: 8600 +--- + +TEST GOAL: inline LaTeX formula baseline alignment and line-height expansion (backlog: inline dropped-line reproduction) + +INVARIANTS: the baseline is aligned and the horizontal position is correct within the text flow; fractions that expand the line do not overlap adjacent text + +## Dollar Shorthand + +Expected: Inline formulas rendered at text baseline with correct horizontal position. + +Text with $E=mc^2$ inserted in the middle of a sentence, and also with $\frac{1}{2}$ and $\sqrt{x}$ in regular paragraph text. + +## Fraction Line Height Expansion + +Expected: Line height expands to accommodate tall formulas; text before and after is not crushed. + +A sentence that contains $\frac{1}{2}$ and also $\frac{a+b}{c-d}$ in the same line must not drop or collapse either formula. + +## Tag Syntax (Same-Line Minimal Reproduction) + +Expected: Same-line placement with `` tag form; identical behavior to dollar shorthand. + +A sentence that contains and also in the same line must render both without line break or overlap. + +## Color + +Expected: Formula rendered in non-default color (#RRGGBB). + +This is rendered in gold color. + +## Scale + +Expected: Formula scaled up relative to text. + +Scaled up (scale=1.5): in the middle of text. + +## Vertical Alignment Variants + +Expected: Each valign mode positions the formula correctly within the text line. + +Default (baseline): aligns math baseline with text baseline. + +Top: is flush with the line top. + +Center: centered on text line. + +Bottom: sits on the text line bottom. + +## Tooltip + +Expected: Hovering over the formula shows tooltip text. + +Hover for source: + +Custom tooltip: + +## Offset + +Expected: Formula shifted from default position by offsetX/offsetY pixels. + +Nudged: right 2px and up 1px. diff --git a/test/visual/resourcepack/assets/guidenh/guidenh/_en_us/visualtest/layout/align.md b/test/visual/resourcepack/assets/guidenh/guidenh/_en_us/visualtest/layout/align.md new file mode 100644 index 00000000..cd69ae99 --- /dev/null +++ b/test/visual/resourcepack/assets/guidenh/guidenh/_en_us/visualtest/layout/align.md @@ -0,0 +1,45 @@ +--- +navigation: + title: Block Alignment + position: 8290 +--- + +TEST GOAL: behavior of block-level align=left/center/right on images, tables and scenes + +INVARIANTS: left is flush left; center is centered; right is flush right; the differences between the comparison groups are obvious + +## BlockImage Alignment + +Expected: Three BlockImages aligned left, center, and right. The center image is horizontally centred; the right image is flush to the right margin. + + + + + +## Table Alignment + +Expected: Three tables aligned left (default), center, and right using the block-level align attribute. The centred table is positioned in the middle of the available width. + +CASE DISABLED (engine bug, recorded in SPEC backlog): JSX `` with `
          ` children produces a table whose column list is empty at layout time, crashing LytTable.layoutColumns (LytTable.java:176, NoSuchElementException at ArrayList.getLast). Repro (shown as literal text): + +```xml +
          CenterAligned
          +``` + +Expected after engine fix: three JSX tables aligned left / center / right; the centred table sits in the middle of the available width. (Case text kept inline so the fix phase can restore it verbatim.) + +## GameScene Alignment + +Expected: Three GameScenes aligned left, center, and right. The centred scene is horizontally centred in the available width. + + + + + + + + + + + + diff --git a/test/visual/resourcepack/assets/guidenh/guidenh/_en_us/visualtest/layout/baseline-align.md b/test/visual/resourcepack/assets/guidenh/guidenh/_en_us/visualtest/layout/baseline-align.md new file mode 100644 index 00000000..0dab1b46 --- /dev/null +++ b/test/visual/resourcepack/assets/guidenh/guidenh/_en_us/visualtest/layout/baseline-align.md @@ -0,0 +1,71 @@ +--- +navigation: + title: Baseline Alignment (true text-baseline) + position: 8310 +--- + +## Baseline (text: baseline = image bottom edge) + + +Baseline text must sit on the image baseline. + + + +Baseline text must sit on the image baseline. + + +Expected: two text children in a baseline row share the block image's bottom edge as their common baseline line. Per CSS, a replaced element's baseline is its bottom edge, so the PLAN 2.7 post-layout (RowBaselineAligner) shifts both text boxes DOWN so their first-line baselines sit exactly on the image's bottom edge. Exemption protocol (WORKFLOW §3.3): this BASELINE row's second-pass geometry deviates from the raw taffy writeback (which would bottom-degrade the text); non-BASELINE containers are byte-identical. + +## Control: start (text, top flush) + +Expected: the same [text, image, text] row with `alignItems="start"` — both text boxes start at the row top (their top margins), the image extends downward. Non-BASELINE container, untouched by the post-layout. + + +Baseline text must sit on the image baseline. + + + +Baseline text must sit on the image baseline. + + +## Control: center (text, middle flush) + +Expected: the same [text, image, text] row with `alignItems="center"` — both text boxes are vertically centered against the image. Non-BASELINE container, untouched by the post-layout. + + +Baseline text must sit on the image baseline. + + + +Baseline text must sit on the image baseline. + + +## Baseline (images: bottom-edge is the CSS image baseline) + +Expected: A Row with `alignItems="baseline"` and mixed-height children (BlockImage scale 1 vs scale 2). Per CSS, a replaced element's baseline is its bottom edge, so the images' bottom edges are flush — taffy's BASELINE output for image-only rows is already correct and is NOT modified by the PLAN 2.7 post-layout. Compare against the start/center controls below. + + + + + + + +## Control: start (top-edge flush) + +Expected: The same mixed-height children with `alignItems="start"`. Children's top edges are flush; the taller middle child extends downward. Non-BASELINE container — untouched by the post-layout. + + + + + + + +## Control: center (middle-line flush) + +Expected: The same mixed-height children with `alignItems="center"`. Children share a common vertical middle line. Non-BASELINE container — untouched by the post-layout. + + + + + + diff --git a/test/visual/resourcepack/assets/guidenh/guidenh/_en_us/visualtest/layout/callout-combos.md b/test/visual/resourcepack/assets/guidenh/guidenh/_en_us/visualtest/layout/callout-combos.md new file mode 100644 index 00000000..a4ceca57 --- /dev/null +++ b/test/visual/resourcepack/assets/guidenh/guidenh/_en_us/visualtest/layout/callout-combos.md @@ -0,0 +1,77 @@ +--- +navigation: + title: Callout Combinations + position: 8265 +--- + +TEST GOAL: multiple callout types combined on one page (info/warn/danger and other alerts + custom quotes with and without titles + multi-line content) + +INVARIANTS: alert boxes have a type-colored left border and a title row; custom quotes (including title= and color-only directives) fall into the plain blockquote path: gray background, 2px gray left border, no title row; multi-line content is fully contained inside the box; no red error text + +Syntax note: in the current engine the `[!NOTE]`-style marker is consumed by the markdown parser, so alert detection keys on the first body text starting with the alert keyword (`NOTE`/`TIP`/`IMPORTANT`/`WARNING`/`CAUTION`). The blocks below therefore open their body with the keyword. + +## Info Alert (NOTE) + +Here it should: render a Note alert box with a single-line body. + +> [!NOTE] +> Note: a short info callout with one body line. + +## Info Alert With Multi-Line Body + +Here it should: render a Note alert box whose body spans three paragraphs — all inside one alert box with the title row. + +> [!NOTE] +> Note: first paragraph of a multi-paragraph info callout. +> +> Note: second paragraph separated by a `>`-marked blank line, still inside the same alert box. +> +> Note: third paragraph closes the multi-line body. + +## Warn Alert (WARNING) + +Here it should: render a Warning alert box with a two-line body. + +> [!WARNING] +> Warning: first line of the warning callout. +> Warning: second line stays in the same alert body. + +## Danger Alert (CAUTION) + +Here it should: render a Caution alert box immediately after the warning — different alert types stack on the same page without visual bleed. + +> [!CAUTION] +> Caution: a danger callout that follows the warning directly. + +## Tip And Important Stack + +Here it should: render Tip and Important alerts back to back to exercise adjacent-type transitions. + +> [!TIP] +> Tip: a single-line tip callout. + +> [!IMPORTANT] +> Important: a single-line important callout. + +## Custom Quote With Title + +Here it should: render a quote directive WITH `title=` — per `BlockquoteCompiler` the `title` attribute produces no header row: the directive falls through to the plain-blockquote path exactly like the colour-only directive below (grey 2px left border, no header row), and only the body line renders. + +> {: title="Custom Info" color="#638ef1" } +> Custom quote body with a header title row. + +## Custom Quote Without Title + +Here it should: render a quote directive WITHOUT `title=` or `icon=` — per `BlockquoteCompiler` such a directive falls through to the plain-blockquote path: grey 2px left border, no header row, and the body below. + +> {: color="#e46150" } +> A bare colour-only directive renders as a plain blockquote without a title row. + +## Custom Quote With Multi-Line Body + +Here it should: render the same `title=` directive with a multi-line body — as above, `title=` produces no header row and the directive falls through to the plain-blockquote path (grey 2px left border, no header row); the two paragraphs stack inside the same grey quote box. + +> {: title="Long Custom Quote" color="#61b75d" } +> First paragraph of the custom quote body. +> +> Second paragraph of the custom quote body, still inside the same box. diff --git a/test/visual/resourcepack/assets/guidenh/guidenh/_en_us/visualtest/layout/callouts.md b/test/visual/resourcepack/assets/guidenh/guidenh/_en_us/visualtest/layout/callouts.md new file mode 100644 index 00000000..e6c696bb --- /dev/null +++ b/test/visual/resourcepack/assets/guidenh/guidenh/_en_us/visualtest/layout/callouts.md @@ -0,0 +1,100 @@ +--- +navigation: + title: Callouts & Blockquotes + position: 8260 +--- + +TEST GOAL: the five GitHub alerts (NOTE/TIP/IMPORTANT/WARNING/CAUTION) + bare-keyword matching + custom quotes with three icon kinds (TEXT/ITEM/PNG) + plain blockquotes + nested lists and paragraphs mixed inside blockquotes + +INVARIANTS: alerts render a 3px type-colored left border + title row (i18n title + type-colored symbol icon); custom quotes take a color= tinted 3px left border, with the header rendered only when title/icon is present; plain blockquotes have a gray background + 2px border and no title row; the bare keyword `> NOTE:` also matches the Note alert; when `[!NOTE]` is on its own line followed by a blank line the literal marker enters the alert body (known boundary) + +## Plain Blockquote with Multiple Paragraphs + +Here it should: render a plain blockquote — grey background, 2px grey left border, no title row, and the two paragraphs stacked inside the same grey quote box. + +> First paragraph of a plain blockquote. It carries the main content of the quote. +> +> Second paragraph of the same blockquote, separated by a `>`-marked blank line. Both paragraphs share the grey quote box. + +## GitHub Alerts + +### Note Alert + +Here it should: render a Note alert box — 3px blue (#638ef1) left border, a bold title row with the blue ⓘ symbol followed by the i18n Note title, and the body text below the title row. + +> [!NOTE] +> Note alert with the blue accent line and icon. + +### Tip Alert + +Here it should: render a Tip alert box — 3px green (#61b75d) left border, a bold title row with the green ✦ symbol followed by the i18n Tip title. + +> [!TIP] +> Tip alert with a green accent line and icon. + +### Important Alert + +Here it should: render an Important alert box — 3px purple (#8755dd) left border, a bold title row with the purple ➤ symbol followed by the i18n Important title. + +> [!IMPORTANT] +> Important alert with the purple accent line and icon. + +### Warning Alert + +Here it should: render a Warning alert box — 3px gold (#c79d3e) left border, a bold title row with the gold ⚠ symbol followed by the i18n Warning title. + +> [!WARNING] +> Warning alert with the gold accent line and icon. + +### Caution Alert + +Here it should: render a Caution alert box — 3px red (#e46150) left border, a bold title row with the red ☢ symbol followed by the i18n Caution title. + +> [!CAUTION] +> Caution alert with the red accent line and icon. + +## Custom Quote: Text Icon + +Here it should: render a custom quote box — 3px #638ef1 left border, a header row containing the plain-text icon `i` followed by the bold title "Custom Quote", and the body below the header. + +> {: title="Custom Quote" color="#638ef1" icon="i" } +> Custom title, accent colour and text icon. + +## Custom Quote: Item Icon + +Here it should: render a custom quote box — 3px #61b75d left border, a header row containing a small emerald item icon (`minecraft:emerald`) followed by the bold title "Item Quote". + +> {: title="Item Quote" color="#61b75d" iconItem="minecraft:emerald" } +> ItemStack icon in the quote header. + +## Custom Quote: PNG Icon + +Here it should: render a custom quote box — 3px #c79d3e left border, a header row containing the 8×8 red-64.png image icon followed by the bold title "PNG Quote". + +> {: title="PNG Quote" color="#c79d3e" iconPng="../assets/red-64.png" } +> PNG icon loaded from guide assets. + +## Bare Keyword Trap + +Here it should: match the Note alert even without brackets — the bare keyword `> NOTE:` (no `[!]` wrappers) still renders the blue Note alert box with title row, and the body retains the full line text including the `NOTE:` prefix. + +> NOTE: A bare keyword without brackets hits the Note alert. + +## Alert Marker Alone with Blank Line + +Here it should: record a known boundary — when `[!NOTE]` sits alone on its line and a blank line separates the body, the engine still builds a Note alert box (title row present), but the literal `[!NOTE]` marker text lands in the box body because the directive prefix is only stripped when body text follows it in the same paragraph. The text after the blank line renders as a separate plain blockquote. + +> [!NOTE] + +> Body text after a blank line — the literal `[!NOTE]` marker stays in the alert body (known boundary, documented expected behaviour). + +## Blockquote with Nested List and Paragraph Mix + +Here it should: render a single plain blockquote containing a nested unordered list between two paragraphs — list markers and indentation stay inside the grey quote box, and the closing paragraph still sits in the same box. + +> Opening paragraph of a blockquote that mixes paragraphs and a list. +> +> * First item of a nested list. +> * Second item of a nested list. +> +> Closing paragraph after the list, still inside the same blockquote. diff --git a/test/visual/resourcepack/assets/guidenh/guidenh/_en_us/visualtest/layout/content-tabs.md b/test/visual/resourcepack/assets/guidenh/guidenh/_en_us/visualtest/layout/content-tabs.md new file mode 100644 index 00000000..8dd5b21c --- /dev/null +++ b/test/visual/resourcepack/assets/guidenh/guidenh/_en_us/visualtest/layout/content-tabs.md @@ -0,0 +1,127 @@ +--- +navigation: + title: Content Tabs + position: 8270 +--- + +TEST GOAL: ContentTabs with multiple tabs (different content types), title/default/defaultIndex/color/icon variants + +INVARIANTS: tab headers correspond to their content; switching tabs shows the correct content; title/color/icon render correctly + +## Three Tabs with Different Content Types + +Expected: ContentTabs with three tabs: Text, Code, and Image. Clicking each tab reveals its respective content type. + + + +This is plain text content inside the first tab. + +A second paragraph of text. + + +```xml + +``` + + +![](../assets/red-64.png) + + + +## Four Tabs with Title and Icon + +Expected: ContentTabs with a title heading "Resource Variants", an icon of a crafting table, and four tabs showing different block images. + + + + + + + + + + + + + + + + +## Default Tab via defaultIndex + +Expected: ContentTabs with defaultIndex=1. The second tab (Code) is selected on first render instead of the first. + + + +Introduction text shown first by default without defaultIndex. + + +```java +// This tab is selected by default via defaultIndex=1 +int x = 42; +``` + + +Additional notes that require manual tab switch. + + + +## Default Tab via default Title + +Expected: ContentTabs with default="Notes". The tab titled "Notes" is selected on first render. + + + +Welcome text. + + +Detailed explanation. + + +This tab is selected by default via the default attribute. + + + +## Color and Icon Variants + +Expected: ContentTabs with color="#ff6b6b" (red accent) and iconItem="minecraft:redstone". The accent color is applied to the tab decoration line or highlight. + + + +Overview content with red accent theme. + + +Configuration details. + + +Output visualization. + + + +## Text and PNG Icons + +Expected: ContentTabs with a title heading that renders the plain-text icon `` before the bold title "Code Style"; the tab strip and content render normally. + + + +```java +int x = 42; +``` + + +```xml + +``` + + + +Expected: ContentTabs with a title heading that renders the 8×8 red-64.png image icon before the bold title "Red Theme"; the tab strip and content render normally. + + + +Overview content with an image heading icon. + + +Configuration details. + + diff --git a/test/visual/resourcepack/assets/guidenh/guidenh/_en_us/visualtest/layout/details.md b/test/visual/resourcepack/assets/guidenh/guidenh/_en_us/visualtest/layout/details.md new file mode 100644 index 00000000..dbd7eb97 --- /dev/null +++ b/test/visual/resourcepack/assets/guidenh/guidenh/_en_us/visualtest/layout/details.md @@ -0,0 +1,86 @@ +--- +navigation: + title: Details + position: 8280 +--- + +TEST GOAL: details open/closed, embedded tables/code/images, several in a row + +INVARIANTS: content is visible when open and only the summary row shows when closed; embedded blocks render normally inside the content area; consecutive details blocks do not interfere with each other + +## Details Open + +Expected: Details is open by default. The body content (text and image) is visible below the summary. + +
          +Open Details (click to toggle) + +This is the body of an open details block. + +![](../assets/red-64.png) +
          + +## Details Closed + +Expected: Details is closed by default. Only the summary line is visible; the body content is hidden. + +
          +Closed Details (click to expand) + +This body is hidden until the user clicks the summary. + +![](../assets/checker-128.png) +
          + +## Details with Embedded Table and Code + +Expected: Open details containing a markdown table and a code block. Both render correctly inside the details body. + +
          +Details with Table and Code + +| Item | Value | +|------|-------| +| Diamond | 64 | +| Gold | 32 | + +```java +System.out.println("Hello from details"); +``` +
          + +## Details with Embedded Image and Scene + +Expected: Open details containing an image and a GameScene. Both render within the details content area. + +
          +Details with Image and Scene + +![](../assets/checker-128.png) + + + + +
          + +## Multiple Consecutive Details + +Expected: Three details blocks stacked vertically. Each toggles independently; the open state of one does not affect the others. + +
          +First Details (open) + +Content of the first details block. +
          + +
          +Second Details (closed) + +Content of the second details block. +
          + +
          +Third Details (open) + +Content of the third details block. +
          diff --git a/test/visual/resourcepack/assets/guidenh/guidenh/_en_us/visualtest/layout/row-column.md b/test/visual/resourcepack/assets/guidenh/guidenh/_en_us/visualtest/layout/row-column.md new file mode 100644 index 00000000..91ce42a8 --- /dev/null +++ b/test/visual/resourcepack/assets/guidenh/guidenh/_en_us/visualtest/layout/row-column.md @@ -0,0 +1,102 @@ +--- +navigation: + title: Row and Column + position: 8300 +--- + +TEST GOAL: Row/Column gap variants, alignItems values, fullWidth, width, nesting + +INVARIANTS: flex gaps are correct; the alignItems direction is correct; fullWidth fills the available width; nested inner layers do not overflow their outer container + +## Gap Variants + +Expected: Three rows with gap 2, 10, and 20. Spacing between child items matches the gap value. + + + + + + + + + + + + + + + + + + + +## alignItems Values + +Expected: Three rows demonstrating alignItems start, center, end. Items align at the top, middle, and bottom of the row respectively. + + + + + + + + + + + + + + + + + + + +## fullWidth vs Natural Width + +Expected: The first Column has no fullWidth and shrinks to content width. The second Column uses fullWidth and spans the full available width. + + +**Without fullWidth (natural width)** + + + + + +**With fullWidth** + + + + +## Width Constraint + +Expected: The Column restricted to width=120 renders narrower than the unrestricted Column below. + + +Red text and a constrained box. + +![](../assets/red-64.png) + + + +Unconstrained Column for comparison. + +![](../assets/checker-128.png) + + +## Nesting (Row inside Column) + +Expected: A Column containing two Rows. Each Row contains three BlockImages. The inner Rows lay out horizontally within the vertical Column. + + + + + + + + + + + + + diff --git a/test/visual/resourcepack/assets/guidenh/guidenh/_en_us/visualtest/lists/basic.md b/test/visual/resourcepack/assets/guidenh/guidenh/_en_us/visualtest/lists/basic.md new file mode 100644 index 00000000..da5690a6 --- /dev/null +++ b/test/visual/resourcepack/assets/guidenh/guidenh/_en_us/visualtest/lists/basic.md @@ -0,0 +1,58 @@ +--- +navigation: + title: Lists Basic + position: 8900 +--- + +TEST GOAL: three levels of unordered lists, three levels of ordered lists including start, mixed nesting + +INVARIANTS: indentation and spacing are consistent across levels + +## Unordered Three Levels + +Expected: Three levels of unordered list rendered with `*`, `-`, `+` markers; indentation and spacing consistent per level. + +* Level one item A +* Level one item B + - Level two item A + - Level two item B + + Level three item A + + Level three item B +* Level one item C + +## Ordered Three Levels with Start + +Expected: Ordered list starting at 3 with three levels of nesting; numbering restarts per nested level. + +3. First ordered item (start=3) +4. Second ordered item + 1. Nested level two A + 2. Nested level two B + 1. Nested level three A + 2. Nested level three B +5. Third ordered item + +## Mixed Nesting + +Expected: Unordered and ordered lists intermix within a single tree; indentation clearly shows hierarchy. + +* Root category A + 1. Step one + 2. Step two + - Sub detail alpha + - Sub detail beta +* Root category B + - Item one + 1. Numbered sub-step + 2. Numbered sub-step + - Item two + +## Ordered Multi-Digit Markers (start=8) + +Expected: Ordered list starting at 8; the 1→2 digit transition (9. → 10.) keeps every marker right-aligned to the same gutter — 10., 11., 12. extend further left than the single-digit markers, and every marker shares the item's first-line baseline. + +8. Item eight +9. Item nine +10. Item ten +11. Item eleven +12. Item twelve diff --git a/test/visual/resourcepack/assets/guidenh/guidenh/_en_us/visualtest/lists/rich.md b/test/visual/resourcepack/assets/guidenh/guidenh/_en_us/visualtest/lists/rich.md new file mode 100644 index 00000000..adc61669 --- /dev/null +++ b/test/visual/resourcepack/assets/guidenh/guidenh/_en_us/visualtest/lists/rich.md @@ -0,0 +1,81 @@ +--- +navigation: + title: Lists Rich + position: 8890 +--- + +TEST GOAL: rich-content list items: multiple paragraphs, code blocks, links, display formulas, small tables, images + +INVARIANTS: continuation lines are left-aligned; embedded blocks do not break numbering + +## Multi-Paragraph Items + +Expected: Each item contains two or more paragraphs; continuation text aligns with the item text, not the marker. + +1. First paragraph of item one. + + Continuation paragraph indented under the item text. Wraps to show left alignment. +2. First paragraph of item two. + + Second continuation. Still aligned under the text start, not under the marker. + +## Code Block in Item + +Expected: Fenced code block inside a list item is indented and does not break numbering. + +1. Item with embedded Java code block: + + ```java + public class Sample { + public static void main(String[] args) { + System.out.println("list item code"); + } + } + ``` +2. Next item resumes numbering correctly after the code block. +3. Third item confirms numbering continuity. + +## Link in Item + +Expected: Links render with correct styling inside list items; clickable area matches text. + +- Visit the [GuideNH project](https://example.com/guidenh) for details. +- Internal page: open [index page](../index.md) reference. +- Styled link: **[Bold external link](https://example.com)**. + +## Display Formula in Item + +Expected: Display formula centered within a list item does not overflow or break numbering. + +- Inline formula $E=mc^2$ appears within the sentence. +- Display formula on its own line: + + $$\int_0^\infty e^{-x^2}\,dx = \frac{\sqrt{\pi}}{2}$$ + + Text continues normally after the display formula. +- Final item confirms list integrity after the formula block. + +## Small Table in Item + +Expected: Markdown table inside a list item renders with correct column widths and alignment. + +- Comparison table embedded in item: + + | Material | Value | + |----------|-------| + | Iron | 42 | + | Gold | 17 | + | Diamond | 9 | + + Trailing text after the table stays within the item. + +## Image in Item + +Expected: Image inside a list item aligns correctly; inline and block images do not overflow. + +- Red icon inline: ![Red](../assets/red-64.png) appears beside text. +- Block image below text: + + ![Red Square](../assets/red-64.png) + + Caption follows the image on a new line. diff --git a/test/visual/resourcepack/assets/guidenh/guidenh/_en_us/visualtest/lists/tasks.md b/test/visual/resourcepack/assets/guidenh/guidenh/_en_us/visualtest/lists/tasks.md new file mode 100644 index 00000000..37e588c0 --- /dev/null +++ b/test/visual/resourcepack/assets/guidenh/guidenh/_en_us/visualtest/lists/tasks.md @@ -0,0 +1,42 @@ +--- +navigation: + title: Tasks Lists + position: 8880 +--- + +TEST GOAL: task lists mixing completed/uncompleted items, nesting, rich-text labels + +INVARIANTS: checkboxes are aligned with their text; the two states are visually distinguished + +## Mixed Checked and Unchecked + +Expected: Completed `- [x]` and pending `- [ ]` items render with visually distinct checkbox states. + +- [x] Completed task A +- [ ] Pending task B +- [x] Completed task C +- [ ] Pending task D +- [ ] Pending task E + +## Nested Task Lists + +Expected: Task items nest under both checked and unchecked parents; indentation preserves hierarchy. + +- [x] Parent completed + - [ ] Child pending A + - [x] Child completed B +- [ ] Parent pending + - [ ] Sub-task one + - [ ] Sub-task two + - [x] Deep nested completed + - [ ] Deep nested pending + +## Rich Text Labels + +Expected: Task labels support inline markdown formatting and inline game tags; rendering does not break checkbox alignment. + +- [x] **Bold completed** with *italic* suffix +- [ ] `code inline` inside task label +- [x] Task with [link](https://example.com) embedded +- [ ] ~~Strikethrough~~ ++underline++ ^^wave^^ mixed +- [x] icon in label diff --git a/test/visual/resourcepack/assets/guidenh/guidenh/_en_us/visualtest/mdx/file-tree.md b/test/visual/resourcepack/assets/guidenh/guidenh/_en_us/visualtest/mdx/file-tree.md new file mode 100644 index 00000000..3caf3303 --- /dev/null +++ b/test/visual/resourcepack/assets/guidenh/guidenh/_en_us/visualtest/mdx/file-tree.md @@ -0,0 +1,57 @@ +--- +navigation: + title: FileTree MDX Tag + position: 9050 +--- + +TEST GOAL: the MDX block tag `` (indent / gap attributes + icon suffix) renders as a directory tree + +INVARIANTS: FileTree renders as a tree-shaped row structure rather than plain text; the indent and line-gap attributes take effect; no red error text + +Syntax reference: `FileTreeTagCompiler` — `` recovers its raw children source and parses the tree. Supported forms follow the wiki `markdown.md` example. + +## FileTree With Indent And Gap + +Here it should: render a directory tree with 16px indent per depth and 2px extra row gap — real tree connector lines, not literal text. + + +docs +├── intro +│ ├── getting-started.md +│ └── advanced.md +└── guides + ├── recipes.md + └── reference.md + + +## FileTree With Text Icons + +Here it should: render a tree whose rows carry `{:icon=...}` text-icon suffixes displayed beside the row text. + + +world +|-- {:icon=Oak} oak forest +| \-- {:icon=Hill} rolling hills +|-- {:icon=Des} desert oasis +\-- {:icon=Cave} deep cave + + +## FileTree With Item Icons + +Here it should: render a tree whose rows carry `{:iconItem=minecraft:...}` item icons beside the row text. + + +inventory +|-- {:iconItem=minecraft:grass} grass block +|-- {:iconItem=minecraft:wool:14} red wool +\-- {:iconItem=minecraft:diamond} diamond + + +## FileTree With PNG Icon + +Here it should: render a tree whose row carries `{:iconPng=...}` pointing at the fixture asset `../assets/red-64.png` — the icon loads and renders beside the row text. + + +assets +\-- {:iconPng=../assets/red-64.png} red-64.png + diff --git a/test/visual/resourcepack/assets/guidenh/guidenh/_en_us/visualtest/mermaid/deep-subgraphs.md b/test/visual/resourcepack/assets/guidenh/guidenh/_en_us/visualtest/mermaid/deep-subgraphs.md new file mode 100644 index 00000000..4b21a752 --- /dev/null +++ b/test/visual/resourcepack/assets/guidenh/guidenh/_en_us/visualtest/mermaid/deep-subgraphs.md @@ -0,0 +1,117 @@ +--- +navigation: + title: Mermaid Deep Subgraphs + position: 8113 +--- + +TEST GOAL: 4-5 levels of nested subgraphs + cross-subgraph hierarchical edges + a different direction at each level + +INVARIANTS: nested box containment is correct, cross-level edges neither pierce nor get clipped by boxes, direction takes effect at each level, no compile errors + +## Five-Level Nesting with Cross-Layer Edges + +Expected: Five nested subgraph levels with alternating internal directions; edges jump multiple subgraph boundaries without being clipped. + +```mermaid +flowchart TB + subgraph L1["Level 1"] + direction TB + A[Root] + subgraph L2["Level 2"] + direction LR + B[Node B] + subgraph L3["Level 3"] + direction TB + C[Node C] + subgraph L4["Level 4"] + direction LR + D[Node D] + subgraph L5["Level 5"] + E[Node E] + F[Node F] + end + end + G[Node G] + end + end + subgraph L2B["Level 2 Branch"] + direction TB + H[Node H] + I[Node I] + end + end + + A --> B + A --> H + B --> C + C --> D + C --> G + D --> E + D --> F + E --> G + F --> I + G --> H + B --> I +``` + +## Four-Level Nesting with Subgraph Edges + +Expected: Four nested subgraphs; edges declared between nodes of sibling subgraphs route around boxes. + +```mermaid +flowchart LR + subgraph S1["Subgraph One"] + direction LR + A[Alpha] + B[Beta] + subgraph S2["Sub One Deep"] + C[Gamma] + D[Delta] + subgraph S3["Sub One Deeper"] + E[Epsilon] + F[Zeta] + end + end + end + subgraph S4["Subgraph Two"] + direction LR + G[Eta] + H[Theta] + end + + A --> C + B --> D + C --> E + D --> F + E --> G + F --> H + A --> H + B --> G +``` + +## Nested Subgraph with linkStyle + +Expected: linkStyle indices target edges in declaration order even when edges are declared inside subgraphs. + +```mermaid +flowchart TB + subgraph Core["Core"] + direction TB + P[Parse] + Q[Validate] + R[Render] + end + subgraph Side["Side"] + S[Log] + T[Notify] + end + + P --> Q + Q --> R + Q --> S + S --> T + T --> R + + linkStyle 0 stroke:#9ece6a,stroke-width:3px + linkStyle 3 stroke:#e0af68,stroke-width:2px +``` diff --git a/test/visual/resourcepack/assets/guidenh/guidenh/_en_us/visualtest/mermaid/directions.md b/test/visual/resourcepack/assets/guidenh/guidenh/_en_us/visualtest/mermaid/directions.md new file mode 100644 index 00000000..d85e0578 --- /dev/null +++ b/test/visual/resourcepack/assets/guidenh/guidenh/_en_us/visualtest/mermaid/directions.md @@ -0,0 +1,76 @@ +--- +navigation: + title: Mermaid Directions + position: 8110 +--- + +TEST GOAL: the four flowchart directions (LR / TB / RL / BT) + the `graph` synonym (including the TD abbreviation) + +INVARIANTS: each diagram's layout direction is correct, arrow directions follow the diagram direction, no compile errors + +## LR (Left to Right) + +Expected: Nodes laid out left-to-right; arrows point right. + +```mermaid +flowchart LR + A[Start] --> B[Process] --> C[End] + B --> D[Branch] + D --> C +``` + +## TB (Top to Bottom) + +Expected: Nodes laid out top-to-bottom; arrows point down. + +```mermaid +flowchart TB + Top[Top] --> Mid[Middle] --> Bot[Bottom] + Mid --> Side[Side] + Side --> Bot +``` + +## RL (Right to Left) + +Expected: Nodes laid out right-to-left; arrows point left. + +```mermaid +flowchart RL + A[Right] --> B[Center] --> C[Left] + B --> D[Extra] + D --> C +``` + +## BT (Bottom to Top) + +Expected: Nodes laid out bottom-to-top; arrows point up. + +```mermaid +flowchart BT + A[Bottom] --> B[Middle] --> C[Top] + B --> D[Branch] + D --> C +``` + +## graph Synonym (TD abbreviation) + +Expected: `graph TD` behaves identically to `flowchart TB`; `graph` is accepted as a flowchart declaration keyword. + +```mermaid +graph TD + A[Graph Start] --> B[Graph End] + A --> C[Graph Branch] + C --> B +``` + +## graph LR + +Expected: `graph LR` renders the same layout as `flowchart LR`. + +```mermaid +graph LR + P[Phase 1] --> Q[Phase 2] + P --> R[Phase 3] + Q --> S[Phase 4] + R --> S +``` diff --git a/test/visual/resourcepack/assets/guidenh/guidenh/_en_us/visualtest/mermaid/flowchart.md b/test/visual/resourcepack/assets/guidenh/guidenh/_en_us/visualtest/mermaid/flowchart.md new file mode 100644 index 00000000..ee8a4962 --- /dev/null +++ b/test/visual/resourcepack/assets/guidenh/guidenh/_en_us/visualtest/mermaid/flowchart.md @@ -0,0 +1,84 @@ +--- +navigation: + title: Mermaid Flowchart Shapes and Edges + position: 8180 +--- + +TEST GOAL: flowchart node shapes + arrow styles + edge labels + classDef/linkStyle + +INVARIANTS: arrow directions are correct, labels do not overlap the lines, all shapes render + +## Node Shape Showcase + +Expected: Each node displays a distinct shape — stadium, rounded, diamond, rect, cylinder, subprocess, double-circle. + +```mermaid +flowchart LR + classDef primary fill:#7aa2f7,stroke:#2f3b54,color:#fff + + Start([Start]):::primary + Process(Process):::primary + Check{Valid?} + Store[(Database)] + Action[[Subroutine]] + Accept[Accept] + Done(((Done))) +``` + +## Arrow Styles (Solid / Dashed / Dotted × Triangle / Circle / Cross) + +Expected: Three line styles (solid, dashed, dotted) each combined with three arrow head variants (triangle, circle, cross). Arrows point in correct direction; heads render distinctly. + +```mermaid +flowchart LR + A[Solid] + B[Dashed] + C[Dotted] + D[Circle] + E[Cross] + + A --> D1[Triangle] + A --o C1[Circle] + A --x X1[Cross] + + B -.-> D2[Triangle] + B -.o C2[Circle] + B -.x X2[Cross] + + C ~~> D3[Triangle] + C ~~~ N[NoHead] +``` + +## Edge Labels + +Expected: Labels appear centered on edges without overlapping the line or nodes. + +```mermaid +flowchart LR + A[Start] -->|Process| B[End] + C[Begin] -.->|Fallback| D[Finish] + E[Init] ==>|Critical| F[Complete] +``` + +## classDef and linkStyle + +Expected: Nodes styled with classDef show custom fill/stroke colors. linkStyle changes edge stroke color and width. + +```mermaid +flowchart TB + classDef success fill:#9ece6a,stroke:#2f3b54,color:#fff + classDef error fill:#f7768e,stroke:#2f3b54,color:#fff + classDef warn fill:#e0af68,stroke:#2f3b54,color:#fff + + A[OK]:::success + B[Fail]:::error + C[Caution]:::warn + D[Done]:::success + + A --> B + A --> C + C --> D + + linkStyle 0 stroke:#f7768e,stroke-width:3px + linkStyle 2 stroke:#9ece6a,stroke-width:2px +``` diff --git a/test/visual/resourcepack/assets/guidenh/guidenh/_en_us/visualtest/mermaid/large.md b/test/visual/resourcepack/assets/guidenh/guidenh/_en_us/visualtest/mermaid/large.md new file mode 100644 index 00000000..ebeb9f79 --- /dev/null +++ b/test/visual/resourcepack/assets/guidenh/guidenh/_en_us/visualtest/mermaid/large.md @@ -0,0 +1,107 @@ +--- +navigation: + title: Mermaid Large Diagram + position: 8150 +--- + +TEST GOAL: a large 20+ node diagram, verifying the container's initial viewport and page-height behavior + +INVARIANTS: the initial viewport is reasonable and does not grow the page indefinitely + +## Large Flowchart (20+ Nodes) + +Expected: All 23 nodes rendered in a single diagram; initial viewport shows a reasonable center-cropped portion; page height unaffected by diagram overflow (scroll within the Mermaid container). + +```mermaid +flowchart TB + classDef process fill:#7aa2f7,stroke:#2f3b54,color:#fff + classDef data fill:#9ece6a,stroke:#2f3b54,color:#fff + classDef decision fill:#e0af68,stroke:#2f3b54,color:#fff + classDef term fill:#f7768e,stroke:#2f3b54,color:#fff + + Start([Start]):::term + Load[Load Config]:::process + Validate{Valid?}:::decision + Parse[Parse Input]:::process + Check{Has Data?}:::decision + Fetch[Fetch Records]:::process + Process[Process Each]:::process + Filter[Apply Filter]:::process + Sort[Sort Results]:::process + Format[Format Output]:::process + Render[Render View]:::process + Error[Error Handler]:::term + Retry[Retry]:::process + Log[Log Error]:::process + Notify[Notify Admin]:::process + Cache[Cache Result]:::data + Store[(Database)]:::data + Backup[Backup]:::process + Audit[Audit Trail]:::process + Done(((Done))):::term + Report[Generate Report]:::process + Export[Export Data]:::process + Cleanup[Cleanup]:::process + + Start --> Load + Load --> Validate + Validate -->|Yes| Parse + Validate -->|No| Error + Parse --> Check + Check -->|Yes| Fetch + Check -->|No| Error + Fetch --> Process + Process --> Filter + Filter --> Sort + Sort --> Format + Format --> Render + Render --> Done + Error --> Retry + Error --> Log + Error --> Notify + Retry --> Load + Log --> Report + Notify --> Report + Done --> Cache + Done --> Backup + Done --> Audit + Cache --> Store + Backup --> Export + Audit --> Cleanup + Report --> Done +``` + +## Large Mindmap (20+ Nodes) + +Expected: Mindmap with 21 nodes rendered; tree structure visible with root at center and alternating left/right subtrees. + +```mermaid +mindmap + root((Project)) + Planning + Requirements + Design + Review + Approval + Development + Frontend + React + CSS + Tests + Backend + API + Database + Auth + DevOps + CI/CD + Docker + Monitoring + QA + Unit Tests + Integration + E2E + Deploy + Staging + Production + Rollback +``` diff --git a/test/visual/resourcepack/assets/guidenh/guidenh/_en_us/visualtest/mermaid/links.md b/test/visual/resourcepack/assets/guidenh/guidenh/_en_us/visualtest/mermaid/links.md new file mode 100644 index 00000000..fdbffc03 --- /dev/null +++ b/test/visual/resourcepack/assets/guidenh/guidenh/_en_us/visualtest/mermaid/links.md @@ -0,0 +1,107 @@ +--- +navigation: + title: Mermaid Link Styles + position: 8112 +--- + +TEST GOAL: the full arrow style set (solid/thick/dashed/dotted × triangle/circle/cross heads) + edge labels + linkStyle + +INVARIANTS: every line type and arrow head renders correctly, edge labels do not overlap the lines, linkStyle color and line width take effect, no compile errors + +## Solid Links — triangle / circle / cross heads + +Expected: Solid lines with triangle, circle, and cross arrow heads in both directions. + +```mermaid +flowchart LR + A[Solid] --> B[Arrow] + C[Line] --- D[Line] + E[Rev] <--- F[Reverse] + G[Both] <--> H[Bidirectional] + I[Circle] --o J[Circle Head] + K[CircleRev] o-- L[Circle Tail] + M[CircleBoth] o--o N[Circle Both] + O[Cross] --x P[Cross Head] + Q[CrossRev] x-- R[Cross Tail] + S[CrossBoth] x--x T[Cross Both] +``` + +## Thick Links + +Expected: Thick (=) lines render visibly thicker with matching arrow heads. + +```mermaid +flowchart LR + U[Thick] ==> V[Thick Arrow] + W[ThickLine] === X[Thick Line] + Y[ThickBoth] <=> Z[Thick Both] + AA[ThickRev] <=== AB[Thick Reverse] + AC[ThickCircle] ==o AD[TC Head] + AE[ThickCircleRev] o== AF[TC Tail] + AG[ThickBothLong] <===> AH[Thick Long Both] +``` + +## Dashed / Dotted Links + +Expected: Dashed (dot) and dotted (tilde) lines render with distinct patterns. + +```mermaid +flowchart LR + AI[Dashed] -.-> AJ[Dashed Arrow] + AK[DashedLine] -.- AL[Dashed Line] + AM[DashedBoth] <-.-> AN[Dashed Both] + AO[DashedRev] <-.-- AP[Dashed Reverse] + AQ[Dotted] ~~> AR[Dotted Arrow] + AS[DottedLine] ~~~ AT[Dotted Line] + AU[DottedBoth] <~~> AV[Dotted Both] + AW[DottedCircle] ~~o AX[DT Circle] +``` + +## Variable Length Links + +Expected: Longer arrows render longer without breaking; arrow heads stay attached. + +```mermaid +flowchart LR + L1[Short] --> L2[Short] + L2 ---> L3[Medium] + L3 ----> L4[Long] + L4 ---------> L5[Very Long] + M1[Thick1] ==> M2[Short] + M2 ====> M3[Longer] +``` + +## Edge Labels + +Expected: Labels appear centered on edges without overlapping the line or nodes. + +```mermaid +flowchart LR + A[Start] -->|Yes| B[Next] + A -->|No| C[Skip] + B ==>|Critical| D[Done] + C -.->|Fallback| D + E[Init] ---|Plain| F[Result] + G[Root] <-->|Sync| H[Peer] + I[Source] -->|Chinese 标签| J[Target] +``` + +## linkStyle — colors / width / interpolate + +Expected: linkStyle changes edge stroke color, width, and interpolation per edge index. + +```mermaid +flowchart TB + classDef process fill:#7aa2f7,stroke:#2f3b54,color:#fff + + A[Alpha]:::process --> B[Beta]:::process + B --> C[Gamma]:::process + C --> D[Delta]:::process + D --> E[Epsilon]:::process + A --> F[Extra]:::process + + linkStyle 0 stroke:#f7768e,stroke-width:3px + linkStyle 1 stroke:#9ece6a,stroke-width:2px + linkStyle 2 interpolate basis stroke:#e0af68 + linkStyle 3 stroke:#7aa2f7,stroke-width:4px +``` diff --git a/test/visual/resourcepack/assets/guidenh/guidenh/_en_us/visualtest/mermaid/mindmap-variants.md b/test/visual/resourcepack/assets/guidenh/guidenh/_en_us/visualtest/mermaid/mindmap-variants.md new file mode 100644 index 00000000..ecd5d35c --- /dev/null +++ b/test/visual/resourcepack/assets/guidenh/guidenh/_en_us/visualtest/mermaid/mindmap-variants.md @@ -0,0 +1,137 @@ +--- +navigation: + title: Mermaid Mindmap Variants + position: 8115 +--- + +TEST GOAL: mindmap with many branches, deep nesting (10+ levels), both default and tidy-tree modes, shape and class variants + +INVARIANTS: tree edges are correct, nodes do not overlap, deep nesting does not mix levels, the two modes are clearly distinguishable, no compile errors + +## Multi-Branch (Default Mode) + +Expected: Root with four mixed-depth subtrees; children alternate left/right around the root. + +```mermaid +mindmap + root((Guide)) + Overview + What + Why + How + Reference + Mermaid + Flowchart + Mindmap + Markdown + Guide + Tables + Images + Notes + Tips + Warnings +``` + +## Deep Nesting (12 Levels, Default Mode) + +Expected: A single 12-level chain renders without level collision or node overlap. + +```mermaid +mindmap + L0(Level 0) + L1(Level 1) + L2(Level 2) + L3(Level 3) + L4(Level 4) + L5(Level 5) + L6(Level 6) + L7(Level 7) + L8(Level 8) + L9(Level 9) + L10(Level 10) + L11(Level 11) +``` + +## Deep + Wide Mixed (Default Mode) + +Expected: Alternating deep chains and wide fans; no sibling overlap at any depth. + +```mermaid +mindmap + root((Core)) + Alpha + Alpha1 + Alpha1a + Alpha1b + Alpha1c + Alpha1d + Alpha2 + Alpha3 + Beta + Beta1 + Beta1a + Beta1a1 + Beta1a1a + Beta1b + Gamma + Gamma1 + Gamma2 + Gamma3 + Gamma4 + Delta + Delta1 + Delta1a + Delta1a1 + Delta1a1a + Delta1a1a1 + Delta1a1b +``` + +## Tidy-Tree Mode + +Expected: Tree rendered top-down with children stacked below parents; frontmatter `layout: tidy-tree` activates the mode. + +```mermaid +--- +config: + layout: tidy-tree +--- +mindmap + root((Project)) + Backend + API + Routes + Middleware + Database + Schema + Migrations + Frontend + React + Components + Hooks + CSS + DevOps + CI + Deploy +``` + +## Shape + Class Variants (Tidy-Tree) + +Expected: Mindmap shapes (circle, hexagon, cloud, bang, subroutine, cylinder, stadium) plus `:::` class suffix render correctly under tidy-tree layout. + +```mermaid +--- +config: + layout: tidy-tree +--- +mindmap + root((GuideNH)) + rounded(Blocks):::primary + circle((Tags)):::primary + hexagon{{JSON}}:::code + cloud)Data( + subroutine[[Recipes]]:::primary + cylinder[(Storage)]:::data + stadium([Output]):::code + bang))Notes(( +``` diff --git a/test/visual/resourcepack/assets/guidenh/guidenh/_en_us/visualtest/mermaid/mindmap.md b/test/visual/resourcepack/assets/guidenh/guidenh/_en_us/visualtest/mermaid/mindmap.md new file mode 100644 index 00000000..7406255d --- /dev/null +++ b/test/visual/resourcepack/assets/guidenh/guidenh/_en_us/visualtest/mermaid/mindmap.md @@ -0,0 +1,73 @@ +--- +navigation: + title: Mermaid Mindmap Modes + position: 8190 +--- + +TEST GOAL: the two mindmap layout modes + node shapes + multi-level nesting + +INVARIANTS: tree edges are correct, nodes do not overlap, deep nesting does not mix levels + +## Default Layout (Alternating Left-Right) + +Expected: Root rendered center with children alternating left and right; ≥4 levels of nesting; shapes rendered as rounded (ROUNDED), circle (CIRCLE), hexagon (HEXAGON), cloud (CLOUD), bang (BANG). + +```mermaid +mindmap + root((GuideNH)) + rounded(Blocks) + circle((Tag)) + hexagon{{JSON}} + cloud)XML( + bang))NBT(( + subcategories(Recipes) + Shaped + Shapeless + elements(Items) + Tools + Armor + Blocks +``` + +## TIDY_TREE Layout Mode + +Expected: Tree rendered top-down with children stacked vertically below parent; frontmatter activates tidy-tree mode. + +```mermaid +--- +config: + layout: tidy-tree +--- +mindmap + root((GuideNH)) + rounded(Blocks) + circle((Tag)) + hexagon{{JSON}} + bang))NBT(( + subcategories(Recipes) + Shaped + Mining + Smelting + Shapeless + Potions + elements(Items) + Tools + Armor +``` + +## Deep Nesting (6 Levels) + +Expected: In default mindmap mode a deep single-child chain renders as a horizontal spine (each child centered on its parent's row, depth expressed along X) with elbow connection lines and no sibling overlap; vertical top-down hierarchy is the tidy-tree mode (see section above). (Engine enhancement candidate: vertical stagger for deep unary chains to match reference mermaid.) + +```mermaid +mindmap + A(Alpha) + B(Bravo) + C(Charlie) + D(Delta) + E(Echo) + F(Foxtrot) + G(Golf) + H(Hotel) + I(India) +``` diff --git a/test/visual/resourcepack/assets/guidenh/guidenh/_en_us/visualtest/mermaid/mixed-multi.md b/test/visual/resourcepack/assets/guidenh/guidenh/_en_us/visualtest/mermaid/mixed-multi.md new file mode 100644 index 00000000..1df9e53f --- /dev/null +++ b/test/visual/resourcepack/assets/guidenh/guidenh/_en_us/visualtest/mermaid/mixed-multi.md @@ -0,0 +1,116 @@ +--- +navigation: + title: Mermaid Mixed Multi Segments + position: 8118 +--- + +TEST GOAL: several mixed diagrams on one page — mindmaps and flowcharts alternating, chains of several diagrams, fenced and `` tag forms mixed + +INVARIANTS: each diagram compiles and renders independently, types do not interfere with each other, none is a stub, no compile errors + +## Segment 1: Mindmap (Overview) + +Expected: First mindmap renders on its own. + +```mermaid +mindmap + root((Part 1)) + Topics + Mermaid + Markdown + Tools + Renderer + Layout +``` + +## Segment 2: Flowchart (Pipeline) + +Expected: First flowchart renders on its own. + +```mermaid +flowchart LR + A[Input] --> B[Parse] --> C[Render] + B --> D[Error] + D --> E[Retry] + E --> B +``` + +## Segment 3: Mindmap (Detail) + +Expected: Second mindmap renders after the flowchart without interference. + +```mermaid +mindmap + root((Part 3)) + Detail + Deep + Deeper + Deepest + Wide + W1 + W2 + W3 +``` + +## Segment 4: Flowchart (Loop Chain) + +Expected: A longer chain mixed with a branch; consecutive mermaid blocks do not bleed into each other. + +```mermaid +flowchart TB + Start([Start]) + Load[Load Config] + Check{Ready?} + Build[Build Index] + Serve[Serve Page] + Done(((Done))) + Fail[Fail] + + Start --> Load + Load --> Check + Check -->|Yes| Build + Check -->|No| Fail + Build --> Serve + Serve --> Done +``` + +## Segment 5: Mermaid Tag (Mindmap) + +Expected: An `` tag mindmap renders as its own block in the same page. + + +mindmap + root["Tag Segment"] + Tag Node A + Tag Node B + Tag Leaf B1 + + +## Segment 6: Mermaid Tag (Flowchart) + +Expected: An `` tag flowchart renders as its own block in the same page. + + +flowchart LR + subgraph TagSub["Tag Subgraph"] + A[Start] + B[End] + end + A --> B + + +## Segment 7: Final Combined + +Expected: A final alternating pair closes the page. + +```mermaid +mindmap + root((Final)) + Mindmap Node + Another +``` + +```mermaid +flowchart LR + X[Begin] --> Y[Middle] --> Z[End] +``` diff --git a/test/visual/resourcepack/assets/guidenh/guidenh/_en_us/visualtest/mermaid/node-content-tags.md b/test/visual/resourcepack/assets/guidenh/guidenh/_en_us/visualtest/mermaid/node-content-tags.md new file mode 100644 index 00000000..bc46824e --- /dev/null +++ b/test/visual/resourcepack/assets/guidenh/guidenh/_en_us/visualtest/mermaid/node-content-tags.md @@ -0,0 +1,84 @@ +--- +navigation: + title: Mermaid MermaidTag Size Variants + position: 8117 +--- + +TEST GOAL: several width/height variants of the `` tag (200x150, 400x300, 900x600, 1200x800) + +INVARIANTS: each sized tag renders at its declared size, none is a stub, no compile errors; large tags expose horizontal/vertical overflow behavior + +## 200x150 + +Expected: Canvas rendered at 200x150 with the 3-node mindmap inside. + + +mindmap + root["Tiny"] + A + B + C + + +## 400x300 + +Expected: Canvas rendered at 400x300 with the 5-node mindmap inside. + + +mindmap + root["Small"] + One + Two + Three + Four + + +## 900x600 + +Expected: Canvas rendered at 900x600 with a wider 9-node mindmap inside. + + +mindmap + root["Medium"] + Alpha + Alpha1 + Alpha2 + Beta + Beta1 + Beta2 + Gamma + Gamma1 + Delta + Delta1 + + +## 1200x800 + +Expected: Canvas rendered at 1200x800 — wider than the 900px page layout, so horizontal overflow behaviour is exposed. + + +mindmap + root["Wide"] + Branch A + A1 + A2 + A3 + A4 + A5 + Branch B + B1 + B2 + B3 + B4 + Branch C + C1 + C2 + Branch D + D1 + D2 + D3 + D4 + Branch E + E1 + E2 + diff --git a/test/visual/resourcepack/assets/guidenh/guidenh/_en_us/visualtest/mermaid/node-content.md b/test/visual/resourcepack/assets/guidenh/guidenh/_en_us/visualtest/mermaid/node-content.md new file mode 100644 index 00000000..65ac9f3e --- /dev/null +++ b/test/visual/resourcepack/assets/guidenh/guidenh/_en_us/visualtest/mermaid/node-content.md @@ -0,0 +1,52 @@ +--- +navigation: + title: Mermaid NodeContent Rich Blocks + position: 8160 +--- + +TEST GOAL: `` rich-content nodes (formatted text / lists embedded inside a node) + +INVARIANTS: rich content is laid out correctly inside the node box + +Expected: A mindmap where the "runtime" node contains formatted text with an item image, and the "preview" node contains a list. + + +mindmap + root["GuideNH Features"] + runtime["Runtime Blocks"] + preview["Scene Preview"] + + +Runtime nodes support **bold text**, *italic text*, and inline . + +Here is a second paragraph inside a runtime node. + + + +Preview content can contain lists: + +- Block images +- Entity views +- Annotations + +And also **formatted** text. + + + +## NodeContent in Flowchart + +Expected: A flowchart node with NodeContent renders a crafting recipe block inside it. + + +flowchart LR + subgraph Craft["Crafting"] + Ingredients[Ingredients] + Result[Result] + end + + Ingredients --> Result + + + + + diff --git a/test/visual/resourcepack/assets/guidenh/guidenh/_en_us/visualtest/mermaid/rich-text.md b/test/visual/resourcepack/assets/guidenh/guidenh/_en_us/visualtest/mermaid/rich-text.md new file mode 100644 index 00000000..120c224c --- /dev/null +++ b/test/visual/resourcepack/assets/guidenh/guidenh/_en_us/visualtest/mermaid/rich-text.md @@ -0,0 +1,102 @@ +--- +navigation: + title: Mermaid Rich Text Labels + position: 8116 +--- + +TEST GOAL: rich text markup inside nodes (**bold**, _italic_, `code`), inline `
          `, special characters (Chinese / emoji / math symbols / long URLs) + +INVARIANTS: rich text markup renders as literal text without crashing, `
          ` wraps correctly, special characters are neither lost nor overlapping, no compile errors + +## Markup Literals in Labels + +Expected: `**bold**` / `_italic_` / `` `code` `` markers render as literal text inside node labels (markdown markup is not processed); no crash, no truncated text. + +```mermaid +flowchart TB + B[**bold text**] + I[_italic text_] + C[plain `code` span] + M[**bold** and _italic_ together] + S[~~strikethrough~~ marker] + + B --> I --> C + C --> M + M --> S +``` + +## Inline
          Line Breaks + +Expected: `
          ` / `
          ` inside a label produce a line break; node box height grows accordingly. + +```mermaid +flowchart TB + A[Line one
          Line two] + B[Alpha
          Beta
          Gamma] + C[Short
          also works] + D[Multi
          line
          node
          with
          five
          rows] + + A --> B + B --> C + C --> D +``` + +## Special Characters: Chinese / emoji / math + +Expected: CJK, emoji, and math symbols render without missing glyphs or overlapping boxes. + +```mermaid +flowchart LR + zh1[合成配方] + zh2[地狱岩浆引擎升级] + em1[⚡ 闪电] + em2[⛏ 镐] + em3[🔥 火焰] + em4[🌍 世界] + math1[E = mc²] + math2[∑ π √ ∞ ∫] + mixed[配方 3 × 5 = 15] + + zh1 --> zh2 + em1 --> em2 + em3 --> em4 + math1 --> math2 + zh1 --> mixed + em4 --> mixed +``` + +## Long URL in Label + +Expected: A long unbroken URL folds via codepoint-level wrapping; node stays inside the diagram bounds. + +```mermaid +flowchart TB + U[https://example.com/very/long/path/with/many/segments/and?a=1&b=2&c=3#fragment] + V[Short URL https://a.b/c] + W[中文链接 https://例子.中文/路径/很长/的一部分] + + U --> V + V --> W +``` + +## Mindmap Rich Labels + +Expected: Mindmap nodes carry the same rich/special text without breaking the tree structure. + +```mermaid +mindmap + root((GuideNH)) + **Bold** Branch + _Italic_ Leaf + `Code` Leaf + Emoji Branch + ⚡ Fast + 🔥 Hot + Math Branch + E = mc² + π ≈ 3.14159 + Chinese + 合成配方 + 矿物词典 + Line Break + First
          Second diff --git a/test/visual/resourcepack/assets/guidenh/guidenh/_en_us/visualtest/mermaid/shapes.md b/test/visual/resourcepack/assets/guidenh/guidenh/_en_us/visualtest/mermaid/shapes.md new file mode 100644 index 00000000..728abaa0 --- /dev/null +++ b/test/visual/resourcepack/assets/guidenh/guidenh/_en_us/visualtest/mermaid/shapes.md @@ -0,0 +1,76 @@ +--- +navigation: + title: Mermaid Node Shapes + position: 8111 +--- + +TEST GOAL: every node shape syntax variant + Chinese-label nodes + +INVARIANTS: each shape renders its corresponding outline, Chinese labels lose no glyphs and do not overlap, no compile errors + +## All Shape Variants + +Expected: Each node renders its documented shape distinct from the others. + +```mermaid +flowchart TB + A[Square] + B(Round) + C([Stadium]) + D[[Subroutine]] + E[(Cylinder)] + F{Diamond} + G{{Hexagon}} + H(-Ellipse-) + I(((Double Circle))) + J[/Trapezoid\] + K[\InvTrapezoid/] + L[/LeanRight/] + M[\LeanLeft\] + N>Asymmetric] + O))Bang(( + P)Cloud( +``` + +## Chinese Labels (with shape mix) + +Expected: Chinese labels render without missing characters; node boxes size to fit CJK text. + +```mermaid +flowchart TB + c1[合成配方] + c2(合成与分解) + c3([圆角中文]) + c4[[子流程中文]] + c5[(数据库中文)] + c6{判定中文} + c7{{六角中文}} + c8[地狱岩浆引擎升级] + + c1 --> c2 --> c3 + c2 --> c4 + c3 --> c5 + c4 --> c6 + c6 --> c7 + c6 --> c8 +``` + +## Shape + classDef combo + +Expected: Shape geometries combine with classDef colors; hexagon/diamond/trapezoid keep their outline under fill styling. + +```mermaid +flowchart LR + classDef blue fill:#7aa2f7,stroke:#2f3b54,color:#fff + classDef green fill:#9ece6a,stroke:#2f3b54,color:#fff + + s1[Square]:::blue + s2{Diamond}:::green + s3{{Hexagon}}:::blue + s4[(Cylinder)]:::green + s5([Stadium]):::blue + + s1 --> s2 --> s3 + s2 --> s4 + s4 --> s5 +``` diff --git a/test/visual/resourcepack/assets/guidenh/guidenh/_en_us/visualtest/mermaid/stress-large.md b/test/visual/resourcepack/assets/guidenh/guidenh/_en_us/visualtest/mermaid/stress-large.md new file mode 100644 index 00000000..35cd0133 --- /dev/null +++ b/test/visual/resourcepack/assets/guidenh/guidenh/_en_us/visualtest/mermaid/stress-large.md @@ -0,0 +1,382 @@ +--- +navigation: + title: Mermaid Stress Large + position: 8114 +--- + +TEST GOAL: a 100+ node flowchart TB (many levels and branches) + a 50+ node mindmap, for stress/performance exposure + +INVARIANTS: large-diagram layout completes without crashing, no compile errors, not a stub + +## Large Flowchart (100 Nodes, 10x10 mesh) + +Expected: 100 nodes laid out in a 10-layer grid with vertical, horizontal, and diagonal edges; layout completes without crash or unbounded canvas. + +```mermaid +flowchart TB + classDef p fill:#7aa2f7,stroke:#2f3b54,color:#fff + classDef d fill:#9ece6a,stroke:#2f3b54,color:#fff + + L0N0[L0N0]:::p + L0N1[L0N1]:::d + L0N2[L0N2]:::p + L0N3[L0N3]:::d + L0N4[L0N4]:::p + L0N5[L0N5]:::d + L0N6[L0N6]:::p + L0N7[L0N7]:::d + L0N8[L0N8]:::p + L0N9[L0N9]:::d + L1N0[L1N0]:::p + L1N1[L1N1]:::d + L1N2[L1N2]:::p + L1N3[L1N3]:::d + L1N4[L1N4]:::p + L1N5[L1N5]:::d + L1N6[L1N6]:::p + L1N7[L1N7]:::d + L1N8[L1N8]:::p + L1N9[L1N9]:::d + L2N0[L2N0]:::p + L2N1[L2N1]:::d + L2N2[L2N2]:::p + L2N3[L2N3]:::d + L2N4[L2N4]:::p + L2N5[L2N5]:::d + L2N6[L2N6]:::p + L2N7[L2N7]:::d + L2N8[L2N8]:::p + L2N9[L2N9]:::d + L3N0[L3N0]:::p + L3N1[L3N1]:::d + L3N2[L3N2]:::p + L3N3[L3N3]:::d + L3N4[L3N4]:::p + L3N5[L3N5]:::d + L3N6[L3N6]:::p + L3N7[L3N7]:::d + L3N8[L3N8]:::p + L3N9[L3N9]:::d + L4N0[L4N0]:::p + L4N1[L4N1]:::d + L4N2[L4N2]:::p + L4N3[L4N3]:::d + L4N4[L4N4]:::p + L4N5[L4N5]:::d + L4N6[L4N6]:::p + L4N7[L4N7]:::d + L4N8[L4N8]:::p + L4N9[L4N9]:::d + L5N0[L5N0]:::p + L5N1[L5N1]:::d + L5N2[L5N2]:::p + L5N3[L5N3]:::d + L5N4[L5N4]:::p + L5N5[L5N5]:::d + L5N6[L5N6]:::p + L5N7[L5N7]:::d + L5N8[L5N8]:::p + L5N9[L5N9]:::d + L6N0[L6N0]:::p + L6N1[L6N1]:::d + L6N2[L6N2]:::p + L6N3[L6N3]:::d + L6N4[L6N4]:::p + L6N5[L6N5]:::d + L6N6[L6N6]:::p + L6N7[L6N7]:::d + L6N8[L6N8]:::p + L6N9[L6N9]:::d + L7N0[L7N0]:::p + L7N1[L7N1]:::d + L7N2[L7N2]:::p + L7N3[L7N3]:::d + L7N4[L7N4]:::p + L7N5[L7N5]:::d + L7N6[L7N6]:::p + L7N7[L7N7]:::d + L7N8[L7N8]:::p + L7N9[L7N9]:::d + L8N0[L8N0]:::p + L8N1[L8N1]:::d + L8N2[L8N2]:::p + L8N3[L8N3]:::d + L8N4[L8N4]:::p + L8N5[L8N5]:::d + L8N6[L8N6]:::p + L8N7[L8N7]:::d + L8N8[L8N8]:::p + L8N9[L8N9]:::d + L9N0[L9N0]:::p + L9N1[L9N1]:::d + L9N2[L9N2]:::p + L9N3[L9N3]:::d + L9N4[L9N4]:::p + L9N5[L9N5]:::d + L9N6[L9N6]:::p + L9N7[L9N7]:::d + L9N8[L9N8]:::p + L9N9[L9N9]:::d + + L0N0 --> L1N0 + L0N1 --> L1N1 + L0N2 --> L1N2 + L0N3 --> L1N3 + L0N4 --> L1N4 + L0N5 --> L1N5 + L0N6 --> L1N6 + L0N7 --> L1N7 + L0N8 --> L1N8 + L0N9 --> L1N9 + L1N0 --> L2N0 + L1N1 --> L2N1 + L1N2 --> L2N2 + L1N3 --> L2N3 + L1N4 --> L2N4 + L1N5 --> L2N5 + L1N6 --> L2N6 + L1N7 --> L2N7 + L1N8 --> L2N8 + L1N9 --> L2N9 + L2N0 --> L3N0 + L2N1 --> L3N1 + L2N2 --> L3N2 + L2N3 --> L3N3 + L2N4 --> L3N4 + L2N5 --> L3N5 + L2N6 --> L3N6 + L2N7 --> L3N7 + L2N8 --> L3N8 + L2N9 --> L3N9 + L3N0 --> L4N0 + L3N1 --> L4N1 + L3N2 --> L4N2 + L3N3 --> L4N3 + L3N4 --> L4N4 + L3N5 --> L4N5 + L3N6 --> L4N6 + L3N7 --> L4N7 + L3N8 --> L4N8 + L3N9 --> L4N9 + L4N0 --> L5N0 + L4N1 --> L5N1 + L4N2 --> L5N2 + L4N3 --> L5N3 + L4N4 --> L5N4 + L4N5 --> L5N5 + L4N6 --> L5N6 + L4N7 --> L5N7 + L4N8 --> L5N8 + L4N9 --> L5N9 + L5N0 --> L6N0 + L5N1 --> L6N1 + L5N2 --> L6N2 + L5N3 --> L6N3 + L5N4 --> L6N4 + L5N5 --> L6N5 + L5N6 --> L6N6 + L5N7 --> L6N7 + L5N8 --> L6N8 + L5N9 --> L6N9 + L6N0 --> L7N0 + L6N1 --> L7N1 + L6N2 --> L7N2 + L6N3 --> L7N3 + L6N4 --> L7N4 + L6N5 --> L7N5 + L6N6 --> L7N6 + L6N7 --> L7N7 + L6N8 --> L7N8 + L6N9 --> L7N9 + L7N0 --> L8N0 + L7N1 --> L8N1 + L7N2 --> L8N2 + L7N3 --> L8N3 + L7N4 --> L8N4 + L7N5 --> L8N5 + L7N6 --> L8N6 + L7N7 --> L8N7 + L7N8 --> L8N8 + L7N9 --> L8N9 + L8N0 --> L9N0 + L8N1 --> L9N1 + L8N2 --> L9N2 + L8N3 --> L9N3 + L8N4 --> L9N4 + L8N5 --> L9N5 + L8N6 --> L9N6 + L8N7 --> L9N7 + L8N8 --> L9N8 + L8N9 --> L9N9 + L0N0 --> L0N1 + L0N1 --> L0N2 + L0N2 --> L0N3 + L0N3 --> L0N4 + L0N4 --> L0N5 + L0N5 --> L0N6 + L0N6 --> L0N7 + L0N7 --> L0N8 + L0N8 --> L0N9 + L1N0 --> L1N1 + L1N1 --> L1N2 + L1N2 --> L1N3 + L1N3 --> L1N4 + L1N4 --> L1N5 + L1N5 --> L1N6 + L1N6 --> L1N7 + L1N7 --> L1N8 + L1N8 --> L1N9 + L2N0 --> L2N1 + L2N1 --> L2N2 + L2N2 --> L2N3 + L2N3 --> L2N4 + L2N4 --> L2N5 + L2N5 --> L2N6 + L2N6 --> L2N7 + L2N7 --> L2N8 + L2N8 --> L2N9 + L3N0 --> L3N1 + L3N1 --> L3N2 + L3N2 --> L3N3 + L3N3 --> L3N4 + L3N4 --> L3N5 + L3N5 --> L3N6 + L3N6 --> L3N7 + L3N7 --> L3N8 + L3N8 --> L3N9 + L4N0 --> L4N1 + L4N1 --> L4N2 + L4N2 --> L4N3 + L4N3 --> L4N4 + L4N4 --> L4N5 + L4N5 --> L4N6 + L4N6 --> L4N7 + L4N7 --> L4N8 + L4N8 --> L4N9 + L5N0 --> L5N1 + L5N1 --> L5N2 + L5N2 --> L5N3 + L5N3 --> L5N4 + L5N4 --> L5N5 + L5N5 --> L5N6 + L5N6 --> L5N7 + L5N7 --> L5N8 + L5N8 --> L5N9 + L6N0 --> L6N1 + L6N1 --> L6N2 + L6N2 --> L6N3 + L6N3 --> L6N4 + L6N4 --> L6N5 + L6N5 --> L6N6 + L6N6 --> L6N7 + L6N7 --> L6N8 + L6N8 --> L6N9 + L7N0 --> L7N1 + L7N1 --> L7N2 + L7N2 --> L7N3 + L7N3 --> L7N4 + L7N4 --> L7N5 + L7N5 --> L7N6 + L7N6 --> L7N7 + L7N7 --> L7N8 + L7N8 --> L7N9 + L8N0 --> L8N1 + L8N1 --> L8N2 + L8N2 --> L8N3 + L8N3 --> L8N4 + L8N4 --> L8N5 + L8N5 --> L8N6 + L8N6 --> L8N7 + L8N7 --> L8N8 + L8N8 --> L8N9 + L9N0 --> L9N1 + L9N1 --> L9N2 + L9N2 --> L9N3 + L9N3 --> L9N4 + L9N4 --> L9N5 + L9N5 --> L9N6 + L9N6 --> L9N7 + L9N7 --> L9N8 + L9N8 --> L9N9 + L0N0 --> L1N9 + L1N0 --> L2N9 + L2N0 --> L3N9 + L3N0 --> L4N9 + L4N0 --> L5N9 + L5N0 --> L6N9 + L6N0 --> L7N9 + L7N0 --> L8N9 + L8N0 --> L9N9 +``` + +## Large Mindmap (64 Nodes) + +Expected: 64-node mindmap with a wide root fanout and deep single-branch chains; alternating left/right subtrees do not overlap. + +```mermaid +mindmap + root((Root)) + Branch0 + Leaf0_0 + Leaf0_1 + Leaf0_2 + Leaf0_3 + Leaf0_4 + Leaf0_5 + Leaf0_6 + Leaf0_7 + Branch1 + Leaf1_0 + Leaf1_1 + Leaf1_2 + Leaf1_3 + Leaf1_4 + Leaf1_5 + Leaf1_6 + Leaf1_7 + Branch2 + Leaf2_0 + Leaf2_1 + Leaf2_2 + Leaf2_3 + Leaf2_4 + Leaf2_5 + Leaf2_6 + Leaf2_7 + Branch3 + Leaf3_0 + Leaf3_1 + Leaf3_2 + Leaf3_3 + Leaf3_4 + Leaf3_5 + Leaf3_6 + Leaf3_7 + Branch4 + Leaf4_0 + Leaf4_1 + Leaf4_2 + Leaf4_3 + Leaf4_4 + Leaf4_5 + Leaf4_6 + Leaf4_7 + Branch5 + Leaf5_0 + Leaf5_1 + Leaf5_2 + Leaf5_3 + Leaf5_4 + Leaf5_5 + Leaf5_6 + Leaf5_7 + Branch6 + Leaf6_0 + Leaf6_1 + Leaf6_2 + Leaf6_3 + Leaf6_4 + Leaf6_5 + Leaf6_6 + Leaf6_7 +``` diff --git a/test/visual/resourcepack/assets/guidenh/guidenh/_en_us/visualtest/mermaid/subgraphs.md b/test/visual/resourcepack/assets/guidenh/guidenh/_en_us/visualtest/mermaid/subgraphs.md new file mode 100644 index 00000000..8dde5a1c --- /dev/null +++ b/test/visual/resourcepack/assets/guidenh/guidenh/_en_us/visualtest/mermaid/subgraphs.md @@ -0,0 +1,85 @@ +--- +navigation: + title: Mermaid Nested Subgraphs + position: 8170 +--- + +TEST GOAL: nested subgraphs (2 levels, 3 levels) + edges crossing subgraphs + +INVARIANTS: nested box containment is correct, colors are layered, cross edges do not pierce boxes + +## Two-Level Nesting + +Expected: Outer subgraph contains two inner subgraphs; edges connect nodes across subgraph boundaries; subgraph borders use two different color layers (index 0, 1). + +```mermaid +flowchart TB + subgraph Outer["Outer Process"] + direction TB + A[Start] + subgraph Left["Left Branch"] + B[Step 1] + C[Step 2] + end + subgraph Right["Right Branch"] + D[Step 3] + E[Step 4] + end + F[Merge] + end + + A --> B + A --> D + B --> C + C --> F + D --> E + E --> F +``` + +## Three-Level Nesting + +Expected: Three layers of subgraph nesting with distinct background colors per depth level (index 0, 1, 2); edges cross subgraph boundaries correctly. + +```mermaid +flowchart LR + subgraph Level1["Level 1"] + direction LR + A[Root] + subgraph Level2["Level 2"] + B[Middle] + subgraph Level3["Level 3"] + C[Leaf 1] + D[Leaf 2] + end + end + end + + A --> B + B --> C + B --> D + A --> C +``` + +## Cross-Subgraph Edges + +Expected: Edges connecting nodes in different subgraphs pass through subgraph box boundaries without being clipped or misrouted. + +```mermaid +flowchart TB + subgraph SG1["Subgraph A"] + A[Alpha] + B[Beta] + end + subgraph SG2["Subgraph B"] + C[Gamma] + D[Delta] + end + subgraph SG3["Subgraph C"] + E[Epsilon] + end + + A --> C + B --> D + A --> E + C --> E +``` diff --git a/test/visual/resourcepack/assets/guidenh/guidenh/_en_us/visualtest/mermaid/wrap-long-labels.md b/test/visual/resourcepack/assets/guidenh/guidenh/_en_us/visualtest/mermaid/wrap-long-labels.md new file mode 100644 index 00000000..de1ebc33 --- /dev/null +++ b/test/visual/resourcepack/assets/guidenh/guidenh/_en_us/visualtest/mermaid/wrap-long-labels.md @@ -0,0 +1,27 @@ +--- +navigation: + title: Mermaid Wrap (forced long-label fold) + position: 8140 +--- + +TEST GOAL: this page forces mermaid node labels to wrap — long labels trigger word-level wrapping and long space-free words trigger codepoint-level wrapping, verifying that the GuideText.wrap result determines the node box height + +INVARIANTS: long-label nodes wrap onto multiple lines correctly, node box height grows with the wrap, no compile errors, no node overflow + +## Forced Wrap: Long Label vs Unbroken Long Word vs Short Control + +Expected: The long-space-separated label (74 chars, > node single-line budget of 180px) folds across multiple lines via word-level wrapping; the long unbroken word (54 chars, no whitespace) folds via codepoint-level wrapping; the short control label stays on a single line. Node box heights grow with the number of wrapped lines; nothing overflows the diagram bounds. + +```mermaid +flowchart TB + classDef process fill:#7aa2f7,stroke:#2f3b54,color:#fff + classDef data fill:#9ece6a,stroke:#2f3b54,color:#fff + classDef decision fill:#e0af68,stroke:#2f3b54,color:#fff + + Wrapped[This is a deliberately long node label that must wrap across multiple lines]:::process + Codepoint[SupercalifragilisticexpialidociousLongIdentifierString]:::decision + Short[Short]:::data + + Wrapped --> Codepoint + Codepoint --> Short +``` diff --git a/test/visual/resourcepack/assets/guidenh/guidenh/_en_us/visualtest/meta/error-parse.md b/test/visual/resourcepack/assets/guidenh/guidenh/_en_us/visualtest/meta/error-parse.md new file mode 100644 index 00000000..5eb6bd93 --- /dev/null +++ b/test/visual/resourcepack/assets/guidenh/guidenh/_en_us/visualtest/meta/error-parse.md @@ -0,0 +1,17 @@ +--- +navigation: + title: Error Page (Parse Failure) + position: 7800 +--- + +TEST GOAL: error text should render in red (the createErrorFlowContent / buildErrorPage paths) + +INVARIANTS: the attribute-error text, the red of the Color tag and the red of §4 are all the same color; error text is not the gray body-text color. + +Color reference A (Color tag): THIS_SHOULD_BE_RED_VIA_COLOR_TAG + +Color reference B (section code): §4THIS_SHOULD_BE_RED_VIA_SECTION_CODE§r + +Attribute error below (should also be red, was gray): + + diff --git a/test/visual/resourcepack/assets/guidenh/guidenh/_en_us/visualtest/meta/frontmatter.md b/test/visual/resourcepack/assets/guidenh/guidenh/_en_us/visualtest/meta/frontmatter.md new file mode 100644 index 00000000..17e892aa --- /dev/null +++ b/test/visual/resourcepack/assets/guidenh/guidenh/_en_us/visualtest/meta/frontmatter.md @@ -0,0 +1,55 @@ +--- +navigation: + title: Frontmatter Variants + position: 7800 + icon: guidenh:visualtest_fake_item + icon_texture: guidenh:textures/gui/visualtest_fake.png + icons: + - guidenh:visualtest_fake_item + - guidenh:visualtest_fake_item_alt + icon_textures: + - guidenh:textures/gui/visualtest_fake.png + - guidenh:textures/gui/visualtest_fake_b.png +categories: + - visualtest + - frontmatter +item_ids: + - guidenh:visualtest_fake_item + - guidenh:visualtest_fake_item_alt +author: Visual Test Suite +date: 2026-07-28 +--- + +TEST GOAL: frontmatter navigation.icon / icon_texture / icons / icon_textures variants + categories + item_ids + author / date + +INVARIANTS: Sidebar-only fields (icon / icons / icon_textures) must not crash loading even when sidebar is hidden; body renders normally. + +## Navigation Icon (Single) + +Expected: navigation.icon set to guidenh:visualtest_fake_item — sidebar icon uses this item id. Loads without error. + +Body paragraph for the icon page. No sidebar-dependent content here. + +## Navigation Icon Texture (Single) + +Expected: navigation.icon_texture set to guidenh:textures/gui/visualtest_fake.png — sidebar icon uses this texture path. Loads without error. + +## Navigation Icons List + +Expected: navigation.icons with two entries — sidebar cycles through both item icons on each page open. Loads without error. + +## Navigation Icon Textures List + +Expected: navigation.icon_textures with two entries — sidebar cycles through both texture icons. Loads without error. + +## Categories Frontmatter + +Expected: categories: [visualtest, frontmatter] — page appears in CategoryIndex for both categories. + +## Item IDs Frontmatter + +Expected: item_ids: [guidenh:visualtest_fake_item, guidenh:visualtest_fake_item_alt] — these fictional bindings do not resolve to any real item. Loads without error. + +## Author & Date + +Expected: author: Visual Test Suite, date: 2026-07-28 — metadata displays in the page bottom bar if the engine renders it. diff --git a/test/visual/resourcepack/assets/guidenh/guidenh/_en_us/visualtest/meta/indexes-sub-a.md b/test/visual/resourcepack/assets/guidenh/guidenh/_en_us/visualtest/meta/indexes-sub-a.md new file mode 100644 index 00000000..9c7547b9 --- /dev/null +++ b/test/visual/resourcepack/assets/guidenh/guidenh/_en_us/visualtest/meta/indexes-sub-a.md @@ -0,0 +1,17 @@ +--- +navigation: + title: Index Sub Page A + parent: indexes.md + position: 1 +categories: + - visualtest + - subpages +item_ids: + - guidenh:visualtest_fake_sub_a +--- + +TEST GOAL: Auxiliary child page for SubPages testing in indexes.md + +INVARIANTS: Page renders as a navigation child of indexes.md; appears in CategoryIndex under categories visualtest and subpages. + +This is auxiliary page A for the SubPages test. Its frontmatter declares parent: indexes.md so that it appears as a child of the indexes page in the navigation tree. diff --git a/test/visual/resourcepack/assets/guidenh/guidenh/_en_us/visualtest/meta/indexes-sub-b.md b/test/visual/resourcepack/assets/guidenh/guidenh/_en_us/visualtest/meta/indexes-sub-b.md new file mode 100644 index 00000000..330e2964 --- /dev/null +++ b/test/visual/resourcepack/assets/guidenh/guidenh/_en_us/visualtest/meta/indexes-sub-b.md @@ -0,0 +1,17 @@ +--- +navigation: + title: Index Sub Page B + parent: indexes.md + position: 2 +categories: + - visualtest + - subpages +item_ids: + - guidenh:visualtest_fake_sub_b +--- + +TEST GOAL: Auxiliary child page for SubPages testing in indexes.md + +INVARIANTS: Page renders as a navigation child of indexes.md; appears in CategoryIndex under categories visualtest and subpages. + +This is auxiliary page B for the SubPages test. Its frontmatter declares parent: indexes.md so that it appears as a child of the indexes page in the navigation tree. diff --git a/test/visual/resourcepack/assets/guidenh/guidenh/_en_us/visualtest/meta/indexes.md b/test/visual/resourcepack/assets/guidenh/guidenh/_en_us/visualtest/meta/indexes.md new file mode 100644 index 00000000..88032436 --- /dev/null +++ b/test/visual/resourcepack/assets/guidenh/guidenh/_en_us/visualtest/meta/indexes.md @@ -0,0 +1,26 @@ +--- +navigation: + title: Indexes (SubPages + Category) + position: 7770 +categories: + - visualtest + - indexes +item_ids: + - guidenh:visualtest_fake_item +--- + +TEST GOAL: `` navigation child listing + `` category member listing + +INVARIANTS: SubPages renders a link list of child pages; Category renders a listing of pages in the given category; no compile errors. + +## SubPages + +Expected: renders an unordered list of links to child pages (indexes-sub-a.md, indexes-sub-b.md) which declare parent: indexes.md in their frontmatter. Each link title matches the child's navigation.title. + + + +## Category + +Expected: `` renders a listing of all pages that declare categories: [visualtest] in their frontmatter, including pages from meta/ and auxiliary pages. (Note: `` has no compiler in the engine; `` is the real tag.) + + diff --git a/test/visual/resourcepack/assets/guidenh/guidenh/_en_us/visualtest/meta/special.md b/test/visual/resourcepack/assets/guidenh/guidenh/_en_us/visualtest/meta/special.md new file mode 100644 index 00000000..6a42b198 --- /dev/null +++ b/test/visual/resourcepack/assets/guidenh/guidenh/_en_us/visualtest/meta/special.md @@ -0,0 +1,32 @@ +--- +navigation: + title: Special Index (MediaWikiSpecialGeneratedBlock) + position: 7760 +categories: + - visualtest + - indexes +item_ids: + - guidenh:visualtest_fake_item +--- + +TEST GOAL: `` tag family — MediaWikiSpecialGeneratedBlock F7a pixel-font-migrated rendering: Special catalog, category grid, page grid, empty-state fallback. + +INVARIANTS: SpecialPages renders the grouped special-page catalog (2 columns, group headers, clickable link rows, top/bottom 1px borders); all Special blocks render through the primitives/GuideText path (no 8×8 MC pixel font); no compile errors. + +## Special Pages Index (grouped catalog) + +Expected: `` renders the special-page catalog grouped by group (maintenance / lists / media / developer / translation / other), two columns, each entry a clickable title link; top/bottom borders visible. Static-catalog data, always non-empty — this is the F7a Special Index block. + + + +## Categories (grid) + +Expected: `` renders the category list (GRID kind, 2 columns), each row: category title + member-count subtitle, clickable to the category page. Derived from the local CategoryIndex — non-empty in the visualtest corpus. + + + +## All Pages (grid, index-backed; empty-state fallback allowed) + +Expected: `` renders every normal page of the guide (3 columns) with page-id subtitles. Depends on the MediaWiki data index; if the async index is not yet warmed at mount time the block renders the empty-state text instead — which itself verifies the EMPTY_STYLE path of the migrated block. + + diff --git a/test/visual/resourcepack/assets/guidenh/guidenh/_en_us/visualtest/meta/zoom-large.md b/test/visual/resourcepack/assets/guidenh/guidenh/_en_us/visualtest/meta/zoom-large.md new file mode 100644 index 00000000..85240833 --- /dev/null +++ b/test/visual/resourcepack/assets/guidenh/guidenh/_en_us/visualtest/meta/zoom-large.md @@ -0,0 +1,58 @@ +--- +navigation: + title: Zoom Large (1.5) + position: 7780 +zoom: 1.5 +--- + +TEST GOAL: frontmatter zoom: 1.5 enlarges entire page content to 150% + +INVARIANTS: All text and blocks render at 150% scale relative to normal; no overflow clipping despite the larger size. + +## Comparison Text + +Expected: Every element on this page is rendered at 150% of the default zoom level. Compare with zoom-small.md (0.8) to confirm proportional scaling. + +Normal paragraph text at zoom 1.5. This line should appear visibly larger than the default zoom page. The enlarged scale may cause more content to extend beyond the initial viewport — the page should scroll without truncation. + +## Heading Level Two at 1.5 Zoom + +Expected: H2 heading scales up proportionally; spacing increases with zoom. + +More body text to fill visual space. The quick brown fox jumps over the lazy dog. The quick brown fox jumps over the lazy dog. The quick brown fox jumps over the lazy dog. The quick brown fox jumps over the lazy dog. + +## Heading Level Three at 1.5 Zoom + +Expected: H3 heading also scales; hierarchy between H2 and H3 is preserved at enlarged size. + +Additional paragraph to provide enough vertical extent for screenshot comparison. This page uses zoom: 1.5 in frontmatter. All elements — headings, paragraphs, inline styles, code — are affected uniformly. + +### Inline Markup + +Expected: Bold, italic, code, and other inline marks scale with the zoom factor. + +- **bold text** at 1.5 zoom +- *italic text* at 1.5 zoom +- `code span` at 1.5 zoom +- ~~strikethrough~~ at 1.5 zoom + +### Code Block + +Expected: Code block font and padding scale to 150%. + +``` +zoom: 1.5 +navigation: + title: Zoom Test + position: 7780 +``` + +The zoom factor is applied as a page-level transform. All measurements (font sizes, padding, margins, borders) are multiplied by 1.5. + +## Horizontal Ruler + +Expected: Ruler line weight and spacing scale with zoom. + +--- + +Final paragraph at the bottom of the zoom-large page. The total page height at 1.5 zoom should be noticeably taller than the equivalent content at default zoom. diff --git a/test/visual/resourcepack/assets/guidenh/guidenh/_en_us/visualtest/meta/zoom-small.md b/test/visual/resourcepack/assets/guidenh/guidenh/_en_us/visualtest/meta/zoom-small.md new file mode 100644 index 00000000..5424a89d --- /dev/null +++ b/test/visual/resourcepack/assets/guidenh/guidenh/_en_us/visualtest/meta/zoom-small.md @@ -0,0 +1,58 @@ +--- +navigation: + title: Zoom Small (0.8) + position: 7790 +zoom: 0.8 +--- + +TEST GOAL: frontmatter zoom: 0.8 shrinks entire page content to 80% + +INVARIANTS: All text and blocks render at 80% scale relative to normal; layout remains readable; no overflow clipping. + +## Comparison Text + +Expected: Every element on this page is rendered at 80% of the default zoom level. Compare with zoom-large.md (1.5) to confirm proportional scaling. + +Normal paragraph text at zoom 0.8. This line should appear visibly smaller than the default zoom page. + +## Heading Level Two at 0.8 Zoom + +Expected: H2 heading shrinks proportionally; leading and spacing scale with zoom. + +More body text to fill visual space. The quick brown fox jumps over the lazy dog. The quick brown fox jumps over the lazy dog. The quick brown fox jumps over the lazy dog. The quick brown fox jumps over the lazy dog. + +## Heading Level Three at 0.8 Zoom + +Expected: H3 heading also scales; hierarchy between H2 and H3 is preserved at reduced size. + +Additional paragraph to provide enough vertical extent for screenshot comparison. This page uses zoom: 0.8 in frontmatter. All elements — headings, paragraphs, inline styles, code — are affected uniformly. + +### Inline Markup + +Expected: Bold, italic, code, and other inline marks scale with the zoom factor. + +- **bold text** at 0.8 zoom +- *italic text* at 0.8 zoom +- `code span` at 0.8 zoom +- ~~strikethrough~~ at 0.8 zoom + +### Code Block + +Expected: Code block font and padding scale to 80%. + +``` +zoom: 0.8 +navigation: + title: Zoom Test + position: 7790 +``` + +The zoom factor is applied as a page-level transform. All measurements (font sizes, padding, margins, borders) are multiplied by 0.8. + +## Horizontal Ruler + +Expected: Ruler line weight and spacing scale with zoom. + +--- + +Final paragraph at the bottom of the zoom-small page. The total page height at 0.8 zoom should be noticeably shorter than the equivalent content at default zoom. diff --git a/test/visual/resourcepack/assets/guidenh/guidenh/_en_us/visualtest/nei/item-grid.md b/test/visual/resourcepack/assets/guidenh/guidenh/_en_us/visualtest/nei/item-grid.md new file mode 100644 index 00000000..045faada --- /dev/null +++ b/test/visual/resourcepack/assets/guidenh/guidenh/_en_us/visualtest/nei/item-grid.md @@ -0,0 +1,59 @@ +--- +navigation: + title: ItemGrid Multi-Item Grid + position: 7990 +--- + +TEST GOAL: `` multi-item grid rendering (ItemIcon children arranged in rows/columns) + +INVARIANTS: all ItemIcon children are laid out correctly in the grid, with no overlap or overflow + +## Basic Grid — 3 Items + +Expected: Three item icons displayed in a single row. + + + + + + + +## Grid — 6 Items (Two Rows) + +Expected: Six item icons arranged in two rows forming a compact grid. + + + + + + + + + + +## Grid — 9 Items (Three Rows) + +Expected: Nine item icons forming a 3×3 grid layout. + + + + + + + + + + + + + +## Grid with Ore Dictionary Entries + +Expected: Items resolved by ore dictionary name alongside id-based icons. + + + + + + + diff --git a/test/visual/resourcepack/assets/guidenh/guidenh/_en_us/visualtest/nei/recipes.md b/test/visual/resourcepack/assets/guidenh/guidenh/_en_us/visualtest/nei/recipes.md new file mode 100644 index 00000000..c41e2284 --- /dev/null +++ b/test/visual/resourcepack/assets/guidenh/guidenh/_en_us/visualtest/nei/recipes.md @@ -0,0 +1,59 @@ +--- +navigation: + title: Recipe Tags — glScissor Sentinel + position: 8000 +--- + +TEST GOAL: all-attribute variants of ``/``/`` + glScissor clipping regression sentinel + +INVARIANTS: recipe boxes render in full with correct clipping; fallbackText displays normally; handlerName/handlerOrder filtering works + +Known backlog: `recipes.md::gamescene:40` materialization failure exists in the main guide — reproducing similar symptoms on this page is expected, not a regression. + +## Basic `` + +Expected: Single recipe box for stick (minecraft:stick). + + + +## `` — Output Filter + +Expected: Recipe box whose result slot matches planks. + + + +## `` — Input Filter + +Expected: Recipe box for stick where at least one ingredient is any planks variant. + + + +## `` with Limit + +Expected: Up to 2 recipe boxes for planks (any recipe). + + + +## handlerName Filter + +Expected: Only shaped crafting recipes for chest; shapeless and other handler types excluded. + + + +## handlerOrder Filter + +Expected: First handler recipe (index 0) for iron pickaxe. + + + +## handlerOrder Out-of-Range Fallback + +Expected: Fallback text displayed because index 999 exceeds available handlers. + + + +## fallbackText for Unregistered Item + +Expected: Fallback text displayed because the item id has no registered recipe. + + diff --git a/test/visual/resourcepack/assets/guidenh/guidenh/_en_us/visualtest/overflow/exact-fit.md b/test/visual/resourcepack/assets/guidenh/guidenh/_en_us/visualtest/overflow/exact-fit.md new file mode 100644 index 00000000..8e0025bd --- /dev/null +++ b/test/visual/resourcepack/assets/guidenh/guidenh/_en_us/visualtest/overflow/exact-fit.md @@ -0,0 +1,55 @@ +--- +navigation: + title: Exact Fit Screens + position: 7690 +--- + +TEST GOAL: content that fits an exact whole number of screens, with a single-element final page + +INVARIANTS: no abnormal trailing whitespace; full-screen elements do not overflow + +## Approximately One Screen of Paragraphs + +Expected: Paragraph blocks fill roughly one full viewport height; no extra trailing whitespace at the visual page boundary; content ends near the natural bottom of the first screen. + +Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. + +Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum. + +Sed ut perspiciatis unde omnis iste natus error sit voluptatem accusantium doloremque laudantium, totam rem aperiam, eaque ipsa quae ab illo inventore veritatis et quasi architecto beatae vitae dicta sunt explicabo. + +Nemo enim ipsam voluptatem quia voluptas sit aspernatur aut odit aut fugit, sed quia consequuntur magni dolores eos qui ratione voluptatem sequi nesciunt. Neque porro quisquam est, qui dolorem ipsum quia dolor sit amet, consectetur, adipisci velit, sed quia non numquam eius modi tempora incidunt ut labore et dolore magnam aliquam quaerat voluptatem. + +Ut enim ad minima veniam, quis nostrum exercitationem ullam corporis suscipit laboriosam, nisi ut aliquid ex ea commodi consequatur? Quis autem vel eum iure reprehenderit qui in ea voluptate velit esse quam nihil molestiae consequatur, vel illum qui dolorem eum fugiat quo voluptas nulla pariatur? + +## Approximately Two Screens of Content + +Expected: Paragraphs span roughly two viewport heights; the transition between first and second screen shows no partial paragraph cutoff; no anomalous gap or overlap at the natural page split between screens. + +First block of text starting near the top of the first screen. + +Lorem ipsum dolor sit amet, consectetur adipiscing elit. Integer nec odio. Praesent libero. Sed cursus ante dapibus diam. Sed nisi. Nulla quis sem at nibh elementum imperdiet. Duis sagittis ipsum. Praesent mauris. Fusce nec tellus sed augue semper porta. Mauris massa. Vestibulum lacinia arcu eget nulla. + +Class aptent taciti sociosqu ad litora torquent per conubia nostra, per inceptos himenaeos. Curabitur sodales ligula in libero. Sed dignissim lacinia nunc. Curabitur tortor. Pellentesque nibh. Aenean quam. In scelerisque sem at dolor. Maecenas mattis. Sed convallis tristique sem. + +Second block of text crossing the first screen boundary onto the second screen. + +Proin ut ligula vel nunc egestas porttitor. Morbi lectus risus, iaculis vel, suscipit quis, luctus non, massa. Fusce ac turpis quis ligula lacinia aliquet. Mauris ipsum. Nulla metus metus, ullamcorper vel, tincidunt sed, euismod in, nibh. Quisque volutpat condimentum velit. + +Class aptent taciti sociosqu ad litora torquent per conubia nostra, per inceptos himenaeos. Nam nec ante. Sed lacinia, urna non tincidunt mattis, tortor neque adipiscing diam, a cursus ipsum ante quis turpis. Nulla facilisi. Ut fringilla. Suspendisse potenti. + +Third block of text filling the remainder of the second screen. + +Nunc feugiat mi a tellus consequat imperdiet. Vestibulum sapien. Proin quam. Etiam ultrices. Suspendisse in justo eu magna luctus suscipit. Sed lectus. Integer euismod lacus luctus magna. Quisque cursus, metus vitae pharetra auctor, sem massa mattis sem, at interdum magna augue eget diam. + +Vestibulum ante ipsum primis in faucibus orci luctus et ultrices posuere cubilia Curae; Morbi lacinia molestie dui. Praesent blandit dolor. Sed non quam. In vel mi sit amet augue congue elementum. Morbi in ipsum sit amet pede facilisis laoreet. Donec lacus nunc, viverra nec. + +## Single Element at Page End + +Expected: Page ends with a single `` block as the final rendered element; no extra whitespace block or empty paragraph after the scene; the scene touches the natural bottom of the page without anomalous trailing blank area. + + + + + + diff --git a/test/visual/resourcepack/assets/guidenh/guidenh/_en_us/visualtest/overflow/scroll-containers.md b/test/visual/resourcepack/assets/guidenh/guidenh/_en_us/visualtest/overflow/scroll-containers.md new file mode 100644 index 00000000..80208f24 --- /dev/null +++ b/test/visual/resourcepack/assets/guidenh/guidenh/_en_us/visualtest/overflow/scroll-containers.md @@ -0,0 +1,66 @@ +--- +navigation: + title: Scroll Containers + position: 7680 +--- + +TEST GOAL: overly long content embedded in a fixed-viewport code block `width`/`height`, scrolling inside the container instead of overflowing the page + +INVARIANTS: scrolling happens inside the container rather than as page-level overflow + +## Vertical Scroll — Fixed Height Viewport + +Expected: Code block with `width=180 height=80` renders a fixed-height viewport; 15 lines of content exceed the viewport height; a vertical scrollbar appears; the page itself does not grow vertically to accommodate the overflowing lines. + +```java width=180 height=80 +public class VerticalScroll { + public static void main(String[] args) { + System.out.println("Line 01 of 15"); + System.out.println("Line 02 of 15"); + System.out.println("Line 03 of 15"); + System.out.println("Line 04 of 15"); + System.out.println("Line 05 of 15"); + System.out.println("Line 06 of 15"); + System.out.println("Line 07 of 15"); + System.out.println("Line 08 of 15"); + System.out.println("Line 09 of 15"); + System.out.println("Line 10 of 15"); + System.out.println("Line 11 of 15"); + System.out.println("Line 12 of 15"); + System.out.println("Line 13 of 15"); + System.out.println("Line 14 of 15"); + System.out.println("Line 15 of 15"); + } +} +``` + +## Horizontal Scroll — Fixed Width Viewport + +Expected: Code block with `width=120 height=120` constrains the viewport width; long lines exceeding 120px trigger a horizontal scrollbar; the page itself does not grow in width; content is revealed by scrolling horizontally. + +```python width=120 height=120 +def long_function_signature(param_one, param_two, param_three, param_four, param_five): + result = param_one + param_two + param_three + param_four + param_five + print(f"Computed result from all parameters is: {result}") + return result + +very_long_variable_name_for_configuration_settings = {"setting_alpha": "value_alpha", "setting_beta": "value_beta", "setting_gamma": "value_gamma"} +``` + +## Both Axes — Narrow Tall Viewport + +Expected: Code block with `width=150 height=60` constrains both dimensions; content exceeds viewport in both axes; both horizontal and vertical scrollbars appear; the page height and width remain at their natural values unaffected by the code content overflow. + +```java width=150 height=60 +public class BothAxesScroll { + public static void process(String input, String config, String format, String target) { + System.out.println("Processing input=" + input + " config=" + config); + System.out.println("Format=" + format + " Target=" + target); + System.out.println("Step 1: Validate input parameters"); + System.out.println("Step 2: Apply configuration profile"); + System.out.println("Step 3: Transform source data"); + System.out.println("Step 4: Generate output artifact"); + System.out.println("Step 5: Run cleanup routines"); + System.out.println("Done."); + } +} diff --git a/test/visual/resourcepack/assets/guidenh/guidenh/_en_us/visualtest/overflow/tall-element.md b/test/visual/resourcepack/assets/guidenh/guidenh/_en_us/visualtest/overflow/tall-element.md new file mode 100644 index 00000000..7bf9206f --- /dev/null +++ b/test/visual/resourcepack/assets/guidenh/guidenh/_en_us/visualtest/overflow/tall-element.md @@ -0,0 +1,133 @@ +--- +navigation: + title: Tall Elements + position: 7700 +--- + +TEST GOAL: a GameScene taller than one screen, a very long table, an oversized mermaid diagram + +INVARIANTS: no crash and no infinite loop; the whole thing renders completely (full-height output under the long-screenshot mechanism) + +## Tall GameScene — 20-Layer Block Pillar + +Expected: A 20-block-tall stone pillar renders inside a single ``; the scene content exceeds typical viewport height; no crash or deadlock during scene construction and rendering. + + + + + + + + + + + + + + + + + + + + + + + + +## Very Long Table (30+ Rows) + +Expected: A 30-row table renders in full across multiple screen heights; all rows visible in the long-format screenshot; no crash during table layout with many rows. + +| # | Material | Category | Stack Size | Common Uses | +| --- | --- | --- | --- | --- | +| 1 | Stone | Building | 64 | Construction, Furnace | +| 2 | Cobblestone | Building | 64 | Tools, Furnace fuel | +| 3 | Diorite | Building | 64 | Decoration | +| 4 | Granite | Building | 64 | Decoration | +| 5 | Andesite | Building | 64 | Decoration | +| 6 | Oak Log | Wood | 64 | Planks, Charcoal | +| 7 | Spruce Log | Wood | 64 | Planks | +| 8 | Birch Log | Wood | 64 | Planks | +| 9 | Iron Ingot | Metal | 64 | Tools, Armor | +| 10 | Gold Ingot | Metal | 64 | Tools, Mechanism | +| 11 | Diamond | Gem | 64 | Tools, Armor | +| 12 | Redstone Dust | Dust | 64 | Wiring, Mechanisms | +| 13 | Lapis Lazuli | Gem | 64 | Enchanting, Dye | +| 14 | Emerald | Gem | 64 | Trading with Villagers | +| 15 | Coal | Fuel | 64 | Smelting, Torches | +| 16 | Charcoal | Fuel | 64 | Smelting | +| 17 | Glass | Building | 64 | Windows, Beacons | +| 18 | Sand | Building | 64 | Glass, TNT | +| 19 | Gravel | Building | 64 | Concrete, Flint | +| 20 | Clay | Building | 64 | Bricks, Flower Pots | +| 21 | Brick | Building | 64 | Brick Blocks, Decoration | +| 22 | Netherrack | Nether | 64 | Nether Base | +| 23 | Glowstone | Nether | 64 | Lighting, Potions | +| 24 | Obsidian | Building | 64 | Nether Portals | +| 25 | End Stone | End | 64 | End Dimension | +| 26 | Purpur Block | End | 64 | End Cities | +| 27 | Prismarine | Ocean | 64 | Ocean Monuments | +| 28 | Sea Lantern | Ocean | 64 | Underwater Lighting | +| 29 | Sponge | Ocean | 64 | Water Absorption | +| 30 | Coral Block | Ocean | 64 | Reef Decoration | + +## Large Mermaid Flowchart (22 Nodes) + +Expected: All 22 nodes and their edges rendered in a single flowchart diagram; known issue: mermaid renders placeholder box only (confirmed real engine problem — game client and offline rendering both show a placeholder label box; suspect ELK layout / async render chain lost during migration). + +```mermaid +flowchart TB + classDef process fill:#7aa2f7,stroke:#2f3b54,color:#fff + classDef data fill:#9ece6a,stroke:#2f3b54,color:#fff + classDef decision fill:#e0af68,stroke:#2f3b54,color:#fff + classDef term fill:#f7768e,stroke:#2f3b54,color:#fff + + Start([Start]):::term + Load[Load Config]:::process + Validate{Valid?}:::decision + Parse[Parse Input]:::process + Check{Has Data?}:::decision + Fetch[Fetch Records]:::process + Process[Process Each]:::process + Filter[Apply Filter]:::process + Sort[Sort Results]:::process + Format[Format Output]:::process + Render[Render View]:::process + Error[Error Handler]:::term + Retry[Retry]:::process + Log[Log Error]:::process + Notify[Notify Admin]:::process + Cache[Cache Result]:::data + Store[(Database)]:::data + Backup[Backup]:::process + Audit[Audit Trail]:::process + Done(((Done))):::term + Report[Generate Report]:::process + Export[Export Data]:::process + + Start --> Load + Load --> Validate + Validate -->|Yes| Parse + Validate -->|No| Error + Parse --> Check + Check -->|Yes| Fetch + Check -->|No| Error + Fetch --> Process + Process --> Filter + Filter --> Sort + Sort --> Format + Format --> Render + Render --> Done + Error --> Retry + Error --> Log + Error --> Notify + Retry --> Load + Log --> Report + Notify --> Report + Done --> Cache + Done --> Backup + Done --> Audit + Cache --> Store + Backup --> Export + Audit --> Report diff --git a/test/visual/resourcepack/assets/guidenh/guidenh/_en_us/visualtest/scenes/annotations.md b/test/visual/resourcepack/assets/guidenh/guidenh/_en_us/visualtest/scenes/annotations.md new file mode 100644 index 00000000..328d273c --- /dev/null +++ b/test/visual/resourcepack/assets/guidenh/guidenh/_en_us/visualtest/scenes/annotations.md @@ -0,0 +1,92 @@ +--- +navigation: + title: Scene Annotations + position: 7880 +--- + +TEST GOAL: five annotation kinds `` / `` / `` / `` / `` + `` + `` + +INVARIANTS: annotations are positioned correctly, color/thickness take effect, hover tooltip hotspots do not shift + +## DiamondAnnotation Marker + +Expected: Gold diamond (#FFD24C) at the center of the beacon top (gray outer ring by design); tooltip shows "Activated Beacon" text (interactive, out-of-scope in static screenshots). Second diamond at (2.5, 1.5, 0.5) is red. + + + + + + + + + + + + + + Activated Beacon + + + Red Marker + + + +## BoxAnnotation And BlockAnnotation + +Expected: Red semi-transparent box wraps half an iron block; cyan block annotation on gold block at (2,0,2) with alwaysOnTop. + + + + + + Box wraps half a block + + + Block annotation always on top + + + +## LineAnnotation With Polyline And Arrow + +Expected: Yellow line from (0.5,1.2,0.5) to (2.5,1.2,2.5); blue polyline with three points and end arrow; first and second points shown as cubes. + + + + + + Simple line + + + + + Polyline with arrow + + + +## TextAnnotation Independent And Anchored + +Expected: Independent label at screen top; world-anchored label at (1.5,2.0,1.5) with connector side right. + + + + + + + + +## BlockAnnotationTemplate + +Expected: All four logs at corners receive a red diamond annotation — template applies to every matching block automatically. + + + + + + + + + + Log block + + + diff --git a/test/visual/resourcepack/assets/guidenh/guidenh/_en_us/visualtest/scenes/blocks.md b/test/visual/resourcepack/assets/guidenh/guidenh/_en_us/visualtest/scenes/blocks.md new file mode 100644 index 00000000..f0f79b3f --- /dev/null +++ b/test/visual/resourcepack/assets/guidenh/guidenh/_en_us/visualtest/scenes/blocks.md @@ -0,0 +1,55 @@ +--- +navigation: + title: Block Scene Primitives + position: 7900 +--- + +TEST GOAL: all attributes of `` + `` / `` / `` — scale-fix regression sentinel + +INVARIANTS: blocks inside the scene are arranged correctly, not offset, not missing, coordinates aligned + +## Single Block With Attributes + +Expected: Five blocks in a row — stone at origin, oak_stairs with meta=1, furnace facing south, chest with NBT items, furnace with formed=false. + + + + + + + + + +## PlaceBlock Fill Region + +Expected: A 3x1x3 cobblestone platform with a glass center column 2 blocks high — `dx`/`dy`/`dz` fill works correctly. + + + + + + +## ReplaceBlock With Bounds + +Expected: Cobblestone at (0,0,0) replaced with brick_block; glass at (1,0,0) replaced with diamond_block. Unchanged blocks remain visible. + + + + + + + + +## RemoveBlocks By Id + +Expected: A 5-block row of stone, iron_block, gold_block, diamond_block, obsidian. RemoveBlocks removes the iron_block and gold_block leaving gaps at their positions. + + + + + + + + + + diff --git a/test/visual/resourcepack/assets/guidenh/guidenh/_en_us/visualtest/scenes/camera.md b/test/visual/resourcepack/assets/guidenh/guidenh/_en_us/visualtest/scenes/camera.md new file mode 100644 index 00000000..1a2048d0 --- /dev/null +++ b/test/visual/resourcepack/assets/guidenh/guidenh/_en_us/visualtest/scenes/camera.md @@ -0,0 +1,96 @@ +--- +navigation: + title: Camera & Viewport + position: 7860 +--- + +TEST GOAL: the three perspective values + rotateX/Y/Z + offset/center + `` + zoom + +INVARIANTS: viewport size corresponds correctly to the presets; zoom and rotation do not cause abnormal scene clipping; offset values take effect + +## Perspective Presets + +Expected: Three small scenes side by side — isometric-north-east (NE), isometric-north-west (NW), and top-down (up). NE furnace shows front-right face, NW shows front-left face, up shows top face. + + + + + + + + + + + + + + + + +## Explicit rotateX / rotateY / rotateZ + +Expected: Left scene default; right scene rotated with rotateX=15 and rotateY=30 — blocks visibly tilted. + + + + + + + + + + + + +## Offset Pan + +Expected: Left scene shows centered diamond blocks; right scene with offsetX=2 and offsetY=1 shows blocks panned right and down. + + + + + + + + + + + + +## IsometricCamera Yaw / Pitch / Roll + +Expected: Left scene with NE preset; right scene with explicit yaw=45 pitch=30 roll=0 (should match); third scene with roll=15 (tilted). + + + + + + + + + + + + + + + + + + + +## Zoom Variant + +Expected: Same block displayed at zoom=2, zoom=4, and zoom=6 — size increases proportionally. + + + + + + + + + + + + diff --git a/test/visual/resourcepack/assets/guidenh/guidenh/_en_us/visualtest/scenes/effects.md b/test/visual/resourcepack/assets/guidenh/guidenh/_en_us/visualtest/scenes/effects.md new file mode 100644 index 00000000..6a999492 --- /dev/null +++ b/test/visual/resourcepack/assets/guidenh/guidenh/_en_us/visualtest/scenes/effects.md @@ -0,0 +1,61 @@ +--- +navigation: + title: Scene Effects + position: 7870 +--- + +TEST GOAL: `` / `` (rain + snow) / `` (render presence check only) + +INVARIANTS: particles appear at the correct positions inside the scene; rain/snow animation does not overflow the scene area; triggering sound does not cause a crash + +## Static Billboard Particle + +Expected: Default billboard particle at (1.5, 1.85, 0.5) above furnace; smoke particle at (1.5, 1.35, 0.5). Both stationary within the scene. + + + + + + + +## Particle Name Variants + +Expected: Five particles with different names (explode, flash, largeexplode, hugeexplosion, largesmoke) spaced across a cobblestone platform; each visible at its position. + + + + + + + + + + +## Weather Rain + +Expected: Rain falling over a 2-block-wide area above stone and grass blocks; rain columns visible within the scene viewport. + + + + + + + +## Weather Snow + +Expected: Snow falling over a single column above a stone block at (2,0,0); snow flakes drift downward. + + + + + + + +## PlaySound With Click Trigger + +Expected: Scene loads without crash; click-triggered sound cue registered (render existence only — no audible playback during offline capture). + + + + + diff --git a/test/visual/resourcepack/assets/guidenh/guidenh/_en_us/visualtest/scenes/entities.md b/test/visual/resourcepack/assets/guidenh/guidenh/_en_us/visualtest/scenes/entities.md new file mode 100644 index 00000000..09c4f07f --- /dev/null +++ b/test/visual/resourcepack/assets/guidenh/guidenh/_en_us/visualtest/scenes/entities.md @@ -0,0 +1,52 @@ +--- +navigation: + title: Entity Scene Primitives + position: 7890 +--- + +TEST GOAL: `` NBT variants + `` + entity Y-offset reproduction (backlog) + +INVARIANTS: entities sit on the ground (y=1 on grass); no floating with a Y offset; the entity disappears after RemoveEntity + +## Ground-Touch Verification + +Expected: Sheep (red, baby) and zombie (baby) placed at y=1 on grass block — entity feet visibly contact the block surface, no floating gap. + + + + + + + + +## Entity With NBT Data + +Expected: Creeper with `{powered:1b}` shows charged creeper overlay; skeleton with `{Equipment:[{id:"minecraft:bow",Count:1b}]}` holds a bow. Both at y=1 on grass. + + + + + + + + +## Player With Rotation Attributes + +Expected: Player at y=1 with custom arm and head rotations; shows name "GuideNH". + + + + + + +## RemoveEntity By sceneEntityId + +Expected: A horse at (1.5, 1) with a rider mounted, then `` clears both — only grass block remains visible. + + + + + + + + diff --git a/test/visual/resourcepack/assets/guidenh/guidenh/_en_us/visualtest/scenes/import.md b/test/visual/resourcepack/assets/guidenh/guidenh/_en_us/visualtest/scenes/import.md new file mode 100644 index 00000000..e9093426 --- /dev/null +++ b/test/visual/resourcepack/assets/guidenh/guidenh/_en_us/visualtest/scenes/import.md @@ -0,0 +1,72 @@ +--- +navigation: + title: Import Structure + position: 7850 +--- + +TEST GOAL: `` + `` + child tags + the use of `PlaceBlock`/`ReplaceBlock`/`RemoveBlocks` in imported scenes + +INVARIANTS: SNBT structures load correctly; the palette/pos/meta/nbt fields are parsed per the schema; cases unavailable in this environment are recorded as deviations + +## ImportStructure From SNBT + +Expected: A 3x1x4 structure appears — cobblestone floor, glowstone center, stone_stairs (meta=2) at corner, stone_slab (meta=0) on the right, torch (meta=5) at far right, chest with diamond and iron NBT at the back. All blocks placed at their palette-indexed positions. + + + + + +## ImportStructure With ReplaceBlock + +Expected: The imported structure has its glowstone block replaced with glass via `` — only the center block changes appearance. + + + + + + +## ImportStructure With RemoveBlocks + +Expected: The imported structure has its torch removed via `` — torch vanishes, other blocks remain. + + + + + + +## ImportStructureLib With Child Tags + +Expected: (environment-limited) StructureLib requires a registered multiblock controller from a mod (GregTech, BotanicHorizons, etc.). In environments without such mods, the scene renders the fallback placeholder. The child tag syntax is valid: ``, ``, ``, ``, ``, ``. + + + + + + + + + + + + +## ImportStructureLib GT Child Tags + +Expected: (environment-limited) GregTech tags (``, ``) require GregTech mod. Scene renders placeholder without error. + + + + + + + + + + + +## ImportStructure With Offset + +Expected: Structure loaded at offset x=1 and z=1 (shifted right and back relative to origin); blocks visible at (1,0,1)-(3,0,4) instead of default (0,0,0)-(2,0,3). + + + + diff --git a/test/visual/resourcepack/assets/guidenh/guidenh/_en_us/visualtest/scenes/ponder.md b/test/visual/resourcepack/assets/guidenh/guidenh/_en_us/visualtest/scenes/ponder.md new file mode 100644 index 00000000..acfdb9d0 --- /dev/null +++ b/test/visual/resourcepack/assets/guidenh/guidenh/_en_us/visualtest/scenes/ponder.md @@ -0,0 +1,29 @@ +--- +navigation: + title: Ponder Animations + position: 7840 +--- + +TEST GOAL: the `` keyframe timeline (minimal case; recorded as a deviation when external JSON resources are unavailable) + +INVARIANTS: the scene loads without crashing; the timeline UI (play/pause/progress bar) occupies the correct area in offline rendering + +## Minimal Ponder Scene + +Expected: (environment-limited) `` requires an external JSON file with keyframe definitions and a corresponding SNBT structure. Without `ponder_demo.json` and `ponder_demo.snbt` resources in the environment, the scene renders placeholder/empty state. This page validates that the compiler accepts the tag syntax without crash. + + + + + + +## Ponder With StructureLib Base + +Expected: (environment-limited) Same as above — `` with an `` base requires both a controller mod and a JSON timeline. Marking as environment-limited; no crash is the minimum acceptance. + + + + + + + diff --git a/test/visual/resourcepack/assets/guidenh/guidenh/_en_us/visualtest/stress/mixed.md b/test/visual/resourcepack/assets/guidenh/guidenh/_en_us/visualtest/stress/mixed.md new file mode 100644 index 00000000..0e71a325 --- /dev/null +++ b/test/visual/resourcepack/assets/guidenh/guidenh/_en_us/visualtest/stress/mixed.md @@ -0,0 +1,119 @@ +--- +navigation: + title: Mixed Syntax Stress Page + position: 7600 +--- + +TEST GOAL: all-syntax mixed stress page — heading + table + code + formula + diagram + float + tabs + chart + mermaid + scene on one page, verifying interactions between features + +INVARIANTS: no perceptible anomaly overall; review is primarily manual/multimodal + +## Crafting Overview + + + +This section demonstrates a floating image alongside a pipe table with column width metadata. The `FloatingImage` uses `wrap="square"` and `align="left"` so text wraps to its right side. The table below lists basic crafting materials required for the machine assembly. After the table, `
          ` resets the float context. + +| Material | Amount | Source | +| --- | --- | --- | +| Iron Ingot | 5 | Smelting | +| Redstone | 12 | Mining | +| Diamond | 2 | Loot | +{: widths="100,60,80" } + +
          + +Expected: Floating image sits to the left of the table; text wraps around the image; the table occupies the full width below the float after `clear`. + +## Machine Logic + +The `ContentTabs` block holds two tabs: one with a Java code snippet and one with a `BarChart`. This tests tab switching between disparate content types on the same page as a floating element and a table in the previous section. + + + + +```java +public class MachineController { + private int energy; + public MachineController() { + this.energy = 0; + } + public void tick() { + if (this.energy < 1000) { + this.energy += 10; + } + } +} +``` + + + + + + + + + + + + +Expected: Tabs render with a furnace icon; switching tabs toggles between the Java code block and the bar chart; no layout shift or content overlap during tab switch. + +## Formulas and Recipes + +This section mixes inline LaTeX with CJK text and display-style formulas using both `$$...$$` shorthand and the `` tag. The `color` attribute on `` changes formula colour. + +The main reaction formula is $\Delta G = \Delta H - T\Delta S$ embedded in a CJK sentence describing 熵变与焓变的关系. A larger display formula shows the integral form: + +$$\int_0^\infty e^{-x^2}\,dx = \frac{\sqrt{\pi}}{2}$$ + +The next formula uses the `` tag with a gold color override: + + + +Expected: Inline LaTeX aligns with CJK baseline; display formulas are centered; the gold-colored formula renders with distinct colour. + +## Build Preview + +A `GameScene` with diamond blocks and a beacon is annotated with `DiamondAnnotation` markers. Below the scene, a `mermaid` flowchart diagram is shown (renders fully — placeholder issue resolved in R4-R7 fix waves). + + + + + + + + + Beacon Core + + + Corner Marker + + + +Below is a flowchart of the build process. Renders fully (placeholder issue resolved). + +```mermaid +flowchart LR + A[Gather] --> B[Smelt] + B --> C[Assemble] + C --> D[Activate] +``` + +Expected: Scene renders diamond blocks at corners, beacon in center, two diamond annotations with distinct colors; mermaid renders the full flowchart (placeholder issue resolved). + +## Summary + +The following task list tracks verification items for this mixed syntax stress page. + +- [x] Floating image with table wrapping +- [x] ContentTabs containing code and chart +- [ ] Inline LaTeX with CJK alignment verified +- [x] Mermaid flowchart rendered fully (placeholder issue resolved) +- [ ] GameScene annotations match expected positions + +For more details on rendering, consult the [main guide](https://example.com/guidenh). A reference footnote[^stress] collects observations. + +[^stress]: This stress page systematically exercises feature interactions — float with table, tabs with chart, LaTeX with CJK, scene with mermaid — to surface cross-feature layout defects. + +Expected: Task checkboxes render in mixed states; link is clickable; footnote appears in the list at page bottom. diff --git a/test/visual/resourcepack/assets/guidenh/guidenh/_en_us/visualtest/stress/stress-glyphs.md b/test/visual/resourcepack/assets/guidenh/guidenh/_en_us/visualtest/stress/stress-glyphs.md new file mode 100644 index 00000000..fe4d9a3a --- /dev/null +++ b/test/visual/resourcepack/assets/guidenh/guidenh/_en_us/visualtest/stress/stress-glyphs.md @@ -0,0 +1,224 @@ +--- +navigation: + title: Glyph Atlas Capacity Stress + position: 7610 +--- + +TEST GOAL: heavy unique-glyph stress page — the full GB2312 level-1 and level-2 Han set (6763 unique Han characters) + the complete printable ASCII range + special symbols + long Chinese text + a large mermaid diagram. Used to regression-test glyph atlas capacity management (the paging/LRU rework) in high-zoom large-glyph blowout scenarios. + +INVARIANTS: the page compiles and renders with no stage errors; every unique glyph enters layout measurement; when the glyph atlas reaches its capacity limit it degrades per the current policy (discard new glyphs rather than clearing the cache) without crashing and without whole runs of text disappearing. + +## 1. Chinese glyph full-set section (GB2312 level-1 and level-2 Han, 6763 characters) + +Expected: the following 10 runs densely pack the full GB2312 level-1 and level-2 Han set, 6763 unique glyphs in total, with no repetition inside a run; used to blow out glyph atlas capacity and regression-test capacity management after the paging/LRU rework. + +第 1 段(第 1–677 字): + +啊阿埃挨哎唉哀皑癌蔼矮艾碍爱隘鞍氨安俺按暗岸胺案肮昂盎凹敖熬翱袄傲奥懊澳芭捌扒叭吧笆八疤巴拔跋靶把耙坝霸罢爸白柏百摆佰败拜稗斑班搬扳般颁板版扮拌伴瓣半办绊邦帮梆榜膀绑棒磅蚌镑傍谤苞胞包褒剥薄雹保堡饱宝抱报暴豹鲍爆杯碑悲卑北辈背贝钡倍狈备惫焙被奔苯本笨崩绷甭泵蹦迸逼鼻比鄙笔彼碧蓖蔽毕毙毖币庇痹闭敝弊必辟壁臂避陛鞭边编贬扁便变卞辨辩辫遍标彪膘表鳖憋别瘪彬斌濒滨宾摈兵冰柄丙秉饼炳病并玻菠播拨钵波博勃搏铂箔伯帛舶脖膊渤泊驳捕卜哺补埠不布步簿部怖擦猜裁材才财睬踩采彩菜蔡餐参蚕残惭惨灿苍舱仓沧藏操糙槽曹草厕策侧册测层蹭插叉茬茶查碴搽察岔差诧拆柴豺搀掺蝉馋谗缠铲产阐颤昌猖场尝常长偿肠厂敞畅唱倡超抄钞朝嘲潮巢吵炒车扯撤掣彻澈郴臣辰尘晨忱沉陈趁衬撑称城橙成呈乘程惩澄诚承逞骋秤吃痴持匙池迟弛驰耻齿侈尺赤翅斥炽充冲虫崇宠抽酬畴踌稠愁筹仇绸瞅丑臭初出橱厨躇锄雏滁除楚础储矗搐触处揣川穿椽传船喘串疮窗幢床闯创吹炊捶锤垂春椿醇唇淳纯蠢戳绰疵茨磁雌辞慈瓷词此刺赐次聪葱囱匆从丛凑粗醋簇促蹿篡窜摧崔催脆瘁粹淬翠村存寸磋撮搓措挫错搭达答瘩打大呆歹傣戴带殆代贷袋待逮怠耽担丹单郸掸胆旦氮但惮淡诞弹蛋当挡党荡档刀捣蹈倒岛祷导到稻悼道盗德得的蹬灯登等瞪凳邓堤低滴迪敌笛狄涤翟嫡抵底地蒂第帝弟递缔颠掂滇碘点典靛垫电佃甸店惦奠淀殿碉叼雕凋刁掉吊钓调跌爹碟蝶迭谍叠丁盯叮钉顶鼎锭定订丢东冬董懂动栋侗恫冻洞兜抖斗陡豆逗痘都督毒犊独读堵睹赌杜镀肚度渡妒端短锻段断缎堆兑队对墩吨蹲敦顿囤钝盾遁掇哆多夺垛躲朵跺舵剁惰堕蛾峨鹅俄额讹娥恶厄扼遏鄂饿恩而儿耳尔饵洱二贰发罚筏伐乏阀法珐藩帆番翻樊矾钒繁凡烦 + +第 2 段(第 678–1354 字): + +反返范贩犯饭泛坊芳方肪房防妨仿访纺放菲非啡飞肥匪诽吠肺废沸费芬酚吩氛分纷坟焚汾粉奋份忿愤粪丰封枫蜂峰锋风疯烽逢冯缝讽奉凤佛否夫敷肤孵扶拂辐幅氟符伏俘服浮涪福袱弗甫抚辅俯釜斧脯腑府腐赴副覆赋复傅付阜父腹负富讣附妇缚咐噶嘎该改概钙盖溉干甘杆柑竿肝赶感秆敢赣冈刚钢缸肛纲岗港杠篙皋高膏羔糕搞镐稿告哥歌搁戈鸽胳疙割革葛格蛤阁隔铬个各给根跟耕更庚羹埂耿梗工攻功恭龚供躬公宫弓巩汞拱贡共钩勾沟苟狗垢构购够辜菇咕箍估沽孤姑鼓古蛊骨谷股故顾固雇刮瓜剐寡挂褂乖拐怪棺关官冠观管馆罐惯灌贯光广逛瑰规圭硅归龟闺轨鬼诡癸桂柜跪贵刽辊滚棍锅郭国果裹过哈骸孩海氦亥害骇酣憨邯韩含涵寒函喊罕翰撼捍旱憾悍焊汗汉夯杭航壕嚎豪毫郝好耗号浩呵喝荷菏核禾和何合盒貉阂河涸赫褐鹤贺嘿黑痕很狠恨哼亨横衡恒轰哄烘虹鸿洪宏弘红喉侯猴吼厚候后呼乎忽瑚壶葫胡蝴狐糊湖弧虎唬护互沪户花哗华猾滑画划化话槐徊怀淮坏欢环桓还缓换患唤痪豢焕涣宦幻荒慌黄磺蝗簧皇凰惶煌晃幌恍谎灰挥辉徽恢蛔回毁悔慧卉惠晦贿秽会烩汇讳诲绘荤昏婚魂浑混豁活伙火获或惑霍货祸击圾基机畸稽积箕肌饥迹激讥鸡姬绩缉吉极棘辑籍集及急疾汲即嫉级挤几脊己蓟技冀季伎祭剂悸济寄寂计记既忌际妓继纪嘉枷夹佳家加荚颊贾甲钾假稼价架驾嫁歼监坚尖笺间煎兼肩艰奸缄茧检柬碱硷拣捡简俭剪减荐槛鉴践贱见键箭件健舰剑饯渐溅涧建僵姜将浆江疆蒋桨奖讲匠酱降蕉椒礁焦胶交郊浇骄娇嚼搅铰矫侥脚狡角饺缴绞剿教酵轿较叫窖揭接皆秸街阶截劫节桔杰捷睫竭洁结解姐戒藉芥界借介疥诫届巾筋斤金今津襟紧锦仅谨进靳晋禁近烬浸尽劲荆兢茎睛晶鲸京惊精粳经井警景颈静境敬镜径痉靖竟竞净炯窘揪究纠玖韭久灸九酒 + +第 3 段(第 1355–2031 字): + +厩救旧臼舅咎就疚鞠拘狙疽居驹菊局咀矩举沮聚拒据巨具距踞锯俱句惧炬剧捐鹃娟倦眷卷绢撅攫抉掘倔爵觉决诀绝均菌钧军君峻俊竣浚郡骏喀咖卡咯开揩楷凯慨刊堪勘坎砍看康慷糠扛抗亢炕考拷烤靠坷苛柯棵磕颗科壳咳可渴克刻客课肯啃垦恳坑吭空恐孔控抠口扣寇枯哭窟苦酷库裤夸垮挎跨胯块筷侩快宽款匡筐狂框矿眶旷况亏盔岿窥葵奎魁傀馈愧溃坤昆捆困括扩廓阔垃拉喇蜡腊辣啦莱来赖蓝婪栏拦篮阑兰澜谰揽览懒缆烂滥琅榔狼廊郎朗浪捞劳牢老佬姥酪烙涝勒乐雷镭蕾磊累儡垒擂肋类泪棱楞冷厘梨犁黎篱狸离漓理李里鲤礼莉荔吏栗丽厉励砾历利傈例俐痢立粒沥隶力璃哩俩联莲连镰廉怜涟帘敛脸链恋炼练粮凉梁粱良两辆量晾亮谅撩聊僚疗燎寥辽潦了撂镣廖料列裂烈劣猎琳林磷霖临邻鳞淋凛赁吝拎玲菱零龄铃伶羚凌灵陵岭领另令溜琉榴硫馏留刘瘤流柳六龙聋咙笼窿隆垄拢陇楼娄搂篓漏陋芦卢颅庐炉掳卤虏鲁麓碌露路赂鹿潞禄录陆戮驴吕铝侣旅履屡缕虑氯律率滤绿峦挛孪滦卵乱掠略抡轮伦仑沦纶论萝螺罗逻锣箩骡裸落洛骆络妈麻玛码蚂马骂嘛吗埋买麦卖迈脉瞒馒蛮满蔓曼慢漫谩芒茫盲氓忙莽猫茅锚毛矛铆卯茂冒帽貌贸么玫枚梅酶霉煤没眉媒镁每美昧寐妹媚门闷们萌蒙檬盟锰猛梦孟眯醚靡糜迷谜弥米秘觅泌蜜密幂棉眠绵冕免勉娩缅面苗描瞄藐秒渺庙妙蔑灭民抿皿敏悯闽明螟鸣铭名命谬摸摹蘑模膜磨摩魔抹末莫墨默沫漠寞陌谋牟某拇牡亩姆母墓暮幕募慕木目睦牧穆拿哪呐钠那娜纳氖乃奶耐奈南男难囊挠脑恼闹淖呢馁内嫩能妮霓倪泥尼拟你匿腻逆溺蔫拈年碾撵捻念娘酿鸟尿捏聂孽啮镊镍涅您柠狞凝宁拧泞牛扭钮纽脓浓农弄奴努怒女暖虐疟挪懦糯诺哦欧鸥殴藕呕偶沤啪趴爬帕怕琶拍排牌徘湃派攀潘盘磐盼畔判叛乓庞旁耪胖抛咆刨 + +第 4 段(第 2032–2708 字): + +炮袍跑泡呸胚培裴赔陪配佩沛喷盆砰抨烹澎彭蓬棚硼篷膨朋鹏捧碰坯砒霹批披劈琵毗啤脾疲皮匹痞僻屁譬篇偏片骗飘漂瓢票撇瞥拼频贫品聘乒坪苹萍平凭瓶评屏坡泼颇婆破魄迫粕剖扑铺仆莆葡菩蒲埔朴圃普浦谱曝瀑期欺栖戚妻七凄漆柒沏其棋奇歧畦崎脐齐旗祈祁骑起岂乞企启契砌器气迄弃汽泣讫掐恰洽牵扦钎铅千迁签仟谦乾黔钱钳前潜遣浅谴堑嵌欠歉枪呛腔羌墙蔷强抢橇锹敲悄桥瞧乔侨巧鞘撬翘峭俏窍切茄且怯窃钦侵亲秦琴勤芹擒禽寝沁青轻氢倾卿清擎晴氰情顷请庆琼穷秋丘邱球求囚酋泅趋区蛆曲躯屈驱渠取娶龋趣去圈颧权醛泉全痊拳犬券劝缺炔瘸却鹊榷确雀裙群然燃冉染瓤壤攘嚷让饶扰绕惹热壬仁人忍韧任认刃妊纫扔仍日戎茸蓉荣融熔溶容绒冗揉柔肉茹蠕儒孺如辱乳汝入褥软阮蕊瑞锐闰润若弱撒洒萨腮鳃塞赛三叁伞散桑嗓丧搔骚扫嫂瑟色涩森僧莎砂杀刹沙纱傻啥煞筛晒珊苫杉山删煽衫闪陕擅赡膳善汕扇缮墒伤商赏晌上尚裳梢捎稍烧芍勺韶少哨邵绍奢赊蛇舌舍赦摄射慑涉社设砷申呻伸身深娠绅神沈审婶甚肾慎渗声生甥牲升绳省盛剩胜圣师失狮施湿诗尸虱十石拾时什食蚀实识史矢使屎驶始式示士世柿事拭誓逝势是嗜噬适仕侍释饰氏市恃室视试收手首守寿授售受瘦兽蔬枢梳殊抒输叔舒淑疏书赎孰熟薯暑曙署蜀黍鼠属术述树束戍竖墅庶数漱恕刷耍摔衰甩帅栓拴霜双爽谁水睡税吮瞬顺舜说硕朔烁斯撕嘶思私司丝死肆寺嗣四伺似饲巳松耸怂颂送宋讼诵搜艘擞嗽苏酥俗素速粟僳塑溯宿诉肃酸蒜算虽隋随绥髓碎岁穗遂隧祟孙损笋蓑梭唆缩琐索锁所塌他它她塔獭挞蹋踏胎苔抬台泰酞太态汰坍摊贪瘫滩坛檀痰潭谭谈坦毯袒碳探叹炭汤塘搪堂棠膛唐糖倘躺淌趟烫掏涛滔绦萄桃逃淘陶讨套特藤腾疼誊梯剔踢锑提题蹄啼体替嚏惕涕剃屉天 + +第 5 段(第 2709–3385 字): + +添填田甜恬舔腆挑条迢眺跳贴铁帖厅听烃汀廷停亭庭挺艇通桐酮瞳同铜彤童桶捅筒统痛偷投头透凸秃突图徒途涂屠土吐兔湍团推颓腿蜕褪退吞屯臀拖托脱鸵陀驮驼椭妥拓唾挖哇蛙洼娃瓦袜歪外豌弯湾玩顽丸烷完碗挽晚皖惋宛婉万腕汪王亡枉网往旺望忘妄威巍微危韦违桅围唯惟为潍维苇萎委伟伪尾纬未蔚味畏胃喂魏位渭谓尉慰卫瘟温蚊文闻纹吻稳紊问嗡翁瓮挝蜗涡窝我斡卧握沃巫呜钨乌污诬屋无芜梧吾吴毋武五捂午舞伍侮坞戊雾晤物勿务悟误昔熙析西硒矽晰嘻吸锡牺稀息希悉膝夕惜熄烯溪汐犀檄袭席习媳喜铣洗系隙戏细瞎虾匣霞辖暇峡侠狭下厦夏吓掀锨先仙鲜纤咸贤衔舷闲涎弦嫌显险现献县腺馅羡宪陷限线相厢镶香箱襄湘乡翔祥详想响享项巷橡像向象萧硝霄削哮嚣销消宵淆晓小孝校肖啸笑效楔些歇蝎鞋协挟携邪斜胁谐写械卸蟹懈泄泻谢屑薪芯锌欣辛新忻心信衅星腥猩惺兴刑型形邢行醒幸杏性姓兄凶胸匈汹雄熊休修羞朽嗅锈秀袖绣墟戌需虚嘘须徐许蓄酗叙旭序畜恤絮婿绪续轩喧宣悬旋玄选癣眩绚靴薛学穴雪血勋熏循旬询寻驯巡殉汛训讯逊迅压押鸦鸭呀丫芽牙蚜崖衙涯雅哑亚讶焉咽阉烟淹盐严研蜒岩延言颜阎炎沿奄掩眼衍演艳堰燕厌砚雁唁彦焰宴谚验殃央鸯秧杨扬佯疡羊洋阳氧仰痒养样漾邀腰妖瑶摇尧遥窑谣姚咬舀药要耀椰噎耶爷野冶也页掖业叶曳腋夜液一壹医揖铱依伊衣颐夷遗移仪胰疑沂宜姨彝椅蚁倚已乙矣以艺抑易邑屹亿役臆逸肄疫亦裔意毅忆义益溢诣议谊译异翼翌绎茵荫因殷音阴姻吟银淫寅饮尹引隐印英樱婴鹰应缨莹萤营荧蝇迎赢盈影颖硬映哟拥佣臃痈庸雍踊蛹咏泳涌永恿勇用幽优悠忧尤由邮铀犹油游酉有友右佑釉诱又幼迂淤于盂榆虞愚舆余俞逾鱼愉渝渔隅予娱雨与屿禹宇语羽玉域芋郁吁遇喻峪御愈欲狱育誉浴 + +第 6 段(第 3386–4062 字): + +寓裕预豫驭鸳渊冤元垣袁原援辕园员圆猿源缘远苑愿怨院曰约越跃钥岳粤月悦阅耘云郧匀陨允运蕴酝晕韵孕匝砸杂栽哉灾宰载再在咱攒暂赞赃脏葬遭糟凿藻枣早澡蚤躁噪造皂灶燥责择则泽贼怎增憎曾赠扎喳渣札轧铡闸眨栅榨咋乍炸诈摘斋宅窄债寨瞻毡詹粘沾盏斩辗崭展蘸栈占战站湛绽樟章彰漳张掌涨杖丈帐账仗胀瘴障招昭找沼赵照罩兆肇召遮折哲蛰辙者锗蔗这浙珍斟真甄砧臻贞针侦枕疹诊震振镇阵蒸挣睁征狰争怔整拯正政帧症郑证芝枝支吱蜘知肢脂汁之织职直植殖执值侄址指止趾只旨纸志挚掷至致置帜峙制智秩稚质炙痔滞治窒中盅忠钟衷终种肿重仲众舟周州洲诌粥轴肘帚咒皱宙昼骤珠株蛛朱猪诸诛逐竹烛煮拄瞩嘱主著柱助蛀贮铸筑住注祝驻抓爪拽专砖转撰赚篆桩庄装妆撞壮状椎锥追赘坠缀谆准捉拙卓桌琢茁酌啄着灼浊兹咨资姿滋淄孜紫仔籽滓子自渍字鬃棕踪宗综总纵邹走奏揍租足卒族祖诅阻组钻纂嘴醉最罪尊遵昨左佐柞做作坐座亍丌兀丐廿卅丕亘丞鬲孬噩丨禺丿匕乇夭爻卮氐囟胤馗毓睾鼗丶亟鼐乜乩亓芈孛啬嘏仄厍厝厣厥厮靥赝匚叵匦匮匾赜卦卣刂刈刎刭刳刿剀剌剞剡剜蒯剽劂劁劐劓冂罔亻仃仉仂仨仡仫仞伛仳伢佤仵伥伧伉伫佞佧攸佚佝佟佗伲伽佶佴侑侉侃侏佾佻侪佼侬侔俦俨俪俅俚俣俜俑俟俸倩偌俳倬倏倮倭俾倜倌倥倨偾偃偕偈偎偬偻傥傧傩傺僖儆僭僬僦僮儇儋仝氽佘佥俎龠汆籴兮巽黉馘冁夔勹匍訇匐凫夙兕亠兖亳衮袤亵脔裒禀嬴蠃羸冫冱冽冼凇冖冢冥讠讦讧讪讴讵讷诂诃诋诏诎诒诓诔诖诘诙诜诟诠诤诨诩诮诰诳诶诹诼诿谀谂谄谇谌谏谑谒谔谕谖谙谛谘谝谟谠谡谥谧谪谫谮谯谲谳谵谶卩卺阝阢阡阱阪阽阼陂陉陔陟陧陬陲陴隈隍隗隰邗邛邝邙邬邡邴邳邶邺邸邰郏郅邾郐郄郇郓郦郢郜郗郛郫郯郾鄄鄢鄞鄣鄱鄯鄹酃 + +第 7 段(第 4063–4739 字): + +酆刍奂劢劬劭劾哿勐勖勰叟燮矍廴凵凼鬯厶弁畚巯坌垩垡塾墼壅壑圩圬圪圳圹圮圯坜圻坂坩垅坫垆坼坻坨坭坶坳垭垤垌垲埏垧垴垓垠埕埘埚埙埒垸埴埯埸埤埝堋堍埽埭堀堞堙塄堠塥塬墁墉墚墀馨鼙懿艹艽艿芏芊芨芄芎芑芗芙芫芸芾芰苈苊苣芘芷芮苋苌苁芩芴芡芪芟苄苎芤苡茉苷苤茏茇苜苴苒苘茌苻苓茑茚茆茔茕苠苕茜荑荛荜茈莒茼茴茱莛荞茯荏荇荃荟荀茗荠茭茺茳荦荥荨茛荩荬荪荭荮莰荸莳莴莠莪莓莜莅荼莶莩荽莸荻莘莞莨莺莼菁萁菥菘堇萘萋菝菽菖萜萸萑萆菔菟萏萃菸菹菪菅菀萦菰菡葜葑葚葙葳蒇蒈葺蒉葸萼葆葩葶蒌蒎萱葭蓁蓍蓐蓦蒽蓓蓊蒿蒺蓠蒡蒹蒴蒗蓥蓣蔌甍蔸蓰蔹蔟蔺蕖蔻蓿蓼蕙蕈蕨蕤蕞蕺瞢蕃蕲蕻薤薨薇薏蕹薮薜薅薹薷薰藓藁藜藿蘧蘅蘩蘖蘼廾弈夼奁耷奕奚奘匏尢尥尬尴扌扪抟抻拊拚拗拮挢拶挹捋捃掭揶捱捺掎掴捭掬掊捩掮掼揲揸揠揿揄揞揎摒揆掾摅摁搋搛搠搌搦搡摞撄摭撖摺撷撸撙撺擀擐擗擤擢攉攥攮弋忒甙弑卟叱叽叩叨叻吒吖吆呋呒呓呔呖呃吡呗呙吣吲咂咔呷呱呤咚咛咄呶呦咝哐咭哂咴哒咧咦哓哔呲咣哕咻咿哌哙哚哜咩咪咤哝哏哞唛哧唠哽唔哳唢唣唏唑唧唪啧喏喵啉啭啁啕唿啐唼唷啖啵啶啷唳唰啜喋嗒喃喱喹喈喁喟啾嗖喑啻嗟喽喾喔喙嗪嗷嗉嘟嗑嗫嗬嗔嗦嗝嗄嗯嗥嗲嗳嗌嗍嗨嗵嗤辔嘞嘈嘌嘁嘤嘣嗾嘀嘧嘭噘嘹噗嘬噍噢噙噜噌噔嚆噤噱噫噻噼嚅嚓嚯囔囗囝囡囵囫囹囿圄圊圉圜帏帙帔帑帱帻帼帷幄幔幛幞幡岌屺岍岐岖岈岘岙岑岚岜岵岢岽岬岫岱岣峁岷峄峒峤峋峥崂崃崧崦崮崤崞崆崛嵘崾崴崽嵬嵛嵯嵝嵫嵋嵊嵩嵴嶂嶙嶝豳嶷巅彳彷徂徇徉後徕徙徜徨徭徵徼衢彡犭犰犴犷犸狃狁狎狍狒狨狯狩狲狴狷猁狳猃狺狻猗猓猡猊猞猝猕猢猹猥猬猸猱獐獍獗獠獬獯獾舛夥飧夤夂饣饧饨饩饪饫饬饴饷饽馀馄馇馊馍馐馑馓 + +第 8 段(第 4740–5416 字): + +馔馕庀庑庋庖庥庠庹庵庾庳赓廒廑廛廨廪膺忄忉忖忏怃忮怄忡忤忾怅怆忪忭忸怙怵怦怛怏怍怩怫怊怿怡恸恹恻恺恂恪恽悖悚悭悝悃悒悌悛惬悻悱惝惘惆惚悴愠愦愕愣惴愀愎愫慊慵憬憔憧憷懔懵忝隳闩闫闱闳闵闶闼闾阃阄阆阈阊阋阌阍阏阒阕阖阗阙阚丬爿戕氵汔汜汊沣沅沐沔沌汨汩汴汶沆沩泐泔沭泷泸泱泗沲泠泖泺泫泮沱泓泯泾洹洧洌浃浈洇洄洙洎洫浍洮洵洚浏浒浔洳涑浯涞涠浞涓涔浜浠浼浣渚淇淅淞渎涿淠渑淦淝淙渖涫渌涮渫湮湎湫溲湟溆湓湔渲渥湄滟溱溘滠漭滢溥溧溽溻溷滗溴滏溏滂溟潢潆潇漤漕滹漯漶潋潴漪漉漩澉澍澌潸潲潼潺濑濉澧澹澶濂濡濮濞濠濯瀚瀣瀛瀹瀵灏灞宀宄宕宓宥宸甯骞搴寤寮褰寰蹇謇辶迓迕迥迮迤迩迦迳迨逅逄逋逦逑逍逖逡逵逶逭逯遄遑遒遐遨遘遢遛暹遴遽邂邈邃邋彐彗彖彘尻咫屐屙孱屣屦羼弪弩弭艴弼鬻屮妁妃妍妩妪妣妗姊妫妞妤姒妲妯姗妾娅娆姝娈姣姘姹娌娉娲娴娑娣娓婀婧婊婕娼婢婵胬媪媛婷婺媾嫫媲嫒嫔媸嫠嫣嫱嫖嫦嫘嫜嬉嬗嬖嬲嬷孀尕尜孚孥孳孑孓孢驵驷驸驺驿驽骀骁骅骈骊骐骒骓骖骘骛骜骝骟骠骢骣骥骧纟纡纣纥纨纩纭纰纾绀绁绂绉绋绌绐绔绗绛绠绡绨绫绮绯绱绲缍绶绺绻绾缁缂缃缇缈缋缌缏缑缒缗缙缜缛缟缡缢缣缤缥缦缧缪缫缬缭缯缰缱缲缳缵幺畿巛甾邕玎玑玮玢玟珏珂珑玷玳珀珉珈珥珙顼琊珩珧珞玺珲琏琪瑛琦琥琨琰琮琬琛琚瑁瑜瑗瑕瑙瑷瑭瑾璜璎璀璁璇璋璞璨璩璐璧瓒璺韪韫韬杌杓杞杈杩枥枇杪杳枘枧杵枨枞枭枋杷杼柰栉柘栊柩枰栌柙枵柚枳柝栀柃枸柢栎柁柽栲栳桠桡桎桢桄桤梃栝桕桦桁桧桀栾桊桉栩梵梏桴桷梓桫棂楮棼椟椠棹椤棰椋椁楗棣椐楱椹楠楂楝榄楫榀榘楸椴槌榇榈槎榉楦楣楹榛榧榻榫榭槔榱槁槊槟榕槠榍槿樯槭樗樘橥槲橄樾檠橐橛樵檎橹樽樨橘橼檑檐檩檗檫 + +第 9 段(第 5417–6093 字): + +猷獒殁殂殇殄殒殓殍殚殛殡殪轫轭轱轲轳轵轶轸轷轹轺轼轾辁辂辄辇辋辍辎辏辘辚軎戋戗戛戟戢戡戥戤戬臧瓯瓴瓿甏甑甓攴旮旯旰昊昙杲昃昕昀炅曷昝昴昱昶昵耆晟晔晁晏晖晡晗晷暄暌暧暝暾曛曜曦曩贲贳贶贻贽赀赅赆赈赉赇赍赕赙觇觊觋觌觎觏觐觑牮犟牝牦牯牾牿犄犋犍犏犒挈挲掰搿擘耄毪毳毽毵毹氅氇氆氍氕氘氙氚氡氩氤氪氲攵敕敫牍牒牖爰虢刖肟肜肓肼朊肽肱肫肭肴肷胧胨胩胪胛胂胄胙胍胗朐胝胫胱胴胭脍脎胲胼朕脒豚脶脞脬脘脲腈腌腓腴腙腚腱腠腩腼腽腭腧塍媵膈膂膑滕膣膪臌朦臊膻臁膦欤欷欹歃歆歙飑飒飓飕飙飚殳彀毂觳斐齑斓於旆旄旃旌旎旒旖炀炜炖炝炻烀炷炫炱烨烊焐焓焖焯焱煳煜煨煅煲煊煸煺熘熳熵熨熠燠燔燧燹爝爨灬焘煦熹戾戽扃扈扉礻祀祆祉祛祜祓祚祢祗祠祯祧祺禅禊禚禧禳忑忐怼恝恚恧恁恙恣悫愆愍慝憩憝懋懑戆肀聿沓泶淼矶矸砀砉砗砘砑斫砭砜砝砹砺砻砟砼砥砬砣砩硎硭硖硗砦硐硇硌硪碛碓碚碇碜碡碣碲碹碥磔磙磉磬磲礅磴礓礤礞礴龛黹黻黼盱眄眍盹眇眈眚眢眙眭眦眵眸睐睑睇睃睚睨睢睥睿瞍睽瞀瞌瞑瞟瞠瞰瞵瞽町畀畎畋畈畛畲畹疃罘罡罟詈罨罴罱罹羁罾盍盥蠲钅钆钇钋钊钌钍钏钐钔钗钕钚钛钜钣钤钫钪钭钬钯钰钲钴钶钷钸钹钺钼钽钿铄铈铉铊铋铌铍铎铐铑铒铕铖铗铙铘铛铞铟铠铢铤铥铧铨铪铩铫铮铯铳铴铵铷铹铼铽铿锃锂锆锇锉锊锍锎锏锒锓锔锕锖锘锛锝锞锟锢锪锫锩锬锱锲锴锶锷锸锼锾锿镂锵镄镅镆镉镌镎镏镒镓镔镖镗镘镙镛镞镟镝镡镢镤镥镦镧镨镩镪镫镬镯镱镲镳锺矧矬雉秕秭秣秫稆嵇稃稂稞稔稹稷穑黏馥穰皈皎皓皙皤瓞瓠甬鸠鸢鸨鸩鸪鸫鸬鸲鸱鸶鸸鸷鸹鸺鸾鹁鹂鹄鹆鹇鹈鹉鹋鹌鹎鹑鹕鹗鹚鹛鹜鹞鹣鹦鹧鹨鹩鹪鹫鹬鹱鹭鹳疒疔疖疠疝疬疣疳疴疸痄疱疰痃痂痖痍痣痨痦痤痫痧瘃痱 + +第 10 段(第 6094–6763 字): + +痼痿瘐瘀瘅瘌瘗瘊瘥瘘瘕瘙瘛瘼瘢瘠癀瘭瘰瘿瘵癃瘾瘳癍癞癔癜癖癫癯翊竦穸穹窀窆窈窕窦窠窬窨窭窳衤衩衲衽衿袂袢裆袷袼裉裢裎裣裥裱褚裼裨裾裰褡褙褓褛褊褴褫褶襁襦襻疋胥皲皴矜耒耔耖耜耠耢耥耦耧耩耨耱耋耵聃聆聍聒聩聱覃顸颀颃颉颌颍颏颔颚颛颞颟颡颢颥颦虍虔虬虮虿虺虼虻蚨蚍蚋蚬蚝蚧蚣蚪蚓蚩蚶蛄蚵蛎蚰蚺蚱蚯蛉蛏蚴蛩蛱蛲蛭蛳蛐蜓蛞蛴蛟蛘蛑蜃蜇蛸蜈蜊蜍蜉蜣蜻蜞蜥蜮蜚蜾蝈蜴蜱蜩蜷蜿螂蜢蝽蝾蝻蝠蝰蝌蝮螋蝓蝣蝼蝤蝙蝥螓螯螨蟒蟆螈螅螭螗螃螫蟥螬螵螳蟋蟓螽蟑蟀蟊蟛蟪蟠蟮蠖蠓蟾蠊蠛蠡蠹蠼缶罂罄罅舐竺竽笈笃笄笕笊笫笏筇笸笪笙笮笱笠笥笤笳笾笞筘筚筅筵筌筝筠筮筻筢筲筱箐箦箧箸箬箝箨箅箪箜箢箫箴篑篁篌篝篚篥篦篪簌篾篼簏簖簋簟簪簦簸籁籀臾舁舂舄臬衄舡舢舣舭舯舨舫舸舻舳舴舾艄艉艋艏艚艟艨衾袅袈裘裟襞羝羟羧羯羰羲籼敉粑粝粜粞粢粲粼粽糁糇糌糍糈糅糗糨艮暨羿翎翕翥翡翦翩翮翳糸絷綦綮繇纛麸麴赳趄趔趑趱赧赭豇豉酊酐酎酏酤酢酡酰酩酯酽酾酲酴酹醌醅醐醍醑醢醣醪醭醮醯醵醴醺豕鹾趸跫踅蹙蹩趵趿趼趺跄跖跗跚跞跎跏跛跆跬跷跸跣跹跻跤踉跽踔踝踟踬踮踣踯踺蹀踹踵踽踱蹉蹁蹂蹑蹒蹊蹰蹶蹼蹯蹴躅躏躔躐躜躞豸貂貊貅貘貔斛觖觞觚觜觥觫觯訾謦靓雩雳雯霆霁霈霏霎霪霭霰霾龀龃龅龆龇龈龉龊龌黾鼋鼍隹隼隽雎雒瞿雠銎銮鋈錾鍪鏊鎏鐾鑫鱿鲂鲅鲆鲇鲈稣鲋鲎鲐鲑鲒鲔鲕鲚鲛鲞鲟鲠鲡鲢鲣鲥鲦鲧鲨鲩鲫鲭鲮鲰鲱鲲鲳鲴鲵鲶鲷鲺鲻鲼鲽鳄鳅鳆鳇鳊鳋鳌鳍鳎鳏鳐鳓鳔鳕鳗鳘鳙鳜鳝鳟鳢靼鞅鞑鞒鞔鞯鞫鞣鞲鞴骱骰骷鹘骶骺骼髁髀髅髂髋髌髑魅魃魇魉魈魍魑飨餍餮饕饔髟髡髦髯髫髻髭髹鬈鬏鬓鬟鬣麽麾縻麂麇麈麋麒鏖麝麟黛黜黝黠黟黢黩黧黥黪黯鼢鼬鼯鼹鼷鼽鼾齄 + +## 2. Full printable ASCII and common punctuation section + +Expected: the inline text and code blocks below cover the complete printable ASCII range (0x20–0x7E, 95 characters) and common CJK/Latin punctuation, with every character appearing at least once. + +完整 ASCII 可打印字符(行内文本,特殊字符按实体转义): + + ! " # $ % & ' ( ) * + , - . / 0 1 2 3 4 5 6 7 8 9 : ; < = > ? @ A B C D E F G H I J K L M N O P Q R S T U V W X Y Z [ \ ] ^ _ ` a b c d e f g h i j k l m n o p q r s t u v w x y z { | } ~ + +完整 ASCII 可打印字符(代码块内字面原文): + +```text + !"#$%&'()*+,-./0123456789:;<=>?@ABCDEFGHIJKLMNOPQRSTUVWXYZ[\]^_`abcdefghijklmnopqrstuvwxyz{|}~ +``` + +常见全角标点与中文标点: + +,。、;:?!()【】「」《》…——~·‘’“” + +## 3. Special symbols section (emoji / math / arrows / geometric shapes) + +Expected: emoji, math symbols, arrows, geometric shapes, units and copyright signs each appear at least once; rendering does not crash and there are no stage errors. + +Emoji 与警示符号: + +⚠ ☢ ☣ ☠ ⛔ ⚡ ⛏ 🔥 🌍 ⭐ 💎 🧪 ⚙ 🔧 🛠 ⚒ ✨ 💥 🏭 📦 🚀 + +数学与逻辑符号: + +∑ ∏ π √ ∞ ∫ ∂ ∆ ≈ ≠ ≤ ≥ ± × ÷ ½ ¼ ¾ ‰ ′ ″ µ + +箭头符号: + +→ ← ↑ ↓ ↔ ↕ ⇒ ⇐ ⇑ ⇓ ⇔ ⇄ ↦ ↝ ↺ ↻ ⟶ ⟵ ⇢ ⇠ + +几何图形: + +■ □ ▪ ▫ ▲ △ ▼ ▽ ◆ ◇ ○ ● ◎ ◐ ◑ ★ ☆ ✦ ♠ ♥ ♦ ♣ + +单位、版权与杂项符号: + +§ ℃ ℉ ° ™ © ® € ¥ £ ₽ ¢ № † ‡ ¤ ² ³ ¹ + +## 4. Long Chinese text section (real semantics, 300+ characters per paragraph) + +Expected: three long Chinese texts with real semantics, each over 300 characters, used to verify glyph reuse and line wrapping under long text. + +### 段落一:GuideNH 渲染管线说明 + +GuideNH 的渲染管线从资源包中的 Markdown 源文件开始,经过编译阶段把标记文本转换为布局树,再由 Rust 排版引擎按照页面宽度完成折行与度量,最终将每一个字形栅格化后打包进一张共享的纹理图集。字形图集是一块固定大小的二维纹理,所有文字绘制都从这里读取小图块并换算成纹理坐标。当页面包含大量互不相同的汉字时,图集空间会被迅速填满;如果容量管理不完善,高缩放倍率下的大尺寸字形会更快耗尽剩余空间,进而出现个别字形缺失。这条管线还同时承担浮动图片、表格、代码块、图表与 mermaid 图的混合布局,因此对字形图集的复用策略提出了很高的要求,任何单页超量的字形集合都可能触发容量问题。编译阶段会把标题、段落、列表与代码块逐层转换为布局节点,每个节点的文本内容随后被送入排版引擎度量宽度,计算出行高与换行位置,最后统一交给渲染阶段绘制。整个过程中字形缓存与图集上传的节奏必须保持一致,否则就会出现同一页面内部前一段落有字而后一段落缺字的反常现象,这种缺陷在人工审阅时极难察觉,必须依靠专门的回归页面反复触发才能稳定暴露。 + +### 段落二:字形图集容量与分页/LRU 策略 + +字形图集容量管理是渲染稳定性的关键环节。现行实现采用单张固定尺寸的纹理并配合简单的行式打包,一旦剩余空间不足就会丢弃新字形,表现为个别文字缺笔画或者整块空缺,严重时影响阅读体验。即将实施的分页与最近最少使用淘汰策略将把图集拆分为多个页面,并在容量逼近上限时优先淘汰最久未被引用的字形,从而让同一页面内反复出现的文字始终保持可绘制状态。回归测试页需要人为制造大量唯一字形来验证新策略:既要保证初次全量布局时所有字形都能进入度量阶段,又要验证翻页之后旧字形可以被回收,而常用字形不会被误淘汰。这样设计才能同时覆盖容量上限触达、翻页复用与淘汰正确性三条回归路径。分页策略通常会为每个页面分配独立的纹理单元,并按字符出现频率决定页面间的分配比例;淘汰策略则记录每个字形的最近引用时间戳,在需要腾出空间时批量回收最旧的条目。这两套机制彼此配合,既限制了单张纹理的体积,又保证了高频文字的命中率,是图集容量问题从根上得到缓解的关键所在。 + +### 段落三:回归验证流程说明 + +本次回归验证以本压测页作为统一入口:页面上半部分密集排列 GB2312 一二级汉字全集,中段覆盖完整可打印 ASCII 与常见标点,随后是表情、数学、箭头、几何等特殊符号,最后以三段真实语义的长文与两个大型 mermaid 图收尾。验证时使用无头渲染方式生成整页截图,并依据布局边界数据检查是否存在块溢出、零尺寸或者文字重叠。渲染日志中一旦出现图集容量告警,就说明压测已经触及容量上限,此时应结合分页与淘汰策略的回归结果判定改造是否达到预期。整个流程应当在有限的时间预算内稳定复现,避免依赖随机性,以便在每次渲染轮次中给出可对比的基线。截图产出后还需要人工或者多模态模型对几个关键区域做定点确认:第一是汉字密集段是否存在成片缺字,第二是特殊符号段是否出现大量豆腐块,第三是 mermaid 大图是否被错误替换为占位符。只有这三项同时满足要求,本次容量管理改造才算真正通过回归验收,后续迭代中任何图集相关的改动都要先跑本页。 + +## 5. Large mermaid diagram section + +Expected: the flowchart's 22 Chinese-label nodes all render non-stub; the mindmap's 16 Chinese branches all render non-stub; no compile errors. + +### Flowchart:矿石加工流水线(22 节点,中文标签) + +```mermaid +flowchart TD + A[原矿开采] + B[矿石破碎] + C[磁选分离] + D[品位检测] + E[精炼提纯] + F[二次研磨] + G[浮选富集] + H[沉降过滤] + I[电解沉积] + J[熔炼铸锭] + K[机械加工] + L[电路装配] + M[软件烧录] + N[整机调试] + O[老化测试] + P[质量检验] + Q[包装入库] + R[物流发运] + S[现场安装] + T[交付验收] + U[售后维护] + V[回收利用] + + A --> B + B --> C + C --> D + D -->|合格| E + D -->|不合格| F + F --> C + E --> G + G --> H + H --> I + I --> J + J --> K + K --> L + L --> M + M --> N + N --> O + O --> P + P -->|通过| Q + P -->|返修| N + Q --> R + R --> S + S --> T + T --> U + U --> V + V --> A +``` + +### Mindmap:字形压测主题树(16 分支,中文标签) + +```mermaid +mindmap + root((字形压测)) + 中文字形 + 一级汉字 + 二级汉字 + 拉丁字符 + ASCII 全集 + 希腊字母 + 数字字符 + 阿拉伯数字 + 全角数字 + 标点符号 + 全角标点 + 半角标点 + 数学符号 + 运算符 + 关系符 + 箭头符号 + 单向箭头 + 双向箭头 + 几何图形 + 实心图形 + 空心图形 + Emoji 表情 + 警示符号 + 工具符号 + 单位符号 + 温度单位 + 版权商标 + 货币符号 + 人民币 + 欧元 + 字形图集 + 纹理打包 + 图集分页 + 缓存策略 + LRU 淘汰 + 容量上限 + 布局引擎 + 折行度量 + 段落间距 + 渲染验证 + 无豆腐块 + 无缺字 + 无溢出 + 页面排版 + 段落标题 + 列表缩进 + 回归目标 + 高缩放字形 + 多页复用 +``` diff --git a/test/visual/resourcepack/assets/guidenh/guidenh/_en_us/visualtest/tables/basic.md b/test/visual/resourcepack/assets/guidenh/guidenh/_en_us/visualtest/tables/basic.md new file mode 100644 index 00000000..9f6ac5f3 --- /dev/null +++ b/test/visual/resourcepack/assets/guidenh/guidenh/_en_us/visualtest/tables/basic.md @@ -0,0 +1,37 @@ +--- +navigation: + title: Basic Tables + position: 8800 +--- + +TEST GOAL: a narrow 2-column table, a 3-column alignment table (left/center/right) + +INVARIANTS: total width ≤ page width; wrapping does not overflow column bounds; row heights are consistent; column separators are aligned + +## Two-Column Narrow Table + +Expected: Two columns rendered side by side with minimum width; no overflow; header row in bold. + +| Material | Count | +| --- | --- | +| Iron Ingot | 128 | +| Gold Ingot | 64 | +| Diamond | 9 | + +## Three-Column Alignment Table (Left / Center / Right) + +Expected: First column left-aligned, second center-aligned, third right-aligned; separator lines aligned. + +| Name | Amount | Price | +| :--- | :----: | ----: | +| Iron | 42 | 128 | +| Gold | 17 | 64 | +| Diamond | 5 | 512 | + +## Single-Row Table Edge Case + +Expected: A single-data-row table renders correctly with bold header and equal column widths. + +| Key | Value | +| --- | ----- | +| version | 1.0 | diff --git a/test/visual/resourcepack/assets/guidenh/guidenh/_en_us/visualtest/tables/cjk.md b/test/visual/resourcepack/assets/guidenh/guidenh/_en_us/visualtest/tables/cjk.md new file mode 100644 index 00000000..fdfc7122 --- /dev/null +++ b/test/visual/resourcepack/assets/guidenh/guidenh/_en_us/visualtest/tables/cjk.md @@ -0,0 +1,38 @@ +--- +navigation: + title: CJK Tables + position: 8780 +--- + +TEST GOAL: CJK table headers/cells/mixed, long CJK runs + +INVARIANTS: total width ≤ page width; wrapping does not overflow column bounds; row heights are consistent; column separators are aligned + +## CJK Headers and Cells + +Expected: Chinese headers render correctly; cell content with mixed CJK and ASCII wraps properly within column boundaries. + +| 材料 | 数量 | 备注 | +| --- | ---- | ---- | +| 圆石 | 64 | 基础建筑材料 | +| 萤石粉 | 16 | 光源材料 | +| 钻石 | 3 | 稀有宝石 | + +## Mixed CJK and English Content + +Expected: CJK + English mixed strings in one cell wrap without overflow; baseline alignment is consistent across mixed-script cells. + +| 物品 | 说明 | +| --- | ---- | +| 神秘锭 | Thaumium ingot from Thaumcraft 4 | +| 红石 | Redstone dust used in GTNH circuits | +| 铂 | Iridium neutron reflector plate | + +## Long CJK String Without Spaces + +Expected: Long CJK-only string without spaces wraps at character boundaries; no overflow beyond right column edge. + +| 类别 | 长文本 | +| --- | ------ | +| 说明 | 这是一段没有空格的中文长文本测试折行行为确保不溢出列边界 | +| 备注 | 格雷科技社区版GTNH整合包包含大量魔改内容 请参考NEI 查询配方 | diff --git a/test/visual/resourcepack/assets/guidenh/guidenh/_en_us/visualtest/tables/csv.md b/test/visual/resourcepack/assets/guidenh/guidenh/_en_us/visualtest/tables/csv.md new file mode 100644 index 00000000..936f9f16 --- /dev/null +++ b/test/visual/resourcepack/assets/guidenh/guidenh/_en_us/visualtest/tables/csv.md @@ -0,0 +1,59 @@ +--- +navigation: + title: CSV Tables + position: 8760 +--- + +TEST GOAL: the `` tag + the `csv` code block, two forms (header/widths variants) + +INVARIANTS: total width ≤ page width; wrapping does not overflow column bounds; row heights are consistent; column separators are aligned + +## CsvTable Tag — Default (header=true) + +Expected: CSV rendered as a table with bold header row; columns auto-sized. + + + +## CsvTable Tag — With Widths + +Expected: Table columns constrained to 120px and 80px widths (header row bold). + + + +## CsvTable Tag — No Header With Widths + +Expected: Table rendered without bold header row; column widths 100, 60, 120 px. + + + +## CSV Code Block — Default (header=true) + +Expected: Inline CSV data rendered as a table with bold header row. + +```csv +name,value +iron,42 +gold,17 +diamond,9 +``` + +## CSV Code Block — With Widths + +Expected: Columns constrained to 120px and 80px widths. + +```csv widths=120,80 +name,value +iron,42 +gold,17 +diamond,9 +``` + +## CSV Code Block — No Header With Widths + +Expected: Table rendered without header row; column widths 100px and 80px. + +```csv widths="100,80" header=false +iron,42 +gold,17 +diamond,9 +``` diff --git a/test/visual/resourcepack/assets/guidenh/guidenh/_en_us/visualtest/tables/metadata.md b/test/visual/resourcepack/assets/guidenh/guidenh/_en_us/visualtest/tables/metadata.md new file mode 100644 index 00000000..e910e067 --- /dev/null +++ b/test/visual/resourcepack/assets/guidenh/guidenh/_en_us/visualtest/tables/metadata.md @@ -0,0 +1,42 @@ +--- +navigation: + title: Table Width Metadata + position: 8770 +--- + +TEST GOAL: `{: widths=}` column-width metadata, wide/narrow combinations + +INVARIANTS: total width ≤ page width; wrapping does not overflow column bounds; row heights are consistent; column separators are aligned + +## Widths Set on Two-Column Table + +Expected: First column 120px wide, second column 80px wide; columns respect the specified widths. + +| Name | Value | +| --- | --- | +| Iron | 42 | +| Gold | 17 | +| Diamond | 9 | +{: widths="120,80" } + +## Widths Set on Three-Column Table + +Expected: Three columns with widths 130, 70, 150 px respectively; wider third column accommodates longer text. + +| Material | Count | Notes | +| --- | --- | --- | +| Iron | 42 | base line | +| Gold | 17 | compact | +| Diamond | 9 | rare | +{: widths="130,70,150" } + +## Wide-Narrow Combination + +Expected: First column wide (200px), second narrow (50px); narrow column wraps text aggressively. + +| Description | # | +| ----------- | -: | +| Stack of cobblestone blocks | 64 | +| Block of diamond | 9 | +| Tiny pile of redstone dust | 128 | +{: widths="200,50" } diff --git a/test/visual/resourcepack/assets/guidenh/guidenh/_en_us/visualtest/tables/wide.md b/test/visual/resourcepack/assets/guidenh/guidenh/_en_us/visualtest/tables/wide.md new file mode 100644 index 00000000..2c626c89 --- /dev/null +++ b/test/visual/resourcepack/assets/guidenh/guidenh/_en_us/visualtest/tables/wide.md @@ -0,0 +1,39 @@ +--- +navigation: + title: Wide Tables + position: 8790 +--- + +TEST GOAL: a wide 5-column table, cells with very long English words, multi-line cells + +INVARIANTS: total width ≤ page width; wrapping does not overflow column bounds; row heights are consistent; column separators are aligned + +## Five-Column Wide Table + +Expected: All 5 columns visible across full page width; header bold; column separator lines continuous. + +| A | B | C | D | E | +| --- | --- | --- | --- | --- | +| 1 | 2 | 3 | 4 | 5 | +| Iron | Gold | Diamond | Redstone | Emerald | +| 64 | 32 | 9 | 128 | 4 | + +## Cell With Very Long English Word + +Expected: Long unbroken word wraps inside its column without overflowing column boundary. + +| Item | Description | +| --- | ----------- | +| Potion | Antidisestablishmentarianism | +| Map | Supercalifragilisticexpialidocious documentation | +| Tool | Pneumonoultramicroscopicsilicovolcanoconiosis | + +## Multi-Line Cell Content + +Expected: Cells with `
          ` line breaks render as multiple visual lines within the same table row; row height accommodates tallest cell. + +| Command | Output | +| ------- | ------ | +| help | Shows available commands
          Use /guidenhc open | +| list | Page listing
          Filter by category
          Sort alphabetically | +| version | Current build
          Release 1.0.0 | diff --git a/test/visual/resourcepack/assets/guidenh/guidenh/_en_us/visualtest/tags/block-stats.md b/test/visual/resourcepack/assets/guidenh/guidenh/_en_us/visualtest/tags/block-stats.md new file mode 100644 index 00000000..33bce8c4 --- /dev/null +++ b/test/visual/resourcepack/assets/guidenh/guidenh/_en_us/visualtest/tags/block-stats.md @@ -0,0 +1,42 @@ +--- +navigation: + title: BlockStats Tag + position: 9100 +--- + +TEST GOAL: compilation and rendering of the `` child tag (visible / buttonEnabled attributes) inside a GameScene + +INVARIANTS: a scene containing `` renders normally as a scene block with no red error text; the scene keeps its true width and height + +Syntax reference: `SceneScript.applyBlockStatsConfig` reads `visible` and `buttonEnabled` (the code-supported attribute set; the wiki documents additional attributes not wired in the current compiler). + +## BlockStats Visible With Blocks + +Here it should: render a small scene of three blocks with the block-statistics overlay requested explicitly via `` — no compile error, scene block with real dimensions. + + + + + + + + +## BlockStats With Button Disabled + +Here it should: render the same scene with the statistics toggle button disabled via `buttonEnabled={false}` — scene still mounts without a red error paragraph. + + + + + + + +## BlockStats With Visible False + +Here it should: render a scene that explicitly hides the statistics overlay via `visible={false}` while keeping the toggle button — no error output. + + + + + + diff --git a/test/visual/resourcepack/assets/guidenh/guidenh/_en_us/visualtest/tags/quest-link.md b/test/visual/resourcepack/assets/guidenh/guidenh/_en_us/visualtest/tags/quest-link.md new file mode 100644 index 00000000..f661c33f --- /dev/null +++ b/test/visual/resourcepack/assets/guidenh/guidenh/_en_us/visualtest/tags/quest-link.md @@ -0,0 +1,29 @@ +--- +navigation: + title: QuestLink Tag + position: 9080 +--- + +TEST GOAL: compilation and rendering of the `` inline tag (id / text / show_tooltip attributes) + +INVARIANTS: QuestLink renders as inline text without triggering compile errors; paragraphs wrap normally + +Syntax reference: `QuestLinkCompiler` — `` compiles into an inline BetterQuesting quest link. The fixture screenshot world has an empty BetterQuesting quest database, so the runtime state for these quest ids is MISSING — `QuestLinkScript` renders MISSING links as red italic text (feature rendering for a missing quest, not a compiler error). + +## QuestLink With Text Override + +Here it should: render an inline quest link for a quest UUID with a custom `text` label — no compile-time error, text flows inline. + +Follow to learn about furnaces. + +## QuestLink Without Text Override + +Here it should: render a quest link that falls back to the localized quest-name label (missing-quest bracket label in this empty-database world). + + + +## QuestLink Without Tooltip + +Here it should: render a quest link with `show_tooltip="false"` suppressing the hover tooltip while keeping the inline text. + +Quest progress: . diff --git a/test/visual/resourcepack/assets/guidenh/guidenh/_en_us/visualtest/tags/recipes-usage.md b/test/visual/resourcepack/assets/guidenh/guidenh/_en_us/visualtest/tags/recipes-usage.md new file mode 100644 index 00000000..58338250 --- /dev/null +++ b/test/visual/resourcepack/assets/guidenh/guidenh/_en_us/visualtest/tags/recipes-usage.md @@ -0,0 +1,29 @@ +--- +navigation: + title: RecipesUsage Tag + position: 9090 +--- + +TEST GOAL: the `` tag (usage query) renders real recipe boxes + +INVARIANTS: the usage query produces `LytNeiRecipeBox` recipe boxes; limit takes effect; no red error text + +Syntax reference: `RecipeCompiler` — `` shares the `` attribute set; tag names ending in `Usage` run a "recipes that use this item" query (`usageQuery`). + +## Stick As Ingredient + +Here it should: render up to 3 recipe boxes for recipes that use `minecraft:stick` as an ingredient — a usage query, not a crafting-of query. + + + +## Planks As Ingredient + +Here it should: render up to 2 recipe boxes for recipes that consume `minecraft:planks` — the usage query filters to recipes referencing planks as an ingredient. + + + +## Iron Ingot As Ingredient + +Here it should: render at least one recipe box for recipes that use `minecraft:iron_ingot` as an ingredient. + + diff --git a/test/visual/resourcepack/assets/guidenh/guidenh/_en_us/visualtest/text/cjk-mixed.md b/test/visual/resourcepack/assets/guidenh/guidenh/_en_us/visualtest/text/cjk-mixed.md new file mode 100644 index 00000000..7f4fe6c7 --- /dev/null +++ b/test/visual/resourcepack/assets/guidenh/guidenh/_en_us/visualtest/text/cjk-mixed.md @@ -0,0 +1,51 @@ +--- +navigation: + title: CJK Mixed Text + position: 8950 +--- + +TEST GOAL: Chinese/English mixed text, full-width punctuation, long CJK runs without spaces, English words of 40+ characters, CJK mixed with inline code/links + +INVARIANTS: any line break stays inside the margin; the mixed-text baseline is consistent + +## Chinese-English Mixed + +Expected: Both CJK glyphs and Latin characters are on the same baseline; line spacing is consistent. + +本指南涵盖了许多 Minecraft 的 GTNH 模组包内容,包括 Processing Array 和 Advanced Miner II 等高级机器。 + +Please read the 合成表 carefully before crafting the 工业电路板。 + +这个句子混合English和中文,目的是测试baseline alignment和line spacing。 + +## Full-Width Punctuation + +Expected: Full-width punctuation (,。!?【】「」) does not break the line or cause overflow. + +指南的「第一章」介绍了基础合成;「第二章」则深入讲解了【多区块结构】的搭建方法。 + +注意:全角逗号,句号。以及感叹号!问号?都应该正常显示。 + +## Long CJK String without Spaces + +Expected: Long consecutive CJK text wraps at glyph boundaries; no overflow beyond right margin. + +这是一个没有空格的长中文串测试用来验证引擎的断行行为是否正确因为中文没有空格所以引擎需要根据字符边界自动换行不能溢出到右边距外面同时也不能在错误的位置断行这一段继续延伸以超过测试页宽从而强制触发字形边界断行引擎应当在到达右边距时自动换到下一行而不是让文本溢出容器外面这是检验长串连续表意文字折行能力的关键用例补充内容确保总宽度超过九百像素迫使引擎真正执行折行 + +## Long English Word (40+ Characters) + +Expected: Long uninterrupted Latin text breaks or overflows gracefully; no horizontal scrollbar at page level. + +Supercalifragilisticexpialidocious is an English word that contains 34 characters, and antidisestablishmentarianism contains 28 characters. + +Pneumonoultramicroscopicsilicovolcanoconiosis is a 45-character lung disease — such long words should not break layout. + +## CJK with Inline Code and Links + +Expected: CJK text flowing inline with `code spans` and [markdown links](./inline-game-tags.md) without breaking the baseline or causing misalignment. + +请打开 `config/guidenh/DefaultGuide` 目录并阅读 [Links 页面](./links.md) 的说明。 + +代码片段如 `ItemStack stack = new ItemStack(Items.apple)` 应当与中文同行,不撑高行盒。 + +打开 [Footnotes 页](./footnotes.md)查看脚注功能;同时注意 `@Mod(modid = "GuideNH")` 的显示效果。 diff --git a/test/visual/resourcepack/assets/guidenh/guidenh/_en_us/visualtest/text/color-named.md b/test/visual/resourcepack/assets/guidenh/guidenh/_en_us/visualtest/text/color-named.md new file mode 100644 index 00000000..cbf1b343 --- /dev/null +++ b/test/visual/resourcepack/assets/guidenh/guidenh/_en_us/visualtest/text/color-named.md @@ -0,0 +1,39 @@ +--- +navigation: + title: Color Named Colours + position: 8975 +--- + +TEST GOAL: rendering of `` named colors (the 16 Minecraft named colors) + +INVARIANTS: every named color id resolves successfully (no red error text); colors are mutually distinguishable; paragraphs are laid out normally + +Syntax reference: `ColorTagCompiler` + `SymbolicColorResolver` — the id resolves through `SymbolicColor` (case-insensitive); the supported named set is the 16 Minecraft colours: black, dark_blue, dark_green, dark_aqua, dark_red, dark_purple, gold, gray, dark_gray, blue, green, aqua, red, light_purple, yellow, white. + +## All Sixteen Named Colours + +Here it should: render each named colour via `` — every id resolves to its Minecraft colour without an error. + +black sample | dark_blue sample | dark_green sample + +dark_aqua sample | dark_red sample | dark_purple sample + +gold sample | gray sample | dark_gray sample + +blue sample | green sample | aqua sample + +red sample | light_purple sample | yellow sample + +white sample + +## Mixed Colour Paragraph + +Here it should: render multiple named colours in a single flowing paragraph — colour switching mid-sentence works and does not leak into the following text. + +The red apple, the blue sky and the green grass all sit in one paragraph, and gold follows. + +## Named vs Hex Comparison + +Here it should: render the named form next to its hex equivalent — both should match visually. The hex form must be 6-digit (`#RRGGBB`); the engine rejects 3-digit shorthands as malformed color values. + +red vs #FF5555 | green vs #55FF55 | blue vs #5555FF diff --git a/test/visual/resourcepack/assets/guidenh/guidenh/_en_us/visualtest/text/decorations.md b/test/visual/resourcepack/assets/guidenh/guidenh/_en_us/visualtest/text/decorations.md new file mode 100644 index 00000000..7ca7bf72 --- /dev/null +++ b/test/visual/resourcepack/assets/guidenh/guidenh/_en_us/visualtest/text/decorations.md @@ -0,0 +1,53 @@ +--- +navigation: + title: Text Decorations (underline/wavy/dots/strike) + position: 8981 +--- + +TEST GOAL: rendering of four text decorations — plain underline ++, wavy underline ^^, dotted underline ::, strikethrough ~~; includes same-line combination comparisons and decoration extension over long text + +INVARIANTS: all four decorations render with no compile errors; decoration lines stop at the boundary and do not leak into following text; decorations do not overlap the lines above and below + +## Plain Underline (++) + +++underlined text++ should render with a continuous straight line below the glyphs. + +Normal text after the mark appears unformatted. + +Expected: a solid underline spans exactly the decorated text and nothing after it. + +## Wavy Underline (^^) + +^^wavy underlined text^^ should render with a wavy line below the glyphs. + +Normal text after the mark appears unformatted. + +Expected: a wavy line spans exactly the decorated text and nothing after it. + +## Dotted Underline (::) + +::dotted underlined text:: should render with dots below the glyphs. + +Normal text after the mark appears unformatted. + +Expected: a dotted underline spans exactly the decorated text and nothing after it. + +## Strikethrough (~~) + +~~strikethrough text~~ should render with a line through the middle of the glyphs. + +Normal text after the mark appears unformatted. + +Expected: the strike line spans exactly the decorated text and nothing after it. + +## Combined Decorations on One Line + +Compare on the same line: ++underlined text++, ^^wavy underlined text^^, ::dotted underlined text:: and ~~strikethrough text~~. + +Expected: each decoration is visually distinct and terminates at its own boundary within the line. + +## Long Text Decoration + +++This is a long paragraph wrapped entirely in the plain-underline decoration, spanning across multiple wrapped lines to verify that the underline extends through the whole paragraph and stays aligned on every line.++ + +Expected: the underline runs continuously across all wrapped lines; the decoration does not leak past the closing marker. diff --git a/test/visual/resourcepack/assets/guidenh/guidenh/_en_us/visualtest/text/error-fallback.md b/test/visual/resourcepack/assets/guidenh/guidenh/_en_us/visualtest/text/error-fallback.md new file mode 100644 index 00000000..ac11d14d --- /dev/null +++ b/test/visual/resourcepack/assets/guidenh/guidenh/_en_us/visualtest/text/error-fallback.md @@ -0,0 +1,47 @@ +--- +navigation: + title: Error Fallback Width (F-N2) + position: 8935 +--- + +TEST GOAL (F-N2 regression sentinel): when ItemLink/ItemImage points at a nonexistent item the error fallback segment must keep its real width, and following body text must not overlap the error segment + +INVARIANTS: each error fallback segment (nested LytParagraph) keeps a real width of ≥200px in the bounds JSON (guarding against width collapse); the body text immediately following the error segment continues on the same line (trailing text start x = error segment x + error segment w > error segment x + 20); the error segment and the body text do not overlap each other (zero sibling_intersection hits). Assertion blind spot: the current ratchet (all LytParagraph w≥200) locks in the "error segment width collapses to zero width" regression mode but does not cover the "error node never emitted at all" mode (the harness has no overlap rule yet; the trailing-x quantity is verified manually) — it is suggested that the harness add sibling overlap assertions later and lock this down + +This page intentionally renders red error-fallback text for non-existent items. Every case below is a single paragraph: [prefix text] + [error fallback] + [trailing prose]. The trailing prose must start strictly right of the error segment — if the fallback width ever collapses to near zero again, the trailing text lands on top of the error text and this page's ratchet assertion fails. + +## Missing ItemImage With Trailing Prose + +Here it should: render the error fallback for a non-existent item, then the trailing prose continues on the same line to the right of the error segment. + + after this the body prose continues on the same line, well to the right of the error text. + +## Missing ItemImage Inside A Sentence + +Here it should: render a missing-item error mid-sentence with prose both before and after — the error sits inline after the prefix and the suffix resumes right after it. + +Before the missing after it the rest of the sentence continues normally without overlapping the error. + +## Missing ItemLink With Trailing Prose + +Here it should: render an ItemLink error fallback whose following prose stays on the same line after the error segment. + + after this the body prose continues on the same line, well to the right of the error text. + +## Missing ItemLink Inside A Sentence + +Here it should: render an ItemLink error mid-sentence — the error reserves real width so the following clause stays clear of it. + +A link to the fictional is expected to fail, and the clause after it must still sit to the right. + +## Two Consecutive Missing Items + +Here it should: render two error fallbacks in a row — both reserve width and the second sits after the first, with trailing prose after both. + + and the trailing prose follows both error segments on the same line. + +## Missing ItemImage At Paragraph Start + +Here it should: render an error fallback that opens the paragraph — the trailing prose must begin strictly to the right of the error's right edge. + + opening error at the paragraph start, followed by prose that must not overlap it. diff --git a/test/visual/resourcepack/assets/guidenh/guidenh/_en_us/visualtest/text/footnotes.md b/test/visual/resourcepack/assets/guidenh/guidenh/_en_us/visualtest/text/footnotes.md new file mode 100644 index 00000000..ad4a4d0b --- /dev/null +++ b/test/visual/resourcepack/assets/guidenh/guidenh/_en_us/visualtest/text/footnotes.md @@ -0,0 +1,51 @@ +--- +navigation: + title: Footnotes + position: 8930 +--- + +TEST GOAL: multiple footnote references [^a] + FootnoteList + +INVARIANTS: references render as superscript links; the list collects them all; footnote numbering is consecutive + +## Single Footnote + +Expected: The footnote reference [^first] renders as a superscript link; the footnote list at the bottom contains the definition. + +This sentence has a footnote reference[^first] inline. + +[^first]: This is the first footnote content. + +## Multiple Footnotes + +Expected: Each reference gets a unique numbered superscript; all definitions appear in the FootnoteList; numbering is sequential across the page. + +Multiple footnotes[^second] can appear in the same[^third] paragraph or across[^fourth] different paragraphs. + +[^second]: Second footnote with more detailed explanation. +[^third]: Third footnote — short text. +[^fourth]: Fourth footnote that contains slightly longer content for multiline testing. + +## Repeated Reference + +Expected: Referencing the same footnote label[^first] multiple times shows the same number each time; the list does not duplicate the definition. + +The same footnote used again here[^first] and here[^second] to verify deduplication. + +## Footnotes with Rich Content + +Expected: Footnote content with inline formatting renders correctly; bold, italic, and code inside footnotes are styled. + +Rich content footnote[^rich] demonstrates inline formatting in definition. + +[^rich]: Contains **bold text**, *italic text*, and `inline code` together. + +## Mix of Footnotes and Regular Text + +Expected: Normal text and footnote references share the same line without layout disruption. + +Before[^third] and after[^first] — footnotes woven into the narrative flow of the page. This paragraph also contains a reference[^fourth] to verify inline behavior with surrounding regular text. + +[^third]: This definition was used earlier but the reference is fresh here. +[^first]: Repeated definition for the same label — list should only show it once. +[^fourth]: Another occurrence of the fourth footnote content. diff --git a/test/visual/resourcepack/assets/guidenh/guidenh/_en_us/visualtest/text/headings.md b/test/visual/resourcepack/assets/guidenh/guidenh/_en_us/visualtest/text/headings.md new file mode 100644 index 00000000..ce894b0f --- /dev/null +++ b/test/visual/resourcepack/assets/guidenh/guidenh/_en_us/visualtest/text/headings.md @@ -0,0 +1,49 @@ +--- +navigation: + title: Heading Levels & Separators (K2) + position: 9000 +--- + +TEST GOAL: all H1-H6 levels + heading immediately followed by body text + heading immediately followed by heading + long-heading wrapping + horizontal-rule-through-text reproduction + +INVARIANTS: horizontal rules do not cut through text (K2); spacing between consecutive headings is consistent; long headings do not overflow the right margin + +## Heading 2 Followed by Body Text + +This paragraph immediately follows an H2 heading. The engine should render normal paragraph spacing below the heading decorative separator line, not overlapping. + +Expected: H2 heading separator line does not intersect the body text below; normal vertical gap between heading and paragraph. + +### Heading 3 Immediately Followed by Another Heading + +#### Heading 4 Immediately Below H3 + +There is no body text between the H3 and H4 above. This paragraph follows the H4. The heading decorative separators for H3 and H4 should not overlap each other, and the spacing between consecutive headings should be consistent. + +Expected: Two consecutive headings (H3 then H4) with no body text between them; their separator lines do not overlap; spacing is uniform. + +##### Heading 5 + +This body text follows an H5 heading. + +###### Heading 6 + +This body text follows an H6 heading. All six heading levels have been demonstrated. + +Expected: H5 and H6 render at progressively smaller sizes; separator lines remain below text; no overlap. + +# H1: Very Long Heading That Should Wrap Across Multiple Lines to Test Overflow and Line Break Behavior Within Heading Text Itself Plus Additional Words Appended To Push The Total Heading Width Beyond The Nine Hundred Pixel Test Page Width So That Word Boundary Wrapping Is Actually Exercised At This Render Scale And Verified + +Expected: Long heading text wraps at word boundaries; text does not overflow the right page margin; the heading separator line stays below the wrapped block, not cutting through any line of text. + +## Separator Line vs Text Collision (K2 Replication) + +The heading above this paragraph and the one below both render decorative separator lines. The engine must not let those lines intersect any character descenders. + +Expected: No part of any heading separator line visually overlaps or cuts through heading text characters on any line. + +--- + +The line above is a thematic break (three dashes on its own line). + +Expected: The `---` thematic break renders as a standalone horizontal line; it does not collide with the preceding heading separator or the following heading text. diff --git a/test/visual/resourcepack/assets/guidenh/guidenh/_en_us/visualtest/text/inline-game-tags.md b/test/visual/resourcepack/assets/guidenh/guidenh/_en_us/visualtest/text/inline-game-tags.md new file mode 100644 index 00000000..efc0f4f2 --- /dev/null +++ b/test/visual/resourcepack/assets/guidenh/guidenh/_en_us/visualtest/text/inline-game-tags.md @@ -0,0 +1,98 @@ +--- +navigation: + title: Inline Game Tags + position: 8940 +--- + +TEST GOAL: ItemImage (id/scale/label/format), ItemLink (showIcon/showText/linksTo), KeyBind, PlayerName, Tooltip (inline trigger), Spoiler + +INVARIANTS: icons are baseline-aligned with text; line height is not abnormally enlarged by icons; tooltip hotspots are correct + +## ItemImage + +Expected: Item icon rendered at specified scale; label position left/right places text correctly; format applies to label text. + + + + + + + + + + + + + +## ItemLink + +Expected: Item name rendered with icon; showIcon and showText control visibility; linksTo navigates to specified page. + + + + + + + + + + + +## KeyBind + +Expected: Current key mapping displayed in styled text; fallback shown when id is unknown. + +Press to move forward. + + to jump — to sneak. + +Inventory: + +## PlayerName + +Expected: Current player username displayed inline; styled distinctively. + +Welcome, ! + +The player is currently online. + +## Tooltip (Inline Trigger) + +Expected: Hovering the trigger text shows rich content box; content tooltip renders Markdown and inline tags. + + + ## Rich Tooltip + Contains **bold**, *italic*, and `code`. + + * List item + * List item + + + + Contains diamond + and apple. + + +## Spoiler + +Expected: Text hidden behind blur/reveal overlay; clicking reveals the content; reveals persist. + +Hidden spoiler text with **bold** and *italic* formatting. + +Spoiler containing a [link](./links.md) that remains clickable after reveal. + +## ItemImage (Inline Label) + +TEST GOAL: inline (within a paragraph text flow, not block-level) ItemImage with a label — the label text baseline sits on the body baseline; the advance of the icon+label combination = icon + gap + label text width; following inline text sits flush against the label text. + +INVARIANTS: the label text baseline lands on the body baseline (anchored by align=1 baseline ascent, replacing the retired align=2 centered setting); the icon does not intrude into the text before/after it; the combined block does not abnormally increase the line height. + +Inline label icons embedded in prose (label="right", mirrors the real corpus pattern, e.g. AE2 getting-started): + +Collect , , and to craft the circuit. + +A left-labeled icon before the prose continues after it: when exploring. + +A scaled inline label icon: obtain from trees. + +Two inline label icons in one sentence: compare with . diff --git a/test/visual/resourcepack/assets/guidenh/guidenh/_en_us/visualtest/text/inline-marks.md b/test/visual/resourcepack/assets/guidenh/guidenh/_en_us/visualtest/text/inline-marks.md new file mode 100644 index 00000000..3e126409 --- /dev/null +++ b/test/visual/resourcepack/assets/guidenh/guidenh/_en_us/visualtest/text/inline-marks.md @@ -0,0 +1,59 @@ +--- +navigation: + title: Inline Marks & Formatting + position: 8980 +--- + +TEST GOAL: all inline marks + nesting them in one another + styles that do not leak + +INVARIANTS: styles stop at the boundary and do not leak into following text; nesting renders correctly; decoration lines do not overlap the lines above and below + +## Bold, Italic, Bold-Italic + +**Bold text** should render with heavier weight. *Italic text* should render with a slant. ***Bold-italic text*** combines both. + +Normal text after each mark should appear unformatted. + +Expected: Bold, italic, and bold-italic render correctly; unaffected text after each mark has no residual formatting; styles terminate at their boundaries. + +## Strikethrough + +~~Strikethrough text~~ has a line through the middle. ~Single tilde strikethrough~ also works. Normal text after must not be struck through. + +Expected: Both `~~` and `~` produce a strikethrough line at consistent vertical position; the line does not extend into surrounding text or overlap adjacent lines. + +## Underline, Wavy Underline, Emphasis Dots, Highlight + +++Underlined text++ renders with a continuous line below. ^^Wavy underline^^ renders with a wavy line below. ::Emphasis dots:: renders with dots below. ==Highlight== renders with a background color. + +Normal text resumes without any decoration. + +Expected: Each decoration type is visually distinct; underline is solid, wavy is wavy, emphasis uses dots, highlight has a background fill; all decorations terminate cleanly; decorative lines do not overlap characters on the line below. + +## Inline Code + +Use `var x = 42;` inline code within a sentence. The code fragment `if (x > 0) { return true; }` contains special characters. Normal text continues after. + +Expected: Inline code renders in a monospace font with a distinct background or border; special characters `<>&"` render correctly; formatting does not leak beyond the backticks. + +## kbd, sub, sup + +Press Ctrl + C to copy. The kbd tag should render with key-cap styling. + +Subscript: H2O and Superscript: E=mc2 render at reduced size with vertical offset. + +Expected: `` renders with key-cap appearance; `` renders below baseline; `` renders above baseline; both are visibly smaller but readable; they do not disrupt the line height. + +## span (No-Op Wrapper) + +The word wrapped in a span tag should render identically to unwrapped text. + +Expected: `` is a transparent wrapper; its content renders with no additional styling; text before and after is indistinguishable. + +## Mixed Nested Marks + +**Bold with *italic inside*** should render bold text that transitions to bold-italic for the inner segment. ~~Strikethrough with ++underline inside++~~ combines two decorations. ==Highlight with `code` inside== nests inline code within a highlight. + +***Bold-italic with ~~strikethrough~~ and ==highlight== mixed*** demonstrates triple nesting. + +Expected: Nested marks apply correctly at each level; inner formatting is properly terminated before outer formatting ends; no visual artifacts at nesting boundaries. diff --git a/test/visual/resourcepack/assets/guidenh/guidenh/_en_us/visualtest/text/item-link-dense.md b/test/visual/resourcepack/assets/guidenh/guidenh/_en_us/visualtest/text/item-link-dense.md new file mode 100644 index 00000000..a0769fb3 --- /dev/null +++ b/test/visual/resourcepack/assets/guidenh/guidenh/_en_us/visualtest/text/item-link-dense.md @@ -0,0 +1,47 @@ +--- +navigation: + title: ItemLink Dense Usage + position: 8985 +--- + +TEST GOAL: layout stability of densely mixed ItemLinks (many ItemLinks + icons across multiple lines) + +INVARIANTS: every ItemLink with an icon renders an icon at its real size (w>0 / h>0); long lines with many links wrap normally; icons do not overlap adjacent text + +Real-corpus note: production guides use ItemLink 827 times vs 1 fixture occurrence; this page reproduces dense multi-line ItemLink flow. + +## Equipment Set (5 Links, Icons Left) + +Here it should: render five item links in one flowing sentence, each with a leading icon and display text, wrapping naturally. + +, , , and form a full tool set. + +## Tool Chain (5 Links, Icons Left) + +Here it should: render five tiered tool links in a second dense sentence — icon and text pairs stay aligned across the line wrap. + +Start with , upgrade to , then , then , and finally . + +## Food Group (6 Links, Icons Left) + +Here it should: render six food item links densely — every link carries its icon before its name, text stays readable. + +Hungry? Grab , , , , or . + +## Mixed Plain Links (Icons Right) + +Here it should: render item links with `showIcon="right"` so the icon trails the display text — icon placement varies without breaking the flow. + +Resources: , , and . + +## Text-Only Links (No Icon) + +Here it should: render text-only item links with no icon (default) interleaved with icon links — density with mixed forms stays stable. + +Compare and and and in the same sentence. + +## Two-Column Mixture + +Here it should: render a paragraph that mixes ItemLink inside bold and a trailing plain text tail. + +Build with , smelt in and then use for repairs — the trailing prose continues on the same lines. diff --git a/test/visual/resourcepack/assets/guidenh/guidenh/_en_us/visualtest/text/itemlink-italic-probe.md b/test/visual/resourcepack/assets/guidenh/guidenh/_en_us/visualtest/text/itemlink-italic-probe.md new file mode 100644 index 00000000..465f1557 --- /dev/null +++ b/test/visual/resourcepack/assets/guidenh/guidenh/_en_us/visualtest/text/itemlink-italic-probe.md @@ -0,0 +1,57 @@ +--- +navigation: + title: ItemLink Italic Probe + position: 8989 +--- + +TEST GOAL: gathering boundary-gap evidence for italic text immediately followed by an item icon (ItemLink inline icon) — probe only, for diagnostics + +INVARIANTS: none (this page is a diagnostic probe; it only measures the glyph ink → icon ink gap) + +## A Italic tight tall + +*Mink* + +## B Upright tight tall + +Mink + +## C Italic tight natural + +*Mine* + +## D Upright tight natural + +Mine + +## E Italic space reference + +*Mine* + +## F Upright space reference + +Mine + +## G Italic right icon + +*Iron* + +## H Italic II tight + +*II* + +## I Italic WW tight + +*WW* + +## J Italic ll tight + +*ll* + +## K Upright II tight + +II + +## L ItemLink display name + right icon + + diff --git a/test/visual/resourcepack/assets/guidenh/guidenh/_en_us/visualtest/text/links.md b/test/visual/resourcepack/assets/guidenh/guidenh/_en_us/visualtest/text/links.md new file mode 100644 index 00000000..d173a258 --- /dev/null +++ b/test/visual/resourcepack/assets/guidenh/guidenh/_en_us/visualtest/text/links.md @@ -0,0 +1,90 @@ +--- +navigation: + title: Links + position: 8960 +--- + +TEST GOAL: the whole link family — in-page anchors, cross-page, external, autolinks, reference-style, sound links, CommandLink, SoundLink, links with title + +INVARIANTS: coloring/underlining is consistent; the clickable area bbox = the text bbox + +## Page-Internal Anchors + +Expected: Named anchor creates a jump target; the TOC-style link navigates to it; anchor name is not visible as rendered text. + + + +[Jump to this anchor](#section-anchors) — click to scroll to that anchor paragraph. + +Anchor target paragraph rendered inline with surrounding text. + +[Jump to midpoint](#midpoint) + +## Cross-Page Links + +Expected: Relative markdown link resolves to another page in the same guide; click navigates. + +[Open CJK Mixed page](./cjk-mixed.md) + +[Open Inline Game Tags page](./inline-game-tags.md) + +[Open Footnotes page](./footnotes.md) + +## External Links + +Expected: Underlined distinct color; opens external browser on click. + +[Visit Example](https://example.com) + +[GTNH Wiki](https://gtnh.huapu.moe) + +## Autolinks (Bare URLs) + +Expected: Bare URL rendered as clickable link with underline. + +https://example.com/docs + +https://github.com/GTNewHorizons/GuideNH + +## Reference-Style Links + +Expected: [ref][] renders the same as a standard inline link; definition line is invisible in output. + +[Reference to CJK Mixed][cjk] + +[Reference to Footnotes][fn] + +[cjk]: ./cjk-mixed.md "CJK mixed text page" +[fn]: ./footnotes.md "Footnotes test page" + +## Action Links (Sound via &[]) + +Expected: &[label](sound:uri) renders as clickable text that plays a sound effect; clickable bounding box matches text. + +&[Play orb pickup](sound:minecraft:entity.experience_orb.pickup) + +&[Click with volume](sound:minecraft:block.note.pling?volume=0.5) + +## CommandLink + +Expected: Clickable text that sends a chat command; close attribute closes GUI after click. + + + + + +## SoundLink + +Expected: Clickable text playing a sound; distinct visual style from regular hyperlinks. + + + + + +## Links with Title Tooltip + +Expected: Hovering shows the title text as a tooltip overlay. + +[Tooltip link to CJK](./cjk-mixed.md "This tooltip appears on hover") + +[Another tooltip](./inline-game-tags.md "Go to game tags page") diff --git a/test/visual/resourcepack/assets/guidenh/guidenh/_en_us/visualtest/text/mark.md b/test/visual/resourcepack/assets/guidenh/guidenh/_en_us/visualtest/text/mark.md new file mode 100644 index 00000000..442c9dd4 --- /dev/null +++ b/test/visual/resourcepack/assets/guidenh/guidenh/_en_us/visualtest/text/mark.md @@ -0,0 +1,40 @@ +--- +navigation: + title: Mark Tag (Text Highlight) + position: 8995 +--- + +TEST GOAL: rendering of the `` text-highlight tag (default color + `color=` custom color) + +INVARIANTS: the mark highlight background decoration is present; the color does not leak into following text; mixed layout with other inline marks works normally + +Syntax reference: `MarkTagCompiler` — `text` applies the default mark background; `text` overrides it. + +## Default Mark Background + +Here it should: render the highlighted span with the engine's default mark background colour (dark golden), inline with surrounding prose. + +This sentence contains a highlighted term that must stand out from the body text. + +A longer highlighted run: engineers mark important references like this entire phrase so the reader can scan for them quickly and the wrapping stays clean. + +## Custom Mark Colour + +Here it should: render the highlighted span with the custom background colour given by `color="#"` — distinct from the default highlight. + +blue mark highlight and green mark highlight side by side. + +Default amber mark vs custom red mark on the same line. + +## Mark Mixed With Other Inline Marks + +Here it should: compose `` with bold, italic, code and `` inside the same paragraph — decorations apply to their own runs without leaking. + +This paragraph mixes **bold inside mark**, *italic inside mark*, plain text, and a highlight that ends before the colored span. + +## Mark In A List + +Here it should: render marks inside list items with the list indentation intact. + +- First item with a highlighted word. +- Second item with green highlight. diff --git a/test/visual/resourcepack/assets/guidenh/guidenh/_en_us/visualtest/text/paragraphs.md b/test/visual/resourcepack/assets/guidenh/guidenh/_en_us/visualtest/text/paragraphs.md new file mode 100644 index 00000000..da2a4213 --- /dev/null +++ b/test/visual/resourcepack/assets/guidenh/guidenh/_en_us/visualtest/text/paragraphs.md @@ -0,0 +1,51 @@ +--- +navigation: + title: Paragraphs & Line Breaks + position: 8990 +--- + +TEST GOAL: multi-paragraph spacing + soft line breaks vs blank-line paragraphs + full-width paragraphs + single-character paragraphs + br and br clear + +INVARIANTS: line spacing and paragraph spacing are consistent; no abnormal blank bands + +## Multiple Paragraphs + +This is the first paragraph of a multi-paragraph block. It contains several sentences that form a normal paragraph. The next paragraph follows after an empty line in the source. + +This is the second paragraph. There should be visible paragraph spacing (larger than line spacing) between this and the preceding paragraph. + +This is the third paragraph. The gap between this and the second paragraph should be identical to the gap between the first and second. + +Expected: All three paragraphs have equal inter-paragraph spacing; spacing between paragraphs is larger than line spacing within a paragraph. + +## Soft Line Break vs Hard Paragraph Break + +This line is the first sentence of the paragraph. +This line is on a new line after a soft break (two trailing spaces in the source). +This line is another soft break continuation. The soft breaks should render as a line feed within the same paragraph, not as a paragraph break. + +This is a separate paragraph after an empty line. The gap above should be larger than the gap between soft-broken lines. + +Expected: Soft line breaks produce smaller vertical gap than paragraph breaks; the visual distinction between soft break and paragraph break is clear. + +## Exactly Full Line Width Paragraph + +The quick brown fox jumps over the lazy dog near the bank of the river. The engine must handle text that fills the entire available line width without overflow. Every character should remain within the content bounds. + +Expected: Text fills to the right margin without exceeding it; no horizontal overflow occurs; wrapping occurs naturally at word boundaries. + +## Single-Word Paragraph + +Pneumonoultramicroscopicsilicovolcanoconiosis + +Expected: A single long word renders on its own line; it does not overflow the right margin; the paragraph height is a single line. + +## br and br clear + +This line contains inline content before a line break.
          This text is after a `
          ` tag. The break should produce a line feed within the same paragraph, not a paragraph gap. + +
          + +This text is after a `
          ` tag. The clear-all break should reset any active float context and start a fresh line. + +Expected: `
          ` produces a line break with no extra vertical space; `
          ` clears any preceding floats and renders subsequent content on a new line below the float boundary. diff --git a/test/visual/resourcepack/assets/guidenh/guidenh/_en_us/visualtest/text/section-codes.md b/test/visual/resourcepack/assets/guidenh/guidenh/_en_us/visualtest/text/section-codes.md new file mode 100644 index 00000000..a588f857 --- /dev/null +++ b/test/visual/resourcepack/assets/guidenh/guidenh/_en_us/visualtest/text/section-codes.md @@ -0,0 +1,66 @@ +--- +navigation: + title: Section Color Codes (K1) + position: 8970 +--- + +TEST GOAL: every § color code line by line + §l/§o/§r combinations + 50 consecutive § codes + a lone § literal + Color tag comparison + +INVARIANTS: no giant glyphs (K1); color switching is correct; glyph size is consistent across the page + +## Full Color Code Table (§0-§9, §a-§f) + +Each line below uses a different § color code applied to the same sample text. The color should match the named Minecraft color. + +§0Black sample +§1Dark Blue sample +§2Dark Green sample +§3Dark Aqua sample +§4Dark Red sample +§5Dark Purple sample +§6Gold sample +§7Gray sample +§8Dark Gray sample +§9Blue sample +§aGreen sample +§bAqua sample +§cRed sample +§dLight Purple sample +§eYellow sample +§fWhite sample + +Expected: Each line renders in the correct named color; colors are visually distinguishable from adjacent lines; no artifacts or missing characters. + +## Formatting Code Combinations (§l, §o, §r) + +§lBold text rendered with section-l +§oItalic text rendered with section-o +§l§oBold and italic combined +Normal text with §obefore reset§r and after reset +§lBold with §ocolor§r reset to normal + +Expected: §l produces bold weight; §o produces italic slant; §r resets all formatting to default; combinations apply correctly; reset terminates formatting precisely at the §r boundary. + +## 50 Consecutive § Codes (K1 Replication) + +§§§§§§§§§§§§§§§§§§§§§§§§§§§§§§§§§§§§§§§§§§§§§§§§§§ + +Expected: (K1 sentinel) No single character renders as a giant oversized glyph; all fifty § characters are uniform in size and equally spaced; the line height matches other lines on the page. + +## Isolated § Literal + +A single § character at the end of this sentence should render as a normal-sized glyph§. The word hello§world contains a § in the middle of a word. A paragraph with only § on its own line: + +§ + +Expected: The isolated § renders at the same font size and scale as surrounding text; it is not a giant glyph; it does not trigger any formatting mode. + +## Color Tag Comparison + +Inline red text via Color tag and green text via Color tag and blue text via Color tag. + +Direct hex: orange via hex color and dark turquoise via hex color. + +These Color tag results should visually match the corresponding § color equivalents above. + +Expected: `` renders the same red as §c; `` matches §a; `` matches §9; custom hex colors render correctly; Color tag does not produce a giant glyph. diff --git a/test/visual/resourcepack/assets/guidenh/guidenh/_en_us/visualtest/text_italic-correction.md b/test/visual/resourcepack/assets/guidenh/guidenh/_en_us/visualtest/text_italic-correction.md new file mode 100644 index 00000000..3da87a79 --- /dev/null +++ b/test/visual/resourcepack/assets/guidenh/guidenh/_en_us/visualtest/text_italic-correction.md @@ -0,0 +1,31 @@ +--- +navigation: + title: Italic Correction Verify + position: 8620 +--- + +TEST GOAL: TeX-level italic correction verification (base-italic shear / italic→upright boundary correction / non-intrusion) + +## Mixed + +Expected: italic→upright boundary spacing correction applies. + +plain *italic* plain mixed words here. + +## Base Italic + +Expected: whole-paragraph italic with real shear on tall glyphs. + +*H I l H I l g y.* + +## Upright Control + +Expected: no shear reference geometry. + +upright control H I l g y. + +## Leading Italic + +Expected: leading italic run with upright tail. + +*lead italic* trail upright. diff --git a/test/visual/resourcepack/pack.mcmeta b/test/visual/resourcepack/pack.mcmeta new file mode 100644 index 00000000..80606efc --- /dev/null +++ b/test/visual/resourcepack/pack.mcmeta @@ -0,0 +1,6 @@ +{ + "pack": { + "pack_format": 1, + "description": "GuideNH visual test fixture pack" + } +} diff --git a/test/visual/resourcepack/pack.png b/test/visual/resourcepack/pack.png new file mode 100644 index 0000000000000000000000000000000000000000..f3df6aaea1334a826a69d1dc66f8d908ee770bf7 GIT binary patch literal 4834 zcmd^DSyWS57EPW+FeqaPf(j8R2o~T7+6eMcP*9PGbO~uBhy!3Dh@mhd!8|RBAr6!{ zAVP?M3iA0(Z8WI#iB9RNx$7C!#5lDTS;K#TvA<sd{3o)KaVy>6<-s?42f-YYq=d}7{B>!??8;&Po14z)>qoCkPf*V(LQX> zmIe&?8lENlaMWC-6$2O4h_P)GGE<+r;sO#GGcvJ&XI}$s^OERxsNzPYKr!y@(U_-@V&6;(s)EYw;?L{o6BMOA+NlD;7h@&b<#O5I31j znbmShaq#c?m{|8JrY&sTa79hVj!aJN>($bc4Ox4-%byn{D9rM-uX=lj&(twyX_nVa zFPzW)^DGi=hrx)J=N5>FEHa`sy7)L+f|3Q8ZY=t3+yMc0RQA>x37o}vF)Hxoz;{u5 zRuEc3l}$prCqgmmdaGmm(Gr~CjX`HVBNQi=$M;zx)iNzoM~lOC9J4Sqd4Uru6^d#C z{I#IQ1&%Dgc?nt~#&3>lyx2vIyH@J+(CcV^C_B6RV%E^m}zxs^|Ni46b=3_iJ-<9my~^m#4DFM@+pty^#8^Ib$FKUlV4bS*O!D z3kE9?CnDQV*g>cd0H))%p^!Tjqn^4$Z$>nj0UUE91Tu~Ux@@i_js=(waLR*VFb(l8 z0d`&zJR9;B?-pQP0o#W^5jT5{`nfSmw+#X=WcfK$kn zh_%9vKpSpMraUCCL7p%T;`<7r)>Kj#Rh6u*_P>>S=up#k{UO@}4TkQX=NhI+r?{M; zvh&1ZZ=`!U^bI~+{mQQ5R;G_g*FQVFZgz^X%ph`f5GkuBx|F@N4Pm$IAq(a-F<#*H zBnn`NDYLLj(uW9*#0lD(iijE ztOx1fY@9fiQx*<{Hpv z{dc&ETAeyhFH@^#R!I5Y=nIkSu&*I&aOI14si~!V<&~ehd_-vj4R^EZU+M>>+H6Ah zl!?m78)9v~RZ;*jjZCp}qa*2+02Oaz!w?nM1X#YcDH2H-kjAYzgGh_P30Xb`Denx3 z@cQaih+Z!-u99KEK5G?DJXTyd575Gv#Yl@MBMrKX@lBexNPu~8Wb-OXK-6Ozi{bro z(=>oGh@&(LqA=P7?3V2CwgIRDc2{_O;V>nCKm&N>ZtHckc;CEh*XI``^w_VLd|Tfuyd*y&pdY@Sr!>2 z7?-~osnHxPYtb545D6753tKh^1DFZW;oU4`2GBZaQ70^syDq|se%2A>t{CXL#%eE; zxC6l48_QAQf~8UpCmI3l2k8HJ19DgBTJDk{XQc65aM$BrMW9Uz#7BH*kKFZ3hT747 zgwvy1*OM}(ZZ_L!Sd#!`$_=Vpy!xYD{Xa6Qhg%$XO6qqSvb5F8ecIs{JGA);rTh;w zP@NC{camrP()EuACPl^D{DOL56tvHa{Og{;2i;kW(=StPr~W-3OOn!xirhqsvs{gI zs4`tv>-=%b{scc=CttTmt>jG?rM*?0=ha`;+;S78aktUryzb{8UqLUr#Hns|auw-x zxsEb8k(k?CXO3vAISfx9_uWVa;8nG!?ehV8f+9>e*dfI-L9t$KApjFWv25mjB-21p z?3VM3k!hNOVi!h_MT!N7+qKNw3LqO4OTRY@xh4w~8}sj9kzyC(L>22W%u&QHkZDK6 zUYL&vI>4OY#v{doZBmYGasgNciuF9W0m)PhioF);3|1`85nxHjJ-&FT$?Tu28s}|S z{m%DQk5oG@TQb>it5V0Sm&>!=kQSS_y-2mRmbX9mEl7xwW;p+qz{)16FloB$&Hn)OEITK3bQvf2e@x~K9-7T! zYw6s^Yh1Dv*C_6lg9(xd1I)}KG)$U&gVXc>IoeM?*59@?;0EH#fADu}LHE~kUxL!Odx8bC5v%+ zhVRc%u-(=yM)$Mp`OqU~2;n}G(>fI@HmY2JMJrpvmIJ-faC7n4V}?9)5a3`&N<2{A zc03|>+kj1x_FIxS$gi{bcJ3)!c8(jPLnaKGyn;w}1n!P={LoSJ7`6mS@KFSXP` z+7DJkKm2fH1q8R2LTi*<;HE)D{V=}rKb=N7ML|wI?_x?J5k5E_XJ;1#Kk)~g7+>vc zi^gXTIN4K --page-width 1800 --out +``` + +Rules: + +- `overflow_width`: the right edge of a block exceeds the page width (`x + w > page-width`, + 2px tolerance). +- `zero_size`: block width or height is at most 0. `LytThematicBreak` and `LytItemImage` are + demoted to info level (known-benign classes, pending calibration). +- `off_page`: block coordinate `x < 0` or `y < 0`. +- `sibling_intersection`: IoU > 0.05 between direct children of the same parent. Each block pair + is reported only once (deduplicated by sorted block id). Legitimate float wrapping overlap + between `LytDocumentFloat` and text classes (`LytParagraph`/`LytHeading`/`LytListBlock`) is + skipped: float wrapping is the designed behaviour, not a layout defect. + +### 2. vlm - layer 1 multimodal first pass + +```bash +py -3 screen.py vlm --shots [--pages name1,name2] [--model overrides-.env] [--tile-h 1400] [--overlap 200] [--dry-run] --out +``` + +- Slices each PNG into full-width tiles of height `tile-h` with `overlap` overlap (`overlap` + must be smaller than `tile-h`, otherwise a `ValueError` is raised to avoid an infinite loop). +- Calls the OpenAI-compatible VLM API once per tile. +- Converts tile-local response coordinates back to whole-page coordinates and deduplicates + across tiles (same class and IoU > 0.5 are merged). +- `--dry-run` only prints information and issues no HTTP requests. +- Concurrency: `ThreadPoolExecutor(VLM_CONCURRENCY)`. +- On API failure it retries 3 times with exponential backoff (1s/4s/16s). + +### ask - lightweight targeted visual question answering + +A lightweight targeted entry point: name specific PNG files plus a custom question prompt, feed +the whole image straight to the VLM, and get a targeted answer quickly. It is meant for +point checks such as "is this icon rendered as a tofu box" or "are rows 3-4 of the navigation bar +ghosted". + +```bash +py -3 screen.py ask --files [,] --prompt "is this icon rendered as a tofu box?" [--out output.json] [--model overrides-.env] [--dry-run] +``` + +Parameter table: + +| Parameter | Required | Description | +|-----------|----------|-------------| +| `--files` | yes | PNG file paths (repeat the flag or separate several with commas; relative and absolute paths are both accepted) | +| `--prompt` | yes | Targeted question text (chosen by the caller) | +| `--out` | no | Output JSON path; when omitted the result is printed to stdout | +| `--model` | no | Overrides `VLM_MODEL` from `.env` | +| `--dry-run` | no | Only prints which files would be sent and the request body size, no HTTP request | + +- Feeds the whole image, no tiling (this is the fundamental difference from the vlm first pass). + Each file is called independently and processed sequentially (no concurrency). +- Custom prompt: the system prompt is the neutral `ASK_SYSTEM_PROMPT` (answer strictly the user + question, rely only on visible evidence in the image, answer directly and concretely, follow + the caller-supplied format when structured output is requested). This differs from the fixed + layout-defect prompt used by the vlm first pass. +- Free-form answer with best-effort structure: `answer` is the model's verbatim reply (not + post-processed); `findings` is best-effort - when the model produced structured JSON with a + `findings` array as requested, that array is extracted, otherwise it is empty. +- When the image area exceeds 6,000,000 pixels it prints a warning (detail may suffer; consider + cropping a region or using the vlm first pass) but still attempts the call. +- A single file failure does not interrupt the other files: that entry's `error` holds the error + description (the `error_detail` returned by `call_vlm_api`, verbatim) and is appended to + `errors`. + +Output JSON schema: + +```json +{ + "tool": "test/visual/tools/screen.py", + "subcommand": "ask", + "model": "qwen3-vl-plus", + "files": ["", ""], + "prompt": "", + "answers": [ + { + "file": "", + "answer": "", + "findings": [ + {"bbox": [x, y, w, h], "class": "...", "severity": "...", "confidence": 0.0, "evidence": "..."} + ], + "error": null + } + ], + "errors": [] +} +``` + +Differences from the vlm first pass: + +| Dimension | vlm first pass | ask | +|-----------|----------------|-----| +| Input | directory scan, all PNGs | explicitly named files | +| prompt | fixed 9-class layout-defect prompt | chosen by the caller | +| Imaging | tiling + concurrency + deduplication | whole image, sequential | +| Output | fixed findings schema | free-form answer with best-effort structure | + +### 3. report - merged report + +```bash +py -3 screen.py report --inputs a.json,b.json --out triage.json +``` + +- Merges findings from several sources, groups them by page, and sorts by severity + (error, then warn, then info). +- Prints a per-page count table to stdout. + +## Output formats + +### geometric output + +```json +{ + "tool": "test/visual/tools/screen.py", + "subcommand": "geometric", + "page_width": 1800, + "total_pages": 50, + "total_findings": 123, + "pages": [ + { + "page": "page-name", + "source": "geometric", + "findings": [ + { + "page": "page-name", + "rule": "overflow_width", + "bbox": [x, y, w, h], + "severity": "error|warn", + "evidence": "description" + } + ] + } + ] +} +``` + +### vlm output + +```json +{ + "tool": "test/visual/tools/screen.py", + "subcommand": "vlm", + "model": "qwen3-vl-plus", + "total_pages": 5, + "total_findings": 10, + "findings": [ + { + "page": "page-name", + "source": "vlm", + "rule": "text overlap", + "bbox": [x, y, w, h], + "severity": "error|warn|info", + "confidence": 0.95, + "evidence": "one-sentence description" + } + ], + "errors": [] +} +``` + +### report output + +```json +{ + "tool": "test/visual/tools/screen.py", + "subcommand": "report", + "sources": ["a.json", "b.json"], + "total_pages": 50, + "total_findings": 200, + "summary": { + "page-name": {"error": 2, "warn": 5, "info": 0} + }, + "pages": { ... } +} +``` + +## bounds JSON schema + +Reading several real JSON files under `run/client_new/screenshots/` confirmed the following +bounds JSON structure: + +```json +[ + { + "i": 0, + "cls": "LytParagraph", + "x": 5, + "y": 5, + "w": 393, + "h": 40, + "depth": 1 + } +] +``` + +| Field | Type | Description | +|-------|------|-------------| +| `i` | int | Index of the block within the page | +| `cls` | str | Block type (for example `LytParagraph`, `LytHeading`, `LytTable`, `LytTableCell`, `LytFloatAwareBlock`, `LytCodeBlock`, `ScenePlaceholder`, `CategoryPlaceholder`) | +| `x` | int | Left edge x coordinate (px) | +| `y` | int | Top edge y coordinate (px) | +| `w` | int | Width (px) | +| `h` | int | Height (px) | +| `depth` | int | Tree nesting depth (1-based). Blocks are ordered depth-first; depth=1 is a root node, and the parent of a depth=D block is the closest preceding block at depth=D-1 | + +### Deriving parent-child relations + +The JSON is a flat array, so parent-child relations are derived from the `depth` field and the +element order: + +- Keep a stack of the most recent ancestor per depth. +- The parent of block B is the closest block before B whose depth equals B.depth - 1. +- Siblings are direct children that share the same parent. + +Example (from `scene-blocks.md`): + +``` +depth 1: LytFloatAwareBlock <- parent: none (root) +depth 2: LytTable <- parent: LytFloatAwareBlock +depth 3: LytTableRow <- parent: LytTable +depth 4: LytTableCell <- parent: LytTableRow +depth 5: LytParagraph <- parent: LytTableCell +depth 4: LytTableCell <- parent: LytTableRow (sibling of the previous LytTableCell) +``` + + +## 4. check_freshness - screenshot freshness validation + +Guards against stale screenshot list false positives: it checks that every page in the page list +has a screenshot in the most recent batch and that the screenshots are fresh. + +```bash +py -3 test/visual/tools/check_freshness.py --shots --list [--stale-min 10] +``` + +- Page list format: one `guidenh:visualtest/.md` per line; lines starting with `#` are + comments. +- Screenshots are named `_.png`; the newest batch is selected per + stem. +- Three states: OK / MISSING (no screenshot) / STALE (newest screenshot mtime is older than the + list mtime plus stale-min minutes). +- Exit code 1 when anything is MISSING or STALE; the TAP-style output mirrors assert_bounds.py. +- `--self-test`: built-in smoke cases for the OK/MISSING/STALE states. +- Standard library only, no third-party dependencies. + +## 5. scan_logs - log scanning + +Enforces log hygiene: when the client log of a render round contains a `glyph atlas full` or +`OutOfMemory` warning, that round is judged failed. + +```bash +py -3 test/visual/tools/scan_logs.py [--logs run/client_new/logs] [--pattern "glyph atlas full"] +``` + +- Scans `*.log`, `*.log.gz` (streamed gzip decompression) and `latest.log`. +- Every hit prints the file, line number and line content; exit code 1 on any hit or read error. +- `--self-test`: built-in sample logs (both patterns plus noise) verifying hits and the exit code. +- Standard library only, no third-party dependencies. diff --git a/test/visual/tools/assert_bounds.py b/test/visual/tools/assert_bounds.py new file mode 100644 index 00000000..6e14c648 --- /dev/null +++ b/test/visual/tools/assert_bounds.py @@ -0,0 +1,597 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +""" +bounds-JSON assertion ratchet (assert_bounds) + +Purpose: + Verify that the bounds JSON produced by a headless render satisfies the + expected structural assertions (node counts, centering, width ratios, height + caps, parent containment, and so on). CI integration: + + # step 1: render -> bounds JSON (done by the upstream tooling) + # step 2: assert + py -3 test/visual/tools/assert_bounds.py \\ + --shots run/client_new/screenshots_visualtest \\ + --assertions test/visual/ratchet/assertions.json \\ + [--junit build/test-results/assert-bounds.xml] + + Exit code: 0 when everything passes; 1 on any failure, rule error or missing + page. + +Dependencies: Python 3 standard library only +(json / argparse / xml.etree / pathlib / re / sys). +Do not add any third-party package. +""" + +import argparse +import json +import os +import re +import sys +import xml.etree.ElementTree as ET +from pathlib import Path + + +# Utility functions + +def eprint(*args, **kwargs): + """Print to stderr""" + print(*args, file=sys.stderr, **kwargs) + + +def load_json_safe(path): + """Load a JSON file safely; returns None on failure""" + try: + with open(path, 'r', encoding='utf-8') as f: + return json.load(f) + except (json.JSONDecodeError, FileNotFoundError, IOError) as e: + eprint(f"[warn] cannot read JSON: {path} - {e}") + return None + + +# Page discovery + +# Regex: extract the stem and the timestamp +TIMESTAMP_RE = re.compile(r'^(.+)_(\d{4}-\d{2}-\d{2}_\d{6})\.json$') + + +def discover_pages(shots_dir, page_filters): + """ + Scan shots_dir, group by page stem and take the JSON with the newest + timestamp. + page_filters: list of substrings (an empty list means no restriction). + Returns: {stem: json_path} + """ + all_by_stem = {} # stem -> [(ts_str, path), ...] + + shots_path = Path(shots_dir) + if not shots_path.is_dir(): + eprint(f"[fatal] --shots directory does not exist: {shots_dir}") + sys.exit(1) + + for fname in os.listdir(shots_dir): + m = TIMESTAMP_RE.match(fname) + if not m: + continue + stem = m.group(1) + ts = m.group(2) + path = shots_path / fname + # make sure it is a file + if not path.is_file(): + continue + # --page substring filter + if page_filters: + if not any(sub.lower() in stem.lower() for sub in page_filters): + continue + all_by_stem.setdefault(stem, []).append((ts, str(path))) + + # take the newest timestamp per stem + result = {} + for stem, entries in all_by_stem.items(): + entries.sort(key=lambda x: x[0], reverse=True) + result[stem] = entries[0][1] + + return result + + +# Parent chain reconstruction + +def build_parent_map(blocks): + """ + Build the parent index for a list of nodes. + parent_of[i] = the parent node object, or None for a root. + """ + # ancestors[depth] = the most recent node at that depth + ancestors = {} + parent_of = [None] * len(blocks) + + for idx, blk in enumerate(blocks): + d = blk.get('depth', 1) + if d > 1: + parent_of[idx] = ancestors.get(d - 1) + else: + parent_of[idx] = None + ancestors[d] = blk + + return parent_of + + +# Assertion engine + +def default_msg(rule_type, params): + """Build the default message text from the rule type and its parameters""" + cls = params.get('cls', '?') + if rule_type == 'count': + for op in ('eq', 'ge', 'le'): + if op in params: + return f"expect {op}={params[op]} {cls}" + return f"expect count {cls} with constraint" + elif rule_type == 'exists': + return f"expect {cls} to exist" + elif rule_type == 'absent': + return f"expect {cls} to be absent" + elif rule_type == 'centered': + tol = params.get('tol', 0) + return f"expect {cls} centered within tol={tol}" + elif rule_type == 'width_ratio': + of = params.get('of', 'parent') + for op in ('ge', 'le'): + if op in params: + return f"expect {cls} width_ratio of {of} {op}={params[op]}" + return f"expect {cls} width_ratio constraint" + elif rule_type == 'max_height': + return f"expect max_height of {cls} le={params.get('le', '?')}" + elif rule_type == 'max_width': + return f"expect max_width of {cls} le={params.get('le', '?')}" + elif rule_type == 'inside_parent': + pad = params.get('pad', 0) + return f"expect {cls} inside parent with pad={pad}" + elif rule_type == 'attr': + field = params.get('field', '?') + for op in ('gt', 'ge', 'eq', 'le', 'lt'): + if op in params: + return f"expect {cls}.{field} {op}={params[op]} for every node" + return f"expect {cls}.{field} constraint" + return f"rule {rule_type} on {cls}" + + +def check_count(nodes_of_cls, params): + """count: {cls, eq|ge|le: N}""" + actual = len(nodes_of_cls) + for op in ('eq', 'ge', 'le'): + if op in params: + expected = params[op] + if op == 'eq' and actual != expected: + return (False, f"expected eq={expected} {params['cls']}, actual {actual}") + elif op == 'ge' and actual < expected: + return (False, f"expected >= {expected} {params['cls']}, actual {actual}") + elif op == 'le' and actual > expected: + return (False, f"expected <= {expected} {params['cls']}, actual {actual}") + return (True, None) + + +def check_exists(nodes_of_cls, params): + """exists: {cls}""" + if not nodes_of_cls: + return (False, f"expected {params['cls']} to exist, not found") + return (True, None) + + +def check_absent(nodes_of_cls, params): + """absent: {cls}""" + if nodes_of_cls: + return (False, f"expected {params['cls']} to be absent, found {len(nodes_of_cls)}") + return (True, None) + + +def check_centered(blocks, nodes_of_cls, parent_of, params): + """centered: {cls, tol: T} - for every C node + |center_x - center_x(parent)| <= T""" + cls = params['cls'] + tol = params['tol'] + for idx, node in nodes_of_cls: + parent = parent_of[idx] + if parent is None: + return (False, f"RULE ERROR: {cls} node #{idx} has no parent, cannot check centered") + node_cx = node['x'] + node['w'] / 2.0 + parent_cx = parent['x'] + parent['w'] / 2.0 + diff = abs(node_cx - parent_cx) + if diff > tol: + return (False, f"{cls} node #{idx} center_x diff {diff:.1f} exceeds tol {tol}") + return (True, None) + + +def check_width_ratio(blocks, nodes_of_cls, parent_of, params): + """width_ratio: {cls, of: 'parent', ge|le: R}""" + cls = params['cls'] + op = 'ge' if 'ge' in params else 'le' if 'le' in params else None + threshold = params.get(op, 0) if op else 0 + if op is None: + return (False, f"RULE ERROR: width_ratio missing ge/le for {cls}") + + for idx, node in nodes_of_cls: + parent = parent_of[idx] + if parent is None: + return (False, f"RULE ERROR: {cls} node #{idx} has no parent, cannot check width_ratio") + parent_w = parent['w'] + if parent_w == 0: + return (False, f"RULE ERROR: parent of {cls} node #{idx} has width 0") + ratio = node['w'] / float(parent_w) + if op == 'ge' and ratio < threshold: + return (False, f"{cls} node #{idx} width_ratio {ratio:.3f} < {threshold}") + elif op == 'le' and ratio > threshold: + return (False, f"{cls} node #{idx} width_ratio {ratio:.3f} > {threshold}") + return (True, None) + + +def check_max_height(blocks, nodes_of_cls, parent_of, params): + """max_height: {cls, le: H}""" + cls = params['cls'] + limit = params['le'] + for idx, node in nodes_of_cls: + h = node['h'] + if h > limit: + return (False, f"{cls} node #{idx} h={h} > limit {limit}") + return (True, None) + + +def check_max_width(blocks, nodes_of_cls, parent_of, params): + """max_width: {cls, le: W}""" + cls = params['cls'] + limit = params['le'] + for idx, node in nodes_of_cls: + w = node['w'] + if w > limit: + return (False, f"{cls} node #{idx} w={w} > limit {limit}") + return (True, None) + + +def check_inside_parent(blocks, nodes_of_cls, parent_of, params): + """inside_parent: {cls, pad: P}""" + cls = params['cls'] + pad = params.get('pad', 0) + for idx, node in nodes_of_cls: + parent = parent_of[idx] + if parent is None: + return (False, f"RULE ERROR: {cls} node #{idx} has no parent, cannot check inside_parent") + overflow_left = node['x'] - parent['x'] + overflow_top = node['y'] - parent['y'] + overflow_right = (node['x'] + node['w']) - (parent['x'] + parent['w']) + overflow_bottom = (node['y'] + node['h']) - (parent['y'] + parent['h']) + if overflow_left < -pad: + return (False, f"{cls} node #{idx} overflows parent left by {-overflow_left} px (pad={pad})") + if overflow_top < -pad: + return (False, f"{cls} node #{idx} overflows parent top by {-overflow_top} px (pad={pad})") + if overflow_right > pad: + return (False, f"{cls} node #{idx} overflows parent right by {overflow_right} px (pad={pad})") + if overflow_bottom > pad: + return (False, f"{cls} node #{idx} overflows parent bottom by {overflow_bottom} px (pad={pad})") + return (True, None) + + +# Single page evaluation + +def check_attr(nodes_of_cls, params): + """attr: {cls, field: x|y|w|h, gt|ge|eq|le|lt: N} - the numeric field of + every C node must satisfy the comparison; no node at all counts as + failure""" + cls = params['cls'] + field = params['field'] + if not nodes_of_cls: + return (False, f"expected {cls} nodes for attr check, none found") + for idx, node in nodes_of_cls: + actual = node.get(field) + if actual is None: + return (False, f"{cls} node #{idx} missing field '{field}'") + for op in ('gt', 'ge', 'eq', 'le', 'lt'): + if op not in params: + continue + expected = params[op] + ok = {'gt': actual > expected, 'ge': actual >= expected, 'eq': actual == expected, + 'le': actual <= expected, 'lt': actual < expected}[op] + if not ok: + return (False, f"{cls} node #{idx} {field}={actual} violates {op}={expected}") + return (True, None) + + +def validate_rule_structure(rule_obj): + """ + Validate the structure of a rule object. + rule_obj should be {rule_type: params} or {rule_type: params, msg: ...}. + Returns: (True, rule_type, params, msg) or (False, error_msg, None, None) + """ + if not isinstance(rule_obj, dict): + return (False, f"rule is not a dict", None, None) + + # find the rule_type key (skipping metadata such as msg and _readme) + known_keys = {'count', 'exists', 'absent', 'centered', + 'width_ratio', 'max_height', 'max_width', 'inside_parent', 'attr'} + rule_type = None + params = None + for k, v in rule_obj.items(): + if k == 'msg' or k == '_readme' or k.startswith('_'): + continue + if k in known_keys: + if rule_type is not None: + return (False, f"multiple rule keys in one object: {rule_type} and {k}", None, None) + rule_type = k + params = v + else: + return (False, f"unknown rule key '{k}'", None, None) + + if rule_type is None: + return (False, "no known rule key found in rule object", None, None) + + if not isinstance(params, dict): + return (False, f"params for {rule_type} is not a dict", None, None) + + if 'cls' not in params and rule_type in ('count', 'exists', 'absent', 'centered', + 'width_ratio', 'max_height', 'max_width', 'inside_parent', 'attr'): + return (False, f"missing 'cls' in {rule_type} params", None, None) + + # count needs an operator + if rule_type == 'count': + has_op = any(op in params for op in ('eq', 'ge', 'le')) + if not has_op: + return (False, "count rule missing eq/ge/le operator", None, None) + + # width_ratio needs an operator and of + if rule_type == 'width_ratio': + has_op = any(op in params for op in ('ge', 'le')) + if not has_op: + return (False, "width_ratio rule missing ge/le operator", None, None) + if params.get('of') != 'parent': + return (False, "width_ratio only supports of='parent'", None, None) + + # max_height/max_width need le + if rule_type in ('max_height', 'max_width'): + if 'le' not in params: + return (False, f"{rule_type} rule missing 'le' threshold", None, None) + + # centered needs tol + if rule_type == 'centered': + if 'tol' not in params: + return (False, "centered rule missing 'tol'", None, None) + + # attr needs a valid field and at least one comparison operator + if rule_type == 'attr': + if params.get('field') not in ('x', 'y', 'w', 'h'): + return (False, "attr rule field must be one of x/y/w/h", None, None) + if not any(op in params for op in ('gt', 'ge', 'eq', 'le', 'lt')): + return (False, "attr rule missing gt/ge/eq/le/lt operator", None, None) + + msg = rule_obj.get('msg') + if msg is None or not isinstance(msg, str) or msg.strip() == '': + msg = default_msg(rule_type, params) + + return (True, rule_type, params, msg) + + +def evaluate_page(page_stem, page_json_path, assertions_list): + """ + Run the assertions for a single page. + Returns: [(n, page_stem, msg, passed, failure_text), ...] + n: assertion number (global) + failure_text: failure detail, None when it passed + """ + blocks = load_json_safe(page_json_path) + if blocks is None or not isinstance(blocks, list) or len(blocks) == 0: + # invalid JSON - every assertion counts as failed + results = [] + for idx, rule_obj in enumerate(assertions_list): + ok_struct, err, _, _ = validate_rule_structure(rule_obj) + if not ok_struct: + msg = err + else: + _, _, _, msg = validate_rule_structure(rule_obj) + results.append((page_stem, msg, False, + f"page bounds JSON invalid or empty: {page_json_path}")) + return results + + parent_of = build_parent_map(blocks) + + results = [] + for rule_obj in assertions_list: + ok_struct, rule_type, params, msg = validate_rule_structure(rule_obj) + if not ok_struct: + results.append((page_stem, msg, False, + f"RULE ERROR: {ok_struct}")) + continue + + cls = params.get('cls', '') + # collect every node of that class (as (idx, node) pairs) + nodes_of_cls = [(idx, b) for idx, b in enumerate(blocks) if b.get('cls') == cls] + + passed = False + failure_text = None + + try: + if rule_type == 'count': + passed, failure_text = check_count(nodes_of_cls, params) + elif rule_type == 'exists': + passed, failure_text = check_exists(nodes_of_cls, params) + elif rule_type == 'absent': + passed, failure_text = check_absent(nodes_of_cls, params) + elif rule_type == 'centered': + passed, failure_text = check_centered(blocks, nodes_of_cls, parent_of, params) + elif rule_type == 'width_ratio': + passed, failure_text = check_width_ratio(blocks, nodes_of_cls, parent_of, params) + elif rule_type == 'max_height': + passed, failure_text = check_max_height(blocks, nodes_of_cls, parent_of, params) + elif rule_type == 'max_width': + passed, failure_text = check_max_width(blocks, nodes_of_cls, parent_of, params) + elif rule_type == 'inside_parent': + passed, failure_text = check_inside_parent(blocks, nodes_of_cls, parent_of, params) + elif rule_type == 'attr': + passed, failure_text = check_attr(nodes_of_cls, params) + else: + passed = False + failure_text = f"RULE ERROR: unknown rule type '{rule_type}'" + except Exception as e: + passed = False + failure_text = f"RULE ERROR: {type(e).__name__}: {e}" + + results.append((page_stem, msg, passed, failure_text)) + + return results + + +# JUnit XML output + +def build_junit(all_results): + """ + Build the JUnit XML tree. + all_results: [(page_stem, msg, passed, failure_text), ...] + """ + # group by page_stem + by_page = {} + for page_stem, msg, passed, failure_text in all_results: + by_page.setdefault(page_stem, []).append((msg, passed, failure_text)) + + ts_elements = [] + for page_stem in sorted(by_page.keys()): + entries = by_page[page_stem] + suite = ET.Element('testsuite') + suite.set('name', page_stem) + suite.set('tests', str(len(entries))) + + failures = sum(1 for _, p, _ in entries if not p) + suite.set('failures', str(failures)) + + for msg, passed, failure_text in entries: + tc = ET.SubElement(suite, 'testcase') + tc.set('name', msg) + tc.set('classname', f'assert_bounds.{page_stem}') + if not passed: + fail_elem = ET.SubElement(tc, 'failure') + fail_elem.set('message', failure_text) + fail_elem.text = failure_text + + ts_elements.append(suite) + + # root element + if ts_elements: + root = ET.Element('testsuites') + total = sum(len(v) for v in by_page.values()) + failed_count = 0 + for v in by_page.values(): + for _, p, _ in v: + if not p: + failed_count += 1 + root.set('tests', str(total)) + root.set('failures', str(failed_count)) + for suite in ts_elements: + root.append(suite) + return root + else: + root = ET.Element('testsuites') + root.set('tests', '0') + root.set('failures', '0') + return root + + +# Main run + +def main(): + # UTF-8 console + if hasattr(sys.stdout, 'reconfigure'): + sys.stdout.reconfigure(encoding='utf-8', errors='replace') + sys.stderr.reconfigure(encoding='utf-8', errors='replace') + + parser = argparse.ArgumentParser( + description='assertion ratchet for visual test bounds JSON' + ) + parser.add_argument('--shots', required=True, + help='screenshot directory (holding ' + '_YYYY-MM-DD_HHMMSS.json files)') + parser.add_argument('--assertions', required=True, + help='path to the assertions JSON file') + parser.add_argument('--junit', + help='JUnit XML output path (optional)') + parser.add_argument('--page', action='append', dest='page_filters', default=[], + help='page name substring filter (may be given several times)') + args = parser.parse_args() + + # load the assertions file + assertions_data = load_json_safe(args.assertions) + if assertions_data is None: + eprint("[fatal] cannot read the assertions file") + sys.exit(1) + + pages_assertions = assertions_data.get('pages', {}) + if not pages_assertions: + eprint("[fatal] the assertions file is missing the 'pages' key or it is empty") + sys.exit(1) + + # page discovery + discovered = discover_pages(args.shots, args.page_filters) + if not discovered: + eprint("[warn] no matching JSON file found in the --shots directory") + + total_assertions = 0 + total_failed = 0 + all_results = [] # [(n, page_stem, msg, passed, failure_text)] + n = 0 + + # process every page stem of the assertions JSON in declaration order + for page_stem, assertion_list in pages_assertions.items(): + if not isinstance(assertion_list, list): + eprint(f"[warn] page '{page_stem}' assertions is not an array, skipping") + continue + + json_path = discovered.get(page_stem) + + if json_path is None: + # page missing + for rule_obj in assertion_list: + n += 1 + total_assertions += 1 + # try to get msg, fall back to the default + ok_struct, rule_type, params, msg = validate_rule_structure(rule_obj) + if not ok_struct: + msg = rule_obj.get('msg', 'assertion') + + all_results.append((page_stem, msg, False, + "page bounds JSON missing")) + total_failed += 1 + print(f"not ok {n} - {page_stem}: {msg} (page bounds JSON missing)") + continue + + # evaluate the page + page_results = evaluate_page(page_stem, json_path, assertion_list) + for ps, msg, passed, failure_text in page_results: + n += 1 + total_assertions += 1 + all_results.append((ps, msg, passed, failure_text)) + if passed: + print(f"ok {n} - {ps}: {msg}") + else: + total_failed += 1 + print(f"not ok {n} - {ps}: {msg} ({failure_text})") + + # SUMMARY + total_pages = len(pages_assertions) + print(f"SUMMARY: pages={total_pages} assertions={total_assertions} failed={total_failed}") + + # JUnit output + if args.junit: + junit_root = build_junit(all_results) + tree = ET.ElementTree(junit_root) + # make sure the output directory exists + junit_path = Path(args.junit) + junit_path.parent.mkdir(parents=True, exist_ok=True) + tree.write(str(junit_path), encoding='utf-8', xml_declaration=True) + + # exit code + return 1 if total_failed > 0 else 0 + + +if __name__ == '__main__': + try: + sys.exit(main()) + except KeyboardInterrupt: + eprint("[info] interrupted by user") + sys.exit(130) + except Exception as e: + eprint(f"[fatal] unhandled exception: {e}") + sys.exit(1) diff --git a/test/visual/tools/check_freshness.py b/test/visual/tools/check_freshness.py new file mode 100644 index 00000000..983a0afd --- /dev/null +++ b/test/visual/tools/check_freshness.py @@ -0,0 +1,298 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +""" +Screenshot freshness validator (check_freshness) + +Purpose: + Guards against stale screenshot list false positives: it checks whether every + page in a page list has a screenshot in the most recent batch and whether the + screenshots are fresh. Used together with headless render batches: + + py -3 test/visual/tools/check_freshness.py \\ + --shots run/client_new/screenshots_visualtest \\ + --list \\ + [--stale-min 10] + + Exit code: 0 when everything is OK; 1 on any MISSING / STALE. + +Dependencies: Python 3 standard library only +(argparse / os / re / sys / tempfile / datetime). +Do not add any third-party package. +""" + +import argparse +import os +import re +import sys +import tempfile +from datetime import datetime + + +# Screenshot file name regex (same idea as assert_bounds.py TIMESTAMP_RE, +# extended to .png) +SHOT_RE = re.compile(r'^(.+)_(\d{4}-\d{2}-\d{2}_\d{6})\.png$') + +# Page list prefix: guidenh:visualtest/.md +PAGE_PREFIX = 'guidenh:visualtest/' + + +def eprint(*args, **kwargs): + """Print to stderr""" + print(*args, file=sys.stderr, **kwargs) + + +def page_id_to_stem(page_id): + """Page id -> screenshot stem. + + guidenh:visualtest/layout/details.md -> visualtest_layout_details.md + Inputs without the prefix, or with path separators already translated, are + normalized the same way. + """ + s = page_id.strip() + if s.startswith(PAGE_PREFIX): + s = s[len(PAGE_PREFIX):] + # path separator / -> _ + s = s.replace('/', '_') + if not s.startswith('visualtest_'): + s = 'visualtest_' + s + return s + + +def load_page_list(list_path): + """Read the page list. Returns [(line_no, page_id), ...]; lines starting + with '#' and blank lines are ignored.""" + pages = [] + with open(list_path, 'r', encoding='utf-8', errors='replace') as f: + for lineno, line in enumerate(f, 1): + line = line.strip() + if not line or line.startswith('#'): + continue + pages.append((lineno, line)) + return pages + + +def scan_shots(shots_dir): + """Scan the screenshot directory, grouped by stem. + Returns {stem: [(ts_str, path), ...]}.""" + by_stem = {} + try: + entries = os.listdir(shots_dir) + except OSError as e: + eprint(f'[fatal] cannot read screenshot directory {shots_dir}: {e}') + sys.exit(1) + for name in entries: + m = SHOT_RE.match(name) + if not m: + continue + path = os.path.join(shots_dir, name) + if not os.path.isfile(path): + continue + stem, ts = m.group(1), m.group(2) + by_stem.setdefault(stem, []).append((ts, path)) + return by_stem + + +def parse_ts(ts_str): + """File name timestamp 'YYYY-MM-DD_HHMMSS' -> datetime + (returns None when it cannot be parsed).""" + try: + return datetime.strptime(ts_str, '%Y-%m-%d_%H%M%S') + except ValueError: + return None + + +def evaluate(shots_dir, list_path, stale_min): + """Core decision. + + Returns (results, n_missing, n_stale): + results: [(page_id, status, detail), ...], status in OK / MISSING / STALE + """ + pages = load_page_list(list_path) + by_stem = scan_shots(shots_dir) + + try: + list_mtime = os.path.getmtime(list_path) + except OSError as e: + eprint(f'[fatal] cannot read list file {list_path}: {e}') + sys.exit(1) + + results = [] + n_missing = 0 + n_stale = 0 + + for lineno, page_id in pages: + stem = page_id_to_stem(page_id) + entries = by_stem.get(stem, []) + if not entries: + results.append((page_id, 'MISSING', + f'no screenshots for stem {stem} ' + f'(line {lineno})')) + n_missing += 1 + continue + + # Same rule as assert_bounds.py discover_pages: newest file name + # timestamp wins + latest_ts, latest_path = max(entries, key=lambda e: e[0]) + shot_mtime = os.path.getmtime(latest_path) + + age_min = (list_mtime - shot_mtime) / 60.0 + if age_min > stale_min: + results.append(( + page_id, 'STALE', + f'latest shot {latest_ts} is {age_min:.1f} min older than ' + f'list (stale-min {stale_min} min)')) + n_stale += 1 + else: + results.append((page_id, 'OK', f'latest shot {latest_ts}')) + + return results, n_missing, n_stale + + +def run(shots_dir, list_path, stale_min): + """Run the validation, print TAP-style output, return the exit code.""" + results, n_missing, n_stale = evaluate(shots_dir, list_path, stale_min) + + n = 0 + for page_id, status, detail in results: + n += 1 + if status == 'OK': + print(f'ok {n} - {page_id}: OK ({detail})') + elif status == 'MISSING': + print(f'not ok {n} - {page_id}: MISSING ({detail})') + else: # STALE + print(f'not ok {n} - {page_id}: STALE ({detail})') + + print(f'SUMMARY: pages={len(results)} ok={len(results) - n_missing - n_stale} ' + f'missing={n_missing} stale={n_stale}') + + return 1 if (n_missing + n_stale) > 0 else 0 + + +def self_test(): + """Built-in smoke test: build fake screenshots and lists in a temporary + directory and verify the OK/MISSING/STALE states.""" + results = [] # (case_name, passed, detail) + + def check(name, cond, detail=''): + results.append((name, bool(cond), detail)) + + with tempfile.TemporaryDirectory() as tmp: + shots = os.path.join(tmp, 'shots') + os.makedirs(shots) + + # ---- 1) stem conversion ---- + cases = [ + ('guidenh:visualtest/layout/details.md', 'visualtest_layout_details.md'), + ('guidenh:visualtest/mermaid/mindmap.md', 'visualtest_mermaid_mindmap.md'), + ('guidenh:visualtest/foo.md', 'visualtest_foo.md'), + ] + for page_id, want in cases: + got = page_id_to_stem(page_id) + check(f'stem conversion {page_id}', got == want, + f'got {got}, want {want}') + + # ---- 2) OK state ---- + ok_shot = os.path.join(shots, + 'visualtest_layout_details.md_2026-08-02_120000.png') + with open(ok_shot, 'wb') as f: + f.write(b'x') + list_ok = os.path.join(tmp, 'list_ok.txt') + with open(list_ok, 'w', encoding='utf-8') as f: + f.write('# comment line\n') + f.write('guidenh:visualtest/layout/details.md\n') + # Screenshot mtime older, list mtime slightly newer (< stale-min) -> OK + os.utime(ok_shot, (1000000, 1000000)) + os.utime(list_ok, (1000000 + 60, 1000000 + 60)) + + # ---- 3) STALE state: same screenshot but the list mtime is much newer ---- + stale_min = 10 + list_stale = os.path.join(tmp, 'list_stale.txt') + with open(list_stale, 'w', encoding='utf-8') as f: + f.write('guidenh:visualtest/layout/details.md\n') + # List mtime 30 minutes later than the screenshot > stale-min 10 minutes + os.utime(list_stale, (1000000 + 60 * 30, 1000000 + 60 * 30)) + + # ---- 4) MISSING state: the list has a page but there is no screenshot ---- + list_missing = os.path.join(tmp, 'list_missing.txt') + with open(list_missing, 'w', encoding='utf-8') as f: + f.write('guidenh:visualtest/charts/pie.md\n') + + # Run the three decisions + results_ok, nm, ns = evaluate(shots, list_ok, stale_min) + check('OK detection', (nm, ns) == (0, 0) + and results_ok[0][1] == 'OK', + f'missing={nm} stale={ns} status={results_ok[0][1]}') + + results_stale, nm, ns = evaluate(shots, list_stale, stale_min) + check('STALE detection', (nm, ns) == (0, 1) + and results_stale[0][1] == 'STALE', + f'missing={nm} stale={ns} status={results_stale[0][1]}') + + results_missing, nm, ns = evaluate(shots, list_missing, stale_min) + check('MISSING detection', (nm, ns) == (1, 0) + and results_missing[0][1] == 'MISSING', + f'missing={nm} stale={ns} status={results_missing[0][1]}') + + # ---- 5) comment lines and blank lines are ignored ---- + list_comment = os.path.join(tmp, 'list_comment.txt') + with open(list_comment, 'w', encoding='utf-8') as f: + f.write('# only a comment\n\n') + f.write('guidenh:visualtest/layout/details.md\n') + results_comment, nm, ns = evaluate(shots, list_comment, stale_min) + check('comment/blank handling', len(results_comment) == 1, + f'{len(results_comment)} entries, want 1') + + n_fail = 0 + for i, (name, passed, detail) in enumerate(results, 1): + if not passed: + n_fail += 1 + print(f'{"ok" if passed else "not ok"} {i} - self-test: {name}' + f' ({"OK" if passed else detail})') + + print(f'SUMMARY: selftest ok={len(results) - n_fail} failed={n_fail}') + return 1 if n_fail else 0 + + +def main(): + # UTF-8 console + if hasattr(sys.stdout, 'reconfigure'): + sys.stdout.reconfigure(encoding='utf-8', errors='replace') + sys.stderr.reconfigure(encoding='utf-8', errors='replace') + + parser = argparse.ArgumentParser( + description='screenshot freshness validator ' + '(guards against stale page-list false positives)') + parser.add_argument('--shots', + help='screenshot directory ' + '(holding _YYYY-MM-DD_HHMMSS.png)') + parser.add_argument('--list', + help='page list file (one guidenh:visualtest/.md ' + 'per line, lines starting with # are comments)') + parser.add_argument('--stale-min', type=int, default=10, + help='report STALE when a screenshot is more than N ' + 'minutes older than the list (default 10)') + parser.add_argument('--self-test', action='store_true', + help='run the built-in smoke test ' + '(OK/MISSING/STALE states) and exit') + args = parser.parse_args() + + if args.self_test: + return self_test() + + if not args.shots or not args.list: + eprint('[fatal] --shots and --list are required ' + '(or use --self-test)') + return 2 + + return run(args.shots, args.list, args.stale_min) + + +if __name__ == '__main__': + try: + sys.exit(main()) + except KeyboardInterrupt: + eprint('[info] interrupted by user') + sys.exit(130) + except Exception as e: + eprint(f'[fatal] unhandled exception: {e}') + sys.exit(1) diff --git a/test/visual/tools/render_watchdog.py b/test/visual/tools/render_watchdog.py new file mode 100644 index 00000000..27270e0b --- /dev/null +++ b/test/visual/tools/render_watchdog.py @@ -0,0 +1,314 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +""" +Render Watchdog: wrapper for headless render commands to prevent orphaned JVM processes. + +Usage: + py -3 test/visual/tools/render_watchdog.py --timeout 480 -- ./gradlew runClient25 -Dguidenh.headlessRender=true ... + +This script: + 1. Takes a pre-snapshot of all java processes. + 2. Launches the wrapped command with stdout/stderr redirected to a log file. + 3. Monitors the process every 5 s, provides heartbeats every 30 s. + 4. On timeout: kills the process tree (/T /F) and any orphaned JVMs matching + patterns (run/client_new, guidenh, launchwrapper, plus --kill-extra regex). + 5. On normal exit: checks for orphaned JVMs, waits 10 s, kills survivors. +""" + +import argparse +import os +import re +import subprocess +import sys +import time + + +def parse_args(): + """Parse command line arguments. + + Returns (args, command_list). Exits with code 2 on error or --help. + """ + parser = argparse.ArgumentParser( + description='Watchdog wrapper for headless render commands.', + add_help=False, + ) + parser.add_argument('--timeout', type=int, default=480, + help='Timeout in seconds (default: 480)') + parser.add_argument('--log', type=str, + default='run/client_new/logs/watchdog_last.log', + help='Log file path ' + '(default: run/client_new/logs/watchdog_last.log)') + parser.add_argument('--kill-extra', type=str, default='', + help='Additional regex pattern for matching orphan JVMs') + + # --help / -h → print usage and exit non-zero + if '--help' in sys.argv or '-h' in sys.argv: + parser.print_help() + print() + print('Usage:') + print(' py -3 render_watchdog.py [--timeout SEC] [--log PATH] [--kill-extra REGEX] -- COMMAND...') + sys.exit(2) + + # Locate the -- separator + try: + dash_idx = sys.argv.index('--') + except ValueError: + print('Error: missing "--" separator before command', file=sys.stderr) + parser.print_help() + sys.exit(2) + + # Parse options before -- + args, _ = parser.parse_known_args(sys.argv[1:dash_idx]) + + # Everything after -- is the command + command = sys.argv[dash_idx + 1:] + + if not command: + print('Error: empty command after "--"', file=sys.stderr) + sys.exit(2) + + return args, command + + +def enum_java_procs(): + """Enumerate all java processes returning {pid: cmdline}. + + Tries WMIC first, falls back to PowerShell. Returns {} on total failure. + """ + result = {} + + # -- attempt WMIC --------------------------------------------------------- + try: + cmd = [ + 'wmic', 'process', 'where', "name like 'java%'", + 'get', 'processid,commandline', '/format:csv', + ] + proc = subprocess.run(cmd, capture_output=True, text=True, timeout=15) + if proc.returncode == 0: + for line in proc.stdout.strip().splitlines(): + line = line.strip() + if not line or line.startswith('Node,ProcessId'): + continue + # CSV columns: Node,ProcessId,CommandLine + parts = line.split(',', 2) + if len(parts) >= 2: + pid_str = parts[1].strip() + cmdline = parts[2].strip() if len(parts) > 2 else '' + if pid_str.isdigit(): + result[int(pid_str)] = cmdline + if result: + return result + except Exception as exc: + print(f'[watchdog] WARNING: WMIC enumeration failed: {exc}', + file=sys.stderr) + + # -- fallback PowerShell -------------------------------------------------- + try: + ps_cmd = [ + 'powershell', '-NoProfile', '-Command', + 'Get-CimInstance Win32_Process -Filter "Name like \'java%\'" | ' + 'ForEach-Object { $_.ProcessId.ToString() + \'|\' + $_.CommandLine }', + ] + proc = subprocess.run(ps_cmd, capture_output=True, text=True, timeout=30) + if proc.returncode == 0: + for line in proc.stdout.strip().splitlines(): + line = line.strip() + if not line or '|' not in line: + continue + pid_str, _, cmdline = line.partition('|') + if pid_str.strip().isdigit(): + result[int(pid_str.strip())] = cmdline.strip() + else: + print(f'[watchdog] WARNING: PowerShell enumeration failed ' + f'(rc={proc.returncode}): {proc.stderr.strip()}', + file=sys.stderr) + except Exception as exc: + print(f'[watchdog] WARNING: PowerShell enumeration failed: {exc}', + file=sys.stderr) + + return result + + +def matches_orphan_pattern(cmdline, extra_regex): + """Check if *cmdline* matches any orphan JVM pattern. + + *extra_regex* may be a compiled ``re.Pattern`` or *None*. + """ + keywords = ['run/client_new', 'guidenh', 'launchwrapper'] + for kw in keywords: + if kw in cmdline: + return True + if extra_regex is not None and extra_regex.search(cmdline): + return True + return False + + +def find_orphan_javas(before_procs, extra_pattern=None): + """Return [(pid, cmdline), …] for java processes after *before_procs*. + + Only processes whose command line matches one of the orphan patterns + (run/client_new / guidenh / launchwrapper, plus the optional regex) + are included. + """ + extra_regex = re.compile(extra_pattern) if extra_pattern else None + after_procs = enum_java_procs() + return [ + (pid, cmdline) + for pid, cmdline in after_procs.items() + if pid not in before_procs and matches_orphan_pattern(cmdline, extra_regex) + ] + + +def taskkill_tree(pid): + """Kill process tree *pid* with ``taskkill /T /F``. + + Errors are printed as warnings (non-fatal). + """ + try: + subprocess.run( + ['taskkill', '/PID', str(pid), '/T', '/F'], + capture_output=True, text=True, timeout=10, + ) + except Exception as exc: + print(f'[watchdog] WARNING: taskkill tree PID {pid} failed: {exc}', + file=sys.stderr) + + +def kill_pid(pid): + """Kill a single process by *pid* with ``taskkill /F``. + + Errors are printed as warnings (non-fatal). + """ + try: + subprocess.run( + ['taskkill', '/PID', str(pid), '/F'], + capture_output=True, text=True, timeout=10, + ) + except Exception as exc: + print(f'[watchdog] WARNING: taskkill PID {pid} failed: {exc}', + file=sys.stderr) + + +def read_last_lines(filepath, n=15): + """Return the last *n* lines of *filepath* as a list of strings. + + On any error a single-element list with an error message is returned. + """ + try: + with open(filepath, 'r', encoding='utf-8', errors='replace') as fh: + lines = fh.readlines() + return lines[-n:] if len(lines) >= n else lines + except Exception as exc: + return [f''] + + +def print_summary(retcode, elapsed, log_path): + """Print final summary (exit code, elapsed, log path, last 15 lines).""" + print(f'[watchdog] exit code: {retcode}') + print(f'[watchdog] elapsed: {elapsed:.1f}s') + print(f'[watchdog] log: {log_path}') + print('--- last 15 log lines ---') + for line in read_last_lines(log_path, 15): + sys.stdout.write(line.rstrip('\r\n') + '\n') + + +def main(): + """Entry point.""" + # Ensure UTF-8 output on Windows + if hasattr(sys.stdout, 'reconfigure'): + sys.stdout.reconfigure(encoding='utf-8') + if hasattr(sys.stderr, 'reconfigure'): + sys.stderr.reconfigure(encoding='utf-8') + + args, command = parse_args() + + timeout = args.timeout + log_path = args.log + extra_pattern = args.kill_extra # empty string means no extra pattern + + # Create log directory if needed + log_dir = os.path.dirname(log_path) + if log_dir: + os.makedirs(log_dir, exist_ok=True) + + # ---- pre-snapshot ------------------------------------------------------- + before_procs = enum_java_procs() + if not before_procs: + print('[watchdog] WARNING: pre-snapshot returned no java processes ' + '(enumeration may have failed)', file=sys.stderr) + + # ---- launch wrapped command --------------------------------------------- + start_time = time.time() + print(f'[watchdog] starting: {" ".join(command)}') + print(f'[watchdog] timeout: {timeout}s, log: {log_path}') + + try: + with open(log_path, 'w', encoding='utf-8') as log_fh: + proc = subprocess.Popen(command, stdout=log_fh, stderr=log_fh) + except Exception as exc: + print(f'[watchdog] ERROR: failed to start process: {exc}', + file=sys.stderr) + sys.exit(2) + + last_heartbeat = 0.0 + timed_out = False + + # ---- monitoring loop ---------------------------------------------------- + while True: + time.sleep(5) + ret = proc.poll() + elapsed = time.time() - start_time + + # -- process exited --------------------------------------------------- + if ret is not None: + if not timed_out: + # Normal exit → orphan check + orphans = find_orphan_javas(before_procs, extra_pattern) + if orphans: + print(f'[watchdog] WARNING: {len(orphans)} orphan JVM(s) ' + f'detected after process exit, waiting 10 s…') + time.sleep(10) + current_procs = enum_java_procs() + still_alive = [ + (pid, cmdline) + for pid, cmdline in orphans + if pid in current_procs + ] + for pid, cmdline in still_alive: + print(f'[watchdog] killing orphan JVM PID {pid}: ' + f'{cmdline[:120]}') + kill_pid(pid) + print(f'[watchdog] killed {len(still_alive)} orphan JVM(s)') + print_summary(ret, elapsed, log_path) + sys.exit(ret) + + # -- timeout ---------------------------------------------------------- + if elapsed > timeout and not timed_out: + timed_out = True + print(f'[watchdog] TIMEOUT after {int(elapsed)}s, starting cleanup…') + # 4a. Kill entire process tree + taskkill_tree(proc.pid) + # 4b. Wait and mop up orphaned JVMs + time.sleep(5) + orphans = find_orphan_javas(before_procs, extra_pattern) + for pid, cmdline in orphans: + print(f'[watchdog] killing orphan JVM PID {pid}: {cmdline[:120]}') + kill_pid(pid) + print(f'[watchdog] TIMEOUT after {int(elapsed)}s, killed tree + ' + f'{len(orphans)} orphan JVM(s)') + sys.exit(124) + + # -- heartbeat -------------------------------------------------------- + if elapsed - last_heartbeat >= 30: + log_size = 0 + try: + log_size = os.path.getsize(log_path) + except OSError: + pass + print(f'[watchdog] heartbeat: {int(elapsed)}s elapsed, ' + f'log size {log_size} bytes') + last_heartbeat = elapsed + + +if __name__ == '__main__': + main() diff --git a/test/visual/tools/scan_logs.py b/test/visual/tools/scan_logs.py new file mode 100644 index 00000000..8058c51b --- /dev/null +++ b/test/visual/tools/scan_logs.py @@ -0,0 +1,221 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +""" +Log scanner (scan_logs) + +Purpose: + Scans the client log of a render round. A round is judged failed as soon as a + warning pattern such as `glyph atlas full` or `OutOfMemory` is hit: + + py -3 test/visual/tools/scan_logs.py \\ + [--logs run/client_new/logs] \\ + [--pattern "glyph atlas full"] [--pattern "OutOfMemory"] \\ + [--self-test] + + Exit code: 0 when there is no hit; 1 on any hit. + Supports *.log, *.log.gz (streamed gzip decompression) and latest.log. + +Dependencies: Python 3 standard library only +(argparse / os / gzip / sys / tempfile). +Do not add any third-party package. +""" + +import argparse +import gzip +import os +import sys +import tempfile + +# Built-in default warning patterns (log hygiene for render rounds) +DEFAULT_PATTERNS = ['glyph atlas full', 'OutOfMemory'] + + +def eprint(*args, **kwargs): + """Print to stderr""" + print(*args, file=sys.stderr, **kwargs) + + +def find_log_files(logs_dir): + """Collect log files recursively: *.log, *.log.gz and latest.log. + Returns a sorted list of paths.""" + files = [] + if not os.path.isdir(logs_dir): + eprint(f'[fatal] --logs directory does not exist: {logs_dir}') + sys.exit(1) + for root, _dirs, names in os.walk(logs_dir): + for name in names: + if (name.endswith('.log') or name.endswith('.log.gz') + or name == 'latest.log'): + files.append(os.path.join(root, name)) + return sorted(files) + + +def scan_file(path, patterns): + """Scan a single log file (*.gz is decompressed as a gzip stream). + + Returns (hits, error): + hits: [(lineno, pattern, line_text), ...] + error: description of a read failure (None means normal) + """ + hits = [] + error = None + lower_patterns = [p.lower() for p in patterns] + + try: + opener = gzip.open if path.endswith('.gz') else open + with opener(path, 'rt', encoding='utf-8', errors='replace') as f: + for lineno, line in enumerate(f, 1): + low = line.lower() + for pat, low_pat in zip(patterns, lower_patterns): + if low_pat in low: + hits.append((lineno, pat, line.rstrip('\r\n'))) + break + except gzip.BadGzipFile as e: + error = f'bad gzip file: {e}' + except OSError as e: + error = f'read error: {e}' + except Exception as e: + error = f'{type(e).__name__}: {e}' + + return hits, error + + +def run(logs_dir, patterns): + """Run the scan, print TAP-style output, return the exit code.""" + files = find_log_files(logs_dir) + n = 0 + n_hits = 0 + n_files = 0 + errors = [] + + for path in files: + hits, err = scan_file(path, patterns) + n_files += 1 + if err: + errors.append((path, err)) + n += 1 + print(f'not ok {n} - {path}: ERROR ({err})') + continue + if not hits: + n += 1 + print(f'ok {n} - {path}: clean') + continue + n_hits += len(hits) + for lineno, pat, text in hits: + n += 1 + print(f'not ok {n} - {path}:{lineno} matches "{pat}": {text}') + + if errors: + for path, err in errors: + eprint(f'[warn] cannot scan {path}: {err}') + + print(f'SUMMARY: files={n_files} hits={n_hits} errors={len(errors)}') + return 1 if n_hits or errors else 0 + + +def self_test(): + """Built-in smoke test: build sample logs (both patterns plus noise) and + verify the hits and the exit code.""" + results = [] # (case_name, passed, detail) + + def check(name, cond, detail=''): + results.append((name, bool(cond), detail)) + + with tempfile.TemporaryDirectory() as tmp: + # ---- 1) plain .log with glyph atlas full plus noise ---- + plain = os.path.join(tmp, 'plain.log') + with open(plain, 'w', encoding='utf-8') as f: + f.write('[main] INFO clean startup line\n') + f.write('[main] WARN glyph atlas full, dropping glyph key=foo ' + '(978x1110)\n') + f.write('[main] INFO normal text mentioning atlas in prose\n') + + hits, err = scan_file(plain, DEFAULT_PATTERNS) + check('plain log hit count', err is None and len(hits) == 1, + f'hits={len(hits)} err={err}') + + # ---- 2) .log.gz containing OutOfMemory ---- + gz_path = os.path.join(tmp, 'archive.log.gz') + with gzip.open(gz_path, 'wt', encoding='utf-8') as f: + f.write('[main] WARN OutOfMemoryError during render stage\n') + f.write('[main] INFO another normal line\n') + + hits, err = scan_file(gz_path, DEFAULT_PATTERNS) + check('gzip OutOfMemory hit', err is None and len(hits) == 1 + and hits[0][1] == 'OutOfMemory', + f'hits={len(hits)} err={err}') + + # ---- 3) file without hits ---- + clean = os.path.join(tmp, 'clean.log') + with open(clean, 'w', encoding='utf-8') as f: + f.write('[main] INFO clean render completed\n') + + hits, err = scan_file(clean, DEFAULT_PATTERNS) + check('clean log no hits', err is None and len(hits) == 0, + f'hits={len(hits)} err={err}') + + # ---- 4) both patterns hit inside the same file ---- + both = os.path.join(tmp, 'both.log') + with open(both, 'w', encoding='utf-8') as f: + f.write('handling OutOfMemoryError gracefully\n') + f.write('glyph atlas full, dropping glyph key=big (900x1000)\n') + + hits, err = scan_file(both, DEFAULT_PATTERNS) + check('both patterns matched', err is None and len(hits) == 2, + f'hits={len(hits)} err={err}') + + # ---- 5) directory-level run(): a file with hits exists -> exit 1 ---- + import io + import contextlib + with contextlib.redirect_stdout(io.StringIO()): + rc = run(tmp, DEFAULT_PATTERNS) + check('run() exit code with hits', rc == 1, f'rc={rc}') + + n_fail = 0 + for i, (name, passed, detail) in enumerate(results, 1): + if not passed: + n_fail += 1 + print(f'{"ok" if passed else "not ok"} {i} - self-test: {name}' + f' ({"OK" if passed else detail})') + + print(f'SUMMARY: selftest ok={len(results) - n_fail} failed={n_fail}') + return 1 if n_fail else 0 + + +def main(): + # UTF-8 console + if hasattr(sys.stdout, 'reconfigure'): + sys.stdout.reconfigure(encoding='utf-8', errors='replace') + sys.stderr.reconfigure(encoding='utf-8', errors='replace') + + parser = argparse.ArgumentParser( + description='log scanner - fails a render round whose client log ' + 'contains a warning pattern such as glyph atlas full or ' + 'OutOfMemory') + parser.add_argument('--logs', default='run/client_new/logs', + help='log directory (default run/client_new/logs; ' + 'scans *.log / *.log.gz / latest.log)') + parser.add_argument('--pattern', action='append', dest='patterns', + help='extra or overriding warning pattern ' + '(substring match, may be given several times; ' + 'without it the two built-in patterns are used)') + parser.add_argument('--self-test', action='store_true', + help='run the built-in smoke test and exit') + args = parser.parse_args() + + if args.self_test: + return self_test() + + patterns = args.patterns if args.patterns else DEFAULT_PATTERNS + return run(args.logs, patterns) + + +if __name__ == '__main__': + try: + sys.exit(main()) + except KeyboardInterrupt: + eprint('[info] interrupted by user') + sys.exit(130) + except Exception as e: + eprint(f'[fatal] unhandled exception: {e}') + sys.exit(1) diff --git a/test/visual/tools/screen.py b/test/visual/tools/screen.py new file mode 100644 index 00000000..c5ce269f --- /dev/null +++ b/test/visual/tools/screen.py @@ -0,0 +1,1209 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +""" +Visual Inspection Screener (screen) + +Three layers: geometric / vlm / report +Dependencies: Pillow + the Python 3 standard library +""" + +import argparse +import base64 +import io +import json +import os +import re +import sys +import time +import urllib.request +import urllib.error +from concurrent.futures import ThreadPoolExecutor, as_completed +from io import BytesIO + +# Constants - the prompts can be iterated on here + +VLM_SYSTEM_PROMPT = """You are a layout defect inspector. Analyse the layout and rendering problems in the image. + +This is one vertical slice of a long page. Text, images or scenes cut off at the top or bottom edge of the slice are the slice's doing, so do not report them. Report "clipping" only when content is cut off by a container edge inside the slice. + +[Expected elements of this documentation system - do not report] +- Angle-bracket tags such as or appearing in body text in monospace code style are normal layout. +- A solid black bar (possibly with slight noise) is the expected rendering of a Spoiler mask. +- The bilingual TEST GOAL / INVARIANTS block at the top of a page is test annotation, not a content defect. + +[Judging basis] The small grey text "Expected: ..." on a page is the official description of the intended result for the content below it. Use it as the comparison baseline: report only when the actual rendering does not match the Expected description; when it matches, do not report. Paragraphs without an Expected line are judged by general layout standards. Do not report anything merely because of the presence or the wording of the Expected text itself. + +Check the following problem categories: +1. Text overlap - different text blocks overlap each other, or an icon/image sits on top of text +2. Text overflowing its container - text extends beyond its background container's bounds +3. Misplaced elements - elements are misaligned or positioned abnormally (an icon drifting off its line, pressing into an adjacent line, or landing outside its block belongs here too) +4. Abnormal blank space - blank areas that should not be there +5. Broken images or 3D scenes - images/3D rendering display abnormally (corruption / black blocks / missing) +6. Glyph rendering problems - text is incomplete, garbled or wrong glyphs +7. Clipping - content cut off by an edge +8. Font rendering path inconsistency - body text and text inside graphics (chart/flowchart/mindmap nodes, scene labels) on the same page should use one and the same smooth font rendering; when text inside graphics looks like a pixel bitmap and clearly differs from the smooth body font, report this category +9. Other - any other layout defect + +[What not to report, besides the expected elements above] +- In a tree or directory-tree structure, aligned connector lines (such as the box-drawing forms or | |-- \\--) mean the indentation is correct; do not infer parent-child nesting from the text semantics. +- Lines styled like "CSV with ..." or "## ..." are section headings, not unrendered configuration text. +- Text running to the right edge of the page or a container without being cut in half is normal layout; report "clipping" only when text is visibly cut in half. + +Strictly output JSON only, with no other text. +Output format: {"findings": [{"bbox":[x,y,w,h], "class":"problem category", "severity":"error|warn|info", "confidence":0-1, "evidence":"one-sentence description"}]} +The keys must be exactly bbox/class/severity/confidence/evidence; other key names such as type or description are forbidden. +When there is no problem output: {"findings": []} +Note: bbox coordinates are pixel coordinates inside the image.""" + +VLM_USER_TEXT = "Detect layout and rendering defects in this image region." + +# Neutral system prompt used by the ask subcommand (unlike the first-pass +# VLM_SYSTEM_PROMPT) +ASK_SYSTEM_PROMPT = """You are a visual question answering assistant. Answer strictly the specific question the user asks; do not describe the whole image in general terms. + +Requirements: +- Answer only from evidence visible in the image; do not guess or invent information that is not in the image. +- Answer directly, concretely and briefly, staying on the question itself. +- When the user asks for structured output (for example a specified JSON format or key names), output exactly that format with no extra explanatory text. +- When the user cares about one region only, focus the answer on that region and do not discuss other regions.""" + +# Geometric detection constants +# sibling_intersection exclusion rule: legitimate float wrapping between +# LytDocumentFloat and a specific set of blocks is not reported. +# LytFloatAwareBlock wraps at full width, as the CSS float model intends; +# LytDocumentFloat.getBounds returns the inner visible bounds while its flow +# height is 0. +FLOAT_CLASS = "LytDocumentFloat" +FLOAT_EXCLUDED_CLASSES = ["LytParagraph", "LytHeading", "LytListBlock", "LytFloatAwareBlock"] + +# Known benign classes for which the zero_size rule is demoted to info +# (supported by first-round measurement data) +ZERO_SIZE_BENIGN_CLASSES = ["LytThematicBreak"] # LytItemImage removed 2026-07-29: zero-size was the R2-2 defect (D3), not benign +ZERO_SIZE_BENIGN_EVIDENCE_SUFFIX = " (known benign class, pending calibration)" + +# geometric subcommand: file name timestamp extraction regex +TIMESTAMP_RE = re.compile(r'^(.+)_(\d{4}-\d{2}-\d{2}_\d{6})\.(png|json)$') + +# Utility functions + +def eprint(*args, **kwargs): + """Print to stderr""" + print(*args, file=sys.stderr, **kwargs) + + +def load_json(path): + """Load a JSON file safely""" + try: + with open(path, 'r', encoding='utf-8') as f: + return json.load(f) + except (json.JSONDecodeError, FileNotFoundError, IOError) as e: + eprint(f"[warn] skipping unreadable JSON: {path} - {e}") + return None + + +def save_json(path, data): + """Save a JSON file""" + with open(path, 'w', encoding='utf-8') as f: + json.dump(data, f, ensure_ascii=False, indent=2) + + +def iou(a, b): + """Compute the IoU of two bboxes [x,y,w,h]""" + ax1, ay1, aw, ah = a + bx1, by1, bw, bh = b + ax2, ay2 = ax1 + aw, ay1 + ah + bx2, by2 = bx1 + bw, by1 + bh + + ix1 = max(ax1, bx1) + iy1 = max(ay1, by1) + ix2 = min(ax2, bx2) + iy2 = min(ay2, by2) + + iw = max(0, ix2 - ix1) + ih = max(0, iy2 - iy1) + inter = iw * ih + + area_a = aw * ah + area_b = bw * bh + union = area_a + area_b - inter + if union <= 0: + return 0.0 + return inter / union + + +def _contains(outer, inner): + """Check whether outer bbox [x,y,w,h] fully contains inner + (touching edges count as contained)""" + ox, oy, ow, oh = outer + ix, iy, iw, ih = inner + return (ox <= ix and oy <= iy and + ox + ow >= ix + iw and + oy + oh >= iy + ih) + + +# .env parsing + +def load_env(env_path): + """ + Hand-written .env parsing: supports key=value, ignores # comments and + blank lines. Returns a dict. python-dotenv is not allowed. + """ + if not os.path.isfile(env_path): + return None + env = {} + with open(env_path, 'r', encoding='utf-8') as f: + for line in f: + line = line.strip() + if not line or line.startswith('#'): + continue + if '=' not in line: + continue + key, _, val = line.partition('=') + key = key.strip() + val = val.strip() + # strip optional surrounding quotes + if len(val) >= 2 and val[0] == val[-1] and val[0] in ('"', "'"): + val = val[1:-1] + env[key] = val + return env + + +# Geometric detection (geometric) + +def build_parent_map(blocks): + """ + Build a parent -> children map from a depth-ordered block list. + Blocks are ordered depth-first / pre-order, so the parent is the closest + depth-1 ancestor. + Returns: {parent_idx: [child_block, ...]}, parent_idx=-1 means root + """ + # keep the most recent ancestor per depth + ancestors = {} # depth -> block + parent_map = {} # parent_idx -> [children] + + for blk in blocks: + d = blk.get('depth', 1) + pid = blk.get('i', -1) + + # determine the parent + parent = None + if d > 1: + parent = ancestors.get(d - 1) + + parent_key = parent['i'] if parent is not None else -1 + parent_map.setdefault(parent_key, []).append(blk) + ancestors[d] = blk + + return parent_map + + +def _has_non_zero_descendant(blocks, pos): + """ + Check whether blocks[pos] has a descendant with a non-zero bbox + (w>0 and h>0). + Descendant test: the consecutive run of nodes after pos whose depth is + greater than blocks[pos].depth. + """ + if pos >= len(blocks) - 1: + return False + parent_depth = blocks[pos].get('depth', 1) + for i in range(pos + 1, len(blocks)): + child_depth = blocks[i].get('depth', 1) + if child_depth <= parent_depth: + break + w = blocks[i].get('w', 0) + h = blocks[i].get('h', 0) + if w > 0 and h > 0: + return True + return False + + +def run_geometric(shots_dir, page_width, out_path): + """ + Run the geometric checks: + a) overflow beyond page width (x+w > page_width + 2) + b) zero size (w<=0 or h<=0) + c) off-page bounds (x<0 or y<0) + d) sibling intersection (IoU > 0.05 between direct children of one parent) + """ + # collect all PNG/JSON files, grouped by stem with the newest timestamp + png_by_stem = {} # stem -> (path, ts) + json_by_stem = {} # stem -> (path, ts) + for fname in os.listdir(shots_dir): + m = TIMESTAMP_RE.match(fname) + if not m: + continue + stem = m.group(1) + ts = m.group(2) + ext = m.group(3) + path = os.path.join(shots_dir, fname) + if ext == 'png': + # take the PNG with the newest timestamp (compare index [1], the + # ts, not index [0], the path string) + if stem not in png_by_stem or ts > png_by_stem[stem][1]: + png_by_stem[stem] = (path, ts) + else: + if stem not in json_by_stem or ts > json_by_stem[stem][1]: + json_by_stem[stem] = (path, ts) + + # pair by stem + png_files = [] + for stem in png_by_stem: + if stem not in json_by_stem: + eprint(f"[warn] skipping {stem}: no matching JSON") + continue + png_files.append((png_by_stem[stem][0], json_by_stem[stem][0], stem)) + + eprint(f"[info] found {len(png_files)} PNGs with a bounds JSON (deduplicated by stem)") + + all_pages = [] + total_findings = 0 + + for png_path, json_path, page_name in png_files: + blocks = load_json(json_path) + if blocks is None or not isinstance(blocks, list): + eprint(f"[warn] skipping {page_name}: invalid bounds JSON") + continue + + page_findings = [] + + # checks (a)(b)(c): block by block + for pos, blk in enumerate(blocks): + x = blk.get('x', 0) + y = blk.get('y', 0) + w = blk.get('w', 0) + h = blk.get('h', 0) + cls = blk.get('cls', '') + idx = blk.get('i', -1) + + # (a) overflow beyond the page width + if x + w > page_width + 2: + page_findings.append({ + "page": page_name, + "rule": "overflow_width", + "bbox": [x, y, w, h], + "severity": "error", + "evidence": f"block #{idx} ({cls}) x+w={x+w} > page_width={page_width}" + }) + + # (b) zero size + if w <= 0 or h <= 0: + severity = "error" + evidence = f"block #{idx} ({cls}) size w={w}, h={h}" + if cls in ZERO_SIZE_BENIGN_CLASSES: + severity = "info" + evidence += ZERO_SIZE_BENIGN_EVIDENCE_SUFFIX + elif not _has_non_zero_descendant(blocks, pos): + # no non-zero descendant -> legitimate empty container, + # do not report + continue + page_findings.append({ + "page": page_name, + "rule": "zero_size", + "bbox": [x, y, w, h], + "severity": severity, + "evidence": evidence + }) + + # (c) off-page bounds + if x < 0 or y < 0: + page_findings.append({ + "page": page_name, + "rule": "off_page", + "bbox": [x, y, w, h], + "severity": "warn", + "evidence": f"block #{idx} ({cls}) coordinates x={x}, y={y}" + }) + + # (d) sibling intersection + parent_map = build_parent_map(blocks) + for parent_key, siblings in parent_map.items(): + n = len(siblings) + for i in range(n): + for j in range(i + 1, n): + a = siblings[i] + b = siblings[j] + bbox_a = [a['x'], a['y'], a['w'], a['h']] + bbox_b = [b['x'], b['y'], b['w'], b['h']] + # skip zero-size blocks + if a['w'] <= 0 or a['h'] <= 0 or b['w'] <= 0 or b['h'] <= 0: + continue + # skip legitimate float wrapping where one side is + # LytDocumentFloat and the other is a text class block + a_cls = a.get('cls', '') + b_cls = b.get('cls', '') + if (a_cls == FLOAT_CLASS and b_cls in FLOAT_EXCLUDED_CLASSES) or \ + (b_cls == FLOAT_CLASS and a_cls in FLOAT_EXCLUDED_CLASSES): + continue + # ancestor-descendant exemption: when the shallower block + # fully contains the deeper one they are ancestor and + # descendant (intermediate wrappers are skipped), so skip + # and do not report + a_depth = a.get('depth', 1) + b_depth = b.get('depth', 1) + if a_depth != b_depth: + if (_contains(bbox_a, bbox_b) and a_depth < b_depth) or \ + (_contains(bbox_b, bbox_a) and b_depth < a_depth): + continue + overlap = iou(bbox_a, bbox_b) + if overlap > 0.05: + # sort by block id to avoid reporting A intersect B and + # B intersect A twice + if a['i'] <= b['i']: + primary, secondary = a, b + else: + primary, secondary = b, a + page_findings.append({ + "page": page_name, + "rule": "sibling_intersection", + "bbox": [primary['x'], primary['y'], primary['w'], primary['h']], + "severity": "warn", + "evidence": f"block #{primary['i']} ({primary['cls']}) intersects #{secondary['i']} ({secondary['cls']}) IoU={overlap:.3f}" + }) + + total_findings += len(page_findings) + all_pages.append({ + "page": page_name, + "source": "geometric", + "findings": page_findings + }) + + # build the output + output = { + "tool": "test/visual/tools/screen.py", + "subcommand": "geometric", + "page_width": page_width, + "total_pages": len(all_pages), + "total_findings": total_findings, + "pages": all_pages + } + + save_json(out_path, output) + eprint(f"[info] geometric detection done: {len(all_pages)} pages, {total_findings} findings") + eprint(f"[info] output -> {out_path}") + + # print the summary to stdout + print(json.dumps({ + "pages_processed": len(all_pages), + "findings_count": total_findings, + "output": out_path + })) + + return 0 + + +# VLM detection + +def encode_image_png(pil_image): + """PIL Image -> base64 PNG""" + buf = BytesIO() + pil_image.save(buf, format='PNG') + return base64.b64encode(buf.getvalue()).decode('ascii') + + +def tile_image(pil_image, tile_h, overlap): + """ + Slice the image into full-width tiles of height tile_h with overlap + overlap. + Returns: [(tile_index, tile_pil_image, offset_y)] + """ + if overlap >= tile_h: + raise ValueError( + f"overlap ({overlap}) must be smaller than tile_h ({tile_h}), " + f"otherwise the loop never ends" + ) + width, height = pil_image.size + tiles = [] + y = 0 + idx = 0 + while y < height: + top = y + bottom = min(y + tile_h, height) + tile = pil_image.crop((0, top, width, bottom)) + tiles.append((idx, tile, top)) + idx += 1 + y += tile_h - overlap + if y >= height: + break + return tiles + + +def call_vlm_api(api_key, base_url, model, image_b64, timeout, prompt_text, + system_prompt=VLM_SYSTEM_PROMPT, raw_out=None, require_json=True): + """ + Call an OpenAI-compatible VLM API. + Returns: (result, error_detail). On success result is the parsed JSON dict + and error_detail is None; on failure result is None and error_detail is the + error description string (distinguishing an API call failure from a response + parse failure). + + Optional parameters (used by the ask subcommand; the defaults keep the + original vlm behaviour unchanged): + - system_prompt: overrides the system prompt (default VLM_SYSTEM_PROMPT) + - raw_out: when a dict is passed, the verbatim model answer is written to + raw_out['content'] + - require_json=False: a non-JSON answer is not treated as a failure + (returns (None, None)); the verbatim text is still written to raw_out + """ + url = f"{base_url}/chat/completions".rstrip('/') + headers = { + "Content-Type": "application/json", + "Authorization": f"Bearer {api_key}" + } + payload = { + "model": model, + "messages": [ + {"role": "system", "content": system_prompt}, + { + "role": "user", + "content": [ + {"type": "text", "text": prompt_text}, + {"type": "image_url", "image_url": { + "url": f"data:image/png;base64,{image_b64}" + }} + ] + } + ], + "max_tokens": 1024, + "temperature": 0.1 + } + data = json.dumps(payload).encode('utf-8') + + retries = [1, 4, 16] # exponential backoff, only for 429, 5xx and network errors + + for attempt in range(1 + len(retries)): + try: + req = urllib.request.Request(url, data=data, headers=headers, method='POST') + resp = urllib.request.urlopen(req, timeout=timeout) + resp_body = resp.read().decode('utf-8') + resp_json = json.loads(resp_body) + # extract content + choices = resp_json.get('choices', []) + if not choices: + preview = resp_body[:200] + eprint(f"[warn] API returned no choices") + return (None, f"API call failed: response has no choices, raw response: {preview}") + content = choices[0].get('message', {}).get('content', '') + if raw_out is not None: + raw_out['content'] = content + result = extract_json_block(content) + if result is None: + if require_json: + preview = content[:200] + return (None, f"response parse failed: {preview}") + # require_json=False: a non-JSON answer is not a failure (ask + # case); the verbatim text is already in raw_out + return (None, None) + return (result, None) + except urllib.error.HTTPError as e: + code = e.code + body_preview = "" + try: + body_preview = e.read().decode('utf-8', errors='replace')[:200] + except Exception: + pass + err_msg = f"API call failed: HTTP {code} {body_preview}" + # retry only 429 and 5xx; fail other 4xx immediately + if code == 429 or code >= 500: + if attempt < len(retries): + wait = retries[attempt] + eprint(f"[warn] API call failed (attempt {attempt+1}): {e}, waiting {wait}s to retry") + time.sleep(wait) + continue + eprint(f"[error] {err_msg}") + return (None, err_msg) + eprint(f"[error] {err_msg}") + return (None, err_msg) + except (urllib.error.URLError, TimeoutError, OSError) as e: + err_msg = f"API call failed: {e}" + if attempt < len(retries): + wait = retries[attempt] + eprint(f"[warn] API call failed (attempt {attempt+1}): {e}, waiting {wait}s to retry") + time.sleep(wait) + continue + eprint(f"[error] {err_msg}") + return (None, err_msg) + except json.JSONDecodeError as e: + err_msg = f"API call failed: response body is not valid JSON: {e}" + eprint(f"[error] {err_msg}") + return (None, err_msg) + + return (None, "API call failed: unknown error") + + +def extract_json_block(text): + """ + Leniently extract the first complete JSON object block. + The model may wrap it in ```json ... ``` markers. + """ + if not text: + return None + + # try to extract a ```json ... ``` block + m = re.search(r'```(?:json)?\s*\n?({.*?})\s*\n?```', text, re.DOTALL) + if m: + try: + return json.loads(m.group(1)) + except json.JSONDecodeError: + pass + + # try to extract the first { ... } + brace_start = text.find('{') + brace_end = text.rfind('}') + if brace_start >= 0 and brace_end > brace_start: + try: + return json.loads(text[brace_start:brace_end + 1]) + except json.JSONDecodeError: + preview = text[:200] + eprint(f"[warn] tile response JSON parse failed: {preview}") + pass + + return None + + +def _normalize_finding(f): + """ + Normalize the finding key names returned by the model. + Measured behaviour: qwen models occasionally drift on key names + (type->class, description->evidence, bbox_2d->bbox, and so on). + Mapping rule: map only when the target key is absent; a bbox_2d in + four-point form is converted to [x,y,w,h]. + """ + f = dict(f) # do not modify the original dict + + # type -> class + if 'class' not in f and 'type' in f: + f['class'] = f.pop('type') + + # description -> evidence + if 'evidence' not in f and 'description' in f: + f['evidence'] = f.pop('description') + + # box -> bbox + if 'bbox' not in f and 'box' in f: + f['bbox'] = f.pop('box') + + # bbox_2d -> bbox (four-point [x1,y1,x2,y2] converted to [x,y,w,h]) + if 'bbox' not in f and 'bbox_2d' in f: + raw = f.pop('bbox_2d') + if isinstance(raw, (list, tuple)) and len(raw) == 4: + x1, y1, x2, y2 = raw + if x2 > x1 and y2 > y1: + f['bbox'] = [x1, y1, x2 - x1, y2 - y1] + else: + f['bbox'] = list(raw) + else: + f['bbox'] = raw + + return f + + +def process_tile(args): + """Process one tile (used by the thread pool)""" + api_key, base_url, model, timeout, prompt_text = args['cfg'] + tile_img = args['tile_img'] + tile_idx = args['tile_idx'] + offset_y = args['offset_y'] + page_name = args['page_name'] + + try: + b64 = encode_image_png(tile_img) + result, error_detail = call_vlm_api(api_key, base_url, model, b64, timeout, prompt_text) + if error_detail: + return { + "page": page_name, + "source": "vlm", + "tile_index": tile_idx, + "findings": [], + "error": f"tile {tile_idx} {error_detail}" + } + + findings = result.get('findings', []) + # normalize key names: defend against the model ignoring the key + # constraints (qwen models drift on key names occasionally) + findings = [_normalize_finding(f) for f in findings] + # coordinate conversion: y inside the tile -> y on the whole page + converted = [] + for f in findings: + bbox = f.get('bbox', [0, 0, 0, 0]) + bbox[1] += offset_y # add the tile offset to y + converted.append({ + "bbox": bbox, + "class": f.get('class', 'unknown'), + "severity": f.get('severity', 'info'), + "confidence": f.get('confidence', 0.0), + "evidence": f.get('evidence', ''), + "tile_index": tile_idx + }) + + return { + "page": page_name, + "source": "vlm", + "tile_index": tile_idx, + "findings": converted, + "error": None + } + except Exception as e: + eprint(f"[error] tile {tile_idx} processing exception: {e}") + return { + "page": page_name, + "source": "vlm", + "tile_index": tile_idx, + "findings": [], + "error": str(e) + } + + +def deduplicate_findings(findings, iou_threshold=0.5): + """ + Cross-tile deduplication: same class and bbox IoU > threshold are merged, + keeping the one with the higher confidence. + """ + if not findings: + return [] + + # group by class + by_class = {} + for f in findings: + cls = f.get('class', 'unknown') + by_class.setdefault(cls, []).append(f) + + deduped = [] + for cls, items in by_class.items(): + # sort by confidence descending + items.sort(key=lambda x: x.get('confidence', 0), reverse=True) + kept = [] + for item in items: + is_dup = False + for k in kept: + if iou(item['bbox'], k['bbox']) > iou_threshold: + is_dup = True + break + if not is_dup: + kept.append(item) + deduped.extend(kept) + + return deduped + + +def run_vlm(shots_dir, pages_filter, model_override, tile_h, overlap, dry_run, out_path): + """ + Run the VLM first pass. + """ + env_path = os.path.join(os.path.dirname(os.path.abspath(__file__)), '.env') + env = load_env(env_path) + + if env is None: + eprint("[fatal] no .env file found") + eprint(f"[fatal] copy {os.path.dirname(os.path.abspath(__file__))}/.env.example to .env and fill in DASHSCOPE_API_KEY") + sys.exit(1) + + api_key = env.get('DASHSCOPE_API_KEY', '').strip() + + # dry-run mode does not need a real key + if not dry_run: + if not api_key or api_key.startswith('sk-your-key') or api_key == 'dummy': + eprint("[fatal] DASHSCOPE_API_KEY is not configured properly") + eprint(f"[fatal] edit {env_path} and fill in a real API key") + sys.exit(1) + + base_url = env.get('VLM_BASE_URL', 'https://dashscope.aliyuncs.com/compatible-mode/v1').rstrip('/') + model = model_override or env.get('VLM_MODEL', 'qwen2.5-vl-32b-instruct') + concurrency = int(env.get('VLM_CONCURRENCY', '4')) + timeout = int(env.get('VLM_TIMEOUT', '120')) + + # tile parameter guard (avoid an endless loop) + if overlap >= tile_h: + eprint(f"[fatal] overlap ({overlap}) must be smaller than tile_h ({tile_h}), otherwise the loop never ends") + sys.exit(1) + + # collect files + png_files = [] + for fname in os.listdir(shots_dir): + if fname.lower().endswith('.png'): + png_path = os.path.join(shots_dir, fname) + base = os.path.splitext(fname)[0] + # page name = the name without the timestamp + page_name = re.sub(r'_\d{4}-\d{2}-\d{2}_\d{6}$', '', base) + + if pages_filter: + if page_name not in pages_filter: + continue + + png_files.append((png_path, page_name)) + + if not png_files: + eprint("[fatal] no matching PNG file") + sys.exit(1) + + # Dry-run: only print information + if dry_run: + eprint(f"[dry-run] model: {model}") + eprint(f"[dry-run] Base URL: {base_url}") + prompt_len = len(VLM_USER_TEXT) + len(VLM_SYSTEM_PROMPT) + eprint(f"[dry-run] prompt length: {prompt_len} characters") + eprint(f"[dry-run] concurrency: {concurrency}, timeout: {timeout}s") + eprint(f"[dry-run] matched {len(png_files)} pages:") + + total_tiles = 0 + for png_path, page_name in png_files: + try: + from PIL import Image + img = Image.open(png_path) + width, height = img.size + tiles = tile_image(img, tile_h, overlap) + total_tiles += len(tiles) + for idx, tile, off_y in tiles: + buf = BytesIO() + tile.save(buf, format='PNG') + b64_size = len(base64.b64encode(buf.getvalue()).decode('ascii')) + req_body = json.dumps({ + "model": model, + "messages": [ + {"role": "system", "content": VLM_SYSTEM_PROMPT}, + {"role": "user", "content": [ + {"type": "text", "text": VLM_USER_TEXT}, + {"type": "image_url", "image_url": {"url": f"data:image/png;base64,..."}} + ]} + ], + "max_tokens": 1024, + "temperature": 0.1 + }) + eprint(f"[dry-run] page {page_name} tile {idx}: y={off_y}-{off_y+tile.size[1]}, " + f"request body ~{len(req_body)} bytes, base64 ~{b64_size} bytes") + except Exception as e: + eprint(f"[warn] cannot open {png_path}: {e}") + + eprint(f"[dry-run] total: {len(png_files)} pages, {total_tiles} tiles") + print(json.dumps({"dry_run": True, "pages": len(png_files), "tiles": total_tiles})) + return 0 + + # actual run + from PIL import Image + + all_findings = [] + all_errors = [] + + for png_path, page_name in png_files: + try: + img = Image.open(png_path) + except Exception as e: + eprint(f"[warn] cannot open {png_path}: {e}") + all_errors.append({ + "page": page_name, + "source": "vlm", + "error": f"cannot open image: {e}" + }) + continue + + tiles = tile_image(img, tile_h, overlap) + eprint(f"[info] processing page {page_name}: {len(tiles)} tiles") + + cfg = (api_key, base_url, model, timeout, VLM_USER_TEXT) + + tasks = [] + for idx, tile, off_y in tiles: + tasks.append({ + 'cfg': cfg, + 'tile_img': tile, + 'tile_idx': idx, + 'offset_y': off_y, + 'page_name': page_name + }) + + page_findings = [] + with ThreadPoolExecutor(max_workers=concurrency) as executor: + futures = {executor.submit(process_tile, t): t for t in tasks} + for fut in as_completed(futures): + result = fut.result() + if result['error']: + all_errors.append({ + "page": result['page'], + "source": "vlm", + "tile_index": result['tile_index'], + "error": result['error'] + }) + page_findings.extend(result['findings']) + + # deduplicate + deduped = deduplicate_findings(page_findings, iou_threshold=0.5) + for f in deduped: + all_findings.append({ + "page": page_name, + "source": "vlm", + "rule": f.get('class', 'unknown'), + "bbox": f['bbox'], + "severity": f.get('severity', 'info'), + "confidence": f.get('confidence', 0.0), + "evidence": f.get('evidence', '') + }) + + output = { + "tool": "test/visual/tools/screen.py", + "subcommand": "vlm", + "model": model, + "total_pages": len(png_files), + "total_findings": len(all_findings), + "findings": all_findings, + "errors": all_errors + } + + save_json(out_path, output) + eprint(f"[info] VLM detection done: {len(all_findings)} findings, {len(all_errors)} errors") + eprint(f"[info] output -> {out_path}") + + print(json.dumps({ + "pages_processed": len(png_files), + "findings_count": len(all_findings), + "errors_count": len(all_errors), + "output": out_path + })) + + return 0 + + +# Ask - lightweight targeted visual question answering + +def run_ask(files, prompt, model_override, dry_run, out_path): + """ + Lightweight targeted visual question answering: name specific PNG files + plus a custom question, feed the whole image straight to the VLM without + tiling. + The fundamental differences from the vlm first pass: whole image fed + directly / custom prompt / free-form answer with best-effort structure. + """ + env_path = os.path.join(os.path.dirname(os.path.abspath(__file__)), '.env') + env = load_env(env_path) + + if env is None: + eprint("[fatal] no .env file found") + eprint(f"[fatal] copy {os.path.dirname(os.path.abspath(__file__))}/.env.example to .env and fill in DASHSCOPE_API_KEY") + sys.exit(1) + + api_key = env.get('DASHSCOPE_API_KEY', '').strip() + + # dry-run mode does not need a real key (same key validation logic as run_vlm) + if not dry_run: + if not api_key or api_key.startswith('sk-your-key') or api_key == 'dummy': + eprint("[fatal] DASHSCOPE_API_KEY is not configured properly") + eprint(f"[fatal] edit {env_path} and fill in a real API key") + sys.exit(1) + + base_url = env.get('VLM_BASE_URL', 'https://dashscope.aliyuncs.com/compatible-mode/v1').rstrip('/') + model = model_override or env.get('VLM_MODEL', 'qwen2.5-vl-32b-instruct') + timeout = int(env.get('VLM_TIMEOUT', '120')) + + if not files: + eprint("[fatal] no file specified") + sys.exit(1) + + from PIL import Image + + # Dry-run: only print the files that would be called and the request body + # size, no HTTP request + if dry_run: + eprint(f"[dry-run] model: {model}") + eprint(f"[dry-run] Base URL: {base_url}") + prompt_len = len(prompt) + len(ASK_SYSTEM_PROMPT) + eprint(f"[dry-run] prompt length: {prompt_len} characters (system {len(ASK_SYSTEM_PROMPT)} + user {len(prompt)})") + eprint(f"[dry-run] matched {len(files)} files:") + + for path in files: + try: + img = Image.open(path) + width, height = img.size + area = width * height + if area > 6_000_000: + eprint(f"[warn] {path} image area {width}x{height}={area} pixels exceeds 6,000,000; " + f"detail may suffer, consider cropping a region or using the vlm first pass") + buf = BytesIO() + img.save(buf, format='PNG') + b64_size = len(base64.b64encode(buf.getvalue()).decode('ascii')) + req_body = json.dumps({ + "model": model, + "messages": [ + {"role": "system", "content": ASK_SYSTEM_PROMPT}, + {"role": "user", "content": [ + {"type": "text", "text": prompt}, + {"type": "image_url", "image_url": {"url": "data:image/png;base64,..."}} + ]} + ], + "max_tokens": 1024, + "temperature": 0.1 + }) + eprint(f"[dry-run] {path}: size {width}x{height}, area {area}, " + f"request body ~{len(req_body)} bytes, base64 ~{b64_size} bytes") + except Exception as e: + eprint(f"[warn] cannot open {path}: {e}") + + print(json.dumps({"dry_run": True, "files": len(files)})) + return 0 + + # actual run: each file is called independently and processed sequentially + # (no concurrency) + answers = [] + errors = [] + + for path in files: + try: + img = Image.open(path) + except Exception as e: + err = f"cannot open image: {e}" + eprint(f"[warn] {err}: {path}") + answers.append({"file": path, "answer": None, "findings": [], "error": err}) + errors.append({"file": path, "error": err}) + continue + + width, height = img.size + area = width * height + if area > 6_000_000: + eprint(f"[warn] {path} image area {width}x{height}={area} pixels exceeds 6,000,000; " + f"detail may suffer, consider cropping a region or using the vlm first pass") + + eprint(f"[info] processing file {path} ({width}x{height})") + try: + b64 = encode_image_png(img) + except Exception as e: + err = f"image encoding failed: {e}" + eprint(f"[warn] {err}: {path}") + answers.append({"file": path, "answer": None, "findings": [], "error": err}) + errors.append({"file": path, "error": err}) + continue + + raw_out = {} + result, error_detail = call_vlm_api( + api_key, base_url, model, b64, timeout, prompt, + system_prompt=ASK_SYSTEM_PROMPT, raw_out=raw_out, require_json=False + ) + if error_detail: + eprint(f"[warn] {path} call failed: {error_detail}") + answers.append({"file": path, "answer": None, "findings": [], "error": error_detail}) + errors.append({"file": path, "error": error_detail}) + continue + + answer = raw_out.get('content', '') + findings = [] + # best effort: take the findings array out of the result extracted by + # extract_json_block + # when the model produced structured JSON with findings as requested it + # is extracted; otherwise the array stays empty + if isinstance(result, dict): + extracted = result.get('findings', []) + if isinstance(extracted, list): + findings = [_normalize_finding(f) for f in extracted if isinstance(f, dict)] + # result is JSON but has no findings key: the whole JSON is already + # preserved verbatim in answer, so findings stays empty + + answers.append({"file": path, "answer": answer, "findings": findings, "error": None}) + + output = { + "tool": "test/visual/tools/screen.py", + "subcommand": "ask", + "model": model, + "files": list(files), + "prompt": prompt, + "answers": answers, + "errors": errors + } + + if out_path: + save_json(out_path, output) + eprint(f"[info] ask done: {len(answers)} files, {len(errors)} errors") + eprint(f"[info] output -> {out_path}") + print(json.dumps({ + "files_processed": len(files), + "errors_count": len(errors), + "output": out_path + })) + else: + print(json.dumps(output, ensure_ascii=False, indent=2)) + + return 0 + + +# Report merging + +SEVERITY_ORDER = {'error': 0, 'warn': 1, 'info': 2} + + +def severity_key(f): + return SEVERITY_ORDER.get(f.get('severity', 'info'), 99) + + +def run_report(inputs, out_path): + """ + Merge findings from several sources, group by page and sort by severity. + """ + all_findings = [] + total_inputs = 0 + + for inp in inputs: + data = load_json(inp) + if data is None: + eprint(f"[warn] skipping unreadable input file: {inp}") + continue + total_inputs += 1 + + # two supported shapes: {pages: [...]} or {findings: [...]}, or a + # top-level findings array + pages = data.get('pages', None) + if pages is not None: + for p in pages: + pf = p.get('findings', []) + for f in pf: + f['_source_file'] = inp + all_findings.append(f) + else: + findings = data.get('findings', None) + if findings is not None: + for f in findings: + f['_source_file'] = inp + all_findings.append(f) + elif isinstance(data, list): + for f in data: + if isinstance(f, dict) and 'page' in f: + f['_source_file'] = inp + all_findings.append(f) + + # group by page + by_page = {} + for f in all_findings: + page = f.get('page', 'unknown') + by_page.setdefault(page, []).append(f) + + # sort by severity + for page in by_page: + by_page[page].sort(key=severity_key) + + # build the output + summary = {} + for page, findings in by_page.items(): + counts = {'error': 0, 'warn': 0, 'info': 0} + for f in findings: + sev = f.get('severity', 'info') + counts[sev] = counts.get(sev, 0) + 1 + summary[page] = counts + + output = { + "tool": "test/visual/tools/screen.py", + "subcommand": "report", + "sources": inputs, + "total_pages": len(by_page), + "total_findings": len(all_findings), + "summary": summary, + "pages": {page: { + "findings": by_page[page], + "counts": summary[page] + } for page in by_page} + } + + save_json(out_path, output) + + # print the per-page count table to stdout + print(f"Report - merged {len(inputs)} sources") + print(f"{'Page':<45} {'Error':>6} {'Warn':>6} {'Info':>6}") + print("-" * 65) + sorted_pages = sorted(by_page.keys()) + for page in sorted_pages: + c = summary[page] + display = page[:44] if len(page) > 44 else page + print(f"{display:<45} {c['error']:>6} {c['warn']:>6} {c['info']:>6}") + print("-" * 65) + print(f"{'TOTAL':<45} " + f"{sum(c['error'] for c in summary.values()):>6} " + f"{sum(c['warn'] for c in summary.values()):>6} " + f"{sum(c['info'] for c in summary.values()):>6}") + + print(json.dumps({ + "total_pages": len(by_page), + "total_findings": len(all_findings), + "output": out_path + })) + + return 0 + + +# Main entry point + +def main(): + # Fix garbled CJK output on the Windows GBK console + if hasattr(sys.stdout, 'reconfigure'): + sys.stdout.reconfigure(encoding='utf-8', errors='replace') + sys.stderr.reconfigure(encoding='utf-8', errors='replace') + + parser = argparse.ArgumentParser( + description='Visual Inspection Screener' + ) + subparsers = parser.add_subparsers(dest='command', help='subcommand') + + # --- geometric --- + p_geo = subparsers.add_parser('geometric', help='layer 0 mechanical checks') + p_geo.add_argument('--shots', required=True, help='screenshot directory (holding *.png plus the same-named *.json bounds)') + p_geo.add_argument('--page-width', type=int, default=1800, help='reference page width (px)') + p_geo.add_argument('--out', required=True, help='output JSON path') + + # --- vlm --- + p_vlm = subparsers.add_parser('vlm', help='layer 1 multimodal first pass') + p_vlm.add_argument('--shots', required=True, help='screenshot directory') + p_vlm.add_argument('--pages', help='page name filter (comma separated)') + p_vlm.add_argument('--model', help='overrides VLM_MODEL from .env') + p_vlm.add_argument('--tile-h', type=int, default=1400, help='tile height (px)') + p_vlm.add_argument('--overlap', type=int, default=200, help='tile overlap (px)') + p_vlm.add_argument('--dry-run', action='store_true', help='no HTTP request, only print information') + p_vlm.add_argument('--out', required=True, help='output JSON path') + + # --- ask --- + p_ask = subparsers.add_parser('ask', help='lightweight targeted visual question answering: named files plus a custom question, whole image fed directly') + p_ask.add_argument('--files', required=True, action='append', + help='PNG file paths (repeat the flag or separate several with commas)') + p_ask.add_argument('--prompt', required=True, help='targeted question text (chosen by the caller)') + p_ask.add_argument('--out', help='output JSON path (printed to stdout when omitted)') + p_ask.add_argument('--model', help='overrides VLM_MODEL from .env') + p_ask.add_argument('--dry-run', action='store_true', help='no HTTP request, only print information') + + # --- report --- + p_rep = subparsers.add_parser('report', help='merged report') + p_rep.add_argument('--inputs', required=True, help='input JSON paths (comma separated)') + p_rep.add_argument('--out', required=True, help='output triage JSON path') + + args = parser.parse_args() + + if args.command == 'geometric': + return run_geometric(args.shots, args.page_width, args.out) + + elif args.command == 'vlm': + pages_filter = None + if args.pages: + pages_filter = {p.strip() for p in args.pages.split(',') if p.strip()} + return run_vlm( + args.shots, pages_filter, args.model, + args.tile_h, args.overlap, args.dry_run, args.out + ) + + elif args.command == 'ask': + files = [] + for part in args.files: + for f in part.split(','): + f = f.strip() + if f: + files.append(f) + return run_ask(files, args.prompt, args.model, args.dry_run, args.out) + + elif args.command == 'report': + inputs = [s.strip() for s in args.inputs.split(',') if s.strip()] + return run_report(inputs, args.out) + + else: + parser.print_help() + return 1 + + +if __name__ == '__main__': + try: + sys.exit(main()) + except KeyboardInterrupt: + eprint("[info] interrupted by user") + sys.exit(130) + except Exception as e: + eprint(f"[fatal] unhandled exception: {e}") + sys.exit(1) From a1ec40c10fdeb6c6524a4b825dd16c3ef1d9aa84 Mon Sep 17 00:00:00 2001 From: Cheng Ming <8456918+Windorain@users.noreply.github.com> Date: Sun, 4 Oct 2026 04:55:31 +0800 Subject: [PATCH 4/6] refactor(render): layout and render system rework --- .../com/hfstudio/guidenh/ClientProxy.java | 25 +- .../client/command/GuideNhClientCommand.java | 162 +- .../hfstudio/guidenh/config/ModConfig.java | 25 + .../guidenh/guide/compiler/PageCompiler.java | 284 +++- .../guide/compiler/ParsedGuidePage.java | 16 +- .../guide/compiler/tags/BlockTagCompiler.java | 10 +- .../compiler/tags/BlockquoteCompiler.java | 38 +- .../guide/compiler/tags/CodeCompiler.java | 2 +- .../compiler/tags/CommandLinkCompiler.java | 12 +- .../guide/compiler/tags/CsvTableCompiler.java | 13 +- .../compiler/tags/FloatingImageCompiler.java | 440 ++++-- .../guide/compiler/tags/HeadingCompiler.java | 16 + .../guide/compiler/tags/ImageCompiler.java | 45 +- .../guide/compiler/tags/ItemGridCompiler.java | 26 +- .../compiler/tags/ItemImageCompiler.java | 8 +- .../guide/compiler/tags/ItemLinkCompiler.java | 4 +- .../guide/compiler/tags/LatexTagCompiler.java | 24 +- .../guide/compiler/tags/ListItemCompiler.java | 27 +- .../guide/compiler/tags/MermaidCompiler.java | 14 +- .../compiler/tags/NodeContentTagCompiler.java | 47 + .../compiler/tags/ParagraphCompiler.java | 27 + .../guide/compiler/tags/PreCompiler.java | 91 +- .../guide/compiler/tags/RecipeCompiler.java | 17 +- .../compiler/tags/SoundLinkCompiler.java | 18 +- .../compiler/tags/SubscriptTagCompiler.java | 4 +- .../compiler/tags/SuperscriptTagCompiler.java | 4 +- .../guide/compiler/tags/TableCompiler.java | 27 +- .../tags/mediawiki/CategoryCompiler.java | 2 +- .../tags/mediawiki/SpecialCompiler.java | 2 +- .../guidenh/guide/document/DefaultStyles.java | 28 +- .../guide/document/block/AlignItems.java | 3 +- .../guide/document/block/IconMetrics.java | 335 ++++ .../document/block/LatexRenderOptions.java | 63 +- .../guide/document/block/LytAlignedBlock.java | 13 +- .../document/block/LytBalancedColumns.java | 102 -- .../guide/document/block/LytBlock.java | 142 +- .../guidenh/guide/document/block/LytBox.java | 64 + .../guide/document/block/LytCodeBlock.java | 226 ++- .../document/block/LytCodeBlockToolbar.java | 17 + .../document/block/LytContentTabsBlock.java | 367 ++--- .../document/block/LytContentTabsHeader.java | 235 +++ .../document/block/LytCyclingItemImage.java | 8 + .../guide/document/block/LytDetailsBlock.java | 241 ++- .../guide/document/block/LytDocument.java | 477 +++++- .../document/block/LytDocumentFloat.java | 29 +- .../guide/document/block/LytFileTree.java | 223 +-- .../document/block/LytFloatAwareBlock.java | 12 + .../guide/document/block/LytGuiSprite.java | 61 + .../guide/document/block/LytHeading.java | 135 +- .../guide/document/block/LytImage.java | 199 ++- .../guide/document/block/LytImageBlock.java | 12 - .../guide/document/block/LytItemGrid.java | 11 +- .../guide/document/block/LytItemImage.java | 227 ++- .../guide/document/block/LytLatexBlock.java | 206 ++- .../document/block/LytLatexDisplayBlock.java | 125 +- .../guidenh/guide/document/block/LytList.java | 22 + .../guide/document/block/LytListItem.java | 134 +- .../document/block/LytMermaidCanvas.java | 957 ++++++------ .../document/block/LytMermaidFlowchart.java | 1 + .../block/LytMermaidFlowchartCanvas.java | 976 +++++++----- .../block/LytMermaidMindmapCanvas.java | 399 +++-- .../guide/document/block/LytParagraph.java | 492 +++++- .../document/block/LytPlaceholderBlock.java | 81 - .../guide/document/block/LytQuoteBox.java | 22 + .../guide/document/block/LytSizeBox.java | 200 ++- .../guidenh/guide/document/block/LytSlot.java | 29 + .../document/block/LytStructureView.java | 55 +- .../guide/document/block/LytTaskListItem.java | 39 +- .../document/block/LytThematicBreak.java | 15 + .../guidenh/guide/document/block/LytVBox.java | 26 +- .../guide/document/block/LytViewportBox.java | 39 + .../guide/document/block/LytWidthBox.java | 7 + .../document/block/MermaidNodeRenderer.java | 122 +- .../document/block/RowBaselineAligner.java | 227 +++ .../block/chart/CartesianChartRenderer.java | 81 +- .../block/chart/ChartLegendRenderer.java | 98 +- .../block/chart/CornerLegendRenderer.java | 58 +- .../document/block/chart/LytBarChart.java | 198 ++- .../document/block/chart/LytChartBase.java | 183 ++- .../document/block/chart/LytColumnChart.java | 68 +- .../document/block/chart/LytLineChart.java | 26 +- .../document/block/chart/LytPieChart.java | 39 +- .../document/block/chart/LytScatterChart.java | 26 +- .../block/chart/PieInsetRenderer.java | 28 +- .../block/functiongraph/DomainPredicate.java | 23 +- .../block/functiongraph/LytFunctionGraph.java | 434 +++--- .../block/recipes/LytRecipeGalleryRow.java | 29 + .../block/recipes/LytStandardRecipeBox.java | 44 + .../block/shapes/AsymmetricShape.java | 29 +- .../document/block/shapes/BangShape.java | 25 +- .../document/block/shapes/CircleShape.java | 26 +- .../document/block/shapes/CloudShape.java | 32 +- .../document/block/shapes/CylinderShape.java | 41 +- .../document/block/shapes/DiamondShape.java | 25 +- .../block/shapes/DoubleCircleShape.java | 30 +- .../document/block/shapes/EllipseShape.java | 26 +- .../block/shapes/FlowchartShapes.java | 14 +- .../document/block/shapes/HexagonShape.java | 30 +- .../document/block/shapes/RectShape.java | 21 +- .../block/shapes/RoundedRectShape.java | 27 +- .../document/block/shapes/ShapeRenderer.java | 18 +- .../document/block/shapes/ShapeUtils.java | 13 +- .../document/block/shapes/StadiumShape.java | 19 +- .../block/shapes/SubprocessShape.java | 26 +- .../document/block/shapes/TrapezoidShape.java | 26 +- .../guide/document/block/table/LytTable.java | 163 +- .../document/block/table/LytTableColumn.java | 2 + .../document/block/table/LytTableRow.java | 68 +- .../document/flow/LytFlowInlineBlock.java | 21 +- .../guide/document/flow/LytFlowLink.java | 4 +- .../guidenh/guide/indices/OreIndex.java | 6 +- .../GuideLightweightReloadService.java | 37 +- .../guidenh/guide/internal/GuideScreen.java | 240 ++- .../guide/internal/GuideScreenMemory.java | 4 +- .../guide/internal/GuideScreenRoute.java | 12 +- .../guidenh/guide/internal/GuidebookText.java | 1 + .../guide/internal/LazyParsedGuidePage.java | 164 -- .../guidenh/guide/internal/MutableGuide.java | 4 +- .../guide/internal/compile/CompileWorker.java | 6 +- .../datadriven/DataDrivenGuideLoader.java | 50 +- .../datadriven/GuidePageResourceSelector.java | 4 +- .../internal/debug/DebugControlPanel.java | 6 + .../guide/internal/debug/DebugMenuAction.java | 1 + .../resolver/MdxSyntaxResolver.java | 4 +- .../model/SceneEditorSceneNodeType.java | 2 +- .../extensions/DefaultExtensions.java | 6 +- .../DocumentOffscreenFramebuffer.java | 277 ++++ .../headless/GuideNhHeadlessRenderDriver.java | 757 +++++++++ .../headless/GuideNhHeadlessWindow.java | 132 ++ .../internal/headless/RenderPageService.java | 1355 +++++++++++++++++ .../guide/internal/host/LytHostWorkItem.java | 2 +- .../internal/host/ScriptContextImpl.java | 3 +- .../internal/host/scripts/CsvTableScript.java | 11 +- .../host/scripts/FloatingImageScript.java | 3 +- .../internal/host/scripts/ImageScript.java | 3 + .../internal/host/scripts/ItemGridScript.java | 24 +- .../internal/host/scripts/KeyBindScript.java | 7 +- .../internal/host/scripts/MermaidScript.java | 47 + .../internal/host/scripts/RecipeScript.java | 114 +- .../internal/host/scripts/SceneScript.java | 107 +- .../host/scripts/SoundLinkScript.java | 4 + .../GuideLocalizedPageSourceResolver.java | 80 +- .../markdown/CodeBlockLanguageDetector.java | 31 + .../markdown/CodeBlockLanguageRegistry.java | 2 + .../internal/markdown/FileTreeCompiler.java | 1 + .../markdown/FootnotePreprocessor.java | 56 +- .../markdown/MarkdownLatexShorthand.java | 134 +- .../markdown/MarkdownListSemantics.java | 131 +- .../markdown/MarkdownRuntimeBlocks.java | 3 +- .../markdown/MdAstToMdxConverter.java | 143 +- .../mermaid/MermaidLayoutPrecomputer.java | 80 + .../guide/internal/mermaid/MermaidParser.java | 27 +- .../internal/mermaid/NodeShapeDefinition.java | 10 +- .../mermaid/flowchart/ElkLayoutStrategy.java | 2 +- .../mermaid/flowchart/FlowchartDocument.java | 10 +- .../mermaid/flowchart/FlowchartParser.java | 6 +- .../mermaid/flowchart/LinkDefinition.java | 16 + .../internal/recipe/LytNeiRecipeBox.java | 20 + .../internal/screen/GuideIconButton.java | 1 + .../guide/internal/screen/GuideNavBar.java | 504 +++++- .../search/GuideSearchSnippetFormatter.java | 17 +- .../GuideSettingsDocumentBuilder.java | 324 ++++ .../internal/settings/GuideSettingsPage.java | 28 + .../guide/internal/util/DisplayScale.java | 35 +- .../internal/welcome/GuideWelcomeScreen.java | 11 +- .../guide/latex/GuideLatexRenderer.java | 1280 +++++++++++++++- .../guide/latex/GuideLatexTextureCache.java | 32 +- .../guidenh/guide/layout/FontProvider.java | 45 + .../guidenh/guide/layout/LayoutBridge.java | 112 ++ .../guidenh/guide/layout/LayoutContext.java | 23 +- .../guide/layout/LayoutNodeSerializer.java | 1026 +++++++++++++ .../guide/layout/LayoutStyleExtractor.java | 383 +++++ .../guide/layout/LayoutTreeSerializer.java | 736 +++++++++ .../guidenh/guide/layout/Layouts.java | 39 - .../guidenh/guide/layout/RustFontMetrics.java | 71 + .../guide/layout/SystemFontProvider.java | 192 +++ .../guide/layout/flatbuffers/ChartData.java | 194 +++ .../guide/layout/flatbuffers/ClearBreak.java | 95 ++ .../guide/layout/flatbuffers/CustomData.java | 122 ++ .../layout/flatbuffers/DecorationRect.java | 143 ++ .../guide/layout/flatbuffers/Dimension.java | 90 ++ .../guide/layout/flatbuffers/FlatLayout.java | 117 ++ .../guide/layout/flatbuffers/FlatNode.java | 383 +++++ .../guide/layout/flatbuffers/FloatClip.java | 114 ++ .../guide/layout/flatbuffers/FontRequest.java | 216 +++ .../layout/flatbuffers/FunctionGraphData.java | 174 +++ .../guide/layout/flatbuffers/GlyphBitmap.java | 146 ++ .../guide/layout/flatbuffers/GlyphPixels.java | 163 ++ .../guide/layout/flatbuffers/GlyphRun.java | 158 ++ .../flatbuffers/GuidebookSceneData.java | 209 +++ .../guide/layout/flatbuffers/ImageData.java | 168 ++ .../layout/flatbuffers/InlineBlockRef.java | 146 ++ .../layout/flatbuffers/LatexDisplayData.java | 176 +++ .../guide/layout/flatbuffers/LayoutInput.java | 176 +++ .../layout/flatbuffers/LayoutResult.java | 256 ++++ .../layout/flatbuffers/ListMarkerData.java | 128 ++ .../MediaWikiGeneratedListData.java | 94 ++ .../MediaWikiSpecialGeneratedData.java | 612 ++++++++ .../layout/flatbuffers/PieChartData.java | 196 +++ .../guide/layout/flatbuffers/PlacedGlyph.java | 170 +++ .../guide/layout/flatbuffers/RasterInput.java | 107 ++ .../layout/flatbuffers/RasterResult.java | 107 ++ .../layout/flatbuffers/RecipeBoxData.java | 173 +++ .../guide/layout/flatbuffers/RenderGlyph.java | 152 ++ .../layout/flatbuffers/RenderResult.java | 127 ++ .../layout/flatbuffers/ShapeTextInput.java | 127 ++ .../layout/flatbuffers/ShapeTextResult.java | 208 +++ .../guide/layout/flatbuffers/SlotData.java | 77 + .../layout/flatbuffers/StructureViewData.java | 94 ++ .../guide/layout/flatbuffers/Style.java | 559 +++++++ .../guide/layout/flatbuffers/TextBand.java | 103 ++ .../guide/layout/flatbuffers/TextData.java | 375 +++++ .../guide/layout/flatbuffers/TextSpan.java | 104 ++ .../guide/layout/flatbuffers/TextStyle.java | 207 +++ .../layout/flatbuffers/ThematicBreakData.java | 77 + .../guide/layout/flow/FlowBuilder.java | 227 --- .../guidenh/guide/layout/flow/Line.java | 19 - .../guidenh/guide/layout/flow/LineBlock.java | 36 - .../guide/layout/flow/LineBuilder.java | 781 ---------- .../guide/layout/flow/LineElement.java | 39 - .../guide/layout/flow/LineTextRun.java | 58 - .../MediaWikiGeneratedListBlock.java | 227 ++- .../guide/mediawiki/MediaWikiListPlanner.java | 2 +- .../MediaWikiSpecialGeneratedBlock.java | 692 ++++++--- .../guide/render/DecorationRasterizer.java | 247 +++ .../guidenh/guide/render/GlyphRunData.java | 29 + .../guidenh/guide/render/GlyphRunGroup.java | 13 + .../guidenh/guide/render/GlyphRunHolder.java | 16 + .../guidenh/guide/render/GuideGlyphAtlas.java | 447 ++++++ .../guide/render/GuideRenderEngine.java | 1056 +++++++++++++ .../guide/render/GuideRenderPrimitive.java | 146 ++ .../guidenh/guide/render/GuideText.java | 787 ++++++++++ .../guide/render/GuidebookSceneRenderer.java | 33 + .../guide/render/PrimitiveCollector.java | 298 ++++ .../guidenh/guide/render/RenderContext.java | 26 + .../guide/render/VanillaRenderContext.java | 110 +- .../guidenh/guide/scene/CameraSettings.java | 2 +- .../guide/scene/GuidebookLevelRenderer.java | 35 +- .../scene/GuidebookSceneWeatherSupport.java | 4 +- .../guide/scene/LytGuidebookScene.java | 945 +++++++++--- .../guidenh/guide/scene/SnbtPlacement.java | 2 +- .../guide/scene/StructureLibSceneBinding.java | 12 +- .../scene/annotation/DiamondAnnotation.java | 7 + .../annotation/PonderInputAnnotation.java | 7 + .../annotation/SceneFloorGridAnnotation.java | 2 +- .../scene/annotation/TextAnnotation.java | 19 +- .../BoxAnnotationElementCompiler.java | 2 +- .../LineAnnotationElementCompiler.java | 2 +- .../ImportStructureElementCompiler.java | 122 +- .../ImportStructureLibElementCompiler.java | 4 +- .../IsometricCameraElementCompiler.java | 15 +- .../element/RemoveBlocksElementCompiler.java | 9 +- .../element/ReplaceBlockElementCompiler.java | 57 +- .../guide/scene/level/GuidebookLevel.java | 2 +- .../guide/scene/ponder/PonderKeyframe.java | 6 +- .../preview/StructureLibDefinitionCache.java | 30 +- .../BlockAnnotationTemplateExpander.java | 1 + .../guide/scene/support/GuideDebugLog.java | 15 +- .../site/GuideSiteGraphRenderer.java | 37 +- .../site/GuideSiteHtmlCompiler.java | 403 +++-- .../site/GuideSiteLatexExporter.java | 54 +- .../site/GuideSiteMdxTagRenderer.java | 4 +- .../GuideSiteNeiPhase1BackgroundExporter.java | 10 +- .../site/GuideSiteRecipeExporter.java | 13 +- .../GuideSiteSceneAnnotationSerializer.java | 6 +- .../DefaultSiteRecipeLayoutStrategy.java | 2 +- ...PositionedNeiSiteRecipeLayoutStrategy.java | 2 +- .../guide/style/ResolvedTextStyle.java | 2 +- .../guidenh/guide/style/TextStyle.java | 20 +- .../guidenh/guide/style/token/ColorValue.java | 53 + .../guide/style/token/DimensionValue.java | 49 + .../guidenh/guide/style/token/FloatValue.java | 15 + .../guide/style/token/GuideThemeManager.java | 135 ++ .../guidenh/guide/style/token/IntValue.java | 15 + .../guide/style/token/ResolvedValue.java | 5 + .../guidenh/guide/style/token/Theme.java | 104 ++ .../guide/style/token/ThemeRegistry.java | 27 + .../guidenh/guide/style/token/TokenKey.java | 53 + .../guidenh/guide/style/token/TokenType.java | 36 + .../ae2/Ae2BaseTileNetworkStreamPreview.java | 3 +- .../Ae2BaseTileNetworkStructureSupport.java | 2 +- .../ae2/Ae2CableBusPartStreamCodec.java | 3 +- .../ae2/Ae2CableBusSideStreams.java | 2 +- .../ae2/Ae2CableStructureSupport.java | 2 +- .../GuideNhAe2CableBatchRequestMessage.java | 2 +- .../integration/nei/NeiDirectCalls.java | 2 +- .../NeiCustomDiagramBridge.java | 2 +- .../StructureLibBuildService.java | 20 + ...ctureLibPreviewTooltipMetadataBuilder.java | 7 +- .../StructureLibSceneMetadata.java | 16 +- .../resources/assets/guidenh/lang/en_US.lang | 2 + .../resources/assets/guidenh/lang/zh_CN.lang | 2 + .../assets/guidenh/textures/guide/buttons.png | Bin 6213 -> 4228 bytes 293 files changed, 28874 insertions(+), 5727 deletions(-) create mode 100644 src/main/java/com/hfstudio/guidenh/guide/compiler/tags/NodeContentTagCompiler.java create mode 100644 src/main/java/com/hfstudio/guidenh/guide/document/block/IconMetrics.java delete mode 100644 src/main/java/com/hfstudio/guidenh/guide/document/block/LytBalancedColumns.java create mode 100644 src/main/java/com/hfstudio/guidenh/guide/document/block/LytContentTabsHeader.java delete mode 100644 src/main/java/com/hfstudio/guidenh/guide/document/block/LytPlaceholderBlock.java create mode 100644 src/main/java/com/hfstudio/guidenh/guide/document/block/LytViewportBox.java create mode 100644 src/main/java/com/hfstudio/guidenh/guide/document/block/RowBaselineAligner.java create mode 100644 src/main/java/com/hfstudio/guidenh/guide/document/block/recipes/LytRecipeGalleryRow.java delete mode 100644 src/main/java/com/hfstudio/guidenh/guide/internal/LazyParsedGuidePage.java create mode 100644 src/main/java/com/hfstudio/guidenh/guide/internal/headless/DocumentOffscreenFramebuffer.java create mode 100644 src/main/java/com/hfstudio/guidenh/guide/internal/headless/GuideNhHeadlessRenderDriver.java create mode 100644 src/main/java/com/hfstudio/guidenh/guide/internal/headless/GuideNhHeadlessWindow.java create mode 100644 src/main/java/com/hfstudio/guidenh/guide/internal/headless/RenderPageService.java create mode 100644 src/main/java/com/hfstudio/guidenh/guide/internal/mermaid/MermaidLayoutPrecomputer.java create mode 100644 src/main/java/com/hfstudio/guidenh/guide/internal/settings/GuideSettingsDocumentBuilder.java create mode 100644 src/main/java/com/hfstudio/guidenh/guide/internal/settings/GuideSettingsPage.java create mode 100644 src/main/java/com/hfstudio/guidenh/guide/layout/FontProvider.java create mode 100644 src/main/java/com/hfstudio/guidenh/guide/layout/LayoutBridge.java create mode 100644 src/main/java/com/hfstudio/guidenh/guide/layout/LayoutNodeSerializer.java create mode 100644 src/main/java/com/hfstudio/guidenh/guide/layout/LayoutStyleExtractor.java create mode 100644 src/main/java/com/hfstudio/guidenh/guide/layout/LayoutTreeSerializer.java create mode 100644 src/main/java/com/hfstudio/guidenh/guide/layout/RustFontMetrics.java create mode 100644 src/main/java/com/hfstudio/guidenh/guide/layout/SystemFontProvider.java create mode 100644 src/main/java/com/hfstudio/guidenh/guide/layout/flatbuffers/ChartData.java create mode 100644 src/main/java/com/hfstudio/guidenh/guide/layout/flatbuffers/ClearBreak.java create mode 100644 src/main/java/com/hfstudio/guidenh/guide/layout/flatbuffers/CustomData.java create mode 100644 src/main/java/com/hfstudio/guidenh/guide/layout/flatbuffers/DecorationRect.java create mode 100644 src/main/java/com/hfstudio/guidenh/guide/layout/flatbuffers/Dimension.java create mode 100644 src/main/java/com/hfstudio/guidenh/guide/layout/flatbuffers/FlatLayout.java create mode 100644 src/main/java/com/hfstudio/guidenh/guide/layout/flatbuffers/FlatNode.java create mode 100644 src/main/java/com/hfstudio/guidenh/guide/layout/flatbuffers/FloatClip.java create mode 100644 src/main/java/com/hfstudio/guidenh/guide/layout/flatbuffers/FontRequest.java create mode 100644 src/main/java/com/hfstudio/guidenh/guide/layout/flatbuffers/FunctionGraphData.java create mode 100644 src/main/java/com/hfstudio/guidenh/guide/layout/flatbuffers/GlyphBitmap.java create mode 100644 src/main/java/com/hfstudio/guidenh/guide/layout/flatbuffers/GlyphPixels.java create mode 100644 src/main/java/com/hfstudio/guidenh/guide/layout/flatbuffers/GlyphRun.java create mode 100644 src/main/java/com/hfstudio/guidenh/guide/layout/flatbuffers/GuidebookSceneData.java create mode 100644 src/main/java/com/hfstudio/guidenh/guide/layout/flatbuffers/ImageData.java create mode 100644 src/main/java/com/hfstudio/guidenh/guide/layout/flatbuffers/InlineBlockRef.java create mode 100644 src/main/java/com/hfstudio/guidenh/guide/layout/flatbuffers/LatexDisplayData.java create mode 100644 src/main/java/com/hfstudio/guidenh/guide/layout/flatbuffers/LayoutInput.java create mode 100644 src/main/java/com/hfstudio/guidenh/guide/layout/flatbuffers/LayoutResult.java create mode 100644 src/main/java/com/hfstudio/guidenh/guide/layout/flatbuffers/ListMarkerData.java create mode 100644 src/main/java/com/hfstudio/guidenh/guide/layout/flatbuffers/MediaWikiGeneratedListData.java create mode 100644 src/main/java/com/hfstudio/guidenh/guide/layout/flatbuffers/MediaWikiSpecialGeneratedData.java create mode 100644 src/main/java/com/hfstudio/guidenh/guide/layout/flatbuffers/PieChartData.java create mode 100644 src/main/java/com/hfstudio/guidenh/guide/layout/flatbuffers/PlacedGlyph.java create mode 100644 src/main/java/com/hfstudio/guidenh/guide/layout/flatbuffers/RasterInput.java create mode 100644 src/main/java/com/hfstudio/guidenh/guide/layout/flatbuffers/RasterResult.java create mode 100644 src/main/java/com/hfstudio/guidenh/guide/layout/flatbuffers/RecipeBoxData.java create mode 100644 src/main/java/com/hfstudio/guidenh/guide/layout/flatbuffers/RenderGlyph.java create mode 100644 src/main/java/com/hfstudio/guidenh/guide/layout/flatbuffers/RenderResult.java create mode 100644 src/main/java/com/hfstudio/guidenh/guide/layout/flatbuffers/ShapeTextInput.java create mode 100644 src/main/java/com/hfstudio/guidenh/guide/layout/flatbuffers/ShapeTextResult.java create mode 100644 src/main/java/com/hfstudio/guidenh/guide/layout/flatbuffers/SlotData.java create mode 100644 src/main/java/com/hfstudio/guidenh/guide/layout/flatbuffers/StructureViewData.java create mode 100644 src/main/java/com/hfstudio/guidenh/guide/layout/flatbuffers/Style.java create mode 100644 src/main/java/com/hfstudio/guidenh/guide/layout/flatbuffers/TextBand.java create mode 100644 src/main/java/com/hfstudio/guidenh/guide/layout/flatbuffers/TextData.java create mode 100644 src/main/java/com/hfstudio/guidenh/guide/layout/flatbuffers/TextSpan.java create mode 100644 src/main/java/com/hfstudio/guidenh/guide/layout/flatbuffers/TextStyle.java create mode 100644 src/main/java/com/hfstudio/guidenh/guide/layout/flatbuffers/ThematicBreakData.java delete mode 100644 src/main/java/com/hfstudio/guidenh/guide/layout/flow/FlowBuilder.java delete mode 100644 src/main/java/com/hfstudio/guidenh/guide/layout/flow/Line.java delete mode 100644 src/main/java/com/hfstudio/guidenh/guide/layout/flow/LineBlock.java delete mode 100644 src/main/java/com/hfstudio/guidenh/guide/layout/flow/LineBuilder.java delete mode 100644 src/main/java/com/hfstudio/guidenh/guide/layout/flow/LineElement.java delete mode 100644 src/main/java/com/hfstudio/guidenh/guide/layout/flow/LineTextRun.java create mode 100644 src/main/java/com/hfstudio/guidenh/guide/render/DecorationRasterizer.java create mode 100644 src/main/java/com/hfstudio/guidenh/guide/render/GlyphRunData.java create mode 100644 src/main/java/com/hfstudio/guidenh/guide/render/GlyphRunGroup.java create mode 100644 src/main/java/com/hfstudio/guidenh/guide/render/GlyphRunHolder.java create mode 100644 src/main/java/com/hfstudio/guidenh/guide/render/GuideGlyphAtlas.java create mode 100644 src/main/java/com/hfstudio/guidenh/guide/render/GuideRenderEngine.java create mode 100644 src/main/java/com/hfstudio/guidenh/guide/render/GuideRenderPrimitive.java create mode 100644 src/main/java/com/hfstudio/guidenh/guide/render/GuideText.java create mode 100644 src/main/java/com/hfstudio/guidenh/guide/render/GuidebookSceneRenderer.java create mode 100644 src/main/java/com/hfstudio/guidenh/guide/render/PrimitiveCollector.java create mode 100644 src/main/java/com/hfstudio/guidenh/guide/style/token/ColorValue.java create mode 100644 src/main/java/com/hfstudio/guidenh/guide/style/token/DimensionValue.java create mode 100644 src/main/java/com/hfstudio/guidenh/guide/style/token/FloatValue.java create mode 100644 src/main/java/com/hfstudio/guidenh/guide/style/token/GuideThemeManager.java create mode 100644 src/main/java/com/hfstudio/guidenh/guide/style/token/IntValue.java create mode 100644 src/main/java/com/hfstudio/guidenh/guide/style/token/ResolvedValue.java create mode 100644 src/main/java/com/hfstudio/guidenh/guide/style/token/Theme.java create mode 100644 src/main/java/com/hfstudio/guidenh/guide/style/token/ThemeRegistry.java create mode 100644 src/main/java/com/hfstudio/guidenh/guide/style/token/TokenKey.java create mode 100644 src/main/java/com/hfstudio/guidenh/guide/style/token/TokenType.java diff --git a/src/main/java/com/hfstudio/guidenh/ClientProxy.java b/src/main/java/com/hfstudio/guidenh/ClientProxy.java index b3e5b624..a516146a 100644 --- a/src/main/java/com/hfstudio/guidenh/ClientProxy.java +++ b/src/main/java/com/hfstudio/guidenh/ClientProxy.java @@ -24,6 +24,8 @@ import com.hfstudio.guidenh.guide.internal.GuideOnStartup; import com.hfstudio.guidenh.guide.internal.GuideReloadListener; import com.hfstudio.guidenh.guide.internal.compile.CompileWorker; +import com.hfstudio.guidenh.guide.internal.headless.GuideNhHeadlessRenderDriver; +import com.hfstudio.guidenh.guide.internal.headless.GuideNhHeadlessWindow; import com.hfstudio.guidenh.guide.internal.host.LytHost; import com.hfstudio.guidenh.guide.internal.host.LytHostWorkItem; import com.hfstudio.guidenh.guide.internal.host.scripts.BlockImageScript; @@ -69,6 +71,7 @@ import com.hfstudio.guidenh.network.GuideNhRegionExportReplyMessage; import com.hfstudio.structurelibexport.StructureExportBootstrap; +import cpw.mods.fml.common.FMLCommonHandler; import cpw.mods.fml.common.event.FMLInitializationEvent; import cpw.mods.fml.common.event.FMLLoadCompleteEvent; import cpw.mods.fml.common.event.FMLPostInitializationEvent; @@ -93,6 +96,7 @@ public static CompileWorker getWorker() { @Override public void preInit(FMLPreInitializationEvent event) { super.preInit(event); + GuideNhHeadlessWindow.installEarly(); GuideNhClientTaskScheduler.initialize(); GuidebookLevel.setPreviewWorldFactory(GuidebookFakeWorld::new); GuidebookLevel.setDefaultBuildHeightProvider(() -> { @@ -142,7 +146,7 @@ public void init(FMLInitializationEvent event) { MasterScheduler.getInstance() .submit(new ElkWarmupWorkItem()); - // Phase 3: LytScript registrations + // LytScript registrations lytHost.registerScript("CommandLink", new CommandLinkScript()); lytHost.registerScript("Img", new ImageScript()); lytHost.registerScript("FloatingImage", new FloatingImageScript()); @@ -162,11 +166,11 @@ public void init(FMLInitializationEvent event) { lytHost.registerScript("Mermaid", new MermaidScript()); lytHost.registerScript("QuestLink", new QuestLinkScript()); lytHost.registerScript("QuestCard", new QuestCardScript()); - // Phase 3: SceneScript handles Scene and GameScene + // SceneScript handles Scene and GameScene SceneScript sceneScript = new SceneScript(); lytHost.registerScript("Scene", sceneScript); lytHost.registerScript("GameScene", sceneScript); - // Phase 3: RecipeScript handles Recipe, Usage, RecipeFor, RecipeUsage, RecipesFor, RecipesUsage + // RecipeScript handles Recipe, Usage, RecipeFor, RecipeUsage, RecipesFor, RecipesUsage RecipeScript recipeScript = new RecipeScript(); lytHost.registerScript("Recipe", recipeScript); lytHost.registerScript("Usage", recipeScript); @@ -195,6 +199,7 @@ public void init(FMLInitializationEvent event) { ModConfig.runtimeBridge.maxConnections, ModConfig.runtimeBridge.maxDeltaEntries)); } + GuideNhHeadlessWindow.hideNow(); } @Override @@ -215,6 +220,20 @@ public void completeInit(FMLLoadCompleteEvent event) { MasterScheduler.getInstance() .submit(new DevWatchWorkItem()); GuideOnStartup.init(); + + if (Boolean.getBoolean("guidenh.headlessRender")) { + GuideNhHeadlessRenderDriver.HeadlessRenderConfig config = GuideNhHeadlessRenderDriver.parseConfig(); + if (config == null) { + GuideDebugLog.error("[GuideNH] [HeadlessRender] Invalid headless render configuration, exiting"); + FMLCommonHandler.instance() + .exitJava(1, false); + return; + } + GuideDebugLog.infoAlways("[GuideNH] [HeadlessRender] Registering headless render driver"); + FMLCommonHandler.instance() + .bus() + .register(new GuideNhHeadlessRenderDriver(config)); + } } @SubscribeEvent diff --git a/src/main/java/com/hfstudio/guidenh/client/command/GuideNhClientCommand.java b/src/main/java/com/hfstudio/guidenh/client/command/GuideNhClientCommand.java index 98320e58..58eb045e 100644 --- a/src/main/java/com/hfstudio/guidenh/client/command/GuideNhClientCommand.java +++ b/src/main/java/com/hfstudio/guidenh/client/command/GuideNhClientCommand.java @@ -16,6 +16,7 @@ import net.minecraft.entity.player.EntityPlayer; import net.minecraft.item.ItemStack; import net.minecraft.util.AxisAlignedBB; +import net.minecraft.util.ChatComponentText; import net.minecraft.util.ChatComponentTranslation; import net.minecraft.util.MathHelper; import net.minecraft.util.ResourceLocation; @@ -28,6 +29,7 @@ import com.hfstudio.guidenh.guide.internal.GuideScreen; import com.hfstudio.guidenh.guide.internal.GuidebookText; import com.hfstudio.guidenh.guide.internal.editor.SceneEditorScreen; +import com.hfstudio.guidenh.guide.internal.headless.RenderPageService; import com.hfstudio.guidenh.guide.internal.item.RegionWandExportMode; import com.hfstudio.guidenh.guide.internal.item.RegionWandExporter; import com.hfstudio.guidenh.guide.internal.item.RegionWandSelection; @@ -35,6 +37,7 @@ import com.hfstudio.guidenh.guide.internal.localization.GuidePageLangDumpTask; import com.hfstudio.guidenh.guide.internal.structure.GuideStructureCoordinateParser; import com.hfstudio.guidenh.guide.internal.structure.GuideStructureVolume; +import com.hfstudio.guidenh.guide.scene.support.GuideDebugLog; import com.hfstudio.guidenh.guide.siteexport.ExportTask; import com.hfstudio.guidenh.guide.siteexport.site.GuideSiteExportOptions; import com.hfstudio.guidenh.guide.siteexport.site.GuideSiteExportTask; @@ -44,9 +47,11 @@ public class GuideNhClientCommand extends CommandBase { public static final String[] ROOT_SUB_COMMANDS = { "editor", "guideeditor", "guideedit", "list", "open", "reload", "search", "export", "exportsite", "exportstructure", "dumppagelang", "pos1", "pos2", "clearselection", "bind", - "unbind" }; + "unbind", "renderpage" }; public static final String[] EXPORT_STRUCTURE_FLAGS = { "--mode", "snbt", "snbt_e", "blocks", "blocks_e" }; public static final String[] EXPORT_SITE_FLAGS = { "--ponder-frames", "--ponder-every-tick" }; + public static final String[] RENDERPAGE_FLAGS = { "--guide", "--page", "--md", "--width", "--out", "--lang", + "--bounds", "--overlay", "--scale" }; @Override public String getCommandName() { @@ -96,6 +101,7 @@ public void processCommand(ICommandSender sender, String[] args) throws CommandE } case "bind" -> updateRegionWandBinding(sender, true); case "unbind" -> updateRegionWandBinding(sender, false); + case "renderpage" -> renderPage(sender, args); default -> send(sender, GuidebookText.CommandClientUsage); } } @@ -121,6 +127,9 @@ public List addTabCompletionOptions(ICommandSender sender, String[] args if (args.length >= 2 && args[0].equalsIgnoreCase("exportstructure")) { return getListOfStringsMatchingLastWord(args, EXPORT_STRUCTURE_FLAGS); } + if (args.length >= 2 && args[0].equalsIgnoreCase("renderpage")) { + return getListOfStringsMatchingLastWord(args, RENDERPAGE_FLAGS); + } return List.of(); } @@ -464,6 +473,153 @@ private void clearSelection(ICommandSender sender) { send(sender, GuidebookText.RegionWandSelectionCleared); } + // Render-page command handling. + + private void renderPage(ICommandSender sender, String[] args) { + RenderPageOptions opts = parseRenderPageOptions(args); + + if (opts.guideId() == null) { + sender.addChatMessage( + new ChatComponentText( + "§cUsage: /guidenhc renderpage --guide [--page | --md ] [--width ] [--out ] [--lang ] [--bounds] [--overlay] [--scale <1-4>]")); + return; + } + + boolean hasPage = opts.pageId() != null; + boolean hasMd = opts.mdFile() != null; + if (!hasPage && !hasMd) { + sender.addChatMessage(new ChatComponentText("§cError: Either --page or --md is required.")); + return; + } + if (hasPage && hasMd) { + sender.addChatMessage(new ChatComponentText("§cError: --page and --md are mutually exclusive.")); + return; + } + + if (opts.widthError() != null) { + sender.addChatMessage( + new ChatComponentText( + "§cInvalid --width value: '" + opts.widthError() + "', should be integer between 100-4096")); + return; + } + + if (opts.width() < 100 || opts.width() > 4096) { + sender.addChatMessage(new ChatComponentText("§cError: --width must be between 100 and 4096.")); + return; + } + + if (opts.scaleError() != null) { + sender.addChatMessage( + new ChatComponentText( + "§cInvalid --scale value: '" + opts.scaleError() + "', must be integer between 1-4")); + return; + } + if (opts.scale() < 1 || opts.scale() > 4) { + sender.addChatMessage(new ChatComponentText("§cError: --scale must be between 1 and 4.")); + return; + } + + try { + RenderPageService.RenderPageResult result = RenderPageService.render( + new RenderPageService.RenderPageRequest( + opts.guideId(), + opts.pageId(), + opts.mdFile(), + opts.language(), + opts.width(), + opts.outDir(), + opts.emitBoundsJson(), + opts.emitDebugOverlay(), + opts.scale())); + sender.addChatMessage( + new ChatComponentText( + String.format( + "§aRenderPage success: PNG=%s, %dx%dpx, %d blocks", + result.pngPath(), + result.widthPx(), + result.heightPx(), + result.blockCount()))); + } catch (RenderPageService.RenderPageException e) { + sender.addChatMessage( + new ChatComponentText(String.format("§cRenderPage failed at %s: %s", e.getStage(), e.getMessage()))); + } catch (Throwable t) { + sender.addChatMessage(new ChatComponentText("§cRenderPage internal error: " + getErrorMessage(t))); + GuideDebugLog.error("RenderPage internal error", t); + } + } + + private RenderPageOptions parseRenderPageOptions(String[] args) { + String guideId = null; + String pageId = null; + Path mdFile = null; + int width = 900; + String widthError = null; + Path outDir = Minecraft.getMinecraft().mcDataDir.toPath() + .resolve("screenshots"); + String language = "en_us"; + boolean emitBoundsJson = false; + boolean emitDebugOverlay = false; + int scale = 1; + String scaleError = null; + + for (int i = 1; i < args.length; i++) { + String arg = args[i]; + switch (arg.toLowerCase()) { + case "--guide" -> { + if (i + 1 < args.length) guideId = args[++i]; + } + case "--page" -> { + if (i + 1 < args.length) pageId = args[++i]; + } + case "--md" -> { + if (i + 1 < args.length) mdFile = Paths.get(args[++i]); + } + case "--width" -> { + if (i + 1 < args.length) { + String raw = args[++i]; + try { + width = Integer.parseInt(raw); + } catch (NumberFormatException e) { + widthError = raw; + } + } + } + case "--out" -> { + if (i + 1 < args.length) outDir = Paths.get(args[++i]) + .toAbsolutePath(); + } + case "--lang" -> { + if (i + 1 < args.length) language = args[++i]; + } + case "--bounds" -> emitBoundsJson = true; + case "--overlay" -> emitDebugOverlay = true; + case "--scale" -> { + if (i + 1 < args.length) { + String raw = args[++i]; + try { + scale = Integer.parseInt(raw); + } catch (NumberFormatException e) { + scaleError = raw; + } + } + } + } + } + + return new RenderPageOptions( + guideId, + pageId, + mdFile, + width, + outDir, + language, + emitBoundsJson, + emitDebugOverlay, + widthError, + scale, + scaleError); + } + private void updateRegionWandBinding(ICommandSender sender, boolean bind) { if (!bind) { boolean changed = RegionWandSelection.clearBinding(); @@ -514,4 +670,8 @@ public ExportSiteCommandOptions(String outDirArgument, boolean exportPonderEvery } private record ExportStructureOptions(RegionWandExportMode mode, int coordinateStartIndex) {} + + private record RenderPageOptions(String guideId, String pageId, Path mdFile, int width, Path outDir, + String language, boolean emitBoundsJson, boolean emitDebugOverlay, String widthError, int scale, + String scaleError) {} } diff --git a/src/main/java/com/hfstudio/guidenh/config/ModConfig.java b/src/main/java/com/hfstudio/guidenh/config/ModConfig.java index fa369130..719835c7 100644 --- a/src/main/java/com/hfstudio/guidenh/config/ModConfig.java +++ b/src/main/java/com/hfstudio/guidenh/config/ModConfig.java @@ -25,6 +25,7 @@ public static void registerConfig() throws ConfigException { public static final Ui ui = new Ui(); public static final Runtime runtime = new Runtime(); public static final RuntimeBridge runtimeBridge = new RuntimeBridge(); + public static final Reader reader = new Reader(); @Comment("Debug section") public static class Debug { @@ -94,6 +95,10 @@ public static class Debug { @DefaultBoolean(true) public boolean showMousePosition = true; + @Comment("Show layout diagnostic overlay (green=Java bounds, red=Rust rects, blue=glyph quads, yellow=viewport scissor, gray=culled blocks)") + @DefaultBoolean(false) + public boolean layoutOverlay = false; + @Comment("Debug text scale factor") @DefaultFloat(0.8f) @RangeFloat(min = 0.5f, max = 2.0f) @@ -312,6 +317,26 @@ public static int clampPositiveHomeLimit(int value, int fallback) { return value >= 1 ? value : fallback; } + @Comment("Reader section: guide reader font settings.") + public static class Reader { + + @Comment("Base font size for reader text in pixels. " + + "Set to 0 to follow the system default (11px, the historic GuideText.BASE_FONT_SIZE). " + + "Positive values override the base em size end-to-end. Default: 0.") + public int readerFontSize = 0; + + @Comment("Font family for reader body text. \"system\" uses the platform default. " + + "Consumed by the reader settings page font steppers. " + + "Default: \"system\".") + public String readerFontFamily = "system"; + + @Comment("Font family for inline code / monospace reader text. " + + "\"mono\" uses the platform monospace default. " + + "Consumed by the reader settings page font steppers. " + + "Default: \"mono\".") + public String readerCodeFontFamily = "mono"; + } + @Comment("Runtime section") public static class Runtime { diff --git a/src/main/java/com/hfstudio/guidenh/guide/compiler/PageCompiler.java b/src/main/java/com/hfstudio/guidenh/guide/compiler/PageCompiler.java index 5f6d957a..17d1946a 100644 --- a/src/main/java/com/hfstudio/guidenh/guide/compiler/PageCompiler.java +++ b/src/main/java/com/hfstudio/guidenh/guide/compiler/PageCompiler.java @@ -71,6 +71,7 @@ import com.hfstudio.guidenh.guide.scene.support.GuideDebugLog; import com.hfstudio.guidenh.guide.sound.GuideSoundParsers; import com.hfstudio.guidenh.guide.style.TextAlignment; +import com.hfstudio.guidenh.guide.style.TextStyle; import com.hfstudio.guidenh.guide.style.WhiteSpaceMode; import com.hfstudio.guidenh.libs.mdast.MdAst; import com.hfstudio.guidenh.libs.mdast.MdAstYamlFrontmatter; @@ -82,6 +83,7 @@ import com.hfstudio.guidenh.libs.mdast.mdx.model.MdxJsxTextElement; import com.hfstudio.guidenh.libs.mdast.model.MdAstAnyContent; import com.hfstudio.guidenh.libs.mdast.model.MdAstDefinition; +import com.hfstudio.guidenh.libs.mdast.model.MdAstFlowContent; import com.hfstudio.guidenh.libs.mdast.model.MdAstNode; import com.hfstudio.guidenh.libs.mdast.model.MdAstParagraph; import com.hfstudio.guidenh.libs.mdast.model.MdAstParent; @@ -284,21 +286,36 @@ public static ParsedGuidePage parse(String sourcePack, String language, Resource * {@link ParsedGuidePage#getAstRoot()}. * *

          - * F3+T reload uses this path so that index/navigation rebuilds — - * which only need frontmatter — complete without paying Micromark cost. + * F3+T reload uses this path so that index/navigation rebuilds, which + * only need frontmatter, complete without paying the Micromark cost. *

          */ public static ParsedGuidePage parseFrontmatterOnly(String sourcePack, String language, ResourceLocation id, String pageContent) { + return parseFrontmatterOnly(sourcePack, null, language, id, pageContent); + } + + /** + * Frontmatter-only parse that also records the exact resource pack the page was read from. The + * namespace inside {@code sourcePack} cannot tell several packs apart, so a page read from a resource + * pack has to carry its provenance explicitly for the compiled page to name the real source. + * + * @param sourcePack identifies the page for compiler purposes and is used when no pack is known. + * @param sourceResourcePack the pack the page bytes were read from, or {@code null} when the page did + * not originate from a resource pack selection. + */ + public static ParsedGuidePage parseFrontmatterOnly(String sourcePack, @Nullable IResourcePack sourceResourcePack, + String language, ResourceLocation id, String pageContent) { pageContent = pageContent != null ? pageContent : ""; pageContent = normalizeLineEndings(pageContent); var sourceFrontmatter = parseFrontmatterFromSource(id, pageContent); return new ParsedGuidePage( sourcePack, + sourceResourcePack, id, pageContent, - null, // astRoot — triggers lazy parse on first getAstRoot() + null, // astRoot - triggers lazy parse on first getAstRoot() sourceFrontmatter, language, null, @@ -332,24 +349,50 @@ public static MdAstRoot buildErrorPage(String errorText) { public static MdAstRoot buildErrorPage(String headingText, String errorText) { var root = new MdAstRoot(); + //

          headingText

          var heading = new MdxJsxFlowElement(); heading.setName("h1"); heading.addAttribute("depth", 1); root.addChild(heading); + var headingColor = new MdxJsxTextElement("Color", new ArrayList<>()); + headingColor.addAttribute("id", "error_text"); var headingTextNode = new MdAstText(); headingTextNode.setValue(headingText); - heading.addChild(headingTextNode); + headingColor.addChild(headingTextNode); + safeAddChild(heading, headingColor); + //

          errorText

          var errorParagraph = new MdxJsxFlowElement(); errorParagraph.setName("p"); root.addChild(errorParagraph); + var errorColor = new MdxJsxTextElement("Color", new ArrayList<>()); + errorColor.addAttribute("id", "error_text"); var errorTextNode = new MdAstText(); errorTextNode.setValue(errorText); - errorParagraph.addChild(errorTextNode); + errorColor.addChild(errorTextNode); + safeAddChild(errorParagraph, errorColor); return root; } + /** + * Adds a child node to an {@link MdxJsxFlowElement} with type validation. + * If the node is a valid {@link MdAstFlowContent} (the expected child type), + * it is added via the normal {@code addChild} path. Otherwise, raw-type + * access is used as a safe fallback to bypass the type constraint - this + * prevents the error page builder itself from crashing when attempting to + * add phrasing content (e.g. {@link MdAstText}) that is semantically valid + * inside flow elements like {@code

          } or {@code

          }. + */ + @SuppressWarnings({"unchecked", "rawtypes"}) + private static void safeAddChild(MdxJsxFlowElement element, MdAstNode node) { + if (node instanceof MdAstFlowContent) { + element.addChild(node); + } else { + ((List) element.children()).add(node); + } + } + public static GuidePage buildErrorGuidePage(PageCollection pages, ExtensionCollection extensions, String sourcePack, ResourceLocation id, String pageContent, String headingText, String errorText) { return buildErrorGuidePage(pages, extensions, sourcePack, null, id, pageContent, headingText, errorText); @@ -711,7 +754,7 @@ private void compileBlockContextAtCurrentDepth(List c } } } else if (child instanceof MdxJsxTextElement el) { - // Inline element at block level — merge into previous paragraph when possible + // Inline element at block level - merge into previous paragraph when possible if (previousLayoutChild instanceof LytParagraph paragraph) { var flowCompiler = tagCompilers.get(el.name()); if (flowCompiler != null) { @@ -726,7 +769,7 @@ private void compileBlockContextAtCurrentDepth(List c } layoutChild = paragraph; } else if (child instanceof MdAstText text) { - // Orphan text — merge into previous paragraph when possible + // Orphan text - merge into previous paragraph when possible if (previousLayoutChild instanceof LytParagraph paragraph) { var flowText = new LytFlowText(); flowText.setText(text.value); @@ -784,63 +827,6 @@ private void compileParagraphBlock(MdAstParagraph astParagraph, LytBlockContaine parent.append(wrapFloatAwareIfNeeded(paragraph)); } - private LytBlock compileTable(GfmTable astTable, List widthHints) { - var table = new LytTable(); - table.setMarginBottom(DEFAULT_ELEMENT_SPACING); - - var astRows = astTable.children(); - // The GFM table parser swallows a trailing kramdown attribute line such as - // `{: widths="..." }` as an extra row; drop it during rendering so it does - // not appear as the last visible row of the table. - int rowCount = astRows.size(); - if (rowCount > 0) { - var lastRow = astRows.get(rowCount - 1); - String lastRowText = getTableRowText(lastRow); - if (lastRowText != null && TABLE_ATTRIBUTE_LINE.matcher(lastRowText.trim()) - .matches()) { - if (widthHints == null || widthHints.isEmpty()) { - Matcher matcher = TABLE_ATTRIBUTE_LINE.matcher(lastRowText.trim()); - if (matcher.matches()) { - widthHints = parseWidthHintsFromMetaExpression(matcher.group(1)); - } - } - rowCount--; - } - } - - boolean firstRow = true; - int rowIndex = 0; - for (int rowI = 0; rowI < rowCount; rowI++) { - var astRow = astRows.get(rowI); - var row = table.appendRow(); - if (firstRow) { - row.modifyStyle(style -> style.bold(true)); - firstRow = false; - } - - var astCells = astRow.children(); - for (int i = 0; i < astCells.size(); i++) { - if (rowIndex == 0 && i < widthHints.size() && widthHints.get(i) > 0) { - table.getOrCreateColumn(i) - .setPreferredWidth(widthHints.get(i)); - } - var cell = row.appendCell(); - // Apply alignment - if (astTable.align != null && i < astTable.align.size()) { - switch (astTable.align.get(i)) { - case CENTER -> cell.modifyStyle(style -> style.alignment(TextAlignment.CENTER)); - case RIGHT -> cell.modifyStyle(style -> style.alignment(TextAlignment.RIGHT)); - } - } - - compileTableCellContent(astCells.get(i), cell); - } - rowIndex++; - } - - return wrapFloatAwareIfNeeded(table); - } - public static LytBlock wrapFloatAwareIfNeeded(LytBlock block) { if (block instanceof LytParagraph || block instanceof LytDocumentFloat || block instanceof LytFloatAwareBlock @@ -850,18 +836,6 @@ public static LytBlock wrapFloatAwareIfNeeded(LytBlock block) { return new LytFloatAwareBlock(block); } - private @Nullable String getTableRowText(GfmTableRow row) { - StringBuilder sb = new StringBuilder(); - for (var cell : row.children()) { - if (!sb.isEmpty()) { - sb.append(' '); - } - sb.append(cell.toText()); - } - String text = sb.toString(); - return text.isEmpty() ? null : text; - } - public void compileFlowContext(MdAstParent markdownParent, LytFlowParent layoutParent) { compileFlowContext(markdownParent.children(), layoutParent); } @@ -895,9 +869,18 @@ private void compileFlowContent(LytFlowParent layoutParent, MdAstAnyContent cont } else if (compileInlineDollarLatex(layoutParent, astText.value)) { layoutChild = null; } else { - var text = new LytFlowText(); - text.setText(astText.value); - layoutChild = text; + String value = astText.value; + if (value.indexOf('§') >= 0) { + List fragments = parseSectionFormatting(value); + for (var fragment : fragments) { + layoutParent.append(fragment); + } + layoutChild = null; + } else { + var text = new LytFlowText(); + text.setText(value); + layoutChild = text; + } } } else if (content instanceof MdxJsxTextElement el) { if ("Spoiler".equals(el.name())) { @@ -1006,6 +989,7 @@ private boolean compileInlineDollarLatex(LytFlowParent layoutParent, String text var block = new LytLatexBlock( segment.getValue(), LatexRenderOptions.builder() + .style(org.scilab.forge.jlatexmath.TeXConstants.STYLE_TEXT) .valign(LatexVerticalAlign.BASELINE) .build()); layoutParent.append(LytFlowInlineBlock.of(block)); @@ -1323,4 +1307,146 @@ private record BlockTagChildrenCacheEntry(@Nullable String source, @Nullable Par private record SourceSlice(String source) {} public record State (String name, Class dataClass, T defaultValue) {} + + // Minecraft section-sign colour and format code parsing. + + /** + * Parses Minecraft § color/format codes in {@code text} and returns a list of + * styled flow content fragments (plain {@link LytFlowText} or {@link LytFlowSpan} + * wrapping a text node). + */ + static List parseSectionFormatting(String text) { + if (text.isEmpty()) { + return Collections.emptyList(); + } + + List result = new ArrayList<>(); + StringBuilder segment = new StringBuilder(); + + // Current style state. Boolean null = inherit/not set. + ConstantColor color = null; + Boolean bold = null; + Boolean italic = null; + Boolean underlined = null; + Boolean strikethrough = null; + Boolean obfuscated = null; + + for (int i = 0; i < text.length(); i++) { + char ch = text.charAt(i); + if (ch == '§' && i + 1 < text.length()) { + char code = text.charAt(i + 1); + int mappedColor = mapSectionColor(code); + if (mappedColor != 0 || isSectionFormatCode(code)) { + // Valid section-sign code: flush the current segment and apply it. + flushSectionSegment(result, segment, color, bold, italic, underlined, strikethrough, obfuscated); + + if (mappedColor != 0) { + // Colour codes 0-f: reset all formatting and set the colour. + color = new ConstantColor(mappedColor); + bold = false; + italic = false; + underlined = false; + strikethrough = false; + obfuscated = false; + } else { + // §k-§o, §r: format code + switch (Character.toLowerCase(code)) { + case 'l' -> bold = true; + case 'o' -> italic = true; + case 'm' -> strikethrough = true; + case 'n' -> underlined = true; + case 'k' -> obfuscated = true; + case 'r' -> { + color = null; + bold = null; + italic = null; + underlined = null; + strikethrough = null; + obfuscated = null; + } + default -> { /* unreachable: isSectionFormatCode already validated */ } + } + } + i++; // skip the format-code character + continue; + } + } + segment.append(ch); + } + + flushSectionSegment(result, segment, color, bold, italic, underlined, strikethrough, obfuscated); + return result; + } + + /** Appends the accumulated {@code segment} text as either plain or styled flow content. */ + private static void flushSectionSegment(List result, StringBuilder segment, + ConstantColor color, Boolean bold, Boolean italic, Boolean underlined, + Boolean strikethrough, Boolean obfuscated) { + if (segment.isEmpty()) { + return; + } + String text = segment.toString(); + segment.setLength(0); + + if (color == null && bold == null && italic == null && underlined == null + && strikethrough == null && obfuscated == null) { + result.add(LytFlowText.of(text)); + return; + } + + var span = new LytFlowSpan(); + var builder = TextStyle.builder(); + if (color != null) { + builder = builder.color(color); + } + if (bold != null) { + builder = builder.bold(bold); + } + if (italic != null) { + builder = builder.italic(italic); + } + if (underlined != null) { + builder = builder.underlined(underlined); + } + if (strikethrough != null) { + builder = builder.strikethrough(strikethrough); + } + if (obfuscated != null) { + builder = builder.obfuscated(obfuscated); + } + span.setStyle(builder.build()); + span.appendText(text); + result.add(span); + } + + /** Returns the ARGB colour for colour codes 0-f, or 0 if {@code code} is not a colour code. */ + static int mapSectionColor(char code) { + return switch (Character.toLowerCase(code)) { + case '0' -> 0xFF000000; // Black + case '1' -> 0xFF0000AA; // Dark Blue + case '2' -> 0xFF00AA00; // Dark Green + case '3' -> 0xFF00AAAA; // Dark Aqua + case '4' -> 0xFFAA0000; // Dark Red + case '5' -> 0xFFAA00AA; // Dark Purple + case '6' -> 0xFFFFAA00; // Gold + case '7' -> 0xFFAAAAAA; // Gray + case '8' -> 0xFF555555; // Dark Gray + case '9' -> 0xFF5555FF; // Blue + case 'a' -> 0xFF55FF55; // Green + case 'b' -> 0xFF55FFFF; // Aqua + case 'c' -> 0xFFFF5555; // Red + case 'd' -> 0xFFFF55FF; // Light Purple + case 'e' -> 0xFFFFFF55; // Yellow + case 'f' -> 0xFFFFFFFF; // White + default -> 0; + }; + } + + /** Returns true for §k/l/m/n/o/r (format codes, not colour codes). */ + private static boolean isSectionFormatCode(char code) { + return switch (Character.toLowerCase(code)) { + case 'k', 'l', 'm', 'n', 'o', 'r' -> true; + default -> false; + }; + } } diff --git a/src/main/java/com/hfstudio/guidenh/guide/compiler/ParsedGuidePage.java b/src/main/java/com/hfstudio/guidenh/guide/compiler/ParsedGuidePage.java index fd62658f..37edf803 100644 --- a/src/main/java/com/hfstudio/guidenh/guide/compiler/ParsedGuidePage.java +++ b/src/main/java/com/hfstudio/guidenh/guide/compiler/ParsedGuidePage.java @@ -35,9 +35,9 @@ public class ParsedGuidePage { private final Frontmatter frontmatter; @Getter private final String language; - private volatile @Nullable String parseFailureMessage; - private volatile @Nullable UnistPoint parseFailureFrom; - private volatile @Nullable UnistPoint parseFailureTo; + private final @Nullable String parseFailureMessage; + private final @Nullable UnistPoint parseFailureFrom; + private final @Nullable UnistPoint parseFailureTo; @Deprecated public ParsedGuidePage(String sourcePack, ResourceLocation id, String source, MdAstRoot astRoot, @@ -124,16 +124,6 @@ public boolean hasParseFailure() { return parseFailureMessage != null && !parseFailureMessage.isEmpty(); } - /** Copies deferred body-parse diagnostics into a lazy page shell. */ - protected final void adoptParseFailure(ParsedGuidePage parsed) { - if (parsed == null || !parsed.hasParseFailure()) { - return; - } - parseFailureMessage = parsed.getParseFailureMessage(); - parseFailureFrom = parsed.getParseFailureFrom(); - parseFailureTo = parsed.getParseFailureTo(); - } - @Override public String toString() { if (id.getResourceDomain() diff --git a/src/main/java/com/hfstudio/guidenh/guide/compiler/tags/BlockTagCompiler.java b/src/main/java/com/hfstudio/guidenh/guide/compiler/tags/BlockTagCompiler.java index cc5fb085..4815d215 100644 --- a/src/main/java/com/hfstudio/guidenh/guide/compiler/tags/BlockTagCompiler.java +++ b/src/main/java/com/hfstudio/guidenh/guide/compiler/tags/BlockTagCompiler.java @@ -23,9 +23,9 @@ * Word-style content embedding behaviour: * *

            - *
          • {@code wrap} — one of {@code inline}, {@code square}, {@code tight}, {@code through}, + *
          • {@code wrap} - one of {@code inline}, {@code square}, {@code tight}, {@code through}, * {@code top-bottom}, {@code behind}, {@code front}. Defaults to {@code inline}. - *
          • {@code align} — one of {@code left}, {@code center}, {@code right}. Defaults to + *
          • {@code align} - one of {@code left}, {@code center}, {@code right}. Defaults to * {@code left}. *
          * @@ -98,7 +98,7 @@ private static void applyFlowFloatMargins(LytBlock node, InlineBlockAlignment al } } - private static LytBlock applyBlockEmbed(LytBlock node, ContentWrapMode wrapMode, ContentAlign align) { + public static LytBlock embedBlock(LytBlock node, ContentWrapMode wrapMode, ContentAlign align) { if (wrapMode.isDocumentFloat()) { return new LytDocumentFloat(node, align == ContentAlign.RIGHT); } @@ -107,4 +107,8 @@ private static LytBlock applyBlockEmbed(LytBlock node, ContentWrapMode wrapMode, } return PageCompiler.wrapFloatAwareIfNeeded(node); } + + private static LytBlock applyBlockEmbed(LytBlock node, ContentWrapMode wrapMode, ContentAlign align) { + return embedBlock(node, wrapMode, align); + } } diff --git a/src/main/java/com/hfstudio/guidenh/guide/compiler/tags/BlockquoteCompiler.java b/src/main/java/com/hfstudio/guidenh/guide/compiler/tags/BlockquoteCompiler.java index a16d8dda..a5b1d69e 100644 --- a/src/main/java/com/hfstudio/guidenh/guide/compiler/tags/BlockquoteCompiler.java +++ b/src/main/java/com/hfstudio/guidenh/guide/compiler/tags/BlockquoteCompiler.java @@ -76,7 +76,7 @@ private void compileDirectiveBody(PageCompiler compiler, BlockquoteDirective dir : null; // When there's a remainingText override and the first paragraph is still present // at the head of the children list, replace its leading text. - // Otherwise — just compile children normally. + // Otherwise - just compile children normally. if (!directive.children() .isEmpty() && firstParagraph != null && directive.children() @@ -101,14 +101,34 @@ private void compileDirectiveBody(PageCompiler compiler, BlockquoteDirective dir } private void normalizeBlockMargins(LytNode box) { - var boxChildren = box.getChildren(); - if (!boxChildren.isEmpty()) { - if (boxChildren.getFirst() instanceof LytParagraph) { - ((LytParagraph) boxChildren.getFirst()).setMarginTop(0); - } - if (boxChildren.getLast() instanceof LytParagraph) { - ((LytParagraph) boxChildren.getLast()).setMarginBottom(0); - } + // The alert/quote box title row is an independent margin-less paragraph, + // so the FIRST BODY paragraph still carries the block-paragraph top + // margin (5) that would otherwise double the title-row gap (VBox gap 4 + + // margin 5 ≈ the reported one-line blank band). The plain blockquote has + // no title row and its children ARE the body paragraphs. + if (box instanceof LytQuoteBox quoteBox) { + // Body paragraphs live in a nested content container below the title + // row; clear the first body paragraph's top margin so the title-row + // spacing is carried by the container's gap alone. + clearFirstParagraphTopMargin(quoteBox.getBodyContainer()); + return; + } + var children = box.getChildren(); + // LytAlertBox always carries its title row at index 0 (appended in its + // constructor), so the first BODY paragraph is at index 1. + int firstBody = box instanceof LytAlertBox ? 1 : 0; + if (children.size() > firstBody && children.get(firstBody) instanceof LytParagraph first) { + first.setMarginTop(0); + } + if (!children.isEmpty() && children.getLast() instanceof LytParagraph last) { + last.setMarginBottom(0); + } + } + + private static void clearFirstParagraphTopMargin(LytNode container) { + var children = container.getChildren(); + if (!children.isEmpty() && children.getFirst() instanceof LytParagraph first) { + first.setMarginTop(0); } } diff --git a/src/main/java/com/hfstudio/guidenh/guide/compiler/tags/CodeCompiler.java b/src/main/java/com/hfstudio/guidenh/guide/compiler/tags/CodeCompiler.java index b0e47d8e..4e8941e0 100644 --- a/src/main/java/com/hfstudio/guidenh/guide/compiler/tags/CodeCompiler.java +++ b/src/main/java/com/hfstudio/guidenh/guide/compiler/tags/CodeCompiler.java @@ -20,7 +20,7 @@ public Set getTagNames() { @Override protected void compile(PageCompiler compiler, LytFlowParent parent, MdxJsxElementFields el) { var text = new LytFlowText(); - // Extract text from child — has one MdAstText child from converter + // Extract text from child - has one MdAstText child from converter String value = ""; if (!el.children() .isEmpty() diff --git a/src/main/java/com/hfstudio/guidenh/guide/compiler/tags/CommandLinkCompiler.java b/src/main/java/com/hfstudio/guidenh/guide/compiler/tags/CommandLinkCompiler.java index 7b1ea5f1..d0c26292 100644 --- a/src/main/java/com/hfstudio/guidenh/guide/compiler/tags/CommandLinkCompiler.java +++ b/src/main/java/com/hfstudio/guidenh/guide/compiler/tags/CommandLinkCompiler.java @@ -11,6 +11,7 @@ import com.hfstudio.guidenh.guide.document.flow.LytFlowLink; import com.hfstudio.guidenh.guide.document.flow.LytFlowParent; import com.hfstudio.guidenh.guide.document.interaction.TextTooltip; +import com.hfstudio.guidenh.guide.render.GuideText; import com.hfstudio.guidenh.libs.mdast.mdx.model.MdxJsxElementFields; public class CommandLinkCompiler extends FlowTagCompiler { @@ -41,7 +42,14 @@ protected void compile(PageCompiler compiler, LytFlowParent parent, MdxJsxElemen link.setData("close", closeGuide); link.setData("title", title); - compiler.compileFlowContext(el.children(), link); + var children = el.children(); + if (children.isEmpty()) { + // Self-closing: synthesize visible label from attributes + String label = title.isEmpty() ? command : title; + link.appendText(label); + } else { + compiler.compileFlowContext(children, link); + } parent.append(link); } @@ -52,7 +60,7 @@ public static TextTooltip buildTooltip(@Nullable String title, String command) { sb.append(tooltipTitle) .append("\n"); } - var displayCmd = command.length() > 25 ? command.substring(0, 25) + "..." : command; + var displayCmd = GuideText.clipToChars(command, 28, GuideText.ClipSuffix.DOTS3); sb.append(displayCmd); return new TextTooltip(sb.toString()); } diff --git a/src/main/java/com/hfstudio/guidenh/guide/compiler/tags/CsvTableCompiler.java b/src/main/java/com/hfstudio/guidenh/guide/compiler/tags/CsvTableCompiler.java index 6ffdf903..fd34b681 100644 --- a/src/main/java/com/hfstudio/guidenh/guide/compiler/tags/CsvTableCompiler.java +++ b/src/main/java/com/hfstudio/guidenh/guide/compiler/tags/CsvTableCompiler.java @@ -45,6 +45,8 @@ protected void compile(PageCompiler compiler, LytBlockContainer parent, MdxJsxEl boolean header = MdxAttrs.getBoolean(compiler, parent, el, "header", true); List widths = parseWidthHints(MdxAttrs.getString(compiler, parent, el, "widths", null)); + String wrapAttr = el.getAttributeString("wrap", null); + String alignAttr = el.getAttributeString("align", null); CsvTablePlaceholder placeholder = new CsvTablePlaceholder( csvId.toString(), @@ -53,7 +55,9 @@ protected void compile(PageCompiler compiler, LytBlockContainer parent, MdxJsxEl compiler.getSourcePack(), compiler.getLanguage(), compiler.getPageId() - .toString()); + .toString(), + wrapAttr, + alignAttr); placeholder.appendText("[CsvTable]"); parent.append(placeholder); } @@ -109,6 +113,7 @@ public static LytTable buildTable(PageCompiler compiler, List> rows var row = table.appendRow(); if (firstRow && header) { row.modifyStyle(style -> style.bold(true)); + row.setHeader(true); } for (int columnIndex = 0; columnIndex < values.size(); columnIndex++) { if (rowIndex == 0 && columnIndex < widthHints.size() && widthHints.get(columnIndex) > 0) { @@ -193,15 +198,19 @@ public static class CsvTablePlaceholder extends LytParagraph { public final String sourcePack; public final String language; public final String pageId; + public final String wrap; + public final String align; public CsvTablePlaceholder(String src, boolean header, List widths, String sourcePack, String language, - String pageId) { + String pageId, String wrap, String align) { this.src = src; this.header = header; this.widths = widths; this.sourcePack = sourcePack; this.language = language; this.pageId = pageId; + this.wrap = wrap; + this.align = align; setStyleClass("CsvTable"); setStyle(LytParagraph.PLACEHOLDER_STYLE); } diff --git a/src/main/java/com/hfstudio/guidenh/guide/compiler/tags/FloatingImageCompiler.java b/src/main/java/com/hfstudio/guidenh/guide/compiler/tags/FloatingImageCompiler.java index eb24f499..3e16788b 100644 --- a/src/main/java/com/hfstudio/guidenh/guide/compiler/tags/FloatingImageCompiler.java +++ b/src/main/java/com/hfstudio/guidenh/guide/compiler/tags/FloatingImageCompiler.java @@ -14,7 +14,15 @@ import com.hfstudio.guidenh.guide.compiler.IndexingContext; import com.hfstudio.guidenh.guide.compiler.IndexingSink; import com.hfstudio.guidenh.guide.compiler.PageCompiler; +import com.hfstudio.guidenh.guide.compiler.TagCompiler; +import com.hfstudio.guidenh.guide.document.LytErrorSink; +import com.hfstudio.guidenh.guide.document.block.ContentAlign; +import com.hfstudio.guidenh.guide.document.block.ContentWrapMode; import com.hfstudio.guidenh.guide.document.block.ImageRegionAnnotation; +import com.hfstudio.guidenh.guide.document.block.LytAlignedBlock; +import com.hfstudio.guidenh.guide.document.block.LytBlock; +import com.hfstudio.guidenh.guide.document.block.LytBlockContainer; +import com.hfstudio.guidenh.guide.document.block.LytDocumentFloat; import com.hfstudio.guidenh.guide.document.block.LytImageBlock; import com.hfstudio.guidenh.guide.document.block.LytParagraph; import com.hfstudio.guidenh.guide.document.block.LytVBox; @@ -26,17 +34,33 @@ import com.hfstudio.guidenh.guide.sound.GuideSoundParsers; import com.hfstudio.guidenh.guide.sound.GuideSoundTrigger; import com.hfstudio.guidenh.libs.mdast.mdx.model.MdxJsxElementFields; +import com.hfstudio.guidenh.libs.mdast.mdx.model.MdxJsxFlowElement; +import com.hfstudio.guidenh.libs.mdast.mdx.model.MdxJsxTextElement; -public class FloatingImageCompiler extends FlowTagCompiler { +public class FloatingImageCompiler implements TagCompiler { public static final String TAG_NAME = "FloatingImage"; private static final Random RANDOM = new Random(0); - private record CropSpec(int x, int y, int width, int height) {} + /** + * Parsed crop / size specification. With both dimensions given this is a + * classic crop rectangle (x/y/width/height). With exactly one dimension + * given (hasWidth XOR hasHeight) it is a whole-image display size: the + * given dimension is the final display pixel size × scale and the missing + * dimension is inferred from the image's natural aspect ratio downstream + * (Rust measure_image and the Java mirror paths). + */ + private record CropSpec(int x, int y, int width, int height, boolean hasWidth, boolean hasHeight) {} private record ScaleSpec(double scaleX, double scaleY) {} + /** + * Parsed explicit pixel display size. An absent dimension stays -1, and + * {@link #isSpecified()} tells "no display size given" apart from a + * validated size. The size is forwarded as the block explicit size, the + * same display-size mechanism the single-dimension crop form uses. + */ private record DisplaySpec(int width, int height) { private boolean isSpecified() { @@ -50,28 +74,148 @@ public Set getTagNames() { } @Override - protected void compile(PageCompiler compiler, LytFlowParent parent, MdxJsxElementFields el) { + public void compileBlockContext(PageCompiler compiler, LytBlockContainer parent, MdxJsxFlowElement el) { + String wrapAttr = el.getAttributeString("wrap", null); + String alignAttr = el.getAttributeString("align", null); + + var wrapMode = ContentWrapMode.fromString(wrapAttr); + var align = ContentAlign.fromString(alignAttr); + + // Inline wrap: only explicit wrap="inline" goes to the inline-block path. + if ("inline".equals(wrapAttr)) { + var paragraph = new LytParagraph(); + compileInline(compiler, paragraph, el); + parent.append(paragraph); + return; + } + + // Build the image block for all non-inline modes. + LytImageBlock imageBlock = buildImageBlock(compiler, parent, el); + if (imageBlock == null) return; + + // No explicit wrap + left/right align → document float (matching legacy behaviour). + if (wrapAttr == null && ("left".equals(alignAttr) || "right".equals(alignAttr))) { + LytDocumentFloat docFloat = new LytDocumentFloat(imageBlock, "right".equals(alignAttr)); + parent.append(docFloat); + return; + } + + // Square / tight / through → document float (same as BlockTagCompiler.applyBlockEmbed). + if (wrapMode.isDocumentFloat()) { + LytDocumentFloat docFloat = new LytDocumentFloat(imageBlock, align == ContentAlign.RIGHT); + parent.append(docFloat); + return; + } + + // Behind / front / top-bottom → aligned block path (matching + // BlockTagCompiler.applyBlockEmbed: lines 105-108), not a document float. + LytBlock result = imageBlock; + if (align != ContentAlign.LEFT) { + result = new LytAlignedBlock(result, align); + } + parent.append(PageCompiler.wrapFloatAwareIfNeeded(result)); + } + + @Override + public void compileFlowContext(PageCompiler compiler, LytFlowParent parent, MdxJsxTextElement el) { + compileInline(compiler, parent, el); + } + + /** + * Inline path: build the image block and wrap in a {@link LytFlowInlineBlock} + * with FLOAT_LEFT / FLOAT_RIGHT / INLINE alignment. + *

          + * Used by: + *

            + *
          • {@link #compileFlowContext} - the parent is the actual flow container
          • + *
          • {@link #compileBlockContext} inline fallback - the parent is a freshly + * created {@link LytParagraph} that will be appended to the block container
          • + *
          + */ + private void compileInline(PageCompiler compiler, LytFlowParent parent, MdxJsxElementFields el) { + LytImageBlock block = buildImageBlock(compiler, parent, el); + if (block == null) return; + + String wrap = el.getAttributeString("wrap", null); + String align = el.getAttributeString("align", "left"); + var inlineBlock = new LytFlowInlineBlock(); + inlineBlock.setBlock(block); + if ("inline".equals(wrap)) { + inlineBlock.setAlignment(InlineBlockAlignment.INLINE); + parent.append(inlineBlock); + return; + } + switch (align) { + case "left" -> { + inlineBlock.setAlignment(InlineBlockAlignment.FLOAT_LEFT); + block.setMarginRight(5); + block.setMarginBottom(5); + } + case "right" -> { + inlineBlock.setAlignment(InlineBlockAlignment.FLOAT_RIGHT); + block.setMarginLeft(5); + block.setMarginBottom(5); + } + default -> { + parent.append(compiler.createErrorFlowContent("Invalid align. Must be left or right.", el)); + return; + } + } + parent.append(inlineBlock); + } + + /** + * Shared block-building logic used by both + * {@link #compileBlockContext(PageCompiler, LytBlockContainer, MdxJsxFlowElement)} + * (document-float path) and + * {@link #compileInline(PageCompiler, LytFlowParent, MdxJsxElementFields)} + * (inline path). + * + * @return the fully-configured {@link LytImageBlock}, or {@code null} on parse failure + */ + @Nullable + private static LytImageBlock buildImageBlock(PageCompiler compiler, LytErrorSink errorSink, + MdxJsxElementFields el) { var src = el.getAttributeString("src", null); if (src == null || src.trim() .isEmpty()) { - parent.appendError(compiler, "FloatingImage requires a non-empty src attribute.", el); - return; + errorSink.appendError(compiler, "FloatingImage requires a non-empty src attribute.", el); + return null; } var align = el.getAttributeString("align", "left"); var title = el.getAttributeString("title", null); var alt = el.getAttributeString("alt", null); - DisplaySpec display = parseDisplaySpec(compiler, parent, el); - CropSpec crop = parseCropSpec(compiler, parent, el, display != null && display.isSpecified()); - ScaleSpec scale = parseScaleSpec(compiler, parent, el); - if (crop == null || display == null || scale == null) { - return; + DisplaySpec display = parseDisplaySpec(compiler, errorSink, el); + if (display == null) { + return null; } - if (display.isSpecified() && hasScaleAttributes(el)) { - parent.appendError( - compiler, - "FloatingImage displayWidth/displayHeight cannot be used with scaleX/scaleY.", - el); - return; + CropSpec crop = parseCropSpec(compiler, errorSink, el, display.isSpecified()); + ScaleSpec scale = parseScaleSpec(compiler, errorSink, el); + if (crop == null || scale == null) { + return null; + } + // displayWidth / displayHeight state the final pixel size directly, so + // they replace the size derived from the crop rectangle and the scale + // factors, and cannot be combined with scaleX / scaleY. Either the whole + // source image is displayed (no crop attributes at all) or the crop + // rectangle is complete; a partial crop would make the target of a + // single display dimension ambiguous. + if (display.isSpecified()) { + if (hasScaleAttributes(el)) { + errorSink.appendError( + compiler, + "FloatingImage displayWidth/displayHeight cannot be used with scaleX/scaleY.", + el); + return null; + } + if (crop.hasWidth() != crop.hasHeight()) { + errorSink.appendError( + compiler, + "FloatingImage displayWidth/displayHeight require either no crop attributes or the full " + + "x, y, width or w, and height or h crop rectangle.", + el); + return null; + } } LytImageBlock block = new LytImageBlock(); @@ -87,12 +231,19 @@ protected void compile(PageCompiler compiler, LytFlowParent parent, MdxJsxElemen } block.setCropX(crop.x()); block.setCropY(crop.y()); - block.setCropWidth(crop.width()); - block.setCropHeight(crop.height()); + // Single-parameter mode (width-only / height-only) is a + // whole-image display size: no crop is applied and the missing explicit + // dimension stays -1 so downstream measurement infers it from the + // natural aspect ratio. + if (crop.hasWidth() && crop.hasHeight()) { + block.setCropWidth(crop.width()); + block.setCropHeight(crop.height()); + } else { + block.setCropWidth(-1); + block.setCropHeight(-1); + } block.setScaleX(scale.scaleX()); block.setScaleY(scale.scaleY()); - block.setDisplayWidth(display.width()); - block.setDisplayHeight(display.height()); // Resolve the image src to a string identifier for later script use without // loading the actual asset at compile time. @@ -108,11 +259,11 @@ protected void compile(PageCompiler compiler, LytFlowParent parent, MdxJsxElemen } block.setSrc(resolvedSrc); - var wholeImageSound = GuideSoundParsers.parseAttributes(compiler, parent, el, "soundSrc"); + var wholeImageSound = GuideSoundParsers.parseAttributes(compiler, errorSink, el, "soundSrc"); if (wholeImageSound != null) { var soundAnnotation = new ImageRegionAnnotation(false, ConstantColor.WHITE, 1); soundAnnotation.setSound(wholeImageSound); - soundAnnotation.setSoundTrigger(parseTrigger(compiler, parent, el)); + soundAnnotation.setSoundTrigger(parseTrigger(compiler, errorSink, el)); block.addAnnotation(soundAnnotation); } @@ -121,10 +272,10 @@ protected void compile(PageCompiler compiler, LytFlowParent parent, MdxJsxElemen if (children != null) { for (var child : children) { if (child instanceof MdxJsxElementFields annEl && "ImageAnnotation".equals(annEl.name())) { - var ann = parseImageAnnotation(compiler, parent, annEl); + var ann = parseImageAnnotation(compiler, errorSink, annEl); block.addAnnotation(ann); } else if (child instanceof MdxJsxElementFields soundEl && "SoundArea".equals(soundEl.name())) { - var ann = parseSoundArea(compiler, parent, soundEl); + var ann = parseSoundArea(compiler, errorSink, soundEl); if (ann != null) { block.addAnnotation(ann); } @@ -132,34 +283,29 @@ protected void compile(PageCompiler compiler, LytFlowParent parent, MdxJsxElemen } } - // Wrap it in a flow content inline block - var inlineBlock = new LytFlowInlineBlock(); - inlineBlock.setBlock(block); - String wrap = el.getAttributeString("wrap", null); - boolean inlineWrap = "inline".equals(wrap); - if (inlineWrap) { - inlineBlock.setAlignment(InlineBlockAlignment.INLINE); - parent.append(inlineBlock); - return; - } - switch (align) { - case "left" -> { - inlineBlock.setAlignment(InlineBlockAlignment.FLOAT_LEFT); - block.setMarginRight(5); - block.setMarginBottom(5); - } - case "right" -> { - inlineBlock.setAlignment(InlineBlockAlignment.FLOAT_RIGHT); - block.setMarginLeft(5); - block.setMarginBottom(5); + // Forward the final display size as the explicit size for Rust + // measure_image. displayWidth / displayHeight are already final, while + // the crop rectangle form has to be multiplied by its scale factors + // first. In single-parameter mode the given dimension scaled by its + // factor is the explicit display size (missing axis inferred from the + // natural aspect ratio); the missing dimension stays -1. + if (display.isSpecified()) { + block.setExplicitWidth(display.width()); + block.setExplicitHeight(display.height()); + } else { + if (crop.hasWidth()) { + block.setExplicitWidth((int) Math.round(crop.width() * scale.scaleX())); + } else { + block.setExplicitWidth(-1); } - default -> { - parent.append(compiler.createErrorFlowContent("Invalid align. Must be left or right.", el)); - return; + if (crop.hasHeight()) { + block.setExplicitHeight((int) Math.round(crop.height() * scale.scaleY())); + } else { + block.setExplicitHeight(-1); } } - parent.append(inlineBlock); + return block; } /** @@ -178,9 +324,9 @@ protected void compile(PageCompiler compiler, LytFlowParent parent, MdxJsxElemen * Child MDX content is compiled as the rich-text tooltip body. */ @NotNull - private static ImageRegionAnnotation parseImageAnnotation(PageCompiler compiler, LytFlowParent parent, + private static ImageRegionAnnotation parseImageAnnotation(PageCompiler compiler, LytErrorSink errorSink, MdxJsxElementFields annEl) { - ImageRegionAnnotation ann = parseImageAnnotationRegion(compiler, parent, annEl, true); + ImageRegionAnnotation ann = parseImageAnnotationRegion(compiler, errorSink, annEl, true); // Compile tooltip rich-text content from child elements. var contentBox = new LytVBox(); @@ -189,39 +335,39 @@ private static ImageRegionAnnotation parseImageAnnotation(PageCompiler compiler, .isEmpty()) { ann.setTooltip(new ContentTooltip(contentBox)); } - ann.setSound(GuideSoundParsers.parseAttributes(compiler, parent, annEl)); - ann.setSoundTrigger(parseTrigger(compiler, parent, annEl)); + ann.setSound(GuideSoundParsers.parseAttributes(compiler, errorSink, annEl)); + ann.setSoundTrigger(parseTrigger(compiler, errorSink, annEl)); return ann; } - private static ImageRegionAnnotation parseSoundArea(PageCompiler compiler, LytFlowParent parent, + private static ImageRegionAnnotation parseSoundArea(PageCompiler compiler, LytErrorSink errorSink, MdxJsxElementFields el) { - var sound = GuideSoundParsers.parseAttributes(compiler, parent, el); + var sound = GuideSoundParsers.parseAttributes(compiler, errorSink, el); if (sound == null) { - parent.appendError(compiler, "SoundArea requires a sound or src attribute.", el); + errorSink.appendError(compiler, "SoundArea requires a sound or src attribute.", el); return null; } - ImageRegionAnnotation ann = parseImageAnnotationRegion(compiler, parent, el, false); + ImageRegionAnnotation ann = parseImageAnnotationRegion(compiler, errorSink, el, false); ann.setSound(sound); - ann.setSoundTrigger(parseTrigger(compiler, parent, el)); + ann.setSoundTrigger(parseTrigger(compiler, errorSink, el)); return ann; } - private static ImageRegionAnnotation parseImageAnnotationRegion(PageCompiler compiler, LytFlowParent parent, + private static ImageRegionAnnotation parseImageAnnotationRegion(PageCompiler compiler, LytErrorSink errorSink, MdxJsxElementFields el, boolean allowBorder) { - int x = MdxAttrs.getInt(compiler, parent, el, "x", -1); - int y = MdxAttrs.getInt(compiler, parent, el, "y", -1); - int w = MdxAttrs.getInt(compiler, parent, el, "w", -1); - int h = MdxAttrs.getInt(compiler, parent, el, "h", -1); + int x = MdxAttrs.getInt(compiler, errorSink, el, "x", -1); + int y = MdxAttrs.getInt(compiler, errorSink, el, "y", -1); + int w = MdxAttrs.getInt(compiler, errorSink, el, "w", -1); + int h = MdxAttrs.getInt(compiler, errorSink, el, "h", -1); boolean wholeImage = x < 0 && y < 0 && w < 0 && h < 0; - boolean showBorder = allowBorder && MdxAttrs.getBoolean(compiler, parent, el, "border", false); - int borderThickness = allowBorder ? MdxAttrs.getInt(compiler, parent, el, "borderThickness", 1) : 1; + boolean showBorder = allowBorder && MdxAttrs.getBoolean(compiler, errorSink, el, "border", false); + int borderThickness = allowBorder ? MdxAttrs.getInt(compiler, errorSink, el, "borderThickness", 1) : 1; ColorValue borderColor; if (allowBorder && el.getAttribute("borderColor") != null) { - borderColor = MdxAttrs.getColor(compiler, parent, el, "borderColor", ConstantColor.WHITE); + borderColor = MdxAttrs.getColor(compiler, errorSink, el, "borderColor", ConstantColor.WHITE); } else { borderColor = allowBorder ? new ConstantColor(ColorUtils.BLACK.getColor() | RANDOM.nextInt(0x1000000)) : ConstantColor.WHITE; @@ -238,9 +384,10 @@ private static ImageRegionAnnotation parseImageAnnotationRegion(PageCompiler com return new ImageRegionAnnotation(ax, ay, aw, ah, showBorder, borderColor, borderThickness); } - private static GuideSoundTrigger parseTrigger(PageCompiler compiler, LytFlowParent parent, MdxJsxElementFields el) { + private static GuideSoundTrigger parseTrigger(PageCompiler compiler, LytErrorSink errorSink, + MdxJsxElementFields el) { return GuideSoundTrigger - .parse(MdxAttrs.getString(compiler, parent, el, "trigger", null), GuideSoundTrigger.CLICK); + .parse(MdxAttrs.getString(compiler, errorSink, el, "trigger", null), GuideSoundTrigger.CLICK); } @Override @@ -261,43 +408,67 @@ public static int parseIntAttr(MdxJsxElementFields el, String name, int def) { } } + /** + * Parses the crop / display-size specification. With both dimensions given + * this is a crop rectangle; with exactly one dimension given it is a + * whole-image display size (see {@link CropSpec}). + * + * @param allowWholeImage when true, giving no crop attribute at all is + * valid and yields an empty crop, because an explicit display size + * from displayWidth / displayHeight defines the block instead + */ @Nullable - private static CropSpec parseCropSpec(PageCompiler compiler, LytFlowParent parent, MdxJsxElementFields el, + private static CropSpec parseCropSpec(PageCompiler compiler, LytErrorSink errorSink, MdxJsxElementFields el, boolean allowWholeImage) { String widthValue = el.getAttributeString("width", null); String widthAlias = el.getAttributeString("w", null); String heightValue = el.getAttributeString("height", null); String heightAlias = el.getAttributeString("h", null); if (widthValue != null && widthAlias != null) { - parent.appendError(compiler, "FloatingImage cannot use both width and w.", el); + errorSink.appendError(compiler, "FloatingImage cannot use both width and w.", el); return null; } if (heightValue != null && heightAlias != null) { - parent.appendError(compiler, "FloatingImage cannot use both height and h.", el); + errorSink.appendError(compiler, "FloatingImage cannot use both height and h.", el); return null; } - if (!hasCropAttributes(el)) { - if (allowWholeImage) { - return new CropSpec(0, 0, -1, -1); + boolean hasWidth = (widthValue != null && !widthValue.trim() + .isEmpty()) || (widthAlias != null + && !widthAlias.trim() + .isEmpty()); + boolean hasHeight = (heightValue != null && !heightValue.trim() + .isEmpty()) || (heightAlias != null + && !heightAlias.trim() + .isEmpty()); + // A single explicit dimension (width-only or height-only) is a + // valid whole-image display size; only the "both missing" case is an + // error, unless an explicit display size replaces the crop entirely and + // no crop offset was given either. + if (!hasWidth && !hasHeight) { + if (allowWholeImage && !hasCropAttributes(el)) { + return new CropSpec(0, 0, -1, -1, false, false); } - parent.appendError(compiler, "FloatingImage requires x, y, width or w, and height or h.", el); + errorSink.appendError(compiler, "FloatingImage requires width or w, and height or h.", el); + return null; + } + Integer x = parseOptionalIntAttr(compiler, errorSink, el, "x", 0); + Integer y = parseOptionalIntAttr(compiler, errorSink, el, "y", 0); + Integer width = parseAliasedIntAttr(compiler, errorSink, el, "width", "w"); + Integer height = parseAliasedIntAttr(compiler, errorSink, el, "height", "h"); + if (x == null || y == null) { return null; } - Integer x = parseRequiredIntAttr(compiler, parent, el, "x"); - Integer y = parseRequiredIntAttr(compiler, parent, el, "y"); - Integer width = parseRequiredAliasedIntAttr(compiler, parent, el, "width", "w"); - Integer height = parseRequiredAliasedIntAttr(compiler, parent, el, "height", "h"); - if (x == null || y == null || width == null || height == null) { + if ((hasWidth && width == null) || (hasHeight && height == null)) { return null; } - if (x < 0 || y < 0 || width <= 0 || height <= 0) { - parent.appendError( + if (x < 0 || y < 0 || (hasWidth && width <= 0) || (hasHeight && height <= 0)) { + errorSink.appendError( compiler, "FloatingImage crop values must be non-negative and width/height must be positive.", el); return null; } - return new CropSpec(x, y, width, height); + return new CropSpec(x, y, hasWidth ? width : -1, hasHeight ? height : -1, hasWidth, hasHeight); } private static boolean hasCropAttributes(MdxJsxElementFields el) { @@ -309,95 +480,124 @@ private static boolean hasCropAttributes(MdxJsxElementFields el) { } @Nullable - private static ScaleSpec parseScaleSpec(PageCompiler compiler, LytFlowParent parent, MdxJsxElementFields el) { - Double scaleX = parseDoubleAttr(compiler, parent, el, "scaleX", 1.0d); - Double scaleY = parseDoubleAttr(compiler, parent, el, "scaleY", 1.0d); + private static ScaleSpec parseScaleSpec(PageCompiler compiler, LytErrorSink errorSink, MdxJsxElementFields el) { + Double scaleX = parseDoubleAttr(compiler, errorSink, el, "scaleX", 1.0d); + Double scaleY = parseDoubleAttr(compiler, errorSink, el, "scaleY", 1.0d); if (scaleX == null || scaleY == null) { return null; } if (scaleX <= 0.0d || scaleY <= 0.0d) { - parent.appendError(compiler, "FloatingImage scaleX and scaleY must be positive.", el); + errorSink.appendError(compiler, "FloatingImage scaleX and scaleY must be positive.", el); return null; } return new ScaleSpec(scaleX, scaleY); } + /** + * Parses an aliased integer attribute (primary name or alias) as optional. + * Returns {@code null} when the attribute is absent - a valid state under + * single-parameter mode where exactly one dimension is required (the + * "both missing" error is reported by {@link #parseCropSpec}). A present + * but malformed value appends an error and returns {@code null}. + */ @Nullable - private static DisplaySpec parseDisplaySpec(PageCompiler compiler, LytFlowParent parent, MdxJsxElementFields el) { - Integer width = parseOptionalPositiveIntAttr(compiler, parent, el, "displayWidth"); - Integer height = parseOptionalPositiveIntAttr(compiler, parent, el, "displayHeight"); - if ((width == null && el.getAttributeString("displayWidth", null) != null) - || (height == null && el.getAttributeString("displayHeight", null) != null)) { - return null; - } - return new DisplaySpec(width != null ? width : -1, height != null ? height : -1); - } - - private static boolean hasScaleAttributes(MdxJsxElementFields el) { - return el.getAttributeString("scaleX", null) != null || el.getAttributeString("scaleY", null) != null; - } - - @Nullable - private static Integer parseRequiredAliasedIntAttr(PageCompiler compiler, LytFlowParent parent, - MdxJsxElementFields el, String primaryName, String aliasName) { + private static Integer parseAliasedIntAttr(PageCompiler compiler, LytErrorSink errorSink, MdxJsxElementFields el, + String primaryName, String aliasName) { String primaryValue = el.getAttributeString(primaryName, null); String aliasValue = el.getAttributeString(aliasName, null); if (primaryValue != null && aliasValue != null) { - parent + errorSink .appendError(compiler, "FloatingImage cannot use both " + primaryName + " and " + aliasName + ".", el); return null; } String resolved = primaryValue != null ? primaryValue : aliasValue; if (resolved == null || resolved.trim() .isEmpty()) { - parent.appendError(compiler, "FloatingImage requires x, y, width or w, and height or h.", el); return null; } try { return Integer.parseInt(resolved.trim()); } catch (NumberFormatException ex) { - parent.appendError(compiler, "FloatingImage " + primaryName + " must be an integer.", el); + errorSink.appendError(compiler, "FloatingImage " + primaryName + " must be an integer.", el); return null; } } @Nullable - private static Integer parseRequiredIntAttr(PageCompiler compiler, LytFlowParent parent, MdxJsxElementFields el, + private static Integer parseRequiredIntAttr(PageCompiler compiler, LytErrorSink errorSink, MdxJsxElementFields el, String name) { String value = el.getAttributeString(name, null); if (value == null || value.trim() .isEmpty()) { - parent.appendError(compiler, "FloatingImage requires x, y, width or w, and height or h.", el); + errorSink.appendError(compiler, "FloatingImage requires x, y, width or w, and height or h.", el); return null; } try { return Integer.parseInt(value.trim()); } catch (NumberFormatException ex) { - parent.appendError(compiler, "FloatingImage " + name + " must be an integer.", el); + errorSink.appendError(compiler, "FloatingImage " + name + " must be an integer.", el); + return null; + } + } + + /** + * Parses the optional explicit pixel display size. Values land in + * {@link DisplaySpec} as -1 when absent; a present but malformed or + * non-positive value is reported and fails the parse. + */ + @Nullable + private static DisplaySpec parseDisplaySpec(PageCompiler compiler, LytErrorSink errorSink, MdxJsxElementFields el) { + Integer width = parseOptionalPositiveIntAttr(compiler, errorSink, el, "displayWidth"); + Integer height = parseOptionalPositiveIntAttr(compiler, errorSink, el, "displayHeight"); + if ((width == null && el.getAttributeString("displayWidth", null) != null) + || (height == null && el.getAttributeString("displayHeight", null) != null)) { return null; } + return new DisplaySpec(width != null ? width : -1, height != null ? height : -1); + } + + private static boolean hasScaleAttributes(MdxJsxElementFields el) { + return el.getAttributeString("scaleX", null) != null || el.getAttributeString("scaleY", null) != null; } @Nullable - private static Integer parseOptionalPositiveIntAttr(PageCompiler compiler, LytFlowParent parent, + private static Integer parseOptionalPositiveIntAttr(PageCompiler compiler, LytErrorSink errorSink, MdxJsxElementFields el, String name) { String value = el.getAttributeString(name, null); + if (value == null) { + return null; + } + if (!value.trim() + .isEmpty()) { + try { + int parsed = Integer.parseInt(value.trim()); + if (parsed > 0) { + return parsed; + } + } catch (NumberFormatException ignored) {} + } + errorSink.appendError(compiler, "FloatingImage " + name + " must be a positive integer.", el); + return null; + } + + @Nullable + private static Integer parseOptionalIntAttr(PageCompiler compiler, LytErrorSink errorSink, MdxJsxElementFields el, + String name, int defaultValue) { + String value = el.getAttributeString(name, null); if (value == null || value.trim() .isEmpty()) { - return null; + return defaultValue; } try { - int parsed = Integer.parseInt(value.trim()); - if (parsed > 0) { - return parsed; - } - } catch (NumberFormatException ignored) {} - parent.appendError(compiler, "FloatingImage " + name + " must be a positive integer.", el); - return null; + return Integer.parseInt(value.trim()); + } catch (NumberFormatException ex) { + errorSink.appendError(compiler, "FloatingImage " + name + " must be an integer.", el); + return null; + } } @Nullable - private static Double parseDoubleAttr(PageCompiler compiler, LytFlowParent parent, MdxJsxElementFields el, + private static Double parseDoubleAttr(PageCompiler compiler, LytErrorSink errorSink, MdxJsxElementFields el, String name, double defaultValue) { String value = el.getAttributeString(name, null); if (value == null || value.trim() @@ -407,7 +607,7 @@ private static Double parseDoubleAttr(PageCompiler compiler, LytFlowParent paren try { return Double.parseDouble(value.trim()); } catch (NumberFormatException ex) { - parent.appendError(compiler, "FloatingImage " + name + " must be a number.", el); + errorSink.appendError(compiler, "FloatingImage " + name + " must be a number.", el); return null; } } diff --git a/src/main/java/com/hfstudio/guidenh/guide/compiler/tags/HeadingCompiler.java b/src/main/java/com/hfstudio/guidenh/guide/compiler/tags/HeadingCompiler.java index b01fa887..08c9f007 100644 --- a/src/main/java/com/hfstudio/guidenh/guide/compiler/tags/HeadingCompiler.java +++ b/src/main/java/com/hfstudio/guidenh/guide/compiler/tags/HeadingCompiler.java @@ -5,6 +5,7 @@ import com.hfstudio.guidenh.guide.compiler.PageCompiler; import com.hfstudio.guidenh.guide.document.block.LytBlockContainer; import com.hfstudio.guidenh.guide.document.block.LytHeading; +import com.hfstudio.guidenh.guide.document.block.LytNode; import com.hfstudio.guidenh.libs.mdast.mdx.model.MdxJsxElementFields; public class HeadingCompiler extends BlockTagCompiler { @@ -23,6 +24,21 @@ protected void compile(PageCompiler compiler, LytBlockContainer parent, MdxJsxEl heading.setDepth(Math.max(1, Math.min(depth, 6))); compiler.compileFlowContext(el.children(), heading); parent.append(heading); + // Adjacent-heading margin collapse: taffy adds margins without CSS + // collapsing, so two consecutive headings would sum the first's bottom + // margin and the second's top margin into an oversized gap (H3 7 + H4 12 + // ≈ 19px hole). When the sibling directly before this heading is also a + // heading, zero its bottom margin so the pair keeps only this heading's + // top margin (see LytHeading#collapseBottomForAdjacent). Heading→body + // spacing is untouched. + LytNode holder = heading.getParent(); + if (holder != null) { + var siblings = holder.getChildren(); + int size = siblings.size(); + if (size >= 2 && siblings.get(size - 2) instanceof LytHeading previous) { + previous.collapseBottomForAdjacent(); + } + } } private static int parseIntSafe(String s, int fallback) { diff --git a/src/main/java/com/hfstudio/guidenh/guide/compiler/tags/ImageCompiler.java b/src/main/java/com/hfstudio/guidenh/guide/compiler/tags/ImageCompiler.java index 6e47a2ed..c6d69b85 100644 --- a/src/main/java/com/hfstudio/guidenh/guide/compiler/tags/ImageCompiler.java +++ b/src/main/java/com/hfstudio/guidenh/guide/compiler/tags/ImageCompiler.java @@ -5,14 +5,21 @@ import com.hfstudio.guidenh.guide.compiler.IdUtils; import com.hfstudio.guidenh.guide.compiler.PageCompiler; +import com.hfstudio.guidenh.guide.compiler.TagCompiler; +import com.hfstudio.guidenh.guide.document.block.ContentAlign; +import com.hfstudio.guidenh.guide.document.block.LytAlignedBlock; +import com.hfstudio.guidenh.guide.document.block.LytBlock; +import com.hfstudio.guidenh.guide.document.block.LytBlockContainer; import com.hfstudio.guidenh.guide.document.block.LytImageBlock; import com.hfstudio.guidenh.guide.document.block.LytParagraph; import com.hfstudio.guidenh.guide.document.flow.LytFlowInlineBlock; import com.hfstudio.guidenh.guide.document.flow.LytFlowParent; import com.hfstudio.guidenh.guide.scene.support.GuideDebugLog; import com.hfstudio.guidenh.libs.mdast.mdx.model.MdxJsxElementFields; +import com.hfstudio.guidenh.libs.mdast.mdx.model.MdxJsxFlowElement; +import com.hfstudio.guidenh.libs.mdast.mdx.model.MdxJsxTextElement; -public class ImageCompiler extends FlowTagCompiler { +public class ImageCompiler implements TagCompiler { @Override public Set getTagNames() { @@ -20,7 +27,31 @@ public Set getTagNames() { } @Override - protected void compile(PageCompiler compiler, LytFlowParent parent, MdxJsxElementFields el) { + public void compileFlowContext(PageCompiler compiler, LytFlowParent parent, MdxJsxTextElement el) { + LytImageBlock block = buildBlock(compiler, el); + + var inlineBlock = new LytFlowInlineBlock(); + inlineBlock.setBlock(block); + parent.append(inlineBlock); + } + + @Override + public void compileBlockContext(PageCompiler compiler, LytBlockContainer parent, MdxJsxFlowElement el) { + LytImageBlock block = buildBlock(compiler, el); + + String alignAttr = el.getAttributeString("align", null); + LytBlock result = block; + if (alignAttr != null) { + ContentAlign align = ContentAlign.fromString(alignAttr); + if (align != ContentAlign.LEFT) { + result = new LytAlignedBlock(result, align); + } + } + + parent.append(PageCompiler.wrapFloatAwareIfNeeded(result)); + } + + private static LytImageBlock buildBlock(PageCompiler compiler, MdxJsxElementFields el) { LytImageBlock block = new LytImageBlock(); block.setStyleClass("Img"); @@ -40,11 +71,13 @@ protected void compile(PageCompiler compiler, LytFlowParent parent, MdxJsxElemen if (!alt.isEmpty()) block.setAlt(alt); if (!title.isEmpty()) block.setTitle(title); + String alignAttr = el.getAttributeString("align", null); + if (alignAttr != null) { + block.setAlign(alignAttr); + } + block.setStyle(LytParagraph.PLACEHOLDER_STYLE); block.appendText("[Image]"); - - var inlineBlock = new LytFlowInlineBlock(); - inlineBlock.setBlock(block); - parent.append(inlineBlock); + return block; } } diff --git a/src/main/java/com/hfstudio/guidenh/guide/compiler/tags/ItemGridCompiler.java b/src/main/java/com/hfstudio/guidenh/guide/compiler/tags/ItemGridCompiler.java index 70321019..34381a60 100644 --- a/src/main/java/com/hfstudio/guidenh/guide/compiler/tags/ItemGridCompiler.java +++ b/src/main/java/com/hfstudio/guidenh/guide/compiler/tags/ItemGridCompiler.java @@ -5,6 +5,8 @@ import java.util.List; import java.util.Set; +import org.jetbrains.annotations.Nullable; + import com.hfstudio.guidenh.guide.compiler.IndexingContext; import com.hfstudio.guidenh.guide.compiler.IndexingSink; import com.hfstudio.guidenh.guide.compiler.PageCompiler; @@ -21,21 +23,26 @@ public Set getTagNames() { @Override protected void compile(PageCompiler compiler, LytBlockContainer parent, MdxJsxElementFields el) { - List itemIds = new ArrayList<>(); + List entries = new ArrayList<>(); // We expect children to only contain ItemIcon elements for (var childNode : el.children()) { if (childNode instanceof MdxJsxElementFields jsxChild && "ItemIcon".equals(jsxChild.name())) { - var itemId = MdxAttrs.getString(compiler, parent, jsxChild, "id", null); - if (itemId != null) { - itemIds.add(itemId); + // Extract raw attributes (no registry lookups); keep both id and + // ore so ore-dictionary entries can be resolved at runtime. + String itemId = MdxAttrs.getString(compiler, parent, jsxChild, "id", null); + String ore = MdxAttrs.getString(compiler, parent, jsxChild, "ore", null); + if (itemId == null && ore == null) { + parent.appendError(compiler, "Missing id or ore attribute.", jsxChild); + continue; } + entries.add(new ItemGridEntry(itemId != null ? itemId.trim() : null, ore != null ? ore.trim() : null)); continue; } parent.appendError(compiler, "Unsupported child-element in ItemGrid", childNode); } - ItemGridPlaceholder placeholder = new ItemGridPlaceholder(itemIds); + ItemGridPlaceholder placeholder = new ItemGridPlaceholder(entries); parent.append(placeholder); } @@ -44,13 +51,16 @@ public void index(IndexingContext indexer, MdxJsxElementFields el, IndexingSink public static class ItemGridPlaceholder extends LytParagraph { - public final List itemIds; + public final List entries; - public ItemGridPlaceholder(List itemIds) { - this.itemIds = itemIds; + public ItemGridPlaceholder(List entries) { + this.entries = entries; setStyleClass("ItemGrid"); setStyle(LytParagraph.PLACEHOLDER_STYLE); appendText("[ItemGrid]"); } } + + /** A single {@code } child: raw item id and/or ore dictionary name. */ + public record ItemGridEntry(@Nullable String id, @Nullable String ore) {} } diff --git a/src/main/java/com/hfstudio/guidenh/guide/compiler/tags/ItemImageCompiler.java b/src/main/java/com/hfstudio/guidenh/guide/compiler/tags/ItemImageCompiler.java index 89e34fde..9337dace 100644 --- a/src/main/java/com/hfstudio/guidenh/guide/compiler/tags/ItemImageCompiler.java +++ b/src/main/java/com/hfstudio/guidenh/guide/compiler/tags/ItemImageCompiler.java @@ -57,20 +57,20 @@ protected void compile(PageCompiler compiler, LytFlowParent parent, MdxJsxElemen } } - // noTooltip (legacy) and showTooltip — noTooltip=true or showTooltip=false both suppress. + // noTooltip (legacy) and showTooltip - noTooltip=true or showTooltip=false both suppress. Boolean noTooltipAttr = MdxAttrs.getOptionalBoolean(el, "noTooltip"); boolean noTooltip = MdxAttrs.getBoolean(noTooltipAttr, false); Boolean showTooltipAttr = MdxAttrs.getOptionalBoolean(el, "showTooltip"); showTooltip = showTooltipAttr != null ? showTooltipAttr : !noTooltip; - // showIcon — whether to render the icon graphic (default true). + // showIcon - whether to render the icon graphic (default true). showIcon = MdxAttrs.getOptionalBoolean(el, "showIcon"); - // label — "left" or "right" to display the item name next to the icon. + // label - "left" or "right" to display the item name next to the icon. String labelRaw = el.getAttributeString("label", null); labelPosition = resolveLabelPosition(labelRaw); - // format — Markdown-style format pattern for the label text. + // format - Markdown-style format pattern for the label text. String formatRaw = el.getAttributeString("format", null); labelFormat = (formatRaw != null && !formatRaw.isEmpty()) ? formatRaw : null; diff --git a/src/main/java/com/hfstudio/guidenh/guide/compiler/tags/ItemLinkCompiler.java b/src/main/java/com/hfstudio/guidenh/guide/compiler/tags/ItemLinkCompiler.java index 53ef16af..066695b0 100644 --- a/src/main/java/com/hfstudio/guidenh/guide/compiler/tags/ItemLinkCompiler.java +++ b/src/main/java/com/hfstudio/guidenh/guide/compiler/tags/ItemLinkCompiler.java @@ -25,7 +25,7 @@ protected void compile(PageCompiler compiler, LytFlowParent parent, MdxJsxElemen return; } - // showTooltip — default true for ItemLink + // showTooltip - default true for ItemLink Boolean noTooltipAttr = MdxAttrs.getOptionalBoolean(el, "noTooltip"); boolean noTooltip = MdxAttrs.getBoolean(noTooltipAttr, false); Boolean showTooltipAttr = MdxAttrs.getOptionalBoolean(el, "showTooltip"); @@ -34,7 +34,7 @@ protected void compile(PageCompiler compiler, LytFlowParent parent, MdxJsxElemen boolean showText = showTextAttr == null || showTextAttr; float scale = MdxAttrs.getFloat(compiler, parent, el, "scale", 1f); - // showIcon — null/falsy = no icon; "left", "right", or any truthy = icon at that side + // showIcon - null/falsy = no icon; "left", "right", or any truthy = icon at that side String showIconRaw = el.getAttributeString("showIcon", null); String iconPosition = ItemImageCompiler.resolveLabelPosition(showIconRaw); diff --git a/src/main/java/com/hfstudio/guidenh/guide/compiler/tags/LatexTagCompiler.java b/src/main/java/com/hfstudio/guidenh/guide/compiler/tags/LatexTagCompiler.java index b01f7df5..30f25901 100644 --- a/src/main/java/com/hfstudio/guidenh/guide/compiler/tags/LatexTagCompiler.java +++ b/src/main/java/com/hfstudio/guidenh/guide/compiler/tags/LatexTagCompiler.java @@ -28,20 +28,21 @@ *

          * Supported attributes: *

            - *
          • {@code formula} (String, required) — the LaTeX source string
          • - *
          • {@code color} (String, optional, default {@code "#FFFFFF"}) — formula glyph colour as {@code #RRGGBB} + *
          • {@code formula} (String, required) - the LaTeX source string
          • + *
          • {@code color} (String, optional, default {@code "#FFFFFF"}) - formula glyph colour as {@code #RRGGBB} * or {@code #AARRGGBB}
          • - *
          • {@code scale} (float, optional, default {@code 1.0}) — multiplier applied to the automatic display size
          • - *
          • {@code sourceScale} (float, optional, default {@code 100.0}) — jlatexmath internal render quality; - * higher values produce crisper output at the cost of more memory
          • - *
          • {@code showTooltip} (boolean, optional, default {@code false}) — when {@code true}, hovering over + *
          • {@code scale} (float, optional, default {@code 1.0}) - multiplier applied to the automatic display size
          • + *
          • {@code sourceScale} (float, optional) - DEPRECATED: legacy jlatexmath render quality attribute; + * parsed for backward compatibility with old documents but ignored - formulas are typeset directly at + * {@code BASE_FONT_SIZE × scale} (typeset-at-target-size)
          • + *
          • {@code showTooltip} (boolean, optional, default {@code false}) - when {@code true}, hovering over * the formula shows the raw LaTeX source in a tooltip
          • - *
          • {@code tooltip} (String, optional) — plain tooltip text. Child Markdown content takes precedence + *
          • {@code tooltip} (String, optional) - plain tooltip text. Child Markdown content takes precedence * and is compiled as a rich tooltip.
          • - *
          • {@code valign} (String, optional, default {@code "baseline"}) — inline-only; vertical alignment of + *
          • {@code valign} (String, optional, default {@code "baseline"}) - inline-only; vertical alignment of * the formula within the text line: {@code baseline}, {@code top}, {@code center}, or {@code bottom}
          • - *
          • {@code offsetX} (int, optional, default {@code 0}) — horizontal pixel offset applied after alignment
          • - *
          • {@code offsetY} (int, optional, default {@code 0}) — vertical pixel offset applied after alignment
          • + *
          • {@code offsetX} (int, optional, default {@code 0}) - horizontal pixel offset applied after alignment
          • + *
          • {@code offsetY} (int, optional, default {@code 0}) - vertical pixel offset applied after alignment
          • *
          * *

          @@ -84,6 +85,7 @@ private LytLatexBlock buildInlineBlock(PageCompiler compiler, LytFlowParent pare int fillColor = parseColor(compiler, parent, el); float userScale = MdxAttrs.getFloat(compiler, parent, el, "scale", LatexRenderOptions.DEFAULT_USER_SCALE); + // sourceScale is DEPRECATED (parsed for backward compatibility, ignored by rendering). float sourceScale = MdxAttrs .getFloat(compiler, parent, el, "sourceScale", LatexRenderOptions.DEFAULT_SOURCE_SCALE); GuideTooltip tooltip = buildInlineTooltip(compiler, parent, el, formula); @@ -94,6 +96,7 @@ private LytLatexBlock buildInlineBlock(PageCompiler compiler, LytFlowParent pare return new LytLatexBlock( formula, LatexRenderOptions.builder() + .style(org.scilab.forge.jlatexmath.TeXConstants.STYLE_TEXT) .fillColorArgb(fillColor) .sourceScale(sourceScale) .userScale(userScale) @@ -114,6 +117,7 @@ private LytLatexDisplayBlock buildDisplayBlock(PageCompiler compiler, LytBlockCo int fillColor = parseColor(compiler, parent, el); float userScale = MdxAttrs.getFloat(compiler, parent, el, "scale", LatexRenderOptions.DEFAULT_USER_SCALE); + // sourceScale is DEPRECATED (parsed for backward compatibility, ignored by rendering). float sourceScale = MdxAttrs .getFloat(compiler, parent, el, "sourceScale", LatexRenderOptions.DEFAULT_SOURCE_SCALE); GuideTooltip tooltip = buildBlockTooltip(compiler, parent, el, formula); diff --git a/src/main/java/com/hfstudio/guidenh/guide/compiler/tags/ListItemCompiler.java b/src/main/java/com/hfstudio/guidenh/guide/compiler/tags/ListItemCompiler.java index 5ae9582c..21a1b2ea 100644 --- a/src/main/java/com/hfstudio/guidenh/guide/compiler/tags/ListItemCompiler.java +++ b/src/main/java/com/hfstudio/guidenh/guide/compiler/tags/ListItemCompiler.java @@ -1,6 +1,7 @@ package com.hfstudio.guidenh.guide.compiler.tags; import java.util.Collections; +import java.util.List; import java.util.Set; import com.hfstudio.guidenh.guide.compiler.PageCompiler; @@ -10,6 +11,7 @@ import com.hfstudio.guidenh.guide.document.block.LytTaskListItem; import com.hfstudio.guidenh.guide.internal.markdown.MarkdownListSemantics; import com.hfstudio.guidenh.libs.mdast.mdx.model.MdxJsxElementFields; +import com.hfstudio.guidenh.libs.mdast.mdx.model.MdxJsxFlowElement; public class ListItemCompiler extends BlockTagCompiler { @@ -26,13 +28,32 @@ protected void compile(PageCompiler compiler, LytBlockContainer parent, MdxJsxEl if (taskMarker != null) { LytTaskListItem taskItem = new LytTaskListItem(); taskItem.setChecked(taskMarker.checked()); - taskMarker.textNode() - .setValue(taskMarker.remainingText()); + + // Strip the task-marker prefix from text nodes TEMPORARILY so the + // compiled output omits "[x] "/"[ ] ". + // Save original values and restore after compileBlockContext so the + // AST remains immutable for re-compilation (CompileWorker may have + // pre-compiled the same ParsedGuidePage on the guidenh-compile thread, + // then RenderPageService compiles it again - mutation would lose the + // marker on the second pass). + MdxJsxFlowElement p = MarkdownListSemantics.findFirstP(el.children()); + List savedPrefixTexts = p != null + ? MarkdownListSemantics.stripPrefixInPlace(p, taskMarker.prefixLen()) + : List.of(); + listItem = taskItem; + try { + compiler.compileBlockContext(el.children(), listItem); + } finally { + // Restore original text values so AST is reusable + if (p != null) { + MarkdownListSemantics.restoreTextValues(p, savedPrefixTexts); + } + } } else { listItem = new LytListItem(); + compiler.compileBlockContext(el.children(), listItem); } - compiler.compileBlockContext(el.children(), listItem); // Normalize first child margins var children = listItem.getChildren(); diff --git a/src/main/java/com/hfstudio/guidenh/guide/compiler/tags/MermaidCompiler.java b/src/main/java/com/hfstudio/guidenh/guide/compiler/tags/MermaidCompiler.java index 4895f256..c93bd677 100644 --- a/src/main/java/com/hfstudio/guidenh/guide/compiler/tags/MermaidCompiler.java +++ b/src/main/java/com/hfstudio/guidenh/guide/compiler/tags/MermaidCompiler.java @@ -89,9 +89,9 @@ protected void compile(PageCompiler compiler, LytBlockContainer parent, MdxJsxEl @Override public void index(IndexingContext indexer, MdxJsxElementFields el, IndexingSink sink) { - // NB: Phase 2 loaded src-based Mermaid content and indexed the actual diagram source. - // Phase 3 src-based content is resolved at MOUNT time by MermaidScript, so index() only - // indexes the src path string. Inline content (no src attribute) is still indexed here. + // NB: src-based Mermaid content is resolved at MOUNT time by MermaidScript, so + // index() cannot index the diagram source; it records only the src path string. + // Inline content (no src attribute) is still indexed here. // Full indexing for src-based mermaid requires a post-mount indexing pass (TBD). String src; try { @@ -116,10 +116,10 @@ public void index(IndexingContext indexer, MdxJsxElementFields el, IndexingSink private Map compileNodeContentBlocks(PageCompiler compiler, LytBlockContainer parent, MdxJsxElementFields mermaidElement) { - // NB: Phase 2 cross-referenced NodeContent IDs against the parsed MermaidMindmapNode tree - // (via indexNodesById), validated unknown IDs, and provided inline-markdown fallback for - // nodes without explicit NodeContent. Phase 3 defers tree construction to MermaidScript - // (MOUNT time), so cross-validation must happen at runtime. See MermaidScript for the + // NB: tree construction is deferred to MermaidScript (MOUNT time), so NodeContent IDs + // are cross-referenced against the parsed MermaidMindmapNode tree (via indexNodesById) + // at runtime: unknown IDs are validated and nodes without explicit NodeContent get the + // inline-markdown fallback. MermaidScript holds the // runtime counterpart. Map result = new LinkedHashMap<>(); for (MdxJsxFlowElement child : MermaidSourceExtractor.collectNodeContentElements(mermaidElement.children())) { diff --git a/src/main/java/com/hfstudio/guidenh/guide/compiler/tags/NodeContentTagCompiler.java b/src/main/java/com/hfstudio/guidenh/guide/compiler/tags/NodeContentTagCompiler.java new file mode 100644 index 00000000..84c1ea43 --- /dev/null +++ b/src/main/java/com/hfstudio/guidenh/guide/compiler/tags/NodeContentTagCompiler.java @@ -0,0 +1,47 @@ +package com.hfstudio.guidenh.guide.compiler.tags; + +import java.util.Collections; +import java.util.Set; + +import com.hfstudio.guidenh.guide.compiler.PageCompiler; +import com.hfstudio.guidenh.guide.compiler.TagCompiler; +import com.hfstudio.guidenh.guide.document.block.LytBlockContainer; +import com.hfstudio.guidenh.guide.document.flow.LytFlowParent; +import com.hfstudio.guidenh.guide.scene.support.GuideDebugLog; +import com.hfstudio.guidenh.libs.mdast.mdx.model.MdxJsxFlowElement; +import com.hfstudio.guidenh.libs.mdast.mdx.model.MdxJsxTextElement; + +/** + * Tag compiler for {@code } - rich content blocks inside + * {@code } diagrams. + * + *

          + * NodeContent is only meaningful as a child of {@code }, where + * the {@link MermaidCompiler} extracts and compiles it directly from the + * element tree (see {@code compileNodeContentBlocks}). At the page level + * this compiler acts as a no-op (with a debug log) to prevent the + * "Unhandled MDX: NodeContent" error. + */ +public class NodeContentTagCompiler implements TagCompiler { + + @Override + public Set getTagNames() { + return Collections.singleton("NodeContent"); + } + + @Override + public void compileBlockContext(PageCompiler compiler, LytBlockContainer parent, MdxJsxFlowElement el) { + // NodeContent is only valid as a child of . The MermaidCompiler + // handles extraction and compilation directly from el.children(). + // At page level: no-op to suppress "Unhandled MDX" error. + String id = el.getAttributeString("id", null); + GuideDebugLog + .debug("[GuideNH] [NodeContentTagCompiler] Ignored at page level (id={})", id != null ? id : ""); + } + + @Override + public void compileFlowContext(PageCompiler compiler, LytFlowParent parent, MdxJsxTextElement el) { + // Inline NodeContent is not valid; silently ignore. + GuideDebugLog.debug("[GuideNH] [NodeContentTagCompiler] Ignored in flow context"); + } +} diff --git a/src/main/java/com/hfstudio/guidenh/guide/compiler/tags/ParagraphCompiler.java b/src/main/java/com/hfstudio/guidenh/guide/compiler/tags/ParagraphCompiler.java index db5078dd..6bc29688 100644 --- a/src/main/java/com/hfstudio/guidenh/guide/compiler/tags/ParagraphCompiler.java +++ b/src/main/java/com/hfstudio/guidenh/guide/compiler/tags/ParagraphCompiler.java @@ -4,9 +4,13 @@ import java.util.Set; import com.hfstudio.guidenh.guide.compiler.PageCompiler; +import com.hfstudio.guidenh.guide.document.block.LatexRenderOptions; import com.hfstudio.guidenh.guide.document.block.LytBlockContainer; +import com.hfstudio.guidenh.guide.document.block.LytLatexDisplayBlock; import com.hfstudio.guidenh.guide.document.block.LytParagraph; +import com.hfstudio.guidenh.guide.internal.markdown.MarkdownLatexShorthand; import com.hfstudio.guidenh.libs.mdast.mdx.model.MdxJsxElementFields; +import com.hfstudio.guidenh.libs.mdast.model.MdAstText; public class ParagraphCompiler extends BlockTagCompiler { @@ -17,6 +21,29 @@ public Set getTagNames() { @Override protected void compile(PageCompiler compiler, LytBlockContainer parent, MdxJsxElementFields el) { + // Sole $$...$$ block-level display formula detection. + // A paragraph whose single text child is exactly $$formula$$ produces a + // centered LytLatexDisplayBlock instead of an inline LytLatexBlock. + if (el.children() + .size() == 1) { + Object sole = el.children() + .getFirst(); + if (sole instanceof MdAstText soleText) { + String formula = MarkdownLatexShorthand.extractSoleDisplayFormula(soleText.value); + if (formula != null) { + var displayBlock = new LytLatexDisplayBlock( + formula, + LatexRenderOptions.builder() + .build()); + displayBlock.setMarginTop(PageCompiler.DEFAULT_ELEMENT_SPACING); + displayBlock.setMarginBottom(PageCompiler.DEFAULT_ELEMENT_SPACING); + parent.append(PageCompiler.wrapFloatAwareIfNeeded(displayBlock)); + return; + } + } + } + + // Default paragraph compilation (inline flow content). LytParagraph paragraph = new LytParagraph(); compiler.compileFlowContext(el.children(), paragraph); paragraph.setMarginTop(PageCompiler.DEFAULT_ELEMENT_SPACING); diff --git a/src/main/java/com/hfstudio/guidenh/guide/compiler/tags/PreCompiler.java b/src/main/java/com/hfstudio/guidenh/guide/compiler/tags/PreCompiler.java index d1165400..8487252a 100644 --- a/src/main/java/com/hfstudio/guidenh/guide/compiler/tags/PreCompiler.java +++ b/src/main/java/com/hfstudio/guidenh/guide/compiler/tags/PreCompiler.java @@ -13,9 +13,13 @@ import com.hfstudio.guidenh.guide.Guide; import com.hfstudio.guidenh.guide.compiler.PageCompiler; import com.hfstudio.guidenh.guide.compiler.tags.functiongraph.FunctionGraphFenceParser; +import com.hfstudio.guidenh.guide.document.block.ContentAlign; +import com.hfstudio.guidenh.guide.document.block.ContentWrapMode; +import com.hfstudio.guidenh.guide.document.block.LytAlignedBlock; import com.hfstudio.guidenh.guide.document.block.LytBlock; import com.hfstudio.guidenh.guide.document.block.LytBlockContainer; import com.hfstudio.guidenh.guide.document.block.LytCodeBlock; +import com.hfstudio.guidenh.guide.document.block.LytDocumentFloat; import com.hfstudio.guidenh.guide.document.block.LytMermaidFlowchart; import com.hfstudio.guidenh.guide.document.block.LytMermaidMindmap; import com.hfstudio.guidenh.guide.internal.csv.CsvTableParser; @@ -23,6 +27,7 @@ import com.hfstudio.guidenh.guide.internal.markdown.CodeBlockLanguageDetector; import com.hfstudio.guidenh.guide.internal.markdown.FileTreeCompiler; import com.hfstudio.guidenh.guide.internal.mermaid.MermaidDiagramType; +import com.hfstudio.guidenh.guide.internal.mermaid.MermaidLayoutPrecomputer; import com.hfstudio.guidenh.guide.internal.mermaid.MermaidSourceExtractor; import com.hfstudio.guidenh.guide.internal.mermaid.flowchart.FlowchartParser; import com.hfstudio.guidenh.guide.internal.mermaid.mindmap.MindmapParser; @@ -38,6 +43,8 @@ public class PreCompiler extends BlockTagCompiler { private static final Pattern CODEBLOCK_META_WIDTH = Pattern.compile("(^|\\s)width=(\"([^\"]+)\"|'([^']+)'|(\\S+))"); private static final Pattern CODEBLOCK_META_HEIGHT = Pattern .compile("(^|\\s)height=(\"([^\"]+)\"|'([^']+)'|(\\S+))"); + private static final Pattern CODEBLOCK_META_WRAP = Pattern.compile("(^|\\s)wrap=(\"([^\"]+)\"|'([^']+)'|(\\S+))"); + private static final Pattern CODEBLOCK_META_ALIGN = Pattern.compile("(^|\\s)align=(\"([^\"]+)\"|'([^']+)'|(\\S+))"); @Override public Set getTagNames() { @@ -46,7 +53,7 @@ public Set getTagNames() { @Override protected void compile(PageCompiler compiler, LytBlockContainer parent, MdxJsxElementFields el) { - // Extract code text from children — should be a single MdAstText child + // Extract code text from children - should be a single MdAstText child String codeText = ""; var children = el.children(); if (!children.isEmpty() && children.getFirst() instanceof MdAstText text) { @@ -56,6 +63,16 @@ protected void compile(PageCompiler compiler, LytBlockContainer parent, MdxJsxEl String lang = el.getAttributeString("lang", null); String meta = el.getAttributeString("meta", null); + // Indented code block (no lang attribute) → plain text, no toolbar, no language detection + if (lang == null) { + LytCodeBlock codeBlock = new LytCodeBlock(); + codeBlock.setCodeContent("text", codeText); + codeBlock.setToolbarVisible(false); + codeBlock.applyLanguage(new CodeBlockLanguage("text", "Text")); + parent.append(codeBlock); + return; + } + CodeBlockLanguage language = CodeBlockLanguageDetector.detect(lang, codeText); LytBlock contributed = CodeFenceRenderers.render( @@ -109,7 +126,10 @@ protected void compile(PageCompiler compiler, LytBlockContainer parent, MdxJsxEl if (forcedHeight != null) { codeBlock.setForcedBodyHeight(forcedHeight); } - parent.append(codeBlock); + // Parse wrap/align from fence meta for float embedding + ContentWrapMode wrapMode = ContentWrapMode.fromString(parseCodeBlockWrapMeta(meta)); + ContentAlign align = ContentAlign.fromString(parseCodeBlockAlignMeta(meta)); + parent.append(applyBlockEmbed(codeBlock, wrapMode, align)); } private LytBlock compileCsvCodeBlock(PageCompiler compiler, String source, @Nullable String meta) { @@ -215,6 +235,17 @@ private record CsvFenceMeta(boolean header, List widthHints) {} private @Nullable LytMermaidMindmap compileMermaidMindmap(String normalized) { try { LytMermaidMindmap block = new LytMermaidMindmap(MindmapParser.parse(normalized), normalized); + // Pre-compute diagram layout before first Rust layout so the canvas + // gets a correct preferredHeight and the VBox receives its real height + // in the initial layout pass (no second pass needed). + int pageWidth = 480; // no page-width info available at compile time + GuideDebugLog + .debugAlways("[GuideNH-Mermaid] [PreCompiler] precomputeMindmapLayout entered pageWidth={}", pageWidth); + MermaidLayoutPrecomputer.precomputeMindmapLayout(block, pageWidth); + GuideDebugLog.debugAlways( + "[GuideNH-Mermaid] [PreCompiler] precomputeMindmapLayout exit explicitHeight={}", + block.getCanvas() + .getExplicitHeight()); GuideDebugLog .debug("[GuideNH] [PreCompiler] Compiled fenced Mermaid mindmap block ({} chars)", normalized.length()); return block; @@ -228,6 +259,17 @@ private record CsvFenceMeta(boolean header, List widthHints) {} private LytMermaidFlowchart compileMermaidFlowchart(String normalized) { var document = FlowchartParser.parse(normalized); LytMermaidFlowchart block = new LytMermaidFlowchart(document, normalized); + // Pre-compute diagram layout before first Rust layout so the canvas + // gets a correct preferredHeight and the VBox receives its real height + // in the initial layout pass (no second pass needed). + int pageWidth = 480; // no page-width info available at compile time + GuideDebugLog + .debugAlways("[GuideNH-Mermaid] [PreCompiler] precomputeFlowchartLayout entered pageWidth={}", pageWidth); + MermaidLayoutPrecomputer.precomputeFlowchartLayout(block, pageWidth); + GuideDebugLog.debugAlways( + "[GuideNH-Mermaid] [PreCompiler] precomputeFlowchartLayout exit explicitHeight={}", + block.getCanvas() + .getExplicitHeight()); GuideDebugLog .debug("[GuideNH] [PreCompiler] Compiled fenced Mermaid flowchart stub ({} chars)", normalized.length()); return block; @@ -303,4 +345,49 @@ private static boolean isFunctionGraphFence(@Nullable String fenceLanguage) { return null; } } + + private static @Nullable String parseCodeBlockWrapMeta(@Nullable String meta) { + if (meta == null || meta.trim() + .isEmpty()) { + return null; + } + Matcher matcher = CODEBLOCK_META_WRAP.matcher(meta); + if (!matcher.find()) { + return null; + } + String value = matcher.group(3) != null ? matcher.group(3) + : matcher.group(4) != null ? matcher.group(4) : matcher.group(5); + return (value == null || value.trim() + .isEmpty()) ? null : value.trim(); + } + + private static @Nullable String parseCodeBlockAlignMeta(@Nullable String meta) { + if (meta == null || meta.trim() + .isEmpty()) { + return null; + } + Matcher matcher = CODEBLOCK_META_ALIGN.matcher(meta); + if (!matcher.find()) { + return null; + } + String value = matcher.group(3) != null ? matcher.group(3) + : matcher.group(4) != null ? matcher.group(4) : matcher.group(5); + return (value == null || value.trim() + .isEmpty()) ? null : value.trim(); + } + + /** + * Applies floating/alignment embed to a block, consistent with + * {@link BlockTagCompiler#applyBlockEmbed} semantics for JSX wrap/align. + * Duplicated here because {@code applyBlockEmbed} is private in the parent. + */ + private static LytBlock applyBlockEmbed(LytBlock node, ContentWrapMode wrapMode, ContentAlign align) { + if (wrapMode.isDocumentFloat()) { + return new LytDocumentFloat(node, align == ContentAlign.RIGHT); + } + if (align != ContentAlign.LEFT) { + node = new LytAlignedBlock(node, align); + } + return PageCompiler.wrapFloatAwareIfNeeded(node); + } } diff --git a/src/main/java/com/hfstudio/guidenh/guide/compiler/tags/RecipeCompiler.java b/src/main/java/com/hfstudio/guidenh/guide/compiler/tags/RecipeCompiler.java index 7dce43ae..35eace12 100644 --- a/src/main/java/com/hfstudio/guidenh/guide/compiler/tags/RecipeCompiler.java +++ b/src/main/java/com/hfstudio/guidenh/guide/compiler/tags/RecipeCompiler.java @@ -18,9 +18,9 @@ import com.hfstudio.guidenh.config.ModConfig; import com.hfstudio.guidenh.guide.compiler.IdUtils; import com.hfstudio.guidenh.guide.compiler.PageCompiler; -import com.hfstudio.guidenh.guide.document.block.LytBalancedColumns; import com.hfstudio.guidenh.guide.document.block.LytBlock; import com.hfstudio.guidenh.guide.document.block.LytBlockContainer; +import com.hfstudio.guidenh.guide.document.block.LytHBox; import com.hfstudio.guidenh.guide.document.block.LytParagraph; import com.hfstudio.guidenh.guide.internal.recipe.RecipeLookup; import com.hfstudio.guidenh.integration.api.RecipeEntry; @@ -136,8 +136,9 @@ protected void compile(PageCompiler compiler, LytBlockContainer parent, MdxJsxEl } /** - * Wraps multiple recipe boxes in columns that fill the space beside a short recipe. Single recipes are - * appended directly so they keep their original block flow (no extra wrapper overhead). + * Wraps multiple recipe boxes in a horizontal flex row that wraps onto additional lines when + * the available width runs out. Single recipes are appended directly so they keep their + * original block flow (no extra wrapper overhead). */ public static void appendRecipes(LytBlockContainer parent, List boxes, boolean multi) { if (boxes.isEmpty()) return; @@ -145,10 +146,12 @@ public static void appendRecipes(LytBlockContainer parent, List= 0 ? soundPath.substring(lastDot + 1) : soundPath; + link.appendText(shortName); + } + } else { + compiler.compileInlineFragment(children, link); + } parent.append(link); } diff --git a/src/main/java/com/hfstudio/guidenh/guide/compiler/tags/SubscriptTagCompiler.java b/src/main/java/com/hfstudio/guidenh/guide/compiler/tags/SubscriptTagCompiler.java index 976b26d7..34606afd 100644 --- a/src/main/java/com/hfstudio/guidenh/guide/compiler/tags/SubscriptTagCompiler.java +++ b/src/main/java/com/hfstudio/guidenh/guide/compiler/tags/SubscriptTagCompiler.java @@ -18,7 +18,9 @@ public Set getTagNames() { @Override protected void compile(PageCompiler compiler, LytFlowParent parent, MdxJsxElementFields el) { LytFlowSpan span = new LytFlowSpan(); - span.modifyStyle(style -> style.fontScale(0.85f)); + span.modifyStyle( + style -> style.fontScale(0.85f) + .baselineShift(0.3f)); compiler.compileFlowContext(el.children(), span); parent.append(span); } diff --git a/src/main/java/com/hfstudio/guidenh/guide/compiler/tags/SuperscriptTagCompiler.java b/src/main/java/com/hfstudio/guidenh/guide/compiler/tags/SuperscriptTagCompiler.java index 272cd5a3..4c8fd7b0 100644 --- a/src/main/java/com/hfstudio/guidenh/guide/compiler/tags/SuperscriptTagCompiler.java +++ b/src/main/java/com/hfstudio/guidenh/guide/compiler/tags/SuperscriptTagCompiler.java @@ -18,7 +18,9 @@ public Set getTagNames() { @Override protected void compile(PageCompiler compiler, LytFlowParent parent, MdxJsxElementFields el) { LytFlowSpan span = new LytFlowSpan(); - span.modifyStyle(style -> style.fontScale(0.85f)); + span.modifyStyle( + style -> style.fontScale(0.85f) + .baselineShift(-0.3f)); compiler.compileFlowContext(el.children(), span); parent.append(span); } diff --git a/src/main/java/com/hfstudio/guidenh/guide/compiler/tags/TableCompiler.java b/src/main/java/com/hfstudio/guidenh/guide/compiler/tags/TableCompiler.java index 2b47437a..a2fb1396 100644 --- a/src/main/java/com/hfstudio/guidenh/guide/compiler/tags/TableCompiler.java +++ b/src/main/java/com/hfstudio/guidenh/guide/compiler/tags/TableCompiler.java @@ -23,10 +23,12 @@ public Set getTagNames() { @Override protected void compile(PageCompiler compiler, LytBlockContainer parent, MdxJsxElementFields el) { LytTable table = new LytTable(); + table.setMarginTop(PageCompiler.DEFAULT_ELEMENT_SPACING); table.setMarginBottom(PageCompiler.DEFAULT_ELEMENT_SPACING); // Parse align attribute back to list String alignStr = el.getAttributeString("align", ""); + el.removeAttribute("align"); boolean firstRow = true; int rowIndex = 0; @@ -48,6 +50,7 @@ protected void compile(PageCompiler compiler, LytBlockContainer parent, MdxJsxEl LytTableRow row = table.appendRow(); if (firstRow) { row.modifyStyle(style -> style.bold(true)); + row.setHeader(true); firstRow = false; } @@ -74,16 +77,36 @@ protected void compile(PageCompiler compiler, LytBlockContainer parent, MdxJsxEl rowIndex++; } } + if (table.getChildren() + .isEmpty()) { + parent.appendError(compiler, "Empty table: no rows found", el); + return; + } parent.append(table); } private static String extractKramdownExpression(String content) { int start = content.indexOf('{'); int end = content.lastIndexOf('}'); + String stripped; if (start >= 0 && end > start) { - return content.substring(start + 1, end) + stripped = content.substring(start + 1, end) .trim(); + } else { + stripped = content.trim(); + } + // Try double quotes first + int firstQuote = stripped.indexOf('"'); + int lastQuote = stripped.lastIndexOf('"'); + if (firstQuote >= 0 && lastQuote > firstQuote) { + return stripped.substring(firstQuote + 1, lastQuote); + } + // Try single quotes + firstQuote = stripped.indexOf('\''); + lastQuote = stripped.lastIndexOf('\''); + if (firstQuote >= 0 && lastQuote > firstQuote) { + return stripped.substring(firstQuote + 1, lastQuote); } - return ""; + return stripped; } } diff --git a/src/main/java/com/hfstudio/guidenh/guide/compiler/tags/mediawiki/CategoryCompiler.java b/src/main/java/com/hfstudio/guidenh/guide/compiler/tags/mediawiki/CategoryCompiler.java index 87464f73..36131ad4 100644 --- a/src/main/java/com/hfstudio/guidenh/guide/compiler/tags/mediawiki/CategoryCompiler.java +++ b/src/main/java/com/hfstudio/guidenh/guide/compiler/tags/mediawiki/CategoryCompiler.java @@ -50,7 +50,7 @@ public void index(IndexingContext indexer, MdxJsxElementFields el, IndexingSink if (categoryName == null || categoryName.trim() .isEmpty()) return; - // Restore Phase 2: index resolved category member titles for full-text search + // Index resolved category member titles for full-text search var guide = MediaWikiTagCompilerSupport.resolveGuide(indexer); if (guide != null) { CategoryIndex catIndex = indexer.getIndex(CategoryIndex.class); diff --git a/src/main/java/com/hfstudio/guidenh/guide/compiler/tags/mediawiki/SpecialCompiler.java b/src/main/java/com/hfstudio/guidenh/guide/compiler/tags/mediawiki/SpecialCompiler.java index 89bf4f24..efb09b2f 100644 --- a/src/main/java/com/hfstudio/guidenh/guide/compiler/tags/mediawiki/SpecialCompiler.java +++ b/src/main/java/com/hfstudio/guidenh/guide/compiler/tags/mediawiki/SpecialCompiler.java @@ -54,7 +54,7 @@ public void index(IndexingContext indexer, MdxJsxElementFields el, IndexingSink if (specialName == null || specialName.trim() .isEmpty()) return; - // Restore Phase 2: index resolved special page result entries for full-text search + // Index resolved special page result entries for full-text search var guide = MediaWikiTagCompilerSupport.resolveGuide(indexer); if (guide != null) { CategoryIndex catIndex = indexer.getIndex(CategoryIndex.class); diff --git a/src/main/java/com/hfstudio/guidenh/guide/document/DefaultStyles.java b/src/main/java/com/hfstudio/guidenh/guide/document/DefaultStyles.java index 4b9288dc..8d6e1237 100644 --- a/src/main/java/com/hfstudio/guidenh/guide/document/DefaultStyles.java +++ b/src/main/java/com/hfstudio/guidenh/guide/document/DefaultStyles.java @@ -31,7 +31,8 @@ private DefaultStyles() {} TextAlignment.LEFT, false, null, - false); + false, + 0.0f); public static final TextStyle BODY_TEXT = TextStyle.builder() .font(UNIFORM_FONT) @@ -47,33 +48,48 @@ private DefaultStyles() {} .color(ColorUtils.CRAFTING_RECIPE_TYPE) .build(); + /** + * Heading size ladder: strictly monotonic decreasing (H1 > H2 > ... > H6) + * so section depth reads from glyph size alone. All headings are bold and + * white to stay clearly distinct from the regular gray body text + * ({@link ColorUtils#BODY_TEXT} #d2d2d2), compensating for the low + * contrast between title white and body gray. + */ public static final TextStyle HEADING1 = TextStyle.builder() - .fontScale(1.3f) + .fontScale(1.5f) .bold(true) .font(null) .color(ConstantColor.WHITE) .build(); public static final TextStyle HEADING2 = TextStyle.builder() - .fontScale(1.1f) + .fontScale(1.4f) + .bold(true) .font(null) + .color(ConstantColor.WHITE) .build(); public static final TextStyle HEADING3 = TextStyle.builder() - .fontScale(1f) + .fontScale(1.15f) + .bold(true) .font(null) + .color(ConstantColor.WHITE) .build(); public static final TextStyle HEADING4 = TextStyle.builder() - .fontScale(1.1f) + .fontScale(1.08f) .bold(true) .font(UNIFORM_FONT) + .color(ConstantColor.WHITE) .build(); public static final TextStyle HEADING5 = TextStyle.builder() .fontScale(1f) .bold(true) .font(UNIFORM_FONT) + .color(ConstantColor.WHITE) .build(); public static final TextStyle HEADING6 = TextStyle.builder() - .fontScale(1f) + .fontScale(0.95f) + .bold(true) .font(UNIFORM_FONT) + .color(ConstantColor.WHITE) .build(); public static final TextStyle SEARCH_RESULT_HIGHLIGHT = TextStyle.builder() diff --git a/src/main/java/com/hfstudio/guidenh/guide/document/block/AlignItems.java b/src/main/java/com/hfstudio/guidenh/guide/document/block/AlignItems.java index 1f6bfa27..8de0075c 100644 --- a/src/main/java/com/hfstudio/guidenh/guide/document/block/AlignItems.java +++ b/src/main/java/com/hfstudio/guidenh/guide/document/block/AlignItems.java @@ -11,7 +11,8 @@ public enum AlignItems implements SerializedEnum { CENTER, START, - END; + END, + BASELINE; private final String serializedName; diff --git a/src/main/java/com/hfstudio/guidenh/guide/document/block/IconMetrics.java b/src/main/java/com/hfstudio/guidenh/guide/document/block/IconMetrics.java new file mode 100644 index 00000000..35cd8584 --- /dev/null +++ b/src/main/java/com/hfstudio/guidenh/guide/document/block/IconMetrics.java @@ -0,0 +1,335 @@ +package com.hfstudio.guidenh.guide.document.block; + +import java.awt.image.BufferedImage; +import java.io.InputStream; +import java.util.Collections; +import java.util.HashSet; +import java.util.Map; +import java.util.Set; +import java.util.concurrent.ConcurrentHashMap; + +import javax.imageio.ImageIO; + +import net.minecraft.client.Minecraft; +import net.minecraft.client.renderer.texture.TextureAtlasSprite; +import net.minecraft.client.resources.IResource; +import net.minecraft.client.resources.IResourceManager; +import net.minecraft.item.Item; +import net.minecraft.item.ItemStack; +import net.minecraft.util.IIcon; +import net.minecraft.util.ResourceLocation; + +import org.jetbrains.annotations.Nullable; + +import com.hfstudio.guidenh.guide.scene.support.GuideDebugLog; + +/** + * Computes the "ink" bounding box of an item icon texture: the smallest source + * rectangle covering every pixel whose alpha exceeds {@link #INK_ALPHA_THRESHOLD}, + * expressed in 16-unit icon space. This is the optical basis for the tight + * advance of inline item icons: the layout cell shrinks to {@code inkWidth * + * scale + 2 * PAD} and the icon is drawn at {@code -inkLeft * scale + PAD} so + * the ink left edge sits exactly {@code PAD} px from the cell's left edge + * (fixes "ItemLink icon sits too close to the item name / inconsistent gap"). + * + *

          + * Pixel reads are inherently best-effort. The primary source is the atlas + * sprite's CPU frame data ({@link TextureAtlasSprite#getFrameTextureData(int)}), + * which is present for animated sprites (compass/clock) but cleared for static + * sprites after the atlas upload, in which case the source PNG is loaded from + * the resource manager (the same image the atlas stitched, always available on + * disk). Custom icons, the missing-texture sprite, and every other failure + * path return {@code null} so the caller keeps its legacy 16px-cell behavior + * with zero regression. + * + *

          + * Results are cached statically keyed by {@code item:meta} (mirrors + * {@link com.hfstudio.guidenh.guide.internal.item.GuideDisplayItemStacks}), so + * per-frame stack swapping (e.g. {@link LytCyclingItemImage}) never pays the + * pixel scan more than once per item meta. + */ +public final class IconMetrics { + + /** Alpha (0-255) strictly above which a source pixel counts as ink. */ + public static final int INK_ALPHA_THRESHOLD = 8; + + /** Unit size of the icon draw quad (all item icons render into a 16x16 box). */ + private static final float ICON_UNIT_SIZE = 16f; + + private static final int MAX_CACHE_SIZE = 2048; + private static final Map CACHE = new ConcurrentHashMap<>(); + private static final Set WARNED = Collections.synchronizedSet(new HashSet<>()); + + /** Left edge of the ink bbox in 16-unit icon space (inclusive). */ + public final int inkLeft; + /** Right edge of the ink bbox in 16-unit icon space (inclusive). */ + public final int inkRight; + /** Top edge of the ink bbox in 16-unit icon space (inclusive). */ + public final int inkTop; + /** Bottom edge of the ink bbox in 16-unit icon space (inclusive). */ + public final int inkBottom; + /** Ink width in 16-unit icon space ({@code inkRight - inkLeft + 1}). */ + public final int width; + /** Ink height in 16-unit icon space ({@code inkBottom - inkTop + 1}). */ + public final int height; + + private IconMetrics(int inkLeft, int inkRight, int inkTop, int inkBottom) { + this.inkLeft = inkLeft; + this.inkRight = inkRight; + this.inkTop = inkTop; + this.inkBottom = inkBottom; + this.width = inkRight - inkLeft + 1; + this.height = inkBottom - inkTop + 1; + } + + /** + * Returns the ink metrics for {@code stack}, computing and caching them on + * first use. Returns {@code null} (without caching) on any failure path so + * callers fall back to their legacy 16px-cell behavior. + */ + @Nullable + public static IconMetrics forStack(@Nullable ItemStack stack) { + if (stack == null) { + return null; + } + Item item = stack.getItem(); + if (item == null) { + return null; + } + int meta = stack.getItemDamage(); + String key = metaCacheKey(item, meta); + IconMetrics cached = CACHE.get(key); + if (cached != null) { + return cached; + } + IconMetrics computed = compute(stack, item, key); + if (computed != null) { + if (CACHE.size() >= MAX_CACHE_SIZE) { + CACHE.clear(); + } + CACHE.put(key, computed); + } + return computed; + } + + @Nullable + private static IconMetrics compute(ItemStack stack, Item item, String key) { + int passes; + try { + passes = item.requiresMultipleRenderPasses() ? Math.max(1, item.getRenderPasses(stack.getItemDamage())) : 1; + } catch (Throwable t) { + warnOnce("passes:" + key, t); + passes = 1; + } + + int minLeft = Integer.MAX_VALUE; + int minTop = Integer.MAX_VALUE; + int maxRight = -1; + int maxBottom = -1; + + for (int pass = 0; pass < passes; pass++) { + IIcon icon; + try { + icon = passes > 1 ? item.getIcon(stack, pass) : stack.getIconIndex(); + } catch (Throwable t) { + warnOnce("icon:" + key + ":" + pass, t); + continue; + } + // TextureAtlasSprite is the only IIcon implementation carrying + // pixel data; custom icons and the missing-texture sprite fall back + // to the legacy cell. + if (!(icon instanceof TextureAtlasSprite)) { + continue; + } + if ("missingno".equals(icon.getIconName())) { + continue; + } + int[] ink = scanPass(key, (TextureAtlasSprite) icon, pass); + if (ink == null) { + continue; + } + if (ink[0] < minLeft) { + minLeft = ink[0]; + } + if (ink[1] < minTop) { + minTop = ink[1]; + } + if (ink[2] > maxRight) { + maxRight = ink[2]; + } + if (ink[3] > maxBottom) { + maxBottom = ink[3]; + } + } + + // No pass produced a single ink pixel (empty or fully transparent + // texture), which is unmeasurable, so fall back to the legacy cell. + if (maxRight < 0) { + return null; + } + return new IconMetrics(minLeft, maxRight, minTop, maxBottom); + } + + /** + * Scans one render pass's icon for ink pixels. + * + * @return {@code {inkLeft, inkTop, inkRight, inkBottom}} in 16-unit icon + * space, or {@code null} when no readable pixel source yields ink. + */ + @Nullable + private static int[] scanPass(String key, TextureAtlasSprite sprite, int pass) { + // Primary source: the atlas sprite's CPU frame data (task-specified). + // getIconWidth()/getIconHeight() account for the +16px padding that + // anisotropic filtering bakes into the pixel array, so pixel indexing + // MUST use them. + int w = sprite.getIconWidth(); + int h = sprite.getIconHeight(); + if (w > 0 && h > 0) { + try { + int[][] frames = sprite.getFrameTextureData(0); + if (frames != null && frames.length > 0) { + int[] pixels = frames[0]; + if (pixels != null && pixels.length >= w * h) { + return scanInkNormalized(pixels, w, h); + } + } + } catch (Throwable t) { + // Expected for static sprites: their CPU frame data is cleared + // after the atlas upload, so getFrameTextureData throws. This + // is the task-required tolerated fallback path; fall through to + // the resource-pack PNG source below instead of silently + // skipping the item. + } + } + // Fallback source: the sprite's source PNG from the resource manager, + // the same image the atlas stitched, always present on disk. + BufferedImage img = loadIconImage(sprite.getIconName()); + if (img == null) { + warnOnce( + "png:" + key + ":" + pass, + "[GuideNH] IconMetrics: no readable pixel source for {}; falling back to legacy 16px cell"); + return null; + } + int iw = img.getWidth(); + int ih = img.getHeight(); + if (iw <= 0 || ih <= 0) { + warnOnce( + "png:" + key + ":" + pass, + "[GuideNH] IconMetrics: no readable pixel source for {}; falling back to legacy 16px cell"); + return null; + } + int[] pixels = img.getRGB(0, 0, iw, ih, null, 0, iw); + return scanInkNormalized(pixels, iw, ih); + } + + /** + * Scans a row-major ARGB pixel array and returns the ink bbox normalized + * from the raw pixel grid to 16-unit icon space (item icons are drawn on a + * 16x16 quad regardless of the source texture's pixel dimensions). + */ + @Nullable + private static int[] scanInkNormalized(int[] pixels, int w, int h) { + int minLeft = Integer.MAX_VALUE; + int minTop = Integer.MAX_VALUE; + int maxRight = -1; + int maxBottom = -1; + for (int y = 0; y < h; y++) { + int rowBase = y * w; + for (int x = 0; x < w; x++) { + if (((pixels[rowBase + x] >>> 24) & 0xFF) > INK_ALPHA_THRESHOLD) { + if (x < minLeft) { + minLeft = x; + } + if (x > maxRight) { + maxRight = x; + } + if (y < minTop) { + minTop = y; + } + if (y > maxBottom) { + maxBottom = y; + } + } + } + } + if (maxRight < 0) { + return null; + } + return new int[] { Math.round(minLeft * ICON_UNIT_SIZE / w), Math.round(minTop * ICON_UNIT_SIZE / h), + Math.round(maxRight * ICON_UNIT_SIZE / w), Math.round(maxBottom * ICON_UNIT_SIZE / h) }; + } + + /** + * Loads the source PNG of the sprite named {@code iconName} (e.g. + * {@code apple} or {@code crafting_table_top}) from the item or block + * texture folders, mirroring the atlas's own path resolution. + */ + @Nullable + private static BufferedImage loadIconImage(@Nullable String iconName) { + if (iconName == null || iconName.isEmpty()) { + return null; + } + Minecraft mc = Minecraft.getMinecraft(); + if (mc == null) { + return null; + } + IResourceManager rm = mc.getResourceManager(); + if (rm == null) { + return null; + } + ResourceLocation base; + try { + base = new ResourceLocation(iconName); + } catch (Throwable t) { + return null; + } + BufferedImage img = tryLoad(rm, base, "textures/items"); + if (img == null) { + img = tryLoad(rm, base, "textures/blocks"); + } + return img; + } + + @Nullable + private static BufferedImage tryLoad(IResourceManager rm, ResourceLocation base, String folder) { + try { + ResourceLocation loc = new ResourceLocation( + base.getResourceDomain(), + folder + "/" + base.getResourcePath() + ".png"); + IResource res = rm.getResource(loc); + if (res == null) { + return null; + } + try (InputStream in = res.getInputStream()) { + return ImageIO.read(in); + } + } catch (Throwable t) { + return null; + } + } + + /** Reuses the {@code item:meta} key convention from GuideDisplayItemStacks. */ + private static String metaCacheKey(Item item, int meta) { + Object name = Item.itemRegistry.getNameForObject(item); + return (name != null ? name.toString() + : item.getClass() + .getName()) + + ":" + + meta; + } + + private static void warnOnce(String key, String message) { + if (WARNED.add(key)) { + GuideDebugLog.warnAlways(message, key); + } + } + + private static void warnOnce(String key, Throwable t) { + if (WARNED.add(key)) { + GuideDebugLog.warnAlways( + "[GuideNH] IconMetrics: failed to read ink metrics for {}; falling back to legacy 16px cell", + key, + t); + } + } +} diff --git a/src/main/java/com/hfstudio/guidenh/guide/document/block/LatexRenderOptions.java b/src/main/java/com/hfstudio/guidenh/guide/document/block/LatexRenderOptions.java index cf7a90e4..87faecb2 100644 --- a/src/main/java/com/hfstudio/guidenh/guide/document/block/LatexRenderOptions.java +++ b/src/main/java/com/hfstudio/guidenh/guide/document/block/LatexRenderOptions.java @@ -1,14 +1,36 @@ package com.hfstudio.guidenh.guide.document.block; import org.jetbrains.annotations.Nullable; +import org.scilab.forge.jlatexmath.TeXConstants; import com.hfstudio.guidenh.guide.color.ColorUtils; import com.hfstudio.guidenh.guide.document.interaction.GuideTooltip; - -public record LatexRenderOptions(int fillColorArgb, float sourceScale, float userScale, @Nullable GuideTooltip tooltip, - LatexVerticalAlign valign, int offsetX, int offsetY) { +import com.hfstudio.guidenh.guide.render.GuideText; + +/** + * Render options for a LaTeX block (inline or display). + * + *

          + * Typeset-at-target-size contract. Formulas are typeset directly at + * {@link #fontSize()} = {@link GuideText#BASE_FONT_SIZE} × {@code userScale} + * pixels (jlatexmath {@code setSize} is a pixel font size). The {@code scale} + * attribute of the {@code } tag maps to {@code userScale}; the + * {@code sourceScale} attribute is DEPRECATED; it is parsed for backward + * compatibility with old documents but ignored by every computation. + */ +public record LatexRenderOptions(int style, int fillColorArgb, float sourceScale, float userScale, + @Nullable GuideTooltip tooltip, LatexVerticalAlign valign, int offsetX, int offsetY) { public static final int DEFAULT_FILL_COLOR_ARGB = ColorUtils.WHITE.getColor(); + + /** + * DEPRECATED: legacy jlatexmath render size (e.g. 100.0). No longer part of + * the render pipeline; formulas are typeset at {@link #fontSize()} directly. + * Retained so old documents that set {@code sourceScale} still parse; the + * value is ignored by all computation. The getter also feeds a passive + * flatbuffer passthrough field consumed by nothing (see LayoutNodeSerializer / + * Rust measure_latex). + */ public static final float DEFAULT_SOURCE_SCALE = 100.0f; public static final float DEFAULT_USER_SCALE = 1.0f; @@ -22,10 +44,25 @@ public static Builder builder() { if (valign == null) { valign = LatexVerticalAlign.BASELINE; } + if (style != TeXConstants.STYLE_DISPLAY && style != TeXConstants.STYLE_TEXT) { + style = TeXConstants.STYLE_DISPLAY; + } + } + + /** + * Target typeset font size in pixels: the body base font size ({@link GuideText#BASE_FONT_SIZE}) + * × {@link #userScale()}. Passed straight to jlatexmath {@code setSize} so the formula is + * typeset at its final pixel size, with no scaling and no calibration. + * + * @return font size in pixels, e.g. {@code 11f × 1.5f = 16.5f} + */ + public float fontSize() { + return GuideText.BASE_FONT_SIZE * userScale; } public static final class Builder { + private int style = TeXConstants.STYLE_DISPLAY; private int fillColorArgb = DEFAULT_FILL_COLOR_ARGB; private float sourceScale = DEFAULT_SOURCE_SCALE; private float userScale = DEFAULT_USER_SCALE; @@ -37,11 +74,21 @@ public static final class Builder { private Builder() {} + public Builder style(int style) { + this.style = style; + return this; + } + public Builder fillColorArgb(int fillColorArgb) { this.fillColorArgb = fillColorArgb; return this; } + /** + * DEPRECATED: legacy attribute, parsed for backward compatibility and ignored. + * Use {@link #userScale(float)} instead; the render size is now derived from + * {@link GuideText#BASE_FONT_SIZE} × user scale. + */ public Builder sourceScale(float sourceScale) { this.sourceScale = sourceScale; return this; @@ -69,7 +116,15 @@ public Builder offset(int offsetX, int offsetY) { } public LatexRenderOptions build() { - return new LatexRenderOptions(fillColorArgb, sourceScale, userScale, tooltip, valign, offsetX, offsetY); + return new LatexRenderOptions( + style, + fillColorArgb, + sourceScale, + userScale, + tooltip, + valign, + offsetX, + offsetY); } } } diff --git a/src/main/java/com/hfstudio/guidenh/guide/document/block/LytAlignedBlock.java b/src/main/java/com/hfstudio/guidenh/guide/document/block/LytAlignedBlock.java index b0a5c005..13692d63 100644 --- a/src/main/java/com/hfstudio/guidenh/guide/document/block/LytAlignedBlock.java +++ b/src/main/java/com/hfstudio/guidenh/guide/document/block/LytAlignedBlock.java @@ -4,6 +4,7 @@ import com.hfstudio.guidenh.guide.document.LytRect; import com.hfstudio.guidenh.guide.layout.LayoutContext; +import com.hfstudio.guidenh.guide.render.PrimitiveCollector; import com.hfstudio.guidenh.guide.render.RenderContext; import lombok.Getter; @@ -19,7 +20,7 @@ * and then repositioned by issuing a second layout pass at the correct horizontal offset. * *

          - * Example — a Recipe box centred in the page: + * Example: a Recipe box centred in the page: * *

            * {@code
          @@ -91,6 +92,16 @@ public LytNode pickNode(int x, int y) {
                   return inner.pickNode(x, y);
               }
           
          +    @Override
          +    public boolean usePrimitives() {
          +        return true;
          +    }
          +
          +    @Override
          +    public void computePrimitives(PrimitiveCollector c) {
          +        // No-op: inner child is picked up by PrimitiveCollector.collectFrom traversal.
          +    }
          +
               @Override
               public void render(RenderContext context) {
                   inner.render(context);
          diff --git a/src/main/java/com/hfstudio/guidenh/guide/document/block/LytBalancedColumns.java b/src/main/java/com/hfstudio/guidenh/guide/document/block/LytBalancedColumns.java
          deleted file mode 100644
          index edc1c567..00000000
          --- a/src/main/java/com/hfstudio/guidenh/guide/document/block/LytBalancedColumns.java
          +++ /dev/null
          @@ -1,102 +0,0 @@
          -package com.hfstudio.guidenh.guide.document.block;
          -
          -import com.hfstudio.guidenh.guide.document.LytRect;
          -import com.hfstudio.guidenh.guide.layout.LayoutContext;
          -import com.hfstudio.guidenh.guide.layout.Layouts;
          -
          -import lombok.Getter;
          -import lombok.Setter;
          -
          -/**
          - * Places children into columns as wide as the widest of them, filling the shortest column first.
          - *
          - * 

          - * Column count is based on the widest child and limited by {@link #getMaxColumns()}. - * Children wider than a column are laid out at their own width, falling back to a vertical stack. - */ -@Getter -@Setter -public class LytBalancedColumns extends LytBox { - - public static final int DEFAULT_MAX_COLUMNS = 4; - - private int gap; - - private int maxColumns = DEFAULT_MAX_COLUMNS; - - @Override - protected LytRect computeBoxLayout(LayoutContext context, int x, int y, int availableWidth) { - int count = children.size(); - if (count == 0) { - return new LytRect(x, y, 0, 0); - } - if (count == 1 || maxColumns <= 1) { - return verticalStack(context, x, y, availableWidth); - } - - int widest = widestChildWidth(context, x, y, availableWidth); - int columns = columnCountFor(availableWidth, widest); - if (columns <= 1) { - return verticalStack(context, x, y, availableWidth); - } - - int columnWidth = widest; - int[] columnBottoms = new int[columns]; - LytBlock[] previousBlocks = new LytBlock[columns]; - int contentWidth = 0; - int contentHeight = 0; - - for (LytBlock child : children) { - int blockWidth = Math.max(1, columnWidth - child.getMarginLeft() - child.getMarginRight()); - int columnIndex = shortestColumn(columnBottoms); - - int columnX = x + columnIndex * (columnWidth + gap) + child.getMarginLeft(); - int columnY = Layouts.offsetIntoContentArea( - LytAxis.VERTICAL, - y + columnBottoms[columnIndex], - previousBlocks[columnIndex], - child); - LytRect childBounds = child.layout(context, columnX, columnY, blockWidth); - - columnBottoms[columnIndex] = childBounds.bottom() - y + child.getMarginBottom() + gap; - previousBlocks[columnIndex] = child; - contentWidth = Math.max(contentWidth, childBounds.right() - x); - contentHeight = Math.max(contentHeight, childBounds.bottom() - y); - } - - return new LytRect(x, y, contentWidth, contentHeight); - } - - private int widestChildWidth(LayoutContext context, int x, int y, int availableWidth) { - int widest = 0; - for (LytBlock child : children) { - LytRect bounds = child.layout(context, x, y, availableWidth); - widest = Math.max(widest, bounds.width() + child.getMarginLeft() + child.getMarginRight()); - } - return widest; - } - - private int columnCountFor(int availableWidth, int widest) { - int limit = Math.min(children.size(), maxColumns); - if (widest <= 0) { - return Math.max(1, limit); - } - // Columns are as wide as the widest child, so n of them need n widths and n-1 gaps. - int byWidth = (availableWidth + gap) / (widest + gap); - return Math.max(1, Math.min(limit, byWidth)); - } - - private static int shortestColumn(int[] columnBottoms) { - int best = 0; - for (int i = 1; i < columnBottoms.length; i++) { - if (columnBottoms[i] < columnBottoms[best]) { - best = i; - } - } - return best; - } - - private LytRect verticalStack(LayoutContext context, int x, int y, int availableWidth) { - return Layouts.verticalLayout(context, children, x, y, availableWidth, 0, 0, 0, 0, gap, AlignItems.START); - } -} diff --git a/src/main/java/com/hfstudio/guidenh/guide/document/block/LytBlock.java b/src/main/java/com/hfstudio/guidenh/guide/document/block/LytBlock.java index 04652261..80176c80 100644 --- a/src/main/java/com/hfstudio/guidenh/guide/document/block/LytBlock.java +++ b/src/main/java/com/hfstudio/guidenh/guide/document/block/LytBlock.java @@ -1,8 +1,11 @@ package com.hfstudio.guidenh.guide.document.block; +import org.jetbrains.annotations.Nullable; + import com.hfstudio.guidenh.guide.document.LytPoint; import com.hfstudio.guidenh.guide.document.LytRect; import com.hfstudio.guidenh.guide.layout.LayoutContext; +import com.hfstudio.guidenh.guide.render.PrimitiveCollector; import com.hfstudio.guidenh.guide.render.RenderContext; import com.hfstudio.guidenh.guide.style.BorderStyle; @@ -16,6 +19,24 @@ public abstract class LytBlock extends LytNode { */ protected LytRect bounds = LytRect.empty(); + /** + * Bounds used for viewport culling: the union of this block's own bounds + * and all descendants' bounds. Computed after each external layout pass, + * because floated or otherwise overflowing children must keep their + * ancestors visible even when the ancestor's own rect leaves the viewport. + */ + @Nullable + private LytRect cullBounds; + + /** Culling bounds: subtree union when computed, own bounds otherwise. */ + public LytRect getCullBounds() { + return cullBounds != null ? cullBounds : bounds; + } + + public void setCullBounds(@Nullable LytRect cullBounds) { + this.cullBounds = cullBounds; + } + @Getter @Setter private int marginTop; @@ -49,13 +70,61 @@ public abstract class LytBlock extends LytNode { @Setter private boolean fullWidth; + /** + * Flex grow factor for this block inside a row/column flex container + * (declared by the block itself, e.g. the code toolbar's language label + * takes the remaining width). Read directly by the layout compiler; no + * serializer-side special case. + */ + @Getter + @Setter + private float flexGrow; + + /** + * Override the layout bounds with an externally computed rect (the Rust + * layout engine). Children receive their own rects from the same pass, so + * no propagation happens here. Subclasses with position/size-dependent + * internal state (sample caches, precomputed geometry) should override + * {@link #onExternalLayoutApplied} to invalidate it. + */ + public void applyExternalLayout(LytRect rect) { + LytRect old = bounds; + bounds = rect; + onExternalLayoutApplied(old, rect); + } + + /** + * The rect this block occupies in the document flow. Unlike + * {@link #getBounds()} this is never overridden for visual overflow, e.g. + * {@code LytDocumentFloat} reports a zero-height flow rect while its inner + * content visually overflows into the following content. + */ + public LytRect getFlowBounds() { + return bounds; + } + + /** + * Called after {@link #applyExternalLayout} replaced this block's bounds. + * Default no-op. Subclasses with position/size-dependent internal state + * (sample caches, precomputed geometry) should override to invalidate it. + */ + protected void onExternalLayoutApplied(LytRect oldBounds, LytRect newBounds) {} + + /** + * Called after the entire external-layout writeback pass completed, + * i.e. when this block's children's bounds are also final. Default no-op. + * Scroll containers override it to re-apply their scroll offset to the + * content (the writeback resets content to the unscrolled position). + */ + protected void afterExternalLayout() {} + @Override public LytRect getBounds() { return bounds; } public boolean isCulled(LytRect viewport) { - return !viewport.intersects(bounds); + return !viewport.intersects(getCullBounds()); } public final void setLayoutPos(LytPoint point) { @@ -77,6 +146,13 @@ public final void setLayoutPos(LytPoint point) { public final void moveLayoutPos(int deltaX, int deltaY) { if (deltaX != 0 || deltaY != 0) { bounds = bounds.move(deltaX, deltaY); + // The cull bounds (subtree union) move rigidly with the block: + // scroll replay and smooth scrolling move the whole subtree by the + // same delta, so a fresh union is unnecessary; without this, content + // scrolled into view gets culled by its stale rect. + if (cullBounds != null) { + cullBounds = cullBounds.move(deltaX, deltaY); + } onLayoutMoved(deltaX, deltaY); } } @@ -118,4 +194,68 @@ public void setBorder(BorderStyle style) { protected abstract void onLayoutMoved(int deltaX, int deltaY); public abstract void render(RenderContext context); + + // Explicit size. + + /** + * Override to declare a preferred width in pixels. + * Returns -1 when no explicit width is set. + */ + public int getExplicitWidth() { + return -1; + } + + /** + * Override to declare a preferred height in pixels. + * Returns -1 when no explicit height is set. + */ + public int getExplicitHeight() { + return -1; + } + + // Primitive collection. + + /** + * Whether this block renders through {@link #computePrimitives}. When + * {@code false}, {@link PrimitiveCollector} emits a legacy-render fallback + * ({@link GuideRenderPrimitive.HostDraw}) that invokes {@link #render} for + * the whole subtree and does not recurse into children. + *

          + * Defaults to {@code false} so unmigrated blocks keep rendering via the + * legacy path; subclasses that implement {@code computePrimitives} must + * override this to return {@code true}. The decision may be dynamic + * (e.g. LytParagraph returns true only when a Rust-shaped glyph run is + * available). + */ + public boolean usePrimitives() { + return false; + } + + /** + * Emit this block's own draw primitives. Do not iterate + * {@link #getChildren()}; the {@link PrimitiveCollector} handles + * tree traversal. Nodes with private rendering data (e.g. MermaidCanvas) + * may call {@link PrimitiveCollector#collectFrom} on their internal + * subtrees here. + */ + public void computePrimitives(PrimitiveCollector c) {} + + /** + * Override to declare a clip rectangle for this block's children. + * The PrimitiveCollector will automatically emit PushScissor before + * recursing into children and PopScissor after, using the returned + * rectangle in document coordinates. + * + * @return a clip rect in document coordinates, or null for no clipping + */ + public @org.jetbrains.annotations.Nullable LytRect getChildrenClipRect() { + return null; + } + + /** + * Emit decoration primitives (borders, outlines) that must paint + * after children. Called by {@link PrimitiveCollector#collectFrom} + * after recursing into {@link #getChildren()}. + */ + public void emitDecorations(PrimitiveCollector c) {} } diff --git a/src/main/java/com/hfstudio/guidenh/guide/document/block/LytBox.java b/src/main/java/com/hfstudio/guidenh/guide/document/block/LytBox.java index eeb83b38..fdb2a981 100644 --- a/src/main/java/com/hfstudio/guidenh/guide/document/block/LytBox.java +++ b/src/main/java/com/hfstudio/guidenh/guide/document/block/LytBox.java @@ -8,7 +8,10 @@ import com.hfstudio.guidenh.guide.color.ColorValue; import com.hfstudio.guidenh.guide.document.LytRect; import com.hfstudio.guidenh.guide.layout.LayoutContext; +import com.hfstudio.guidenh.guide.render.GuideRenderPrimitive; +import com.hfstudio.guidenh.guide.render.PrimitiveCollector; import com.hfstudio.guidenh.guide.render.RenderContext; +import com.hfstudio.guidenh.guide.style.BorderStyle; import lombok.Setter; @@ -144,4 +147,65 @@ public void render(RenderContext context) { .render(context, bounds, getBorderTop(), getBorderLeft(), getBorderRight(), getBorderBottom()); } } + + @Override + public boolean usePrimitives() { + return true; + } + + @Override + public void computePrimitives(PrimitiveCollector c) { + if (backgroundColor != null) { + c.emit( + new GuideRenderPrimitive.FillRect( + bounds.x(), + bounds.y(), + bounds.width(), + bounds.height(), + resolveBackgroundArgb())); + } + } + + @Override + public void emitDecorations(PrimitiveCollector c) { + if (getBorderTop().width() > 0 || getBorderLeft().width() > 0 + || getBorderRight().width() > 0 + || getBorderBottom().width() > 0) { + c.emit( + new GuideRenderPrimitive.DrawBorder( + bounds.x(), + bounds.y(), + bounds.width(), + bounds.height(), + getBorderTop().width(), + getBorderLeft().width(), + getBorderBottom().width(), + getBorderRight().width(), + resolveBorderArgb())); + } + } + + private int resolveBackgroundArgb() { + if (backgroundColor == null) return 0; + return backgroundColor.resolve(); + } + + private int resolveBorderArgb() { + // DrawBorder is single-color; use the first side that declares one + // (some blocks, e.g. the code toolbar, only set a bottom border). + // Only sides with a non-zero width participate: BorderStyle.NONE is + // (ConstantColor.TRANSPARENT, 0), so a transparent zero-width side must + // not preempt the color of a wider side. + BorderStyle[] sides = { getBorderTop(), getBorderLeft(), getBorderRight(), getBorderBottom() }; + for (BorderStyle side : sides) { + if (side.width() <= 0) { + continue; + } + var color = side.color(); + if (color != null) { + return color.resolve(); + } + } + return 0xFF000000; + } } diff --git a/src/main/java/com/hfstudio/guidenh/guide/document/block/LytCodeBlock.java b/src/main/java/com/hfstudio/guidenh/guide/document/block/LytCodeBlock.java index 6f7e4de0..f4e1cbf2 100644 --- a/src/main/java/com/hfstudio/guidenh/guide/document/block/LytCodeBlock.java +++ b/src/main/java/com/hfstudio/guidenh/guide/document/block/LytCodeBlock.java @@ -18,6 +18,8 @@ import com.hfstudio.guidenh.guide.internal.util.GuideStringLines; import com.hfstudio.guidenh.guide.internal.util.SmoothFloatState; import com.hfstudio.guidenh.guide.layout.LayoutContext; +import com.hfstudio.guidenh.guide.render.GuideRenderPrimitive; +import com.hfstudio.guidenh.guide.render.PrimitiveCollector; import com.hfstudio.guidenh.guide.render.RenderContext; import com.hfstudio.guidenh.guide.style.BorderStyle; import com.hfstudio.guidenh.guide.style.WhiteSpaceMode; @@ -38,10 +40,14 @@ public class LytCodeBlock extends LytVBox implements InteractiveElement, Documen private static final int MIN_SCROLLBAR_THUMB = 14; private final LytCodeBlockToolbar toolbar = new LytCodeBlockToolbar(); + private final LytViewportBox bodyViewport = new LytViewportBox(); private final LytParagraph body = new LytParagraph(); @Getter private String codeText = ""; + + @Getter + private boolean toolbarVisible = true; private String normalizedCodeText = ""; @Getter private String languageFenceName = ""; @@ -54,15 +60,10 @@ public class LytCodeBlock extends LytVBox implements InteractiveElement, Documen @Getter private int forcedBodyHeight; @Getter - private int bodyContentHeight; - private int bodyViewportX; - private int bodyViewportY; - private int bodyViewportWidth; - @Getter - private int bodyViewportHeight; - @Getter private int bodyScrollOffsetY; private final SmoothFloatState visualBodyScrollOffsetY = new SmoothFloatState(); + /** Visual-scroll delta currently baked into the body's bounds (see computePrimitives). */ + private int appliedVisualDeltaY; @Getter private boolean draggingBody; private int dragLastDocumentY; @@ -87,14 +88,32 @@ public LytCodeBlock() { body.setPaddingTop(BODY_PADDING); body.setPaddingBottom(BODY_PADDING); body.modifyStyle( - style -> style.whiteSpace(WhiteSpaceMode.PRE_WRAP) + style -> style.whiteSpace(WhiteSpaceMode.PRE) .color(CODE_DEFAULT)); + bodyViewport.setFullWidth(true); + bodyViewport.append(body); append(toolbar); - append(body); + append(bodyViewport); syncToolbar(); } + @Override + public List getChildren() { + // When toolbar is hidden, exclude it from the children list so the + // Rust layout engine and PrimitiveCollector do not process it. + // The internal children list (including toolbar) is still maintained + // for direct field access (render, mouse click, etc.). + if (!toolbarVisible) { + return List.of(bodyViewport); + } + return super.getChildren(); + } + + public void setToolbarVisible(boolean toolbarVisible) { + this.toolbarVisible = toolbarVisible; + } + public void setCodeText(String codeText) { setCodeContent(languageFenceName, codeText); } @@ -142,6 +161,10 @@ public void setPreferredBodyWidth(int preferredBodyWidth) { public void setForcedBodyHeight(int forcedBodyHeight) { this.forcedBodyHeight = Math.max(0, forcedBodyHeight); + // Make the viewport height visible to the Rust layout serializer: + // propagate the forced height (or -1 for auto) so serialization sees it + // before Rust measures. Aligns with computeBoxLayout semantics. + bodyViewport.setExplicitHeight(this.forcedBodyHeight > 0 ? this.forcedBodyHeight : -1); } public int getBodyLineCount() { @@ -151,7 +174,10 @@ public int getBodyLineCount() { @Override public boolean mouseClicked(GuideUiHost screen, int x, int y, int button, boolean doubleClick) { // Scrollbar-related interactions are handled by beginDrag/dragTo (mouseDown can start a drag directly). - return toolbar.mouseClicked(screen, x, y, button, doubleClick); + if (toolbarVisible && toolbar.mouseClicked(screen, x, y, button, doubleClick)) { + return true; + } + return false; } @Override @@ -159,7 +185,7 @@ public boolean beginDrag(int documentX, int documentY, int button) { if (button != 0) { return false; } - if (toolbar.getBounds() + if (toolbarVisible && toolbar.getBounds() .contains(documentX, documentY)) { return false; } @@ -212,32 +238,133 @@ public boolean scroll(int documentX, int documentY, int wheelDelta) { return true; } + @Override + public boolean usePrimitives() { + return true; + } + + @Override + public void computePrimitives(PrimitiveCollector c) { + super.computePrimitives(c); + c.emit( + new GuideRenderPrimitive.FillRect( + bounds.x(), + bounds.y(), + bounds.width(), + bounds.height(), + CODE_BACKGROUND.resolve())); + + // Advance the smooth scroll and bake the visual delta into the body's + // bounds. The collector traverses the body right after this, so the + // body renders at its animated position and hit-tests stay aligned. + updateVisualScroll(); + int newDelta = bodyScrollOffsetY - visualBodyScrollOffsetY.rounded(); + if (newDelta != appliedVisualDeltaY && !body.getBounds() + .isEmpty()) { + body.moveLayoutPos(0, newDelta - appliedVisualDeltaY); + appliedVisualDeltaY = newDelta; + } + + if (getMaxBodyScroll() > 0) { + LytRect track = getScrollbarTrackBounds(); + if (!track.isEmpty()) { + c.emit( + new GuideRenderPrimitive.FillRect( + track.x(), + track.y(), + track.width(), + track.height(), + CODE_THEME.scrollbarTrackArgb())); + LytRect thumb = getScrollbarThumbBounds(); + if (!thumb.isEmpty()) { + c.emit( + new GuideRenderPrimitive.FillRect( + thumb.x(), + thumb.y(), + thumb.width(), + thumb.height(), + draggingScrollbar ? CODE_THEME.scrollbarThumbActiveArgb() + : CODE_THEME.scrollbarThumbArgb())); + } + } + } + } + + @Override + protected void afterExternalLayout() { + // The writeback reset the body to the unscrolled position; re-apply the + // current scroll offset and restart the visual-delta bookkeeping. + updateBodyPosition(); + appliedVisualDeltaY = 0; + } + @Override protected LytRect computeBoxLayout(LayoutContext context, int x, int y, int availableWidth) { int safeWidth = preferredBodyWidth > 0 ? Math.max(1, Math.min(availableWidth, preferredBodyWidth)) : Math.max(1, availableWidth); - toolbar.setPreferredWidth(safeWidth); - LytRect toolbarBounds = toolbar.layout(context, x, y, safeWidth); - int bodyY = toolbarBounds.bottom() + getGap(); + int toolbarHeight; + int bodyY; + if (toolbarVisible) { + toolbar.setPreferredWidth(safeWidth); + LytRect toolbarBounds = toolbar.layout(context, x, y, safeWidth); + toolbarHeight = toolbarBounds.height() + getGap(); + bodyY = toolbarBounds.bottom() + getGap(); + } else { + toolbarHeight = 0; + bodyY = y; + } int bodyAvailableWidth = safeWidth; LytRect measuredBody = body.layout(context, x, bodyY, bodyAvailableWidth); - bodyContentHeight = measuredBody.height(); - bodyViewportHeight = forcedBodyHeight > 0 ? forcedBodyHeight : bodyContentHeight; - if (forcedBodyHeight > 0 && bodyContentHeight > bodyViewportHeight) { + int contentHeight = measuredBody.height(); + int viewportHeight = forcedBodyHeight > 0 ? forcedBodyHeight : contentHeight; + if (forcedBodyHeight > 0 && contentHeight > viewportHeight) { bodyAvailableWidth = Math.max(1, safeWidth - SCROLLBAR_WIDTH - 4); measuredBody = body.layout(context, x, bodyY, bodyAvailableWidth); - bodyContentHeight = measuredBody.height(); + contentHeight = measuredBody.height(); } - bodyViewportHeight = forcedBodyHeight > 0 ? forcedBodyHeight : bodyContentHeight; - bodyViewportX = x; - bodyViewportY = bodyY; - bodyViewportWidth = bodyAvailableWidth; + viewportHeight = forcedBodyHeight > 0 ? forcedBodyHeight : contentHeight; + bodyViewport.setExplicitHeight(viewportHeight); + bodyViewport.layout(context, x, bodyY, bodyAvailableWidth); setBodyScrollOffset(bodyScrollOffsetY); snapVisualScrollToTarget(); - return new LytRect(x, y, safeWidth, toolbarBounds.height() + getGap() + bodyViewportHeight); + return new LytRect(x, y, safeWidth, toolbarHeight + viewportHeight); + } + + // Derived geometry, computed from current bounds with no layout-time fields. + + private int getBodyContentHeight() { + return body.getBounds() + .height(); + } + + private LytRect getBodyViewportBounds() { + LytRect tb = toolbar.getBounds(); + int x = bounds.x() + getBorderLeft().width() + paddingLeft; + int y = tb.isEmpty() ? bounds.y() + getBorderTop().width() + paddingTop : tb.bottom() + getGap(); + int w = bounds.right() - getBorderRight().width() - paddingRight - x; + int h; + if (forcedBodyHeight > 0) { + h = forcedBodyHeight; + if (getMaxBodyScroll() > 0) { + w = Math.max(1, w - SCROLLBAR_WIDTH - 4); + } + } else { + h = getBodyContentHeight(); + } + return new LytRect(x, y, Math.max(0, w), Math.max(0, h)); + } + + /** Public viewport height accessor (derived; replaces the former layout-time field). */ + public int getBodyViewportHeight() { + return getBodyViewportBounds().height(); + } + + private int getMaxBodyScroll() { + if (forcedBodyHeight <= 0) return 0; + return Math.max(0, getBodyContentHeight() - forcedBodyHeight); } @Override @@ -249,14 +376,8 @@ public void render(RenderContext context) { } context.fillRect(ownBounds, CODE_BACKGROUND); - toolbar.render(context); - - LytRect bodyViewport = getBodyViewportBounds(); - context.pushLocalScissor(bodyViewport); - try { - renderBodyWithVisualOffset(context); - } finally { - context.popScissor(); + if (toolbarVisible) { + toolbar.render(context); } renderScrollbar(context); @@ -316,10 +437,6 @@ private void renderScrollbar(RenderContext context) { } } - private LytRect getBodyViewportBounds() { - return new LytRect(bodyViewportX, bodyViewportY, bodyViewportWidth, Math.max(0, bodyViewportHeight)); - } - private LytRect getScrollbarTrackBounds() { if (getMaxBodyScroll() <= 0) { return LytRect.empty(); @@ -334,8 +451,9 @@ private LytRect getScrollbarThumbBounds() { if (track.isEmpty()) { return LytRect.empty(); } - int thumbHeight = Math - .max(MIN_SCROLLBAR_THUMB, track.height() * track.height() / Math.max(track.height(), bodyContentHeight)); + int thumbHeight = Math.max( + MIN_SCROLLBAR_THUMB, + track.height() * track.height() / Math.max(track.height(), getBodyContentHeight())); thumbHeight = Math.min(thumbHeight, track.height()); int maxScroll = getMaxBodyScroll(); int thumbTrack = Math.max(1, track.height() - thumbHeight); @@ -346,41 +464,23 @@ private LytRect getScrollbarThumbBounds() { return new LytRect(track.x(), thumbY, track.width(), thumbHeight); } - private int getMaxBodyScroll() { - return Math.max(0, bodyContentHeight - bodyViewportHeight); - } - private void setBodyScrollOffset(int bodyScrollOffsetY) { this.bodyScrollOffsetY = SceneEditorVerticalScrollbar.clamp(bodyScrollOffsetY, 0, getMaxBodyScroll()); updateBodyPosition(); } private void updateBodyPosition() { - if (!body.getBounds() - .isEmpty() - && !toolbar.getBounds() - .isEmpty()) { - int bodyViewportY = toolbar.getBounds() - .bottom() + getGap(); + LytRect viewport = getBodyViewportBounds(); + if (!viewport.isEmpty() && !body.getBounds() + .isEmpty()) { body.moveLayoutPos( 0, - bodyViewportY - bodyScrollOffsetY + viewport.y() - bodyScrollOffsetY - body.getBounds() .y()); - } - } - - private void renderBodyWithVisualOffset(RenderContext context) { - int renderDeltaY = bodyScrollOffsetY - visualBodyScrollOffsetY.rounded(); - if (renderDeltaY == 0) { - body.render(context); - return; - } - body.moveLayoutPos(0, renderDeltaY); - try { - body.render(context); - } finally { - body.moveLayoutPos(0, -renderDeltaY); + // Bounds now sit at the scroll target; the visual delta restarts + // from here and is re-baked by computePrimitives each frame. + appliedVisualDeltaY = 0; } } @@ -404,6 +504,6 @@ private void snapVisualScrollToTarget() { private void updateVisualScroll() { visualBodyScrollOffsetY - .updateTowards(bodyScrollOffsetY, 28f, 0.25f, 0.01f, Math.max(128f, bodyViewportHeight * 2f)); + .updateTowards(bodyScrollOffsetY, 28f, 0.25f, 0.01f, Math.max(128f, getBodyViewportBounds().height() * 2f)); } } diff --git a/src/main/java/com/hfstudio/guidenh/guide/document/block/LytCodeBlockToolbar.java b/src/main/java/com/hfstudio/guidenh/guide/document/block/LytCodeBlockToolbar.java index dadd58fe..7b911ab8 100644 --- a/src/main/java/com/hfstudio/guidenh/guide/document/block/LytCodeBlockToolbar.java +++ b/src/main/java/com/hfstudio/guidenh/guide/document/block/LytCodeBlockToolbar.java @@ -17,6 +17,8 @@ import com.hfstudio.guidenh.guide.internal.screen.GuideIconButton; import com.hfstudio.guidenh.guide.layout.LayoutContext; import com.hfstudio.guidenh.guide.render.GuiSprite; +import com.hfstudio.guidenh.guide.render.GuideRenderPrimitive; +import com.hfstudio.guidenh.guide.render.PrimitiveCollector; import com.hfstudio.guidenh.guide.render.RenderContext; import com.hfstudio.guidenh.guide.style.BorderStyle; import com.hfstudio.guidenh.guide.ui.GuideUiHost; @@ -74,6 +76,9 @@ public LytCodeBlockToolbar() { languageLabel.setMarginTop(0); languageLabel.setMarginBottom(0); + // The label takes the remaining toolbar width, declared on the block + // itself, read directly by the layout compiler (no special case). + languageLabel.setFlexGrow(1f); languageLabel.modifyStyle( style -> style.bold(true) .color(toolbarText)); @@ -176,6 +181,18 @@ public Optional getTooltip(float x, float y) { return Optional.empty(); } + @Override + public void computePrimitives(PrimitiveCollector c) { + super.computePrimitives(c); + c.emit( + new GuideRenderPrimitive.FillRect( + bounds.x(), + bounds.y(), + bounds.width(), + bounds.height(), + toolbarBackground.resolve())); + } + @Override public void render(RenderContext context) { context.fillRect(bounds, toolbarBackground); diff --git a/src/main/java/com/hfstudio/guidenh/guide/document/block/LytContentTabsBlock.java b/src/main/java/com/hfstudio/guidenh/guide/document/block/LytContentTabsBlock.java index 05afdf48..cf5af197 100644 --- a/src/main/java/com/hfstudio/guidenh/guide/document/block/LytContentTabsBlock.java +++ b/src/main/java/com/hfstudio/guidenh/guide/document/block/LytContentTabsBlock.java @@ -17,70 +17,37 @@ import com.hfstudio.guidenh.guide.document.interaction.InteractiveElement; import com.hfstudio.guidenh.guide.internal.debug.DebugComponent; import com.hfstudio.guidenh.guide.layout.LayoutContext; +import com.hfstudio.guidenh.guide.render.GuideRenderPrimitive; +import com.hfstudio.guidenh.guide.render.PrimitiveCollector; import com.hfstudio.guidenh.guide.render.RenderContext; import com.hfstudio.guidenh.guide.style.BorderStyle; -import com.hfstudio.guidenh.guide.style.ResolvedTextStyle; -import com.hfstudio.guidenh.guide.style.TextAlignment; -import com.hfstudio.guidenh.guide.style.WhiteSpaceMode; import com.hfstudio.guidenh.guide.ui.GuideUiHost; +/** + * Tabbed container: an optional title paragraph, a {@link LytContentTabsHeader} + * strip, and the ACTIVE tab body. The tree only ever contains those live + * children; hidden tab bodies are reachable to semantic traversals (search, + * anchors, resource export) through {@link #visitChildren} but are never laid + * out or rendered. + */ public class LytContentTabsBlock extends LytBlock implements InteractiveElement, DebugComponent { private static final int ACCENT_WIDTH = 3; private static final int CONTAINER_PAD_X = 10; private static final int CONTAINER_PAD_Y = 6; - private static final int HEADER_GAP_X = 10; - private static final int HEADER_GAP_Y = 5; - private static final int HEADER_PAD_X = 2; - private static final int HEADER_PAD_TOP = 1; - private static final int HEADER_PAD_BOTTOM = 5; - private static final int HEADER_RULE_THICKNESS = 1; - private static final int ACTIVE_RULE_THICKNESS = 2; private static final int TITLE_GAP = 4; private static final int BODY_GAP = 6; - private static final ConstantColor DEFAULT_ACCENT = new ConstantColor(ColorUtils.ARGB_FF7C8795.getColor()); + private static final int HEADER_RULE_THICKNESS = 1; private static final int HEADER_RULE_COLOR = ColorUtils.ARGB_66586275.getColor(); - private final List tabs = new ArrayList<>(); - private final List children = new ArrayList<>(); + private static final ConstantColor DEFAULT_ACCENT = new ConstantColor(ColorUtils.ARGB_FF7C8795.getColor()); + + private final List titles = new ArrayList<>(); + private final List bodies = new ArrayList<>(); private final ColorValue accentColor; @Nullable private final LytParagraph titleParagraph; + private final LytContentTabsHeader headerBlock; private int selectedIndex; - private LytRect titleBounds = LytRect.empty(); - private LytRect headerBounds = LytRect.empty(); - private LytRect contentBounds = LytRect.empty(); - private static final ResolvedTextStyle SELECTED_STYLE = new ResolvedTextStyle( - 1.0f, - false, - false, - false, - false, - false, - false, - false, - "", - new ConstantColor(ColorUtils.ARGB_FFF4F7FB.getColor()), - WhiteSpaceMode.NORMAL, - TextAlignment.LEFT, - false, - null, - false); - private static final ResolvedTextStyle IDLE_STYLE = new ResolvedTextStyle( - 1.0f, - false, - false, - false, - false, - false, - false, - false, - "", - new ConstantColor(ColorUtils.ARGB_FFD5DCE7.getColor()), - WhiteSpaceMode.NORMAL, - TextAlignment.LEFT, - false, - null, - false); public LytContentTabsBlock(@Nullable String title, @Nullable LytFlowContent icon, int selectedIndex, @Nullable ColorValue accentColor, List entries) { @@ -89,13 +56,16 @@ public LytContentTabsBlock(@Nullable String title, @Nullable LytFlowContent icon this.titleParagraph = buildTitleParagraph(title, icon); if (titleParagraph != null) { titleParagraph.parent = this; - children.add(titleParagraph); + titleParagraph.setMarginBottom(TITLE_GAP); } for (ContentTabsSpec.TabEntry entry : entries) { - tabs.add(new TabState(entry.title(), entry.body())); - children.add(entry.body()); + titles.add(entry.title()); + bodies.add(entry.body()); entry.body().parent = this; } + headerBlock = new LytContentTabsHeader(titles, this.accentColor, this::getSafeSelectedIndex, this::selectTab); + headerBlock.parent = this; + headerBlock.setMarginBottom(BODY_GAP); setMarginTop(PageCompiler.DEFAULT_ELEMENT_SPACING); setMarginBottom(PageCompiler.DEFAULT_ELEMENT_SPACING); setFullWidth(true); @@ -104,147 +74,125 @@ public LytContentTabsBlock(@Nullable String title, @Nullable LytFlowContent icon @Override public List getChildren() { - // Expose every tab body to tree visitors so search, anchors, resource export, - // scene collection, and mount-time traversal still see hidden tabs. - return children; + // Live tree only: the layout engine and the render collector must not + // lay out or draw hidden tabs. + List out = new ArrayList<>(); + if (titleParagraph != null) { + out.add(titleParagraph); + } + out.add(headerBlock); + if (!bodies.isEmpty()) { + out.add(activeBody()); + } + return out; + } + + @Override + protected LytVisitor.Result visitChildren(LytVisitor visitor, boolean includeOutOfTreeContent) { + // Semantic traversals (search, anchors, resource export) keep seeing + // every tab body including hidden ones, matching the pre-migration behavior. + for (LytNode child : getChildren()) { + if (child.visit(visitor, includeOutOfTreeContent) == LytVisitor.Result.STOP) { + return LytVisitor.Result.STOP; + } + } + for (LytBlock body : bodies) { + if (body != activeBody() && body.visit(visitor, includeOutOfTreeContent) == LytVisitor.Result.STOP) { + return LytVisitor.Result.STOP; + } + } + return LytVisitor.Result.CONTINUE; } @Override protected LytRect computeLayout(LayoutContext context, int x, int y, int availableWidth) { - if (tabs.isEmpty()) { - titleBounds = LytRect.empty(); - headerBounds = LytRect.empty(); - contentBounds = LytRect.empty(); + if (bodies.isEmpty()) { return new LytRect(x, y, 0, 0); } - - selectedIndex = Math.clamp(selectedIndex, 0, tabs.size() - 1); - int contentX = x + ACCENT_WIDTH + CONTAINER_PAD_X; int contentY = y + CONTAINER_PAD_Y; int contentWidth = Math.max(0, availableWidth - ACCENT_WIDTH - CONTAINER_PAD_X * 2); - int tabsY = contentY; - if (titleParagraph != null) { - titleBounds = titleParagraph.layout(context, contentX, contentY, contentWidth); - tabsY = titleBounds.bottom() + TITLE_GAP; - } else { - titleBounds = LytRect.empty(); - } - int cursorX = contentX; - int cursorY = tabsY; - int rowHeight = 0; - int headerBottom = tabsY; - for (TabState tab : tabs) { - int tabWidth = tab.measureWidth(context); - int tabHeight = tab.measureHeight(context); - if (cursorX > contentX && cursorX + tabWidth > contentX + contentWidth) { - cursorX = contentX; - cursorY += rowHeight + HEADER_GAP_Y; - rowHeight = 0; - } - tab.bounds = new LytRect(cursorX, cursorY, tabWidth, tabHeight); - cursorX += tabWidth + HEADER_GAP_X; - rowHeight = Math.max(rowHeight, tabHeight); - headerBottom = Math.max(headerBottom, tab.bounds.bottom()); + int cursorY = contentY; + int right = contentX; + if (titleParagraph != null) { + LytRect tb = titleParagraph.layout(context, contentX, cursorY, contentWidth); + cursorY = tb.bottom() + TITLE_GAP; + right = Math.max(right, tb.right()); } - - headerBounds = new LytRect(contentX, tabsY, contentWidth, Math.max(0, headerBottom - tabsY)); - int safeSelectedIndex = getSafeSelectedIndex(); - LytBlock activeBody = tabs.get(safeSelectedIndex).body; - LytRect bodyBounds = activeBody.layout(context, contentX, headerBounds.bottom() + BODY_GAP, contentWidth); - int contentRight = Math.max(Math.max(titleBounds.right(), headerBounds.right()), bodyBounds.right()); - int contentBottom = Math.max(headerBounds.bottom(), bodyBounds.bottom()); - contentBounds = new LytRect( - contentX, - contentY, - Math.max(0, contentRight - contentX), - Math.max(0, contentBottom - contentY)); + LytRect hb = headerBlock.layout(context, contentX, cursorY, contentWidth); + cursorY = hb.bottom() + BODY_GAP; + right = Math.max(right, hb.right()); + LytRect bb = activeBody().layout(context, contentX, cursorY, contentWidth); + right = Math.max(right, bb.right()); + + int contentW = right - contentX; + int contentH = bb.bottom() - contentY; return new LytRect( x, y, - Math.max(availableWidth, ACCENT_WIDTH + CONTAINER_PAD_X * 2 + contentBounds.width()), - contentBounds.height() + CONTAINER_PAD_Y * 2); + Math.max(availableWidth, ACCENT_WIDTH + CONTAINER_PAD_X * 2 + contentW), + contentH + CONTAINER_PAD_Y * 2); } @Override protected void onLayoutMoved(int deltaX, int deltaY) { - titleBounds = titleBounds.move(deltaX, deltaY); - headerBounds = headerBounds.move(deltaX, deltaY); - contentBounds = contentBounds.move(deltaX, deltaY); - if (titleParagraph != null) { - titleParagraph.moveLayoutPos(deltaX, deltaY); - } - for (TabState tab : tabs) { - tab.bounds = tab.bounds.move(deltaX, deltaY); - } - if (!tabs.isEmpty()) { - tabs.get(getSafeSelectedIndex()).body.moveLayoutPos(deltaX, deltaY); + for (LytNode child : getChildren()) { + if (child instanceof LytBlock b) { + b.moveLayoutPos(deltaX, deltaY); + } } } @Override - public void render(RenderContext context) { - if (tabs.isEmpty()) { - return; - } - int safeSelectedIndex = getSafeSelectedIndex(); - int accentArgb = context.resolveColor(accentColor); - context.fillRect(bounds, context.resolveColor(ColorUtils.BLOCKQUOTE_BACKGROUND)); - context.fillRect(bounds.x(), bounds.y(), ACCENT_WIDTH, bounds.height(), accentArgb); - if (titleParagraph != null) { - titleParagraph.render(context); - } - float panelRuleY = headerBounds.bottom() + HEADER_RULE_THICKNESS * 0.5f; - context.drawLine( - headerBounds.x(), - panelRuleY, - headerBounds.right(), - panelRuleY, - HEADER_RULE_THICKNESS, - HEADER_RULE_COLOR); - for (int index = 0; index < tabs.size(); index++) { - TabState tab = tabs.get(index); - boolean selected = index == safeSelectedIndex; - context.drawText( - tab.title, - tab.bounds.x() + HEADER_PAD_X, - tab.bounds.y() + HEADER_PAD_TOP, - tab.style(selected)); - if (selected) { - float activeRuleY = tab.bounds.bottom() - ACTIVE_RULE_THICKNESS * 0.5f; - context.drawLine( - tab.bounds.x(), - activeRuleY, - tab.bounds.right(), - activeRuleY, - ACTIVE_RULE_THICKNESS, - accentArgb); - } + public boolean usePrimitives() { + return true; + } + + @Override + public void computePrimitives(PrimitiveCollector c) { + var bounds = getBounds(); + c.emit( + new GuideRenderPrimitive.FillRect( + bounds.x(), + bounds.y(), + bounds.width(), + bounds.height(), + ColorUtils.BLOCKQUOTE_BACKGROUND.resolve())); + c.emit( + new GuideRenderPrimitive.FillRect( + bounds.x(), + bounds.y(), + ACCENT_WIDTH, + bounds.height(), + accentColor.resolve())); + // Panel rule between the header strip and the active body. + var hb = headerBlock.getBounds(); + if (hb != null && !hb.isEmpty()) { + float ruleY = hb.bottom() + HEADER_RULE_THICKNESS * 0.5f; + c.emit( + new GuideRenderPrimitive.DrawLine( + hb.x(), + ruleY, + hb.right(), + ruleY, + HEADER_RULE_THICKNESS, + HEADER_RULE_COLOR)); } - tabs.get(safeSelectedIndex).body.render(context); } + @Override + public void render(RenderContext context) {} + @Override public @Nullable LytNode pickNode(int x, int y) { if (!bounds.contains(x, y)) { return null; } - if (titleParagraph != null) { - LytNode titleNode = titleParagraph.pickNode(x, y); - if (titleNode != null) { - return titleNode; - } - } - for (TabState tab : tabs) { - if (tab.bounds.contains(x, y)) { - return this; - } - } - if (!tabs.isEmpty()) { - LytNode activeNode = tabs.get(getSafeSelectedIndex()).body.pickNode(x, y); - if (activeNode != null) { - return activeNode; + for (LytNode child : getChildren()) { + LytNode picked = child.pickNode(x, y); + if (picked != null) { + return picked; } } return this; @@ -252,32 +200,36 @@ public void render(RenderContext context) { @Override public boolean mouseClicked(GuideUiHost screen, int x, int y, int button, boolean doubleClick) { - if (tabs.isEmpty()) { - return false; + if (headerBlock.mouseClicked(screen, x, y, button, doubleClick)) { + return true; } - if (button == 0) { - for (int index = 0; index < tabs.size(); index++) { - if (tabs.get(index).bounds.contains(x, y)) { - if (selectedIndex != index) { - selectedIndex = index; - invalidateLayout(); - } - return true; - } - } - } - LytBlock activeBody = tabs.get(getSafeSelectedIndex()).body; - return activeBody instanceof InteractiveElement interactive + LytBlock body = activeBody(); + return body instanceof InteractiveElement interactive && interactive.mouseClicked(screen, x, y, button, doubleClick); } @Override public Optional getTooltip(float x, float y) { - if (tabs.isEmpty()) { - return Optional.empty(); + LytBlock body = activeBody(); + return body instanceof InteractiveElement interactive ? interactive.getTooltip(x, y) : Optional.empty(); + } + + private void selectTab(int index) { + if (selectedIndex != index) { + selectedIndex = index; + invalidateLayout(); + } + } + + private LytBlock activeBody() { + return bodies.get(getSafeSelectedIndex()); + } + + private int getSafeSelectedIndex() { + if (bodies.isEmpty()) { + return 0; } - LytBlock activeBody = tabs.get(getSafeSelectedIndex()).body; - return activeBody instanceof InteractiveElement interactive ? interactive.getTooltip(x, y) : Optional.empty(); + return Math.clamp(selectedIndex, 0, bodies.size() - 1); } @Nullable @@ -305,63 +257,24 @@ private LytParagraph buildTitleParagraph(@Nullable String title, @Nullable LytFl return paragraph; } - private int getSafeSelectedIndex() { - if (tabs.isEmpty()) { - return 0; - } - return Math.clamp(selectedIndex, 0, tabs.size() - 1); - } - - private static class TabState { - - private final String title; - private final LytBlock body; - private LytRect bounds = LytRect.empty(); - - private TabState(String title, LytBlock body) { - this.title = title; - this.body = body; - } - - private int measureWidth(LayoutContext context) { - return context.getStringWidth(title, style(false)) + HEADER_PAD_X * 2; - } - - private int measureHeight(LayoutContext context) { - return context.getLineHeight(style(false)) + HEADER_PAD_TOP + HEADER_PAD_BOTTOM; - } - - private ResolvedTextStyle style(boolean selected) { - return selected ? SELECTED_STYLE : IDLE_STYLE; - } - } - // Debug implementation @Override public List getDebugComponents() { List components = new ArrayList<>(); - - if (tabs.isEmpty() || headerBounds == null) { + var hb = headerBlock.getBounds(); + if (bodies.isEmpty() || hb == null) { return components; } - - // Each tab button - int tabWidth = headerBounds.width() / tabs.size(); - for (int i = 0; i < tabs.size(); i++) { - TabState tab = tabs.get(i); - int tabX = headerBounds.x() + (i * tabWidth); - LytRect tabBounds = new LytRect(tabX, headerBounds.y(), tabWidth, headerBounds.height()); - + var tabBounds = headerBlock.getTabBounds(); + for (int i = 0; i < tabBounds.size(); i++) { String extra = "Index: " + i; - if (i == selectedIndex) { + if (i == getSafeSelectedIndex()) { extra += ", Active"; } - - int priority = (i == selectedIndex) ? 20 : 15; - components.add(new SimpleComponentEntry("Tab:" + tab.title, tabBounds, extra, priority)); + int priority = (i == getSafeSelectedIndex()) ? 20 : 15; + components.add(new SimpleComponentEntry("Tab:" + titles.get(i), tabBounds.get(i), extra, priority)); } - return components; } } diff --git a/src/main/java/com/hfstudio/guidenh/guide/document/block/LytContentTabsHeader.java b/src/main/java/com/hfstudio/guidenh/guide/document/block/LytContentTabsHeader.java new file mode 100644 index 00000000..58a68e5f --- /dev/null +++ b/src/main/java/com/hfstudio/guidenh/guide/document/block/LytContentTabsHeader.java @@ -0,0 +1,235 @@ +package com.hfstudio.guidenh.guide.document.block; + +import java.util.ArrayList; +import java.util.List; +import java.util.Optional; +import java.util.function.IntConsumer; +import java.util.function.IntSupplier; + +import org.jetbrains.annotations.Nullable; + +import com.hfstudio.guidenh.guide.color.ColorValue; +import com.hfstudio.guidenh.guide.color.ConstantColor; +import com.hfstudio.guidenh.guide.document.LytRect; +import com.hfstudio.guidenh.guide.document.interaction.GuideTooltip; +import com.hfstudio.guidenh.guide.document.interaction.InteractiveElement; +import com.hfstudio.guidenh.guide.layout.LayoutContext; +import com.hfstudio.guidenh.guide.render.GuideRenderPrimitive; +import com.hfstudio.guidenh.guide.render.GuideText; +import com.hfstudio.guidenh.guide.render.PrimitiveCollector; +import com.hfstudio.guidenh.guide.render.RenderContext; +import com.hfstudio.guidenh.guide.style.ResolvedTextStyle; +import com.hfstudio.guidenh.guide.style.TextAlignment; +import com.hfstudio.guidenh.guide.style.WhiteSpaceMode; +import com.hfstudio.guidenh.guide.ui.GuideUiHost; + +/** + * The clickable tab strip of a {@link LytContentTabsBlock}: wraps tab titles + * into rows and draws them (selected/idle style + active underline). A real + * block, so the Rust layout treats the tabs block as three plain children + * (title, header, active body) with no spacers or pins. + *

          + * Measurement goes through {@link GuideText} (unified text pipeline), keeping + * measure and render on the same font. + */ +public class LytContentTabsHeader extends LytBlock implements InteractiveElement { + + private static final int HEADER_GAP_X = 10; + private static final int HEADER_GAP_Y = 5; + private static final int HEADER_PAD_X = 2; + private static final int HEADER_PAD_TOP = 1; + private static final int HEADER_PAD_BOTTOM = 5; + private static final int ACTIVE_RULE_THICKNESS = 2; + + private static final ResolvedTextStyle SELECTED_STYLE = new ResolvedTextStyle( + 1.0f, + false, + false, + false, + false, + false, + false, + false, + "", + new ConstantColor(0xFFF4F7FB), + WhiteSpaceMode.NORMAL, + TextAlignment.LEFT, + false, + null, + false, + 0.0f); + private static final ResolvedTextStyle IDLE_STYLE = new ResolvedTextStyle( + 1.0f, + false, + false, + false, + false, + false, + false, + false, + "", + new ConstantColor(0xFFD5DCE7), + WhiteSpaceMode.NORMAL, + TextAlignment.LEFT, + false, + null, + false, + 0.0f); + + private final List titles; + private final ColorValue accentColor; + private final IntSupplier selectedIndex; + private final IntConsumer onSelect; + private final List tabBounds = new ArrayList<>(); + + public LytContentTabsHeader(List titles, ColorValue accentColor, IntSupplier selectedIndex, + IntConsumer onSelect) { + this.titles = titles; + this.accentColor = accentColor; + this.selectedIndex = selectedIndex; + this.onSelect = onSelect; + } + + /** Tab hit rects in document coordinates (debug overlay / hit testing). */ + public List getTabBounds() { + return tabBounds; + } + + @Override + protected LytRect computeLayout(LayoutContext context, int x, int y, int availableWidth) { + tabBounds.clear(); + int cursorX = x; + int cursorY = y; + int rowHeight = 0; + int bottom = y; + for (String title : titles) { + int w = GuideText.measureWidth(title, IDLE_STYLE) + HEADER_PAD_X * 2; + int h = GuideText.lineHeight(IDLE_STYLE) + HEADER_PAD_TOP + HEADER_PAD_BOTTOM; + if (cursorX > x && cursorX + w > x + availableWidth) { + cursorX = x; + cursorY += rowHeight + HEADER_GAP_Y; + rowHeight = 0; + } + tabBounds.add(new LytRect(cursorX, cursorY, w, h)); + cursorX += w + HEADER_GAP_X; + rowHeight = Math.max(rowHeight, h); + bottom = Math.max(bottom, cursorY + h); + } + return new LytRect(x, y, availableWidth, bottom - y); + } + + @Override + protected void afterExternalLayout() { + // Recompute tab hit rects from the Rust-computed bounds. The Java + // layout pre-pass no longer calls computeLayout, so tabBounds must + // be rebuilt here to stay valid for hit testing and rendering. + recomputeTabBounds(); + } + + private void recomputeTabBounds() { + tabBounds.clear(); + int x = bounds.x(); + int y = bounds.y(); + int availableWidth = Math.max(1, bounds.width()); + int cursorX = x; + int cursorY = y; + int rowHeight = 0; + int bottom = y; + for (String title : titles) { + int w = GuideText.measureWidth(title, IDLE_STYLE) + HEADER_PAD_X * 2; + int h = GuideText.lineHeight(IDLE_STYLE) + HEADER_PAD_TOP + HEADER_PAD_BOTTOM; + if (cursorX > x && cursorX + w > x + availableWidth) { + cursorX = x; + cursorY += rowHeight + HEADER_GAP_Y; + rowHeight = 0; + } + tabBounds.add(new LytRect(cursorX, cursorY, w, h)); + cursorX += w + HEADER_GAP_X; + rowHeight = Math.max(rowHeight, h); + bottom = Math.max(bottom, cursorY + h); + } + } + + @Override + protected void onLayoutMoved(int deltaX, int deltaY) { + for (int i = 0; i < tabBounds.size(); i++) { + tabBounds.set( + i, + tabBounds.get(i) + .move(deltaX, deltaY)); + } + } + + @Override + public int getExplicitHeight() { + if (titles.isEmpty()) { + return 0; + } + return GuideText.lineHeight(IDLE_STYLE) + HEADER_PAD_TOP + HEADER_PAD_BOTTOM; + } + + @Override + public boolean usePrimitives() { + return true; + } + + @Override + public void computePrimitives(PrimitiveCollector c) { + int sel = Math.clamp(selectedIndex.getAsInt(), 0, Math.max(0, titles.size() - 1)); + int accentArgb = accentColor.resolve(); + for (int i = 0; i < tabBounds.size(); i++) { + LytRect tb = tabBounds.get(i); + boolean selected = i == sel; + GuideText.emitText( + c, + titles.get(i), + tb.x() + HEADER_PAD_X, + tb.y() + HEADER_PAD_TOP, + selected ? SELECTED_STYLE : IDLE_STYLE); + if (selected) { + c.emit( + new GuideRenderPrimitive.FillRect( + tb.x(), + tb.bottom() - ACTIVE_RULE_THICKNESS, + tb.width(), + ACTIVE_RULE_THICKNESS, + accentArgb)); + } + } + } + + @Override + public void render(RenderContext context) {} + + @Override + public @Nullable LytNode pickNode(int x, int y) { + for (LytRect tb : tabBounds) { + if (tb.contains(x, y)) { + return this; + } + } + return null; + } + + @Override + public boolean mouseClicked(GuideUiHost screen, int x, int y, int button, boolean doubleClick) { + if (button != 0) { + return false; + } + for (int i = 0; i < tabBounds.size(); i++) { + if (tabBounds.get(i) + .contains(x, y)) { + if (i != selectedIndex.getAsInt()) { + onSelect.accept(i); + } + return true; + } + } + return false; + } + + @Override + public Optional getTooltip(float x, float y) { + return Optional.empty(); + } +} diff --git a/src/main/java/com/hfstudio/guidenh/guide/document/block/LytCyclingItemImage.java b/src/main/java/com/hfstudio/guidenh/guide/document/block/LytCyclingItemImage.java index bfe7552b..334b157f 100644 --- a/src/main/java/com/hfstudio/guidenh/guide/document/block/LytCyclingItemImage.java +++ b/src/main/java/com/hfstudio/guidenh/guide/document/block/LytCyclingItemImage.java @@ -29,6 +29,14 @@ private ItemStack currentStack() { return stacks.get(cachedIdx); } + @Override + public void computePrimitives(com.hfstudio.guidenh.guide.render.PrimitiveCollector c) { + // Swap in the currently displayed stack before collecting (per-second + // cycling, evaluated fresh each frame). + this.stack = currentStack(); + super.computePrimitives(c); + } + @Override public void render(RenderContext context) { this.stack = currentStack(); diff --git a/src/main/java/com/hfstudio/guidenh/guide/document/block/LytDetailsBlock.java b/src/main/java/com/hfstudio/guidenh/guide/document/block/LytDetailsBlock.java index a4936c24..1663a99f 100644 --- a/src/main/java/com/hfstudio/guidenh/guide/document/block/LytDetailsBlock.java +++ b/src/main/java/com/hfstudio/guidenh/guide/document/block/LytDetailsBlock.java @@ -12,6 +12,8 @@ import com.hfstudio.guidenh.guide.internal.editor.gui.SceneEditorVerticalScrollbar; import com.hfstudio.guidenh.guide.internal.util.SmoothFloatState; import com.hfstudio.guidenh.guide.layout.LayoutContext; +import com.hfstudio.guidenh.guide.render.GuideRenderPrimitive; +import com.hfstudio.guidenh.guide.render.PrimitiveCollector; import com.hfstudio.guidenh.guide.render.RenderContext; import com.hfstudio.guidenh.guide.style.BorderStyle; import com.hfstudio.guidenh.guide.ui.GuideUiHost; @@ -27,6 +29,21 @@ public class LytDetailsBlock extends LytBlock implements InteractiveElement, Lyt private static final int PADDING = 6; private static final int GAP = 4; private static final int BORDER_WIDTH = 1; + /** + * Top margin on the summary-marker paragraph (in px) that optically centers + * the ">"/"v" marker glyphs against the summary text. The marker glyphs are + * mid-em symbols whose ink sits higher inside their 1.55x line-height box + * than the text's visual (x-height) centerline; the row's alignItems CENTER + * aligns whole line-height boxes, so the marker needs its own box nudged + * down. Taffy's CENTER aligns the margin box, which shifts the marker's + * content box down by half this margin (same declaration pattern as the + * search-result icon rows, GuideSearchResultDocumentBuilder + * RESULT_ICON_MARGIN_TOP). A 1px margin = 0.5px downward shift; measured + * against the actual guide font (Microsoft YaHei) ">" sits ~0.55px high and + * "v" ~0px off the x-height middle, so half a pixel puts ">" on the + * centerline with only a negligible nudge to "v". + */ + private static final int SUMMARY_MARKER_ALIGN_MARGIN_TOP = 1; private static final int SCROLLBAR_WIDTH = 5; private static final int SCROLLBAR_GAP = 4; private static final int MIN_SCROLLBAR_THUMB = 14; @@ -36,7 +53,8 @@ public class LytDetailsBlock extends LytBlock implements InteractiveElement, Lyt private final LytHBox summaryRow = new LytHBox(); private final LytParagraph summaryMarker = new LytParagraph(); private final LytParagraph summaryContent = new LytParagraph(); - private final LytVBox content = new LytVBox(); + /** Scrollable content viewport; clips its children to its own bounds. */ + private final LytViewportBox content = new LytViewportBox(); private final BorderRenderer borderRenderer = new BorderRenderer(); private final SmoothFloatState visualContentScrollOffsetY = new SmoothFloatState(); @@ -48,12 +66,9 @@ public class LytDetailsBlock extends LytBlock implements InteractiveElement, Lyt private int preferredWidth; @Getter private int preferredContentHeight; - private int contentHeight; - private int contentViewportX; - private int contentViewportY; - private int contentViewportWidth; - private int contentViewportHeight; private int contentScrollOffsetY; + /** Visual-scroll delta currently baked into the content bounds (see computePrimitives). */ + private int visualDeltaY; private boolean draggingContent; private int dragLastDocumentY; private boolean draggingScrollbar; @@ -66,7 +81,7 @@ public LytDetailsBlock() { summaryRow.setFullWidth(true); summaryRow.setAlignItems(AlignItems.CENTER); - summaryMarker.setMarginTop(0); + summaryMarker.setMarginTop(SUMMARY_MARKER_ALIGN_MARGIN_TOP); summaryMarker.setMarginBottom(0); summaryMarker.modifyStyle( style -> style.bold(true) @@ -80,7 +95,21 @@ public LytDetailsBlock() { content.parent = this; content.setGap(4); - content.setFullWidth(true); + // No setFullWidth(true) on purpose: fullWidth serializes as an explicit + // align-self Stretch, which under Taffy 0.12 resolves against the viewport + // width WITHOUT subtracting the declared margins, so the viewport would + // overflow the details body by 2*(PADDING+BORDER_WIDTH) on each side. + // Leaving the cross size auto makes align-self Auto, so it inherits this + // block's default alignItems Stretch (LayoutStyleExtractor returns Stretch + // for every non-LytAxisBox container), and the stretch computes the width + // as details body width minus the margins below. + // Inset the content viewport inside the details body by the padding + + // border (7px/side): Rust positions the viewport at details.x+7. Declared + // as margins (LytDetailsBlock is a plain LytBlock and has no box + // padding/border to serialize; LayoutStyleExtractor carries block + // margins to the FlatBuffer style verbatim). + content.setMarginLeft(PADDING + BORDER_WIDTH); + content.setMarginRight(PADDING + BORDER_WIDTH); summaryRow.append(summaryMarker); summaryRow.append(summaryContent); @@ -169,31 +198,30 @@ protected LytRect computeLayout(LayoutContext context, int x, int y, int availab LytRect summaryBounds = summaryRow.layout(context, innerX, innerY, innerWidth); int totalHeight = PADDING + BORDER_WIDTH + summaryBounds.height() + PADDING + BORDER_WIDTH; - contentHeight = 0; - contentViewportX = innerX; - contentViewportY = summaryBounds.bottom() + GAP; - contentViewportWidth = innerWidth; - contentViewportHeight = 0; - if (open) { - LytRect measuredContent = content.layout(context, contentViewportX, contentViewportY, contentViewportWidth); - contentHeight = measuredContent.height(); - contentViewportHeight = preferredContentHeight > 0 ? preferredContentHeight : contentHeight; - if (preferredContentHeight > 0 && contentHeight > contentViewportHeight) { - contentViewportWidth = Math.max(1, innerWidth - SCROLLBAR_WIDTH - SCROLLBAR_GAP); - measuredContent = content.layout(context, contentViewportX, contentViewportY, contentViewportWidth); - contentHeight = measuredContent.height(); + int contentX = innerX; + int contentY = summaryBounds.bottom() + GAP; + int contentWidth = innerWidth; + LytRect measuredContent = content.layout(context, contentX, contentY, contentWidth); + int naturalHeight = measuredContent.height(); + int viewportHeight = preferredContentHeight > 0 ? preferredContentHeight : naturalHeight; + if (preferredContentHeight > 0 && naturalHeight > viewportHeight) { + contentWidth = Math.max(1, innerWidth - SCROLLBAR_WIDTH - SCROLLBAR_GAP); + measuredContent = content.layout(context, contentX, contentY, contentWidth); + naturalHeight = measuredContent.height(); + viewportHeight = preferredContentHeight; } - contentViewportHeight = preferredContentHeight > 0 ? preferredContentHeight : contentHeight; + content.setExplicitHeight(viewportHeight); setContentScrollOffset(contentScrollOffsetY); snapVisualScrollToTarget(); totalHeight = PADDING + BORDER_WIDTH + summaryBounds.height() + GAP - + contentViewportHeight + + viewportHeight + PADDING + BORDER_WIDTH; } else { + content.setExplicitHeight(-1); setContentScrollOffset(0); snapVisualScrollToTarget(); } @@ -201,32 +229,124 @@ protected LytRect computeLayout(LayoutContext context, int x, int y, int availab return new LytRect(x, y, safeWidth, totalHeight); } + // Derived geometry, computed from current bounds with no layout-time fields. + + /** The content viewport rect is simply the viewport box's own bounds. */ + private LytRect getContentViewportBounds() { + return content.getBounds(); + } + + /** + * Natural (unscrolled) content height, derived from the children's current + * bounds. The scroll offset cancels out: children and the content box move + * together. + */ + private int getContentNaturalHeight() { + int bottom = Integer.MIN_VALUE; + for (LytNode child : content.getChildren()) { + if (child instanceof LytBlock childBlock) { + bottom = Math.max( + bottom, + childBlock.getBounds() + .bottom()); + } + } + return bottom == Integer.MIN_VALUE ? 0 + : Math.max( + 0, + bottom - content.getBounds() + .y()); + } + + private int getContentViewportHeight() { + return preferredContentHeight > 0 ? preferredContentHeight : getContentNaturalHeight(); + } + + private int getMaxContentScroll() { + return Math.max(0, getContentNaturalHeight() - getContentViewportHeight()); + } + @Override protected void onLayoutMoved(int deltaX, int deltaY) { summaryRow.moveLayoutPos(deltaX, deltaY); content.moveLayoutPos(deltaX, deltaY); - contentViewportX += deltaX; - contentViewportY += deltaY; } @Override - public void render(RenderContext context) { + public boolean usePrimitives() { + return true; + } + + @Override + public void computePrimitives(PrimitiveCollector c) { + c.emit( + new GuideRenderPrimitive.FillRect( + bounds.x(), + bounds.y(), + bounds.width(), + bounds.height(), + ColorUtils.BLOCKQUOTE_BACKGROUND.resolve())); + + // Advance the smooth scroll and bake the visual delta into the content + // bounds (collector traverses the content right after this). updateVisualScroll(); - context.fillRect(bounds, ColorUtils.BLOCKQUOTE_BACKGROUND); - summaryRow.render(context); - if (open) { - LytRect viewport = getContentViewportBounds(); - context.pushLocalScissor(viewport); - try { - renderContentWithVisualOffset(context); - } finally { - context.popScissor(); + int newDelta = contentScrollOffsetY - visualContentScrollOffsetY.rounded(); + if (open && newDelta != visualDeltaY + && !content.getBounds() + .isEmpty()) { + content.moveLayoutPos(0, newDelta - visualDeltaY); + visualDeltaY = newDelta; + } + + LytRect trackBounds = getScrollbarTrackBounds(); + if (open && !trackBounds.isEmpty()) { + c.emit( + new GuideRenderPrimitive.FillRect( + trackBounds.x(), + trackBounds.y(), + trackBounds.width(), + trackBounds.height(), + 0x30242B33)); + LytRect thumbBounds = getScrollbarThumbBounds(); + if (!thumbBounds.isEmpty()) { + c.emit( + new GuideRenderPrimitive.FillRect( + thumbBounds.x(), + thumbBounds.y(), + thumbBounds.width(), + thumbBounds.height(), + draggingScrollbar ? 0xFFCDD6E1 : 0xA0AAB5C2)); } - renderScrollbar(context); } - borderRenderer.render(context, bounds, DETAILS_BORDER, DETAILS_BORDER, DETAILS_BORDER, DETAILS_BORDER); } + @Override + public void emitDecorations(PrimitiveCollector c) { + c.emit( + new GuideRenderPrimitive.DrawBorder( + bounds.x(), + bounds.y(), + bounds.width(), + bounds.height(), + BORDER_WIDTH, + BORDER_WIDTH, + BORDER_WIDTH, + BORDER_WIDTH, + DETAILS_BORDER.color() != null ? DETAILS_BORDER.color() + .resolve() : 0xFF000000)); + } + + @Override + protected void afterExternalLayout() { + // The writeback reset the content to the unscrolled position; re-apply + // the current scroll offset and restart the visual-delta bookkeeping. + updateContentPosition(); + visualDeltaY = 0; + } + + @Override + public void render(RenderContext context) {} + @Override public boolean mouseClicked(GuideUiHost screen, int x, int y, int button, boolean doubleClick) { if (button != 0) { @@ -332,19 +452,12 @@ private void renderScrollbar(RenderContext context) { } } - private LytRect getContentViewportBounds() { - return new LytRect(contentViewportX, contentViewportY, contentViewportWidth, contentViewportHeight); - } - private LytRect getScrollbarTrackBounds() { if (getMaxContentScroll() <= 0) { return LytRect.empty(); } - return new LytRect( - contentViewportX + contentViewportWidth + SCROLLBAR_GAP, - contentViewportY, - SCROLLBAR_WIDTH, - contentViewportHeight); + LytRect viewport = getContentViewportBounds(); + return new LytRect(viewport.right() + SCROLLBAR_GAP, viewport.y(), SCROLLBAR_WIDTH, viewport.height()); } private LytRect getScrollbarThumbBounds() { @@ -352,8 +465,9 @@ private LytRect getScrollbarThumbBounds() { if (track.isEmpty()) { return LytRect.empty(); } - int thumbHeight = Math - .max(MIN_SCROLLBAR_THUMB, track.height() * track.height() / Math.max(track.height(), contentHeight)); + int thumbHeight = Math.max( + MIN_SCROLLBAR_THUMB, + track.height() * track.height() / Math.max(track.height(), getContentNaturalHeight())); thumbHeight = Math.min(thumbHeight, track.height()); int maxScroll = getMaxContentScroll(); int thumbTrack = Math.max(1, track.height() - thumbHeight); @@ -364,10 +478,6 @@ private LytRect getScrollbarThumbBounds() { return new LytRect(track.x(), thumbY, track.width(), thumbHeight); } - private int getMaxContentScroll() { - return Math.max(0, contentHeight - contentViewportHeight); - } - private void setContentScrollOffset(int contentScrollOffsetY) { this.contentScrollOffsetY = SceneEditorVerticalScrollbar.clamp(contentScrollOffsetY, 0, getMaxContentScroll()); updateContentPosition(); @@ -375,30 +485,21 @@ private void setContentScrollOffset(int contentScrollOffsetY) { private void updateContentPosition() { if (!content.getBounds() - .isEmpty()) { + .isEmpty() + && !summaryRow.getBounds() + .isEmpty()) { + int naturalX = bounds.x() + PADDING + BORDER_WIDTH; + int naturalY = summaryRow.getBounds() + .bottom() + GAP; content.moveLayoutPos( - contentViewportX - content.getBounds() + naturalX - content.getBounds() .x(), - contentViewportY - contentScrollOffsetY + naturalY - contentScrollOffsetY - content.getBounds() .y()); } } - private void renderContentWithVisualOffset(RenderContext context) { - int renderDeltaY = contentScrollOffsetY - visualContentScrollOffsetY.rounded(); - if (renderDeltaY == 0) { - content.render(context); - return; - } - content.moveLayoutPos(0, renderDeltaY); - try { - content.render(context); - } finally { - content.moveLayoutPos(0, -renderDeltaY); - } - } - private void updateScrollFromMouseY(int mouseY) { LytRect track = getScrollbarTrackBounds(); LytRect thumb = getScrollbarThumbBounds(); @@ -419,6 +520,6 @@ private void snapVisualScrollToTarget() { private void updateVisualScroll() { visualContentScrollOffsetY - .updateTowards(contentScrollOffsetY, 28f, 0.25f, 0.01f, Math.max(128f, contentViewportHeight * 2f)); + .updateTowards(contentScrollOffsetY, 28f, 0.25f, 0.01f, Math.max(128f, getContentViewportHeight() * 2f)); } } diff --git a/src/main/java/com/hfstudio/guidenh/guide/document/block/LytDocument.java b/src/main/java/com/hfstudio/guidenh/guide/document/block/LytDocument.java index 0c52b97d..c8b4587b 100644 --- a/src/main/java/com/hfstudio/guidenh/guide/document/block/LytDocument.java +++ b/src/main/java/com/hfstudio/guidenh/guide/document/block/LytDocument.java @@ -1,19 +1,36 @@ package com.hfstudio.guidenh.guide.document.block; +import java.nio.ByteBuffer; import java.util.ArrayList; +import java.util.HashMap; import java.util.List; +import java.util.Map; import java.util.Objects; import org.jetbrains.annotations.Nullable; +import org.lwjgl.opengl.GL11; import com.hfstudio.guidenh.guide.document.LytRect; +import com.hfstudio.guidenh.guide.document.block.recipes.LytRecipeGalleryRow; import com.hfstudio.guidenh.guide.document.flow.LytFlowContainer; import com.hfstudio.guidenh.guide.document.flow.LytFlowContent; import com.hfstudio.guidenh.guide.document.flow.LytFlowInlineBlock; import com.hfstudio.guidenh.guide.document.interaction.DocumentInteractionSnapshot; import com.hfstudio.guidenh.guide.document.interaction.FlowInteractionPath; +import com.hfstudio.guidenh.guide.internal.util.DisplayScale; +import com.hfstudio.guidenh.guide.layout.LayoutBridge; import com.hfstudio.guidenh.guide.layout.LayoutContext; -import com.hfstudio.guidenh.guide.layout.Layouts; +import com.hfstudio.guidenh.guide.layout.LayoutTreeSerializer; +import com.hfstudio.guidenh.guide.layout.flatbuffers.LayoutResult; +import com.hfstudio.guidenh.guide.render.GlyphRunData; +import com.hfstudio.guidenh.guide.render.GlyphRunGroup; +import com.hfstudio.guidenh.guide.render.GlyphRunHolder; +import com.hfstudio.guidenh.guide.render.GuideGlyphAtlas; +import com.hfstudio.guidenh.guide.render.GuideRenderEngine; +import com.hfstudio.guidenh.guide.render.GuideRenderPrimitive; +import com.hfstudio.guidenh.guide.render.GuideText; +import com.hfstudio.guidenh.guide.render.GuidebookSceneRenderer; +import com.hfstudio.guidenh.guide.render.PrimitiveCollector; import com.hfstudio.guidenh.guide.render.RenderContext; import com.hfstudio.guidenh.guide.scene.support.GuideDebugLog; @@ -27,6 +44,21 @@ public class LytDocument extends LytNode implements LytBlockContainer { private static final FlowInteractionPath EMPTY_FLOW_PATH = FlowInteractionPath.empty(); + private static volatile GuideRenderEngine renderEngine; + + public static GuideRenderEngine getRenderEngine() { + GuideRenderEngine e = renderEngine; + if (e == null) { + synchronized (LytDocument.class) { + e = renderEngine; + if (e == null) { + renderEngine = e = new GuideRenderEngine(GuideGlyphAtlas.instance(), new GuidebookSceneRenderer()); + } + } + } + return e; + } + @Getter private final List blocks = new ArrayList<>(); @@ -40,6 +72,15 @@ public class LytDocument extends LytNode implements LytBlockContainer { @Getter private long contentRevision; + /** Cache key state for {@link #updateLayout}: font handle + render scale. */ + private long layoutFontHandle = -1; + private int layoutRenderScale = -1; + /** Base font size joins the updateLayout cache key (see {@link #updateLayout}). */ + private float layoutBaseFontSize = -1f; + /** Resolved base/code family join the updateLayout cache key (see {@link #updateLayout}). */ + private String layoutBaseFamily = null; + private String layoutCodeFamily = null; + @Nullable private DocumentInteractionSnapshot hoveredElement; @@ -54,6 +95,20 @@ public class LytDocument extends LytNode implements LytBlockContainer { private int cachedViewportBottom = Integer.MIN_VALUE; private boolean visibleCacheValid; + /** + * Diagnostic overlay (JVM flag {@code -Dguidenh.layoutOverlay=true} or the + * in-game debug menu option): draws Java bounds (green), Rust FlatLayout + * rects (red), glyph quads (blue) and the viewport scissor (yellow) on top + * of the document. Resolved per call so the in-game toggle takes effect + * immediately. + */ + private static boolean isOverlayEnabled() { + return GuideDebugLog.isLayoutOverlayEnabled(); + } + + private final List overlayRustBlocks = new ArrayList<>(); + private final List overlayRustRects = new ArrayList<>(); + public int getAvailableWidth() { return layout != null ? layout.availableWidth() : 0; } @@ -165,30 +220,299 @@ private void invalidateVisibleCache() { } public void updateLayout(LayoutContext context, int availableWidth) { - if (layout != null && layout.availableWidth == availableWidth) { + // Cache key must include the font handle (a 0 to non-zero transition + // must not leave the document stuck on the Java fallback) and the + // display pixel ratio (glyph bitmaps are rasterized per render scale). + // The base font size also joins the key, so a readerFontSize config + // change re-lays out automatically with no explicit event, mirroring the + // contentZoom precedent in GuideScreen.ensureLayout. + // The resolved base and code families join the key as well, so a + // readerFontFamily / readerCodeFontFamily config change re-lays out the + // same way (null-safe compare; the default "system"/"mono" resolve to + // null/"monospace", which adds no extra re-layouts). + long fontHandle = com.hfstudio.guidenh.guide.layout.LayoutBridge.getFontHandle(); + int renderScale = com.hfstudio.guidenh.guide.internal.util.DisplayScale.scaleFactor(); + float baseFontSize = GuideText.resolveBaseFontSize(); + String baseFamily = GuideText.resolveBaseFontFamily(); + String codeFamily = GuideText.resolveCodeFontFamily(); + if (layout != null && layout.availableWidth == availableWidth + && layoutFontHandle == fontHandle + && layoutRenderScale == renderScale + && layoutBaseFontSize == baseFontSize + && Objects.equals(layoutBaseFamily, baseFamily) + && Objects.equals(layoutCodeFamily, codeFamily)) { return; } + layoutFontHandle = fontHandle; + layoutRenderScale = renderScale; + layoutBaseFontSize = baseFontSize; + layoutBaseFamily = baseFamily; + layoutCodeFamily = codeFamily; + groupRecipeGalleries(); layout = createLayout(context, availableWidth); } + /** + * Group runs of ≥ 2 consecutive recipe boxes at the document top level + * into wrapping {@link LytRecipeGalleryRow}s, so recipes fill the available + * width instead of stacking one per row. Idempotent: an already-grouped + * document is left untouched (no mutation, no extra invalidation). Runs + * before every layout so asynchronously inserted recipe boxes (placeholders + * resolving late) join an adjacent gallery. + */ + private void groupRecipeGalleries() { + // Pass 1: existing gallery rows absorb immediately following single + // recipe boxes (manual index, since the list shrinks on each absorb). + for (int i = 0; i + 1 < blocks.size();) { + if (blocks.get(i) instanceof LytRecipeGalleryRow row + && LytRecipeGalleryRow.isRecipeBox(blocks.get(i + 1))) { + row.append(blocks.get(i + 1)); // re-parents the box off the document + invalidateLayout(); + } else { + i++; + } + } + // Pass 2: wrap remaining runs of >= 2 consecutive recipe boxes. + for (int i = 0; i < blocks.size(); i++) { + if (!LytRecipeGalleryRow.isRecipeBox(blocks.get(i))) continue; + int j = i + 1; + while (j < blocks.size() && LytRecipeGalleryRow.isRecipeBox(blocks.get(j))) j++; + if (j - i >= 2) { + List run = new ArrayList<>(blocks.subList(i, j)); + var row = new LytRecipeGalleryRow(); + for (LytBlock box : run) { + row.append(box); // re-parents each box off the document + } + row.parent = this; + blocks.add(i, row); + invalidateLayout(); + } + i = j; // continue after the run; a single box does NOT stop the scan + } + } + private Layout createLayout(LayoutContext context, int availableWidth) { - var bounds = Layouts.verticalLayout(context, blocks, 0, 0, availableWidth, 5, 5, 5, 5, 0, AlignItems.START); - int contentHeight = bounds.height(); - // Document-level floats (LytDocumentFloat) report zero height so they do not advance the - // vertical cursor in verticalLayout. If a float is taller than the text that wraps beside - // it, the float visually extends below the last paragraph but the computed contentHeight - // does not reflect this, causing the scroll area to be truncated. - // After the full layout pass, any remaining active floats represent exactly this case. - // Retrieve their maximum bottom edge and extend contentHeight to cover them. - var floatBottom = context.clearFloats(true, true); - if (floatBottom.isPresent() && floatBottom.getAsInt() > contentHeight) { - contentHeight = floatBottom.getAsInt() + 5; + // The Java layout pre-pass is gone from the main document pipeline: the + // tree is serialized without a pre-layout step and Rust is the sole + // authority for document geometry. The Java pre-pass (Layouts.java) is + // still live for the non-document chains (tooltip, annotation, editor, + // page title, inline-block Mermaid fallback), which bypass the + // collector and lay out via LayoutContext directly. + + // --- Rust layout pipeline (sole authority for geometry) --- + int contentHeight = 0; + long fontHandle = LayoutBridge.getFontHandle(); + // Clear glyph runs up front, across the WHOLE tree (not just the + // serialized nodes): on any Rust failure/empty result, no stale + // run may survive to be drawn at outdated coordinates. + for (LytBlock top : blocks) { + clearGlyphRuns(top); + } + try { + var serializer = new LayoutTreeSerializer(); + byte[] input = serializer + .serialize(this, availableWidth, context.getVisualScale(), DisplayScale.scaleFactor()); + long t0 = System.nanoTime(); + byte[] result = LayoutBridge.measureLayout(fontHandle, input); + long elapsed = System.nanoTime() - t0; + GuideDebugLog.warnAlways("Layout: measureLayout took {} ms", elapsed / 1_000_000); + if (result.length > 0) { + var flatResult = LayoutResult.getRootAsLayoutResult(ByteBuffer.wrap(result)); + String debugInfo = flatResult.debugInfo(); + if (debugInfo != null && !debugInfo.isEmpty()) { + GuideDebugLog.warnAlways("Layout: Rust debug_info = {}", debugInfo); + } + contentHeight = (int) flatResult.contentHeight(); + // Layout landing: overwrite every serialized block's bounds with + // the Rust-computed rect so blocks, glyph runs and floats share + // one coordinate truth. The FlatLayout vector is index-aligned + // with the serializer's flat nodes. + overlayRustBlocks.clear(); + overlayRustRects.clear(); + int numLayouts = flatResult.nodesLength(); + for (int i = 0; i < numLayouts; i++) { + var fl = flatResult.nodes(i); + if (fl == null) continue; + LytNode node = serializer.getNodeByFlatIndex(i); + if (!(node instanceof LytBlock lb)) continue; + LytRect rustRect = new LytRect( + Math.round(fl.x()), + Math.round(fl.y()), + Math.max(0, Math.round(fl.w())), + Math.max(0, Math.round(fl.h()))); + lb.applyExternalLayout(rustRect); + if (isOverlayEnabled()) { + overlayRustBlocks.add(lb); + overlayRustRects.add(rustRect); + } + } + // Upload unique glyph bitmaps (hi-res, rasterized by Rust at + // render_scale) to the atlas. Placement is already baked into + // the per-glyph quads below. + var atlas = GuideGlyphAtlas.instance(); + int numBitmaps = flatResult.bitmapsLength(); + for (int bi = 0; bi < numBitmaps; bi++) { + var bmp = flatResult.bitmaps(bi); + if (bmp == null) continue; + int w = (int) bmp.w(); + int h = (int) bmp.h(); + if (w <= 0 || h <= 0) continue; + ByteBuffer rgbaBuf = bmp.rgbaAsByteBuffer(); + byte[] rgba = new byte[rgbaBuf.remaining()]; + rgbaBuf.get(rgba); + atlas.upload(bmp.key(), rgba, w, h); + } + // Extract glyph runs (final document-space quads, top-left + // origin) and group them per paragraph node; a rich + // paragraph yields one run per span, then inject them together + // with the span decoration rects. + Map> runsByNode = new HashMap<>(); + int numRuns = flatResult.glyphRunsLength(); + for (int ri = 0; ri < numRuns; ri++) { + var fbRun = flatResult.glyphRuns(ri); + if (fbRun == null) continue; + int numGlyphs = fbRun.glyphsLength(); + var placed = new ArrayList(numGlyphs); + for (int gi = 0; gi < numGlyphs; gi++) { + var fbg = fbRun.glyphs(gi); + if (fbg != null) { + placed.add( + new GuideRenderPrimitive.PlacedGlyph( + fbg.bitmapKey(), + fbg.x(), + fbg.y(), + fbg.w(), + fbg.h(), + (int) fbg.lineIndex(), + fbg.baseline())); + } + } + runsByNode.computeIfAbsent((int) fbRun.nodeIndex(), k -> new ArrayList<>()) + .add(new GlyphRunGroup(placed, (int) fbRun.argb(), fbRun.shear())); + } + Map> backgroundsByNode = new HashMap<>(); + Map> linesByNode = new HashMap<>(); + Map> separatorsByNode = new HashMap<>(); + Map> decorationsByNode = new HashMap<>(); + int numDecorations = flatResult.decorationsLength(); + for (int di = 0; di < numDecorations; di++) { + var d = flatResult.decorations(di); + if (d == null) continue; + var rect = new GuideRenderPrimitive.FillRect( + Math.round(d.x()), + Math.round(d.y()), + Math.round(d.w()), + Math.round(d.h()), + (int) d.argb()); + if (d.kind() == 3) { + separatorsByNode.computeIfAbsent((int) d.node(), k -> new ArrayList<>()) + .add(rect); + } else if (d.kind() == 0) { + backgroundsByNode.computeIfAbsent((int) d.node(), k -> new ArrayList<>()) + .add(rect); + } else if (d.kind() == 4 || d.kind() == 5) { + // Wavy (4) / dotted (5) decorations keep their kind so + // the render engine can pick the sine or dot brush, and + // they must NOT fall into the plain-line (lines) bucket. + decorationsByNode.computeIfAbsent((int) d.node(), k -> new ArrayList<>()) + .add( + new GuideRenderPrimitive.DrawDecorationLine( + d.x(), + d.y(), + d.w(), + d.h(), + (int) d.argb(), + d.kind())); + } else { + linesByNode.computeIfAbsent((int) d.node(), k -> new ArrayList<>()) + .add(rect); + } + } + for (var entry : runsByNode.entrySet()) { + LytNode node = serializer.getNodeByFlatIndex(entry.getKey()); + if (node instanceof GlyphRunHolder holder) { + holder.setGlyphData( + new GlyphRunData( + entry.getValue(), + backgroundsByNode.getOrDefault(entry.getKey(), List.of()), + linesByNode.getOrDefault(entry.getKey(), List.of()), + separatorsByNode.getOrDefault(entry.getKey(), List.of()), + decorationsByNode.getOrDefault(entry.getKey(), List.of()))); + GuideDebugLog.warnAlways( + "Layout: set {} glyph groups on paragraph at flat index {}", + entry.getValue() + .size(), + entry.getKey()); + } + } + // Post pass: let blocks re-apply state that depends on their + // children's final bounds (e.g. scroll offsets) BEFORE the + // cull-bounds union is recomputed from the moved subtrees. + // Runs AFTER glyph-data injection so onLayoutMoved() shifts + // existing glyph quads together with the block position. + for (int i = 0; i < numLayouts; i++) { + LytNode node = serializer.getNodeByFlatIndex(i); + if (node instanceof LytBlock lb) { + lb.afterExternalLayout(); + } + } + // Post-layout baseline alignment. Consumes the per-glyph + // baselines to make alignItems=BASELINE row containers truly + // baseline-align their children (taffy 0.12.1 degrades BASELINE + // to the bottom edge). Runs after glyph-data injection AND + // afterExternalLayout so both bounds and glyph positions are + // final; translation goes through moveLayoutPos (onLayoutMoved) + // so the baseline-y invariant holds. Pure walk: containers that + // are not BASELINE rows are never touched, so their layout + // output stays byte-identical to the taffy writeback. + RowBaselineAligner.align(this); + } + } catch (Exception e) { + GuideDebugLog.warnAlways("Layout: Rust pipeline failed", e); } + // Culling bounds: subtree union, recomputed on EVERY layout, success or + // fallback (stale unions cull content that moved into view). A floated + // child, or any overflowing content, must keep its ancestors visible, + // because Taffy does not extend containers for floats. + for (LytBlock top : blocks) { + computeCullBounds(top); + } + var cachedBounds = new LytRect(0, 0, availableWidth, contentHeight); return new Layout(availableWidth, contentHeight, cachedBounds); } + /** + * Recursively clear glyph runs in the subtree (pre-layout wipe so no stale + * run survives a failed layout pass). + */ + private static void clearGlyphRuns(LytBlock block) { + if (block instanceof GlyphRunHolder holder) { + holder.setGlyphData(null); + } + for (LytNode child : block.getChildren()) { + if (child instanceof LytBlock childBlock) { + clearGlyphRuns(childBlock); + } + } + } + + /** + * Recompute the subtree-union cull bounds for {@code block} (post-order: + * children first). Returns the union rect, also stored on the block. + */ + private static LytRect computeCullBounds(LytBlock block) { + LytRect union = block.getBounds() != null ? block.getBounds() : LytRect.empty(); + for (LytNode child : block.getChildren()) { + if (child instanceof LytBlock childBlock) { + union = LytRect.union(union, computeCullBounds(childBlock)); + } + } + block.setCullBounds(union); + return union; + } + public void render(RenderContext context) { var viewport = context.viewport(); var top = viewport.y(); @@ -204,24 +528,119 @@ public void render(RenderContext context) { cachedViewportBottom = bottom; visibleCacheValid = true; } - // Render from the cached visible list. Each block's render is a stable function of its - // own state; viewport-dependent culling has already been factored out above. - for (LytBlock lytBlock : visibleCache) { - try { - lytBlock.render(context); - } catch (Throwable throwable) { - // A malformed or not-yet-ready interactive scene must not abort the - // whole document render. Continue with later paragraphs and retain - // enough context to diagnose the offending block from debug.log. - context.restoreExternalRenderState(); - GuideDebugLog.error( - "[LytDocument] Failed to render block type={} bounds={}", - lytBlock.getClass() - .getName(), - lytBlock.getBounds(), - throwable); + // Primitive pipeline. The render engine owns the document->screen + // conversion: root transform maps screen = doc * zoom + (tx, ty), + // matching VanillaRenderContext.toScreenRect. The screen viewport scissor + // is fixed on screen regardless of scroll/zoom, so it is emitted in + // screen coordinates (PushScreenScissor) before the root transform. + float zoom = context.getZoom(); + float scrollY = context.getPreciseScrollOffsetY(); + int originX = context.getDocumentOriginX(); + int originY = context.getDocumentOriginY(); + LytRect screenViewport = context.getScreenViewport(); + + var engine = getRenderEngine(); + engine.beginFrame(screenViewport, DisplayScale.scaleFactor()); + var pc = new PrimitiveCollector(screenViewport, context); + pc.pushScreenScissor(screenViewport.x(), screenViewport.y(), screenViewport.width(), screenViewport.height()); + pc.pushTransform(originX, originY - scrollY * zoom, zoom); + try { + for (LytBlock lb : visibleCache) { + pc.collectFrom(lb); + } + } finally { + pc.popTransform(); + pc.popScreenScissor(); + } + var prims = pc.result(); + if (!prims.isEmpty()) { + engine.execute(prims); + } + engine.endFrame(); + if (isOverlayEnabled()) { + renderLayoutOverlay(originX, originY - scrollY * zoom, zoom, screenViewport, pc.getCulledDocRects()); + } + } + + // Diagnostic layout overlay (JVM flag or in-game debug menu option). + + private void renderLayoutOverlay(float tx, float ty, float zoom, LytRect screenViewport, + List culledRects) { + // Clip all overlay drawing to the viewport: without this, rects of + // offscreen/culled blocks bleed into the screen and look like empty panels. + var mc = net.minecraft.client.Minecraft.getMinecraft(); + int s = DisplayScale.scaleFactor(); + GL11.glEnable(GL11.GL_SCISSOR_TEST); + GL11.glScissor( + screenViewport.x() * s, + Math.max(0, mc.displayHeight - screenViewport.bottom() * s), + screenViewport.width() * s, + screenViewport.height() * s); + try { + // Yellow: fixed viewport scissor (screen space) + drawScreenOutline( + screenViewport.x(), + screenViewport.y(), + screenViewport.width(), + screenViewport.height(), + 0xFFFFFF00); + // Green: Java-side block bounds; Blue: glyph quads + overlayWalk(this, tx, ty, zoom); + // Red: Rust FlatLayout rects + for (LytRect r : overlayRustRects) { + drawDocOutline(r.x(), r.y(), r.width(), r.height(), tx, ty, zoom, 0xFFFF0000); + } + // Gray: blocks culled this frame (correctly not rendered) + for (LytRect r : culledRects) { + drawDocOutline(r.x(), r.y(), r.width(), r.height(), tx, ty, zoom, 0xFF888888); + } + } finally { + GL11.glDisable(GL11.GL_SCISSOR_TEST); + } + } + + private void overlayWalk(LytNode node, float tx, float ty, float zoom) { + if (node instanceof LytBlock block) { + LytRect b = block.getBounds(); + if (b != null) { + drawDocOutline(b.x(), b.y(), b.width(), b.height(), tx, ty, zoom, 0xFF00FF00); + } + if (block instanceof LytParagraph paragraph && paragraph.getGlyphData() != null) { + for (var group : paragraph.getGlyphData() + .runs()) { + for (var g : group.glyphs()) { + drawDocOutline( + Math.round(g.x()), + Math.round(g.y()), + Math.round(g.w()), + Math.round(g.h()), + tx, + ty, + zoom, + 0xFF0000FF); + } + } } } + for (LytNode child : node.getChildren()) { + overlayWalk(child, tx, ty, zoom); + } + } + + private void drawDocOutline(int x, int y, int w, int h, float tx, float ty, float zoom, int argb) { + drawScreenOutline( + Math.round(x * zoom + tx), + Math.round(y * zoom + ty), + Math.max(1, Math.round(w * zoom)), + Math.max(1, Math.round(h * zoom)), + argb); + } + + private static void drawScreenOutline(int x, int y, int w, int h, int argb) { + net.minecraft.client.gui.Gui.drawRect(x, y, x + w, y + 1, argb); + net.minecraft.client.gui.Gui.drawRect(x, y + h - 1, x + w, y + h, argb); + net.minecraft.client.gui.Gui.drawRect(x, y + 1, x + 1, y + h - 1, argb); + net.minecraft.client.gui.Gui.drawRect(x + w - 1, y + 1, x + w, y + h - 1, argb); } public @Nullable DocumentInteractionSnapshot getHoveredElement() { diff --git a/src/main/java/com/hfstudio/guidenh/guide/document/block/LytDocumentFloat.java b/src/main/java/com/hfstudio/guidenh/guide/document/block/LytDocumentFloat.java index d861ee91..e72e3306 100644 --- a/src/main/java/com/hfstudio/guidenh/guide/document/block/LytDocumentFloat.java +++ b/src/main/java/com/hfstudio/guidenh/guide/document/block/LytDocumentFloat.java @@ -4,6 +4,7 @@ import com.hfstudio.guidenh.guide.document.LytRect; import com.hfstudio.guidenh.guide.layout.LayoutContext; +import com.hfstudio.guidenh.guide.render.PrimitiveCollector; import com.hfstudio.guidenh.guide.render.RenderContext; import lombok.Getter; @@ -25,7 +26,7 @@ * downward into the following paragraphs, exactly like CSS {@code float: left / right}. * *

          - * Example — left-floating GameScene that surrounding text wraps around: + * Example: a left-floating GameScene that surrounding text wraps around: * *

            * {@code
          @@ -40,7 +41,7 @@
           @Getter
           public class LytDocumentFloat extends LytBlock {
           
          -    private static final int FLOAT_GAP = 5;
          +    public static final int FLOAT_GAP = 5;
           
               private LytBlock inner;
               private final boolean floatRight;
          @@ -76,16 +77,22 @@ public boolean isCulled(LytRect viewport) {
           
               @Override
               protected LytRect computeLayout(LayoutContext context, int x, int y, int availableWidth) {
          +        // Measure natural width without fullWidth expansion, then relayout at
          +        // the measured width so the inner does not stretch to page width.
          +        boolean wasFullWidth = inner.isFullWidth();
          +        inner.setFullWidth(false);
          +        var naturalBounds = inner.layout(context, x, y, availableWidth);
          +        inner.setFullWidth(wasFullWidth);
          +        int innerWidth = naturalBounds.width();
          +
                   if (floatRight) {
          -            var naturalBounds = inner.layout(context, x, y, availableWidth);
          -            int innerWidth = naturalBounds.width();
                       int rx = x + availableWidth - innerWidth;
                       inner.layout(context, rx, y, innerWidth);
                       context.addRightFloat(
                           new LytRect(rx - FLOAT_GAP, y, innerWidth + FLOAT_GAP, naturalBounds.height() + FLOAT_GAP));
                   } else {
          -            var innerBounds = inner.layout(context, x, y, availableWidth);
          -            context.addLeftFloat(new LytRect(x, y, innerBounds.width() + FLOAT_GAP, innerBounds.height() + FLOAT_GAP));
          +            inner.layout(context, x, y, innerWidth);
          +            context.addLeftFloat(new LytRect(x, y, innerWidth + FLOAT_GAP, naturalBounds.height() + FLOAT_GAP));
                   }
                   return new LytRect(x, y, 0, 0);
               }
          @@ -105,6 +112,16 @@ public LytNode pickNode(int x, int y) {
                   return inner.pickNode(x, y);
               }
           
          +    @Override
          +    public boolean usePrimitives() {
          +        return true;
          +    }
          +
          +    @Override
          +    public void computePrimitives(PrimitiveCollector c) {
          +        // No-op: inner child is picked up by PrimitiveCollector.collectFrom traversal.
          +    }
          +
               @Override
               public void render(RenderContext context) {
                   inner.render(context);
          diff --git a/src/main/java/com/hfstudio/guidenh/guide/document/block/LytFileTree.java b/src/main/java/com/hfstudio/guidenh/guide/document/block/LytFileTree.java
          index bfe92912..27bfb86a 100644
          --- a/src/main/java/com/hfstudio/guidenh/guide/document/block/LytFileTree.java
          +++ b/src/main/java/com/hfstudio/guidenh/guide/document/block/LytFileTree.java
          @@ -9,18 +9,24 @@
           import com.hfstudio.guidenh.guide.document.LytRect;
           import com.hfstudio.guidenh.guide.internal.markdown.FileTreeParser.SlotKind;
           import com.hfstudio.guidenh.guide.layout.LayoutContext;
          +import com.hfstudio.guidenh.guide.render.GuideRenderPrimitive;
          +import com.hfstudio.guidenh.guide.render.PrimitiveCollector;
           import com.hfstudio.guidenh.guide.render.RenderContext;
           
           import lombok.Getter;
           
           /**
            * A block that renders a file tree as a stack of rows where each row carries a configurable depth
          - * of connector lines drawn directly with {@link RenderContext#fillRect}, an optional icon block
          - * and a {@link LytParagraph} payload re-parsed from inline markdown.
          + * of connector lines drawn directly via {@link #computePrimitives}, an optional icon block and a
          + * {@link LytParagraph} payload re-parsed from inline markdown.
            *
            * 

          - * Connectors are derived strictly from the slot kinds parsed for each row, so the visual output is - * deterministic with respect to the source. + * Each row is wrapped in a {@link LytHBox} row container whose {@code marginLeft} encodes the + * indentation level ({@code slots.size() * indentPx}) and whose children are the optional icon + * block followed by the payload paragraph. The row containers are full tree children + * ({@link #getChildren()}), so they participate in Rust layout, and the paragraphs receive proper + * glyph data and bounds computed by the Rust layout engine. Connector lines are still drawn in + * {@link #computePrimitives} using the Rust-computed row container bounds for Y positions. */ public class LytFileTree extends LytBlock { @@ -31,7 +37,7 @@ public class LytFileTree extends LytBlock { private static final int CONNECTOR_THICKNESS = 1; private final List rows = new ArrayList<>(); - private final List childNodes = new ArrayList<>(); + private final List rowContainers = new ArrayList<>(); @Getter private int indentPx = DEFAULT_INDENT_PX; @Getter @@ -40,14 +46,35 @@ public class LytFileTree extends LytBlock { private int iconGapPx = DEFAULT_ICON_GAP_PX; public void appendRow(List slots, @Nullable LytBlock iconBlock, LytParagraph payload) { - Row row = new Row(new ArrayList<>(slots), iconBlock, payload); + LytHBox container = new LytHBox(); + container.setWrap(false); + container.setGap(iconGapPx); + container.setAlignItems(AlignItems.CENTER); if (iconBlock != null) { - iconBlock.parent = this; - childNodes.add(iconBlock); + container.append(iconBlock); + } + container.append(payload); + // Indentation as margin-left on the row container (previously set in + // computeLayout, moved here so the margin is available for serialization + // even after the Java layout pre-pass is removed). + int marginLeft = slots.size() * indentPx; + container.setMarginLeft(marginLeft); + // Gap between rows as margin-bottom (last-row gap cleared by + // finalizeRowGaps). + container.setMarginBottom(rowGapPx); + rows.add(new Row(new ArrayList<>(slots), container, iconBlock, payload)); + rowContainers.add(container); + } + + /** + * Clear the bottom margin on the last row so no trailing gap is added. + * Call after all rows have been appended. + */ + public void finalizeRowGaps() { + if (!rowContainers.isEmpty()) { + rowContainers.get(rowContainers.size() - 1) + .setMarginBottom(0); } - payload.parent = this; - childNodes.add(payload); - rows.add(row); } public void setIndentPx(int indentPx) { @@ -70,27 +97,16 @@ public List getRows() { @Override public List getChildren() { - return childNodes; + return rowContainers; } @Override public void removeChild(LytNode node) { - for (int rowIndex = 0; rowIndex < rows.size(); rowIndex++) { - Row row = rows.get(rowIndex); - if (row.payload == node) { - row.payload.parent = null; - childNodes.remove(row.payload); - if (row.iconBlock != null) { - row.iconBlock.parent = null; - childNodes.remove(row.iconBlock); - } - rows.remove(rowIndex); - return; - } - if (row.iconBlock == node) { - row.iconBlock.parent = null; - childNodes.remove(row.iconBlock); - rows.set(rowIndex, new Row(row.slots, null, row.payload)); + for (int i = 0; i < rowContainers.size(); i++) { + if (rowContainers.get(i) == node) { + rowContainers.get(i).parent = null; + rowContainers.remove(i); + rows.remove(i); return; } } @@ -98,70 +114,106 @@ public void removeChild(LytNode node) { @Override protected LytRect computeLayout(LayoutContext context, int x, int y, int availableWidth) { + // Margins are now set in appendRow / finalizeRowGaps. + // Children are laid out by the Rust layout engine; this method + // is retained for compatibility but is no longer called by the + // document pipeline (the Java layout pre-pass has been removed). + // If called directly, lay out children minimally. int currentY = y; int totalHeight = 0; - for (int i = 0; i < rows.size(); i++) { Row row = rows.get(i); - int iconX = x + row.slots.size() * indentPx; - int payloadX; - int iconHeight = 0; - if (row.iconBlock != null) { - // Give the icon enough headroom so its natural width can be measured. Cap it to - // half of the remaining row space so a runaway label cannot eat the whole row. - int iconAvailable = Math.max(iconBoxPx, (x + availableWidth - iconX) / 2); - row.iconBlock.layout(context, iconX, currentY, Math.max(1, iconAvailable)); - LytRect iconBounds = row.iconBlock.getBounds(); - int actualIconWidth = iconBounds.width(); - int reservedIconWidth = Math.max(iconBoxPx, actualIconWidth); - iconHeight = iconBounds.height(); - payloadX = iconX + reservedIconWidth + iconGapPx; - } else { - payloadX = iconX; - } - int payloadAvailable = Math.max(1, x + availableWidth - payloadX); - LytRect payloadBounds = row.payload.layout(context, payloadX, currentY, payloadAvailable); - int payloadHeight = payloadBounds.height(); - int rowHeight = Math.max(payloadHeight, iconHeight); - if (rowHeight <= 0) { - rowHeight = 1; - } - centerRowChild(row.payload, rowHeight, payloadHeight); - if (row.iconBlock != null) { - centerRowChild(row.iconBlock, rowHeight, iconHeight); - } - row.rowY = currentY; - row.rowHeight = rowHeight; - currentY += rowHeight; + LytHBox container = row.container; + int marginLeft = container.getMarginLeft(); + container.layout(context, x + marginLeft, currentY, availableWidth - marginLeft); + LytRect rowBounds = container.getBounds(); + int rowHeight = Math.max(1, rowBounds.height()); totalHeight += rowHeight; - if (i < rows.size() - 1) { - currentY += rowGapPx; - totalHeight += rowGapPx; - } + currentY += rowHeight + container.getMarginBottom(); } - return new LytRect(x, y, availableWidth, totalHeight); } @Override protected void onLayoutMoved(int deltaX, int deltaY) { - for (Row row : rows) { - row.rowY += deltaY; - if (row.iconBlock != null) { - row.iconBlock.moveLayoutPos(deltaX, deltaY); + for (LytHBox row : rowContainers) { + row.moveLayoutPos(deltaX, deltaY); + } + } + + @Override + public boolean usePrimitives() { + return true; + } + + @Override + public void computePrimitives(PrimitiveCollector c) { + int baseX = bounds.x(); + int connectorColor = ColorUtils.TABLE_BORDER.resolve(); + int halfIndent = indentPx / 2; + for (int i = 0; i < rows.size(); i++) { + Row row = rows.get(i); + LytRect rowBounds = row.container.getBounds(); + int rowY = rowBounds.y(); + int rowHeight = rowBounds.height(); + int rowMidY = rowY + Math.max(0, rowHeight - CONNECTOR_THICKNESS) / 2; + // Extend vertical connector to the bottom of the margin-box gap. + int rowBottomY = rowY + rowHeight + row.container.getMarginBottom(); + int slotCount = row.slots.size(); + int columnCenterX = baseX + halfIndent; + for (int slotIndex = 0; slotIndex < slotCount; slotIndex++, columnCenterX += indentPx) { + SlotKind slot = row.slots.get(slotIndex); + switch (slot) { + case VERTICAL -> emitVerticalLine(c, columnCenterX, rowY, rowBottomY, connectorColor); + case BRANCH -> { + emitVerticalLine(c, columnCenterX, rowY, rowBottomY, connectorColor); + emitHorizontalLine( + c, + columnCenterX, + columnCenterX - halfIndent + indentPx, + rowMidY, + connectorColor); + } + case LAST_BRANCH -> { + emitVerticalLine(c, columnCenterX, rowY, rowMidY + CONNECTOR_THICKNESS, connectorColor); + emitHorizontalLine( + c, + columnCenterX, + columnCenterX - halfIndent + indentPx, + rowMidY, + connectorColor); + } + case EMPTY -> { + // Empty slot draws nothing. + } + } } - row.payload.moveLayoutPos(deltaX, deltaY); } } + private static void emitVerticalLine(PrimitiveCollector c, int x, int yStart, int yEnd, int color) { + int top = Math.min(yStart, yEnd); + int height = Math.abs(yEnd - yStart); + if (height <= 0) { + return; + } + c.emit(new GuideRenderPrimitive.FillRect(x, top, CONNECTOR_THICKNESS, height, color)); + } + + private static void emitHorizontalLine(PrimitiveCollector c, int xStart, int xEnd, int y, int color) { + int left = Math.min(xStart, xEnd); + int width = Math.abs(xEnd - xStart); + if (width <= 0) { + return; + } + c.emit(new GuideRenderPrimitive.FillRect(left, y, width, CONNECTOR_THICKNESS, color)); + } + @Override public void render(RenderContext context) { renderConnectors(context); - for (Row row : rows) { - if (row.iconBlock != null) { - row.iconBlock.render(context); - } - row.payload.render(context); + for (LytHBox row : rowContainers) { + row.render(context); } } @@ -170,11 +222,13 @@ private void renderConnectors(RenderContext context) { // Resolve symbolic color once per frame instead of on every fillRect. int connectorColor = context.resolveColor(ColorUtils.TABLE_BORDER); int halfIndent = indentPx / 2; - for (Row row : rows) { - int rowY = row.rowY; - int rowHeight = row.rowHeight; + for (int i = 0; i < rows.size(); i++) { + Row row = rows.get(i); + LytRect rowBounds = row.container.getBounds(); + int rowY = rowBounds.y(); + int rowHeight = rowBounds.height(); int rowMidY = rowY + Math.max(0, rowHeight - CONNECTOR_THICKNESS) / 2; - int rowBottomY = rowY + rowHeight + rowGapPx; + int rowBottomY = rowY + rowHeight + row.container.getMarginBottom(); int slotCount = row.slots.size(); int columnCenterX = baseX + halfIndent; for (int slotIndex = 0; slotIndex < slotCount; slotIndex++, columnCenterX += indentPx) { @@ -207,13 +261,6 @@ private void renderConnectors(RenderContext context) { } } - private void centerRowChild(LytBlock child, int rowHeight, int childHeight) { - if (childHeight <= 0 || childHeight >= rowHeight) { - return; - } - child.moveLayoutPos(0, (rowHeight - childHeight) / 2); - } - private static void drawVerticalLine(RenderContext context, int x, int yStart, int yEnd, int color) { int top = Math.min(yStart, yEnd); int height = Math.abs(yEnd - yStart); @@ -235,14 +282,14 @@ private static void drawHorizontalLine(RenderContext context, int xStart, int xE public static class Row { private final List slots; + private final LytHBox container; @Nullable private final LytBlock iconBlock; private final LytParagraph payload; - private int rowY; - private int rowHeight; - Row(List slots, @Nullable LytBlock iconBlock, LytParagraph payload) { + Row(List slots, LytHBox container, @Nullable LytBlock iconBlock, LytParagraph payload) { this.slots = slots; + this.container = container; this.iconBlock = iconBlock; this.payload = payload; } diff --git a/src/main/java/com/hfstudio/guidenh/guide/document/block/LytFloatAwareBlock.java b/src/main/java/com/hfstudio/guidenh/guide/document/block/LytFloatAwareBlock.java index 58c34791..631bf7f2 100644 --- a/src/main/java/com/hfstudio/guidenh/guide/document/block/LytFloatAwareBlock.java +++ b/src/main/java/com/hfstudio/guidenh/guide/document/block/LytFloatAwareBlock.java @@ -6,6 +6,7 @@ import com.hfstudio.guidenh.guide.document.LytRect; import com.hfstudio.guidenh.guide.layout.LayoutContext; +import com.hfstudio.guidenh.guide.render.PrimitiveCollector; import com.hfstudio.guidenh.guide.render.RenderContext; import lombok.Getter; @@ -27,6 +28,7 @@ public class LytFloatAwareBlock extends LytBlock { public LytFloatAwareBlock(LytBlock inner) { this.inner = inner; inner.parent = this; + setFullWidth(inner.isFullWidth()); } @Override @@ -65,6 +67,16 @@ protected void onLayoutMoved(int deltaX, int deltaY) { return inner.pickNode(x, y); } + @Override + public boolean usePrimitives() { + return true; + } + + @Override + public void computePrimitives(PrimitiveCollector c) { + // No-op: inner child is picked up by PrimitiveCollector.collectFrom traversal. + } + @Override public void render(RenderContext context) { inner.render(context); diff --git a/src/main/java/com/hfstudio/guidenh/guide/document/block/LytGuiSprite.java b/src/main/java/com/hfstudio/guidenh/guide/document/block/LytGuiSprite.java index 774632fd..d9686bab 100644 --- a/src/main/java/com/hfstudio/guidenh/guide/document/block/LytGuiSprite.java +++ b/src/main/java/com/hfstudio/guidenh/guide/document/block/LytGuiSprite.java @@ -1,5 +1,9 @@ package com.hfstudio.guidenh.guide.document.block; +import net.minecraft.client.Minecraft; +import net.minecraft.client.renderer.texture.ITextureObject; +import net.minecraft.util.ResourceLocation; + import org.jetbrains.annotations.Nullable; import com.hfstudio.guidenh.guide.color.ColorValue; @@ -10,6 +14,8 @@ import com.hfstudio.guidenh.guide.document.interaction.InteractiveElement; import com.hfstudio.guidenh.guide.layout.LayoutContext; import com.hfstudio.guidenh.guide.render.GuiSprite; +import com.hfstudio.guidenh.guide.render.GuideRenderPrimitive; +import com.hfstudio.guidenh.guide.render.PrimitiveCollector; import com.hfstudio.guidenh.guide.render.RenderContext; import lombok.Getter; @@ -65,6 +71,16 @@ public void setSize(int width, int height) { setSize(new LytSize(width, height)); } + @Override + public int getExplicitWidth() { + return Math.round(size.width()); + } + + @Override + public int getExplicitHeight() { + return Math.round(size.height()); + } + @Override protected LytRect computeLayout(LayoutContext context, int x, int y, int availableWidth) { float actualWidth = size.width(); @@ -98,10 +114,55 @@ public void onMouseLeave() { hovered = false; } + @Override + public boolean usePrimitives() { + return true; + } + + @Override + public void computePrimitives(PrimitiveCollector c) { + if (sprite != null) { + var b = getBounds(); + int texId = getGlTextureId(sprite.getTexture()); + if (texId >= 0) { + float u = (float) sprite.getU() / sprite.getTexWidth(); + float v = (float) sprite.getV() / sprite.getTexHeight(); + float u2 = (float) (sprite.getU() + sprite.getWidth()) / sprite.getTexWidth(); + float v2 = (float) (sprite.getV() + sprite.getHeight()) / sprite.getTexHeight(); + ColorValue tint = hovered && hoverColor != null ? hoverColor : color; + int argb = tint.resolve(); + c.emit( + new GuideRenderPrimitive.BlitTexture( + texId, + b.x(), + b.y(), + b.width(), + b.height(), + u, + v, + u2, + v2, + argb)); + } + } + } + @Override public void render(RenderContext context) { if (sprite != null) { context.fillIcon(getBounds(), sprite, hovered && hoverColor != null ? hoverColor : color); } } + + private static int getGlTextureId(ResourceLocation res) { + try { + ITextureObject tex = Minecraft.getMinecraft() + .getTextureManager() + .getTexture(res); + return tex != null ? tex.getGlTextureId() : -1; + } catch (Throwable t) { + // Headless (unit tests) or texture unavailable: skip drawing. + return -1; + } + } } diff --git a/src/main/java/com/hfstudio/guidenh/guide/document/block/LytHeading.java b/src/main/java/com/hfstudio/guidenh/guide/document/block/LytHeading.java index 4cfbe859..bd638d73 100644 --- a/src/main/java/com/hfstudio/guidenh/guide/document/block/LytHeading.java +++ b/src/main/java/com/hfstudio/guidenh/guide/document/block/LytHeading.java @@ -1,9 +1,10 @@ package com.hfstudio.guidenh.guide.document.block; import com.hfstudio.guidenh.guide.color.ColorUtils; +import com.hfstudio.guidenh.guide.color.ColorValue; import com.hfstudio.guidenh.guide.document.DefaultStyles; -import com.hfstudio.guidenh.guide.document.LytRect; -import com.hfstudio.guidenh.guide.layout.LayoutContext; +import com.hfstudio.guidenh.guide.render.GuideRenderPrimitive; +import com.hfstudio.guidenh.guide.render.PrimitiveCollector; import com.hfstudio.guidenh.guide.render.RenderContext; import lombok.Getter; @@ -12,15 +13,27 @@ public class LytHeading extends LytParagraph { @Getter private int depth = 1; - // Horizontal offset from bounds.x() to the float-adjusted text start position - private int separatorXOffset = 0; - private int separatorWidth = 0; public LytHeading() { setMarginTop(5); setMarginBottom(5); } + /** + * Per-depth vertical margins: the space before a heading grows with its + * level (H1 20 / H2 18 / H3 14 / H4 12 / H5-H6 10) while the space after + * stays small (H1-H2 6 / H3-H6 7; the top two were trimmed 8→6 so the + * separator-to-body gap lands ≈10-12px combined with + * {@link #HEADING_SEPARATOR_GAP}). The strong top/bottom ratio makes a + * heading "breathe" above while binding it to its own content below + * (taffy adds margins, no collapsing), so parent-child and sibling heading + * gaps stay distinguishable. Consecutive headings collapse instead of + * summing; see {@link #collapseBottomForAdjacent()}. Index 0 is the + * depth-agnostic fallback used when no valid depth is assigned. + */ + private static final int[] HEADING_MARGIN_TOP = { 5, 20, 18, 14, 12, 10, 10 }; + private static final int[] HEADING_MARGIN_BOTTOM = { 5, 6, 6, 7, 7, 7, 7 }; + public void setDepth(int depth) { this.depth = depth; var style = switch (depth) { @@ -33,19 +46,52 @@ public void setDepth(int depth) { default -> DefaultStyles.BODY_TEXT; }; setStyle(style); + int idx = (depth >= 1 && depth <= 6) ? depth : 0; + setMarginTop(HEADING_MARGIN_TOP[idx]); + setMarginBottom(HEADING_MARGIN_BOTTOM[idx]); + } + + /** + * CSS-style margin collapse for consecutive headings. Taffy sums adjacent + * margins without collapsing, so two headings with no body between them + * would keep both the first's bottom and the second's top margin (H3 7 + + * H4 12 = 19px, the "hole" between consecutive headings). When this + * heading is directly followed by another heading (detected at compile time + * in {@code HeadingCompiler}), its bottom margin is zeroed so the pair + * keeps only the following heading's top margin. Because every depth's top + * margin in {@link #HEADING_MARGIN_TOP} is ≥ every shallower heading's + * bottom margin in {@link #HEADING_MARGIN_BOTTOM}, this equals the CSS + * {@code max()} collapse rule. Heading→body spacing is unaffected. + */ + public void collapseBottomForAdjacent() { + setMarginBottom(0); } @Override - public LytRect computeLayout(LayoutContext context, int x, int y, int availableWidth) { - // Capture the active inline window so the separator follows the same wrapped line width - // that the heading text uses and never paints under floating content on either side. - int leftEdge = context.getLeftFloatRightEdgeOr(x); - int rightEdge = context.getRightFloatLeftEdgeOr(x + availableWidth); - int clampedLeftEdge = Math.max(x, leftEdge); - int clampedRightEdge = Math.min(x + availableWidth, rightEdge); - separatorXOffset = Math.max(0, clampedLeftEdge - x); - separatorWidth = Math.max(0, clampedRightEdge - clampedLeftEdge); - return super.computeLayout(context, x, y, availableWidth); + public void computePrimitives(PrimitiveCollector c) { + super.computePrimitives(c); + + // Separators stay reserved for the top two levels: the monotonic size + // ladder + bold white now distinguish H3-H6 from body text without a + // rule line (H3 deliberately gets no fainter line). + if (depth == 1) { + emitSeparator(c, ColorUtils.HEADER1_SEPARATOR.resolve()); + } else if (depth == 2) { + emitSeparator(c, ColorUtils.HEADER2_SEPARATOR.resolve()); + } + } + + /** + * Fixed gap between the lowest glyph bottom and the separator line, so the + * rule never grazes descender tails (g/p/y) regardless of how far they + * hang below the baseline. + */ + private static final int HEADING_SEPARATOR_GAP = 5; + + private void emitSeparator(PrimitiveCollector c, int argb) { + int sepY = separatorY(); + int[] ext = separatorExtent(); + c.emit(new GuideRenderPrimitive.FillRect(ext[0], sepY, ext[1], 1, argb)); } @Override @@ -53,15 +99,58 @@ public void render(RenderContext context) { super.render(context); if (depth == 1) { - var bounds = getBounds(); - int sepX = bounds.x() + separatorXOffset; - int sepW = Math.max(0, separatorWidth); - context.fillRect(sepX, bounds.bottom() - 1, sepW, 1, ColorUtils.HEADER1_SEPARATOR); + emitSeparatorLegacy(context, ColorUtils.HEADER1_SEPARATOR); } else if (depth == 2) { - var bounds = getBounds(); - int sepX = bounds.x() + separatorXOffset; - int sepW = Math.max(0, separatorWidth); - context.fillRect(sepX, bounds.bottom() - 1, sepW, 1, ColorUtils.HEADER2_SEPARATOR); + emitSeparatorLegacy(context, ColorUtils.HEADER2_SEPARATOR); + } + } + + private void emitSeparatorLegacy(RenderContext context, ColorValue color) { + int sepY = separatorY(); + int[] ext = separatorExtent(); + context.fillRect(ext[0], sepY, ext[1], 1, color); + } + + /** + * The separator's vertical position: a fixed gap below the actual lowest + * glyph bottom (baseline + real descender extent), decoupled from the + * block bounds bottom whose distance to the text depends on the font's + * ascent/descent allocation. Falls back to the block bottom when no glyph + * run is available (legacy/no-Rust path). + */ + private int separatorY() { + var data = getGlyphData(); + if (data != null) { + float maxBottom = Float.NEGATIVE_INFINITY; + boolean found = false; + for (var run : data.runs()) { + for (var g : run.glyphs()) { + maxBottom = Math.max(maxBottom, g.y() + g.h()); + found = true; + } + } + if (found) { + return Math.round(maxBottom) + HEADING_SEPARATOR_GAP; + } + } + return getBounds().bottom() - 1; + } + + /** + * Returns {@code [x, width]} for the separator, computed from the Rust- + * emitted kind=3 DecorationRect (the full float-compressed line window). + * Falls back to the block bounds when no separator rect is available + * (legacy/no-Rust path). + */ + private int[] separatorExtent() { + var bounds = getBounds(); + var data = getGlyphData(); + if (data != null && !data.separators() + .isEmpty()) { + var r = data.separators() + .get(0); + return new int[] { r.x(), Math.max(0, r.w()) }; } + return new int[] { bounds.x(), bounds.width() }; } } diff --git a/src/main/java/com/hfstudio/guidenh/guide/document/block/LytImage.java b/src/main/java/com/hfstudio/guidenh/guide/document/block/LytImage.java index 29433169..b5f031a0 100644 --- a/src/main/java/com/hfstudio/guidenh/guide/document/block/LytImage.java +++ b/src/main/java/com/hfstudio/guidenh/guide/document/block/LytImage.java @@ -4,16 +4,22 @@ import java.util.List; import java.util.Optional; +import net.minecraft.client.Minecraft; +import net.minecraft.client.renderer.texture.ITextureObject; import net.minecraft.util.ResourceLocation; import org.jetbrains.annotations.Nullable; import com.hfstudio.guidenh.guide.document.LytRect; +import com.hfstudio.guidenh.guide.document.LytSize; import com.hfstudio.guidenh.guide.document.interaction.GuideTooltip; import com.hfstudio.guidenh.guide.document.interaction.InteractiveElement; import com.hfstudio.guidenh.guide.layout.LayoutContext; import com.hfstudio.guidenh.guide.render.GuiAssets; +import com.hfstudio.guidenh.guide.render.GuiSprite; import com.hfstudio.guidenh.guide.render.GuidePageTexture; +import com.hfstudio.guidenh.guide.render.GuideRenderPrimitive; +import com.hfstudio.guidenh.guide.render.PrimitiveCollector; import com.hfstudio.guidenh.guide.render.RenderContext; import com.hfstudio.guidenh.guide.sound.GuideSoundPlayback; import com.hfstudio.guidenh.guide.sound.GuideSoundSpec; @@ -38,16 +44,22 @@ public class LytImage extends LytBlock implements InteractiveElement { @Setter private String alt; + @Getter private int explicitWidth = -1; + @Getter private int explicitHeight = -1; + @Getter private int cropX; + @Getter private int cropY; + @Getter private int cropWidth = -1; + @Getter private int cropHeight = -1; + @Getter private double scaleX = 1.0d; + @Getter private double scaleY = 1.0d; - private int displayWidth = -1; - private int displayHeight = -1; @Getter private final List annotations = new ArrayList<>(); @@ -68,10 +80,23 @@ public void setTexture(@Nullable ResourceLocation id, @Nullable GuidePageTexture this.texture = texture != null ? texture : GuidePageTexture.missing(); } + /** + * Sets the explicit display width. When only one axis carries an explicit + * size, layout infers the other from the source aspect ratio. This is the + * display-size mechanism that the FloatingImage displayWidth attribute maps + * onto. + * + * @param width display width in pixels, or a non-positive value to clear it + */ public void setExplicitWidth(int width) { this.explicitWidth = width > 0 ? width : -1; } + /** + * Sets the explicit display height; see {@link #setExplicitWidth(int)}. + * + * @param height display height in pixels, or a non-positive value to clear it + */ public void setExplicitHeight(int height) { this.explicitHeight = height > 0 ? height : -1; } @@ -88,11 +113,6 @@ public void setScale(double scaleX, double scaleY) { this.scaleY = scaleY > 0.0d ? scaleY : 1.0d; } - public void setDisplaySize(int displayWidth, int displayHeight) { - this.displayWidth = displayWidth > 0 ? displayWidth : -1; - this.displayHeight = displayHeight > 0 ? displayHeight : -1; - } - @Override protected LytRect computeLayout(LayoutContext context, int x, int y, int availableWidth) { if (texture == null) { @@ -104,12 +124,29 @@ protected LytRect computeLayout(LayoutContext context, int x, int y, int availab int sourceHeight = Math.max(1, cropHeight > 0 ? cropHeight : size.height()); int width; int height; - if (displayWidth > 0 || displayHeight > 0) { - width = displayWidth > 0 ? displayWidth - : Math.max(1, (int) Math.round(displayHeight * sourceWidth / (double) sourceHeight)); - height = displayHeight > 0 ? displayHeight - : Math.max(1, (int) Math.round(displayWidth * sourceHeight / (double) sourceWidth)); + // Mirrors Rust measure_image (src/rust/layout-engine/src/measure.rs) exactly: + // two explicit dimensions win as-is; a single explicit dimension + // (whole-image display size) infers the missing axis from the + // natural aspect ratio (inferred = explicit × natural_other / + // natural_given, no per-axis scale on the inferred axis); otherwise + // fall back to natural × DEFAULT_LAYOUT_SCALE × scale. When the + // texture is missing the natural size is unreliable, so no inference + // happens and the legacy fallback applies. + boolean hasNatural = texture != null && !texture.isMissing() && sourceWidth > 0 && sourceHeight > 0; + if (explicitWidth > 0 && explicitHeight > 0) { + width = explicitWidth; + height = explicitHeight; + } else if (explicitWidth > 0 && hasNatural) { + width = explicitWidth; + height = Math.max(1, (int) Math.round(explicitWidth * (sourceHeight / (double) sourceWidth))); + } else if (explicitHeight > 0 && hasNatural) { + width = Math.max(1, (int) Math.round(explicitHeight * (sourceWidth / (double) sourceHeight))); + height = explicitHeight; } else if (explicitWidth > 0 || explicitHeight > 0) { + // Single explicit dimension but the natural size is unavailable + // (missing or placeholder texture), so no aspect-ratio inference is + // possible. The legacy behaviour applies: the explicit axis wins and + // the missing axis is natural × scale. width = explicitWidth > 0 ? explicitWidth : Math.max(1, (int) Math.round(sourceWidth * scaleX)); height = explicitHeight > 0 ? explicitHeight : Math.max(1, (int) Math.round(sourceHeight * scaleY)); } else { @@ -140,6 +177,116 @@ public void onMouseLeave() { hoveredSoundAnnotation = null; } + @Override + public boolean usePrimitives() { + return true; + } + + @Override + public void computePrimitives(PrimitiveCollector c) { + var bounds = getBounds(); + if (texture == null || texture.isMissing()) { + // Fall back to missing texture sprite + emitBlitGuiSprite(c, GuiAssets.MISSING_TEXTURE, bounds.x(), bounds.y(), bounds.width(), bounds.height()); + } else { + ResourceLocation resolvedTex = texture.getTexture(); + int texId = resolvedTex != null ? getGlTextureId(resolvedTex) : -1; + if (texId >= 0) { + // Compute UV from crop rect, or full texture when no cropping. + LytSize texSize = texture.getSize(); + float u1, v1, u2, v2; + if (cropWidth > 0) { + u1 = (float) cropX / texSize.width(); + v1 = (float) cropY / texSize.height(); + u2 = (float) (cropX + cropWidth) / texSize.width(); + v2 = (float) (cropY + cropHeight) / texSize.height(); + } else { + u1 = 0f; + v1 = 0f; + u2 = 1f; + v2 = 1f; + } + c.emit( + new GuideRenderPrimitive.BlitTexture( + texId, + bounds.x(), + bounds.y(), + bounds.width(), + bounds.height(), + u1, + v1, + u2, + v2)); + } else { + // Texture object not (yet) registered with the TextureManager, + // so fall back to the missing-texture sprite instead of leaving + // an empty box. + emitBlitGuiSprite( + c, + GuiAssets.MISSING_TEXTURE, + bounds.x(), + bounds.y(), + bounds.width(), + bounds.height()); + } + } + } + + @Override + public void emitDecorations(PrimitiveCollector c) { + if (annotations.isEmpty()) { + return; + } + var bounds = getBounds(); + int dispW = bounds.width(); + int dispH = bounds.height(); + if (dispW <= 0 || dispH <= 0) { + return; + } + int natW = texture != null && !texture.isMissing() ? getEffectiveSourceWidth() : dispW; + int natH = texture != null && !texture.isMissing() ? getEffectiveSourceHeight() : dispH; + for (var ann : annotations) { + if (!ann.isShowBorder()) { + continue; + } + int bx; + int by; + int bw; + int bh; + if (ann.isWholeImage()) { + bx = bounds.x(); + by = bounds.y(); + bw = bounds.width(); + bh = bounds.height(); + } else { + int clampedX = Math.clamp(ann.getImgX(), 0, natW); + int clampedY = Math.clamp(ann.getImgY(), 0, natH); + int clampedW = Math.min(ann.getImgX() + ann.getImgW(), natW) - clampedX; + int clampedH = Math.min(ann.getImgY() + ann.getImgH(), natH) - clampedY; + if (clampedW <= 0 || clampedH <= 0) { + continue; + } + bx = bounds.x() + clampedX * dispW / natW; + by = bounds.y() + clampedY * dispH / natH; + bw = Math.max(1, clampedW * dispW / natW); + bh = Math.max(1, clampedH * dispH / natH); + } + int borderArgb = ann.getBorderColor() + .resolve(); + c.emit( + new GuideRenderPrimitive.DrawBorder( + bx, + by, + bw, + bh, + ann.getBorderThickness(), + ann.getBorderThickness(), + ann.getBorderThickness(), + ann.getBorderThickness(), + borderArgb)); + } + } + @Override public void render(RenderContext context) { if (texture == null) { @@ -332,6 +479,34 @@ private int getEffectiveSourceHeight() { : 1; } + /** + * Convert a Minecraft ResourceLocation to a GL texture ID for use with BlitTexture. + */ + private static int getGlTextureId(ResourceLocation res) { + try { + ITextureObject tex = Minecraft.getMinecraft() + .getTextureManager() + .getTexture(res); + return tex != null ? tex.getGlTextureId() : -1; + } catch (Throwable t) { + // Headless (unit tests) or texture unavailable: skip drawing. + return -1; + } + } + + /** + * Emit a BlitTexture for a GuiSprite at the given screen coordinates. + */ + private static void emitBlitGuiSprite(PrimitiveCollector c, GuiSprite sprite, int x, int y, int w, int h) { + int texId = getGlTextureId(sprite.getTexture()); + if (texId < 0) return; + float u = (float) sprite.getU() / sprite.getTexWidth(); + float v = (float) sprite.getV() / sprite.getTexHeight(); + float u2 = (float) (sprite.getU() + sprite.getWidth()) / sprite.getTexWidth(); + float v2 = (float) (sprite.getV() + sprite.getHeight()) / sprite.getTexHeight(); + c.emit(new GuideRenderPrimitive.BlitTexture(texId, x, y, w, h, u, v, u2, v2)); + } + public static class ImagePoint { public final float x; diff --git a/src/main/java/com/hfstudio/guidenh/guide/document/block/LytImageBlock.java b/src/main/java/com/hfstudio/guidenh/guide/document/block/LytImageBlock.java index 0efb92da..d2f4ba0c 100644 --- a/src/main/java/com/hfstudio/guidenh/guide/document/block/LytImageBlock.java +++ b/src/main/java/com/hfstudio/guidenh/guide/document/block/LytImageBlock.java @@ -35,10 +35,6 @@ public class LytImageBlock extends LytParagraph { private double scaleX = 1.0d; @Getter private double scaleY = 1.0d; - @Getter - private int displayWidth = -1; - @Getter - private int displayHeight = -1; @Nullable private String align; @Getter @@ -103,14 +99,6 @@ public void setScaleY(double scaleY) { this.scaleY = scaleY > 0.0d ? scaleY : 1.0d; } - public void setDisplayWidth(int displayWidth) { - this.displayWidth = displayWidth > 0 ? displayWidth : -1; - } - - public void setDisplayHeight(int displayHeight) { - this.displayHeight = displayHeight > 0 ? displayHeight : -1; - } - @Nullable public String getAlign() { return align; diff --git a/src/main/java/com/hfstudio/guidenh/guide/document/block/LytItemGrid.java b/src/main/java/com/hfstudio/guidenh/guide/document/block/LytItemGrid.java index 28deda74..4df900b7 100644 --- a/src/main/java/com/hfstudio/guidenh/guide/document/block/LytItemGrid.java +++ b/src/main/java/com/hfstudio/guidenh/guide/document/block/LytItemGrid.java @@ -19,7 +19,11 @@ public LytItemGrid() { @Override protected LytRect computeBoxLayout(LayoutContext context, int x, int y, int availableWidth) { - var cols = Math.max(1, availableWidth / LytSlot.OUTER_SIZE); + // Fixed 3-column semantic: cols = min(3, slotCount), mirroring the + // lowering rule in LayoutStyleExtractor (explicitW = cols * OUTER_SIZE + // + horizontal padding; Taffy 0.12 sizes are border-box, so padding is + // included in size_w and must be added back to reach the content width). + var cols = Math.max(1, Math.min(3, slots.size())); var rows = (slots.size() + cols - 1) / cols; for (int i = 0; i < slots.size(); i++) { @@ -47,4 +51,9 @@ public void addItems(List stacks) { slots.add(slot); append(slot); } + + /** Number of added slots, which drives the fixed 3-column lowering rule. */ + public int getSlotCount() { + return slots.size(); + } } diff --git a/src/main/java/com/hfstudio/guidenh/guide/document/block/LytItemImage.java b/src/main/java/com/hfstudio/guidenh/guide/document/block/LytItemImage.java index f5b104ed..ddf77d7c 100644 --- a/src/main/java/com/hfstudio/guidenh/guide/document/block/LytItemImage.java +++ b/src/main/java/com/hfstudio/guidenh/guide/document/block/LytItemImage.java @@ -6,18 +6,23 @@ import net.minecraft.item.ItemStack; import org.jetbrains.annotations.Nullable; -import org.lwjgl.opengl.GL11; import com.hfstudio.guidenh.guide.document.DefaultStyles; import com.hfstudio.guidenh.guide.document.LytRect; import com.hfstudio.guidenh.guide.document.interaction.GuideTooltip; import com.hfstudio.guidenh.guide.document.interaction.InteractiveElement; import com.hfstudio.guidenh.guide.document.interaction.ItemTooltip; -import com.hfstudio.guidenh.guide.internal.item.GuideDisplayItemStacks; import com.hfstudio.guidenh.guide.layout.LayoutContext; +import com.hfstudio.guidenh.guide.render.GuideRenderPrimitive; +import com.hfstudio.guidenh.guide.render.GuideText; +import com.hfstudio.guidenh.guide.render.PrimitiveCollector; import com.hfstudio.guidenh.guide.render.RenderContext; import com.hfstudio.guidenh.guide.style.ResolvedTextStyle; import com.hfstudio.guidenh.guide.style.TextStyle; +import com.hfstudio.guidenh.guide.style.token.DimensionValue; +import com.hfstudio.guidenh.guide.style.token.GuideThemeManager; +import com.hfstudio.guidenh.guide.style.token.TokenKey; +import com.hfstudio.guidenh.guide.style.token.TokenType; import lombok.Getter; import lombok.Setter; @@ -26,11 +31,16 @@ public class LytItemImage extends LytBlock implements InteractiveElement { public static final int BASE_SIZE = 16; - private static final int LABEL_GAP = 2; - private static final int DEFAULT_INLINE_ITEM_VISUAL_Y_OFFSET = 0; + /** Theme token: gap between the item icon and its label text. */ + private static final TokenKey LABEL_GAP = TokenKey + .define("--lyt-item-image-label-gap", TokenType.DIMENSION, DimensionValue.px(2)); - public static int DEFAULT_TEXT_INLINE_Y_OFFSET = 0; - public static int DEFAULT_INLINE_Y_OFFSET = 0; + private static int labelGap() { + return GuideThemeManager.instance() + .active() + .dim(LABEL_GAP) + .pxInt(); + } @Getter protected ItemStack stack; @@ -67,6 +77,9 @@ public class LytItemImage extends LytBlock implements InteractiveElement { @Nullable private String labelFormat = null; private int layoutYOffset = 0; + /** Label text metrics cached from the last layout pass (used by computePrimitives). */ + private int labelTextW; + private int labelTextH; @Nullable private ResolvedTextStyle cachedLabelStyle = null; @Nullable @@ -80,6 +93,18 @@ public void setScale(float scale) { this.scale = Math.max(0.125f, scale); } + @Override + public int getExplicitWidth() { + if (!showIcon && labelPosition == null) return -1; + return computeContentSize()[0]; + } + + @Override + public int getExplicitHeight() { + if (!showIcon && labelPosition == null) return -1; + return computeContentSize()[1]; + } + /** Kept for backward compatibility. Prefer {@link #setShowTooltip(boolean)}. */ public void setTooltipSuppressed(boolean suppressed) { this.showTooltip = !suppressed; @@ -114,13 +139,46 @@ public void setInlineYOffsetOverride(@Nullable Integer override) { public int getInlineVerticalOffset() { if (!inline) return 0; - int offset = inlineYOffsetOverride != null ? inlineYOffsetOverride : DEFAULT_INLINE_Y_OFFSET; + int offset = inlineYOffsetOverride != null ? inlineYOffsetOverride : 0; return Math.round(offset * scale); } - /** Returns the item-render-only visual correction for Minecraft's item sprite alignment. */ - public int getInlineVisualYOffset() { - return inline && showIcon ? Math.round(DEFAULT_INLINE_ITEM_VISUAL_Y_OFFSET * scale) : 0; + /** + * Whether this block renders a label next to the icon (labelPosition set and + * a stack present). Only icons without a label declare an ink box: the + * label path's declared width contains the label text, so its advance must + * stay the grid cell (no ink-box override). + */ + public boolean hasLabel() { + return labelPosition != null && stack != null; + } + + /** + * Vertical offset in pixels from the block top to the label text baseline, + * for an inline labelled icon (icon plus label text block). The serializer + * uses this value as the {@code param} of an {@code InlineBlockRef} carrying + * {@code align=1} (baseline ascent): Rust anchors the block top at + * {@code text baseline - param}, which places the label baseline exactly on + * the surrounding body baseline. This replaces the retired {@code align=2} + * centring. The ink box applies only to icons without a label: a labelled + * block's advance spans icon, gap and label text, so it cannot carry the + * icon ink box. + *

          + * The measurement matches the label text Y used by + * {@link #computePrimitives}: {@code textY = baseY + textVCenter + + * labelYOffset}, and the baseline is {@code textY + ascent * fontScale}. + * Without an override the label text is vertically centred in the icon + * cell, so the baseline is approximately {@code blockTop + ascent}. + */ + public float getLabelBaselineOffset() { + int iconSize = Math.round(BASE_SIZE * scale); + ResolvedTextStyle textStyle = resolveLabelStyle(); + int textH = GuideText.lineHeight(textStyle); + int textVCenter = showIcon ? (iconSize - textH) / 2 : 0; + int labelYOffset = inline && showIcon + ? Math.round((labelYOffsetOverride != null ? labelYOffsetOverride : 0) * scale) + : 0; + return textVCenter + labelYOffset + GuideText.ascent() * textStyle.fontScale(); } /** Overrides the default inline Y offset for the label text only. Does not affect the icon. */ @@ -128,11 +186,80 @@ public void setLabelYOffsetOverride(@Nullable Integer override) { this.labelYOffsetOverride = override; } + /** + * Computes the full content size of this block (icon + label when both + * present, icon-only or text-only otherwise). Mirrors computeLayout + * arithmetic using static GuideText measurement. + * + * @return int[]{width, height} + */ + private int[] computeContentSize() { + int iconSize = Math.round(BASE_SIZE * scale); + boolean hasLabel = labelPosition != null && stack != null; + + if (!showIcon && !hasLabel) { + return new int[] { 0, 0 }; + } + if (!hasLabel) { + // Optical tight advance for inline icons: shrink the cell to the + // ink width instead of the full 16px square, so the gap to the + // following text is consistent across items. Falls back to the + // legacy 16px cell when ink metrics are unavailable. + // The Rust pen advance no longer comes from this declared width: + // the atom's advance is the ink box (ink_left + ink_w + ink_right, + // expressed in the 16-unit icon space and converted to pixels), and + // the icon quad renders at the Rust position with NO ink-shift + // compensation. The declared tight width (serialization-size vs + // layout-size consistency, see computeLayout) is retained as-is: + // a "PAD" term is constant 0 and was inlined away; simplification + // beyond that (dropping the tight width for the 16px cell) is not + // proven safe because the declared box is still consumed by the + // serializer (measureSerializedInlineSize) and by Rust's inline + // width accounting. + if (inline) { + IconMetrics m = stack != null ? IconMetrics.forStack(stack) : null; + if (m != null) { + int tightW = Math.round(m.width * scale); + return new int[] { tightW, iconSize }; + } + } + return new int[] { iconSize, iconSize }; + } + + ResolvedTextStyle textStyle = resolveLabelStyle(); + String text = resolveLabelText(); + int textW = GuideText.measureWidth(text, textStyle); + int textH = GuideText.lineHeight(textStyle); + + if (!showIcon) { + return new int[] { textW, textH }; + } + + // showIcon + hasLabel: total width is the same for label="left" and label="right" + int labelYOffset = inline && showIcon + ? Math.round((labelYOffsetOverride != null ? labelYOffsetOverride : 0) * scale) + : 0; + int textTop = (iconSize - textH) / 2 + labelYOffset; + int top = Math.min(0, textTop); + int bottom = Math.max(iconSize, textTop + textH); + int totalW = iconSize + labelGap() + textW; + int totalH = Math.max(0, bottom - top); + return new int[] { totalW, totalH }; + } + + /** + * Computes the inline size (width, height) for serialization when no + * LayoutContext is available. Delegates to {@link #computeContentSize()}. + */ + public int[] measureSerializedInlineSize() { + return computeContentSize(); + } + @Override protected LytRect computeLayout(LayoutContext context, int x, int y, int availableWidth) { int iconSize = Math.round(BASE_SIZE * scale); int labelYOffset = inline && showIcon - ? Math.round((labelYOffsetOverride != null ? labelYOffsetOverride : DEFAULT_TEXT_INLINE_Y_OFFSET) * scale) + ? Math.round((labelYOffsetOverride != null ? labelYOffsetOverride : 0) * scale) : 0; boolean hasLabel = labelPosition != null && stack != null; @@ -142,6 +269,17 @@ protected LytRect computeLayout(LayoutContext context, int x, int y, int availab } if (!hasLabel) { layoutYOffset = 0; + // Mirrors computeContentSize: the no-label inline path uses the + // same tight ink advance (no padding term), so serialization-size + // and layout-size never disagree for the same block. Non-inline and + // label paths keep the legacy 16px cell. + if (inline) { + IconMetrics m = stack != null ? IconMetrics.forStack(stack) : null; + if (m != null) { + int tightW = Math.round(m.width * scale); + return new LytRect(x, y, tightW, iconSize); + } + } return new LytRect(x, y, iconSize, iconSize); } @@ -149,6 +287,8 @@ protected LytRect computeLayout(LayoutContext context, int x, int y, int availab String text = resolveLabelText(); int textW = measureTextWidth(context, text, textStyle); int textH = context.getLineHeight(textStyle); + labelTextW = textW; + labelTextH = textH; if (!showIcon) { layoutYOffset = 0; @@ -158,7 +298,7 @@ protected LytRect computeLayout(LayoutContext context, int x, int y, int availab int top = Math.min(0, textTop); int bottom = Math.max(iconSize, textTop + textH); layoutYOffset = top; - int totalW = iconSize + LABEL_GAP + textW; + int totalW = iconSize + labelGap() + textW; int totalH = Math.max(0, bottom - top); return new LytRect(x, y, totalW, totalH); } @@ -167,7 +307,12 @@ protected LytRect computeLayout(LayoutContext context, int x, int y, int availab protected void onLayoutMoved(int deltaX, int deltaY) {} @Override - public void render(RenderContext context) { + public boolean usePrimitives() { + return true; + } + + @Override + public void computePrimitives(PrimitiveCollector c) { if (stack == null || stack.stackSize == 0) return; int baseX = bounds.x(); @@ -182,52 +327,50 @@ public void render(RenderContext context) { if (hasLabel) { ResolvedTextStyle textStyle = resolveLabelStyle(); String text = resolveLabelText(); - int textW = context.getStringWidth(text, textStyle); - int textH = context.getLineHeight(textStyle); - int textVCenter = showIcon ? (iconSize - textH) / 2 : 0; + // On-the-spot measurement when the layout-pass cache is unpopulated + // (the Java layout pre-pass was removed, so computeLayout may not run). + if (labelTextW <= 0) { + labelTextW = GuideText.measureWidth(text, textStyle); + labelTextH = GuideText.lineHeight(textStyle); + } + int textVCenter = showIcon ? (iconSize - labelTextH) / 2 : 0; int labelYOffset = inline && showIcon - ? Math - .round((labelYOffsetOverride != null ? labelYOffsetOverride : DEFAULT_TEXT_INLINE_Y_OFFSET) * scale) + ? Math.round((labelYOffsetOverride != null ? labelYOffsetOverride : 0) * scale) : 0; if ("left".equals(labelPosition)) { textX = baseX; - iconX = showIcon ? baseX + textW + LABEL_GAP : baseX; + iconX = showIcon ? baseX + labelTextW + labelGap() : baseX; } else { iconX = baseX; - textX = showIcon ? baseX + iconSize + LABEL_GAP : baseX; + textX = showIcon ? baseX + iconSize + labelGap() : baseX; } textY = baseY + textVCenter + labelYOffset; - context.drawText(text, textX, textY, textStyle); + GuideText.emitText(c, text, textX, textY, textStyle); } if (showIcon) { + // The icon render position consumes the Rust layout output directly: + // the block x is the pen position produced by the Rust atom + // placement layer (the ink_left side bearing is already included in + // the Rust advance), so no x computation is left on the Java side. + // The former `- Math.round(m.inkLeft * scale)` compensation, needed + // by the old tight cell, is retired. int renderX = iconX; - int renderY = baseY + getInlineVisualYOffset(); - renderIcon(context, renderX, renderY); - } - } - - private void renderIcon(RenderContext context, int renderX, int renderY) { - try { + int renderY = baseY; if (scale == 1f) { - context.renderItem(stack, renderX, renderY); + c.emit(new GuideRenderPrimitive.RenderItem(stack, renderX, renderY)); } else { - GL11.glPushMatrix(); - try { - GL11.glTranslatef(renderX, renderY, 0); - GL11.glScalef(scale, scale, 1f); - context.renderItem(stack, 0, 0); - } finally { - GL11.glPopMatrix(); - } + c.pushTransform(renderX, renderY, scale); + c.emit(new GuideRenderPrimitive.RenderItem(stack, 0, 0)); + c.popTransform(); } - } catch (Throwable t) { - GuideDisplayItemStacks.warnRenderFailure("LytItemImage", stack, t); - context.restoreExternalRenderState(); } } + @Override + public void render(RenderContext context) {} + @Override public Optional getTooltip(float x, float y) { if (!showTooltip) return Optional.empty(); @@ -315,8 +458,8 @@ private static String stripFormatMarkers(String s) { /** * Builds a {@link TextStyle} by interpreting wrapping Markdown-style markers in {@code format}. - * Processed markers: {@code **bold**}, {@code *italic*}, {@code _italic_}, {@code ~~strike~~}, - * {@code __underline__}, {@code ++underline++}, {@code ^^wavy^^}, {@code ::dotted::}. + * Processed markers: bold ({@code **}), italic ({@code *} or {@code _}), strikethrough ({@code ~~}), + * underline ({@code __} or {@code ++}), wavy ({@code ^^}), dotted ({@code ::}). */ private static TextStyle buildFormatStyle(String s) { TextStyle.Builder builder = TextStyle.builder(); diff --git a/src/main/java/com/hfstudio/guidenh/guide/document/block/LytLatexBlock.java b/src/main/java/com/hfstudio/guidenh/guide/document/block/LytLatexBlock.java index e45fe4f1..7887a853 100644 --- a/src/main/java/com/hfstudio/guidenh/guide/document/block/LytLatexBlock.java +++ b/src/main/java/com/hfstudio/guidenh/guide/document/block/LytLatexBlock.java @@ -3,13 +3,18 @@ import java.util.Optional; import org.jetbrains.annotations.Nullable; +import org.scilab.forge.jlatexmath.TeXConstants; import com.hfstudio.guidenh.guide.document.LytRect; import com.hfstudio.guidenh.guide.document.flow.LytFlowInlineBlock; import com.hfstudio.guidenh.guide.document.interaction.GuideTooltip; import com.hfstudio.guidenh.guide.document.interaction.InteractiveElement; +import com.hfstudio.guidenh.guide.internal.util.DisplayScale; import com.hfstudio.guidenh.guide.latex.GuideLatexRenderer; import com.hfstudio.guidenh.guide.layout.LayoutContext; +import com.hfstudio.guidenh.guide.render.GuideRenderPrimitive; +import com.hfstudio.guidenh.guide.render.GuideText; +import com.hfstudio.guidenh.guide.render.PrimitiveCollector; import com.hfstudio.guidenh.guide.render.RenderContext; import lombok.Getter; @@ -21,14 +26,21 @@ * taller than a single character (e.g. fractions). * *

          + * Typeset-at-target-size. The formula is typeset by jlatexmath directly at + * {@code fontSize = GuideText.BASE_FONT_SIZE × userScale} pixels ({@code userScale} = the tag's + * {@code scale} attribute). {@link #getFormulaDisplayW()}/{@link #getFormulaDisplayH()} ARE the + * TeXIcon pixel dimensions returned by {@link GuideLatexRenderer#measureSize}: no scale factor, + * no reference-string calibration, no texture scaling. + * + *

          * Vertical alignment is controlled by {@link LatexVerticalAlign}: *

            - *
          • {@link LatexVerticalAlign#BASELINE} — formula math baseline aligns with the text baseline (default). + *
          • {@link LatexVerticalAlign#BASELINE}: formula math baseline aligns with the text baseline (default). * This is the best choice for most inline formulas: letters and superscripts sit flush with * surrounding text, while fractions and integrals extend above/below the baseline naturally.
          • - *
          • {@link LatexVerticalAlign#TOP} — formula top aligns with the text line top.
          • - *
          • {@link LatexVerticalAlign#CENTER} — formula is centered on the text line.
          • - *
          • {@link LatexVerticalAlign#BOTTOM} — formula bottom aligns with the text line bottom.
          • + *
          • {@link LatexVerticalAlign#TOP}: formula top aligns with the text line top.
          • + *
          • {@link LatexVerticalAlign#CENTER}: formula is centered on the text line.
          • + *
          • {@link LatexVerticalAlign#BOTTOM}: formula bottom aligns with the text line bottom.
          • *
          * {@code offsetX} and {@code offsetY} are pixel offsets applied on top of the alignment. */ @@ -38,10 +50,20 @@ public class LytLatexBlock extends LytBlock implements InteractiveElement { private final String formula; @Getter private final int fillColorArgb; + /** + * DEPRECATED: legacy jlatexmath render size, parsed for backward compatibility and ignored. + * Retained so old documents that set {@code sourceScale} still parse. No longer serialized + * (the LatexDisplayData.source_scale slot is DEPRECATED and Rust never reads it), so the + * active render size is {@link #getFontSize()}. + */ @Getter private final float sourceScale; @Getter private final float userScale; + /** Target typeset font size in pixels: {@link GuideText#BASE_FONT_SIZE} × {@code userScale}. */ + @Getter + private final float fontSize; + private final int style; @Nullable private final GuideTooltip tooltip; @Getter @@ -55,17 +77,41 @@ public class LytLatexBlock extends LytBlock implements InteractiveElement { private int formulaDisplayW; /** Formula display height in GUI pixels, recomputed each layout pass. */ private int formulaDisplayH; - /** Vertical pixel offset inside the layout bounds, recomputed each layout pass. */ - private int renderYOffset; + /** True when lazy computation has been attempted (even if result is 0). */ + private boolean formulaDisplayComputed; + /** + * Distance from the formula's top to the text baseline it aligns with, in GUI + * pixels. Recomputed each layout pass; consumed by the Rust inline post-pass, + * which anchors the block's top this far above the placeholder's baseline. + */ + @Getter + private float baselineAscent; private boolean sourceMetricsResolved; private int sourceWidthPx; private int sourceHeightPx; - private int sourceDepthPx; - private int sourceRefHeightPx; + /** + * Exact jlatexmath math-baseline ratio from + * {@code GuideLatexRenderer.measureBaselineRatio} (see + * {@link TeXIcon#getBaseLine()}: baseline distance from the icon top as a + * fraction of the icon's total height, insets included, in [0,1]). Used + * instead of a source-pixel depth so the display depth is rounded exactly + * once, at display resolution. + */ + private float sourceBaseLineRatio; public LytLatexBlock(String formula, int fillColorArgb, float sourceScale, float userScale, @Nullable GuideTooltip tooltip, LatexVerticalAlign valign, int offsetX, int offsetY) { - this(formula, new LatexRenderOptions(fillColorArgb, sourceScale, userScale, tooltip, valign, offsetX, offsetY)); + this( + formula, + new LatexRenderOptions( + TeXConstants.STYLE_DISPLAY, + fillColorArgb, + sourceScale, + userScale, + tooltip, + valign, + offsetX, + offsetY)); } public LytLatexBlock(String formula, LatexRenderOptions options) { @@ -73,52 +119,73 @@ public LytLatexBlock(String formula, LatexRenderOptions options) { this.fillColorArgb = options.fillColorArgb(); this.sourceScale = options.sourceScale(); this.userScale = options.userScale(); + this.fontSize = options.fontSize(); + this.style = options.style(); this.tooltip = options.tooltip(); this.valign = options.valign(); this.offsetX = options.offsetX(); this.offsetY = options.offsetY(); } - @Override - protected LytRect computeLayout(LayoutContext context, int x, int y, int availableWidth) { + /** + * Returns the formula display width, computing it lazily if no layout pass has been run. + * Uses static font metrics via {@link GuideText} so it works without a {@link LayoutContext}. + */ + public int getFormulaDisplayW() { + if (!formulaDisplayComputed) { + computeFormulaDisplay(); + } + return formulaDisplayW; + } + + /** + * Returns the formula display height, computing it lazily if no layout pass has been run. + * Uses static font metrics via {@link GuideText} so it works without a {@link LayoutContext}. + */ + public int getFormulaDisplayH() { + if (!formulaDisplayComputed) { + computeFormulaDisplay(); + } + return formulaDisplayH; + } + + /** Lazy-compute formula display dimensions using static font metrics. */ + private void computeFormulaDisplay() { + formulaDisplayComputed = true; if (!resolveSourceMetrics()) { formulaDisplayW = 0; formulaDisplayH = 0; - renderYOffset = 0; - return new LytRect(x, y, 0, 0); + return; } - - int lineHeight = context.getLineHeight(null); - formulaDisplayH = scaleSourceMetricCeil(sourceHeightPx, lineHeight); - formulaDisplayW = scaleSourceMetricCeil(sourceWidthPx, lineHeight); - + // Typeset-at-target-size: the icon pixel dimensions ARE the display dimensions + // (no scale factor, no calibration, no texture scaling). + formulaDisplayH = sourceHeightPx; + formulaDisplayW = sourceWidthPx; + // baselineAscent must also be computed here, because the Java layout pre-pass + // has been removed (Rust is sole geometry authority), so computeLayout() + // is never called. The Rust inline post-pass uses this value as param + // (align=1) to anchor the formula's math baseline at the text baseline. + int lineHeight = GuideText.lineHeight(null); + int depthDisplay = scaleSourceDepthFromBaseline(sourceBaseLineRatio, formulaDisplayH); int alignOffset = switch (valign) { case CENTER -> (lineHeight - formulaDisplayH) / 2; case BOTTOM -> lineHeight - formulaDisplayH; - case BASELINE -> { - // Align the formula's math baseline with the text baseline. - // - // Both calibrateRefHeight() and measureSize() apply the same Insets value, - // so the bottom-inset term B cancels out in the algebra: - // - // text_baseline = (refH - B) * lineHeight / refH - // formula_ascent = (size[1] - B - size[2]) * lineHeight * userScale / refH - // alignOffset = text_baseline - formula_ascent - // = (lineHeight - displayH) + size[2] * lineHeight * userScale / refH - // = (lineHeight - displayH) + depthDisplay - // - // For depth-zero formulas (size[2]==0) this is identical to BOTTOM. - int depthDisplay = scaleSourceMetricRound(sourceDepthPx, lineHeight); - yield lineHeight - formulaDisplayH + depthDisplay; - } + case BASELINE -> lineHeight - formulaDisplayH + depthDisplay; default -> 0; // TOP }; - int desiredRenderYOffset = alignOffset + offsetY; - int topInset = Math.max(0, -desiredRenderYOffset); - int bottomInset = Math.max(0, desiredRenderYOffset); - renderYOffset = desiredRenderYOffset + topInset; + baselineAscent = lineHeight - (alignOffset + offsetY); + } - return new LytRect(x, y - topInset, formulaDisplayW, topInset + formulaDisplayH + bottomInset); + /** + * Legacy Java layout path. The Java pre-pass has been removed (Rust is the sole geometry + * authority), so this only keeps the abstract contract of {@link LytBlock} satisfied while + * {@link LytParagraph} still calls {@code layout(...)} for every inline child. Geometry + * comes from {@link #computeFormulaDisplay()}. + */ + @Override + protected LytRect computeLayout(LayoutContext context, int x, int y, int availableWidth) { + computeFormulaDisplay(); + return new LytRect(x, y, formulaDisplayW, formulaDisplayH); } private boolean resolveSourceMetrics() { @@ -126,27 +193,63 @@ private boolean resolveSourceMetrics() { return sourceWidthPx > 0 && sourceHeightPx > 0; } sourceMetricsResolved = true; - int[] size = GuideLatexRenderer.INSTANCE.measureSize(formula, fillColorArgb, sourceScale); + int[] size = GuideLatexRenderer.INSTANCE.measureSize(formula, fillColorArgb, fontSize, style); if (size == null) { return false; } sourceWidthPx = size[0]; sourceHeightPx = size[1]; - sourceDepthPx = size[2]; - sourceRefHeightPx = GuideLatexRenderer.INSTANCE.calibrateRefHeight(sourceScale); + sourceBaseLineRatio = GuideLatexRenderer.INSTANCE.measureBaselineRatio(formula, fillColorArgb, fontSize, style); return sourceWidthPx > 0 && sourceHeightPx > 0; } - private int scaleSourceMetricCeil(int sourceMetric, int lineHeight) { - return (int) Math.max(1, Math.ceil((double) sourceMetric * lineHeight * userScale / sourceRefHeightPx)); + /** + * Display distance from the formula's math baseline to the icon bottom, + * derived directly from the exact jlatexmath baseline ratio instead of a + * source-pixel depth. {@link TeXIcon#getBaseLine()} returns the distance + * from the icon's top edge to its math baseline as a fraction of the + * icon's total height (the true 2px/side insets of the two-arg + * {@code setInsets(insets, true)} included), so the math baseline sits + * {@code formulaDisplayH × (1 - ratio)} above the bottom of the display + * box (which is exactly the icon at typeset-at-target-size, insets + * included). This is rounded exactly once, at display resolution. + */ + private int scaleSourceDepthFromBaseline(float baseLineRatio, int formulaDisplayH) { + return Math.max(0, (int) Math.round(formulaDisplayH * (1f - baseLineRatio))); } - private int scaleSourceMetricRound(int sourceMetric, int lineHeight) { - return (int) Math.round((double) sourceMetric * lineHeight * userScale / sourceRefHeightPx); + @Override + protected void onLayoutMoved(int deltaX, int deltaY) {} + + @Override + public boolean usePrimitives() { + return true; } @Override - protected void onLayoutMoved(int deltaX, int deltaY) {} + public void computePrimitives(PrimitiveCollector c) { + if (formulaDisplayW <= 0 || formulaDisplayH <= 0) { + return; + } + + // Deferred rasterization: emit a stable latex blit token; the render + // engine resolves the real texture at draw time at the exact device + // output scale (texture rasterized at fontSize × rasterScale, blit + // quad stays at the document formulaDisplayW/H, 1:1 device pixels). + int token = GuideLatexRenderer.INSTANCE.registerLatexBlit(formula, fillColorArgb, fontSize, style); + + c.emit( + new GuideRenderPrimitive.BlitTexture( + token, + bounds.x() + offsetX, + bounds.y(), + formulaDisplayW, + formulaDisplayH, + 0f, + 0f, + 1f, + 1f)); + } @Override public void render(RenderContext context) { @@ -154,13 +257,16 @@ public void render(RenderContext context) { return; } - int[] tex = GuideLatexRenderer.INSTANCE.getOrCreateTexture(formula, fillColorArgb, sourceScale); + int token = GuideLatexRenderer.INSTANCE.registerLatexBlit(formula, fillColorArgb, fontSize, style); + // Legacy (non-primitive) render path: no transform stack is available, + // so fall back to the in-game display scale. + int[] tex = GuideLatexRenderer.INSTANCE.resolveLatexTexture(token, DisplayScale.scaleFactor()); if (tex == null) { return; } GuideLatexRenderer.INSTANCE - .renderLatex(bounds.x() + offsetX, bounds.y() + renderYOffset, formulaDisplayW, formulaDisplayH, tex[0]); + .renderLatex(bounds.x() + offsetX, bounds.y(), formulaDisplayW, formulaDisplayH, tex[0]); } @Override @@ -186,7 +292,7 @@ public LytRect getVisualBounds() { if (bounds == null || bounds.isEmpty()) { return LytRect.empty(); } - return new LytRect(bounds.x() + offsetX, bounds.y() + renderYOffset, formulaDisplayW, formulaDisplayH); + return new LytRect(bounds.x() + offsetX, bounds.y(), formulaDisplayW, formulaDisplayH); } @Nullable diff --git a/src/main/java/com/hfstudio/guidenh/guide/document/block/LytLatexDisplayBlock.java b/src/main/java/com/hfstudio/guidenh/guide/document/block/LytLatexDisplayBlock.java index a108fe46..69937feb 100644 --- a/src/main/java/com/hfstudio/guidenh/guide/document/block/LytLatexDisplayBlock.java +++ b/src/main/java/com/hfstudio/guidenh/guide/document/block/LytLatexDisplayBlock.java @@ -3,13 +3,22 @@ import java.util.Optional; import org.jetbrains.annotations.Nullable; +import org.scilab.forge.jlatexmath.TeXConstants; import com.hfstudio.guidenh.guide.document.LytRect; import com.hfstudio.guidenh.guide.document.interaction.GuideTooltip; import com.hfstudio.guidenh.guide.document.interaction.InteractiveElement; +import com.hfstudio.guidenh.guide.internal.util.DisplayScale; import com.hfstudio.guidenh.guide.latex.GuideLatexRenderer; import com.hfstudio.guidenh.guide.layout.LayoutContext; +import com.hfstudio.guidenh.guide.render.GuideRenderPrimitive; +import com.hfstudio.guidenh.guide.render.GuideText; +import com.hfstudio.guidenh.guide.render.PrimitiveCollector; import com.hfstudio.guidenh.guide.render.RenderContext; +import com.hfstudio.guidenh.guide.style.token.DimensionValue; +import com.hfstudio.guidenh.guide.style.token.GuideThemeManager; +import com.hfstudio.guidenh.guide.style.token.TokenKey; +import com.hfstudio.guidenh.guide.style.token.TokenType; import lombok.Getter; @@ -18,20 +27,43 @@ * horizontally, with a small vertical margin above and below. * *

          + * Typeset-at-target-size. The formula is typeset by jlatexmath directly at + * {@code fontSize = GuideText.BASE_FONT_SIZE × userScale} pixels; the display width/height ARE + * the TeXIcon pixel dimensions: no line-height calibration formula and no legacy padding patch. + * + *

          * {@code offsetX} and {@code offsetY} are pixel offsets applied on top of the default centered position. */ public class LytLatexDisplayBlock extends LytBlock implements InteractiveElement { - private static final int VERTICAL_MARGIN = 4; + /** Theme token: vertical margin above and below a display formula. */ + private static final TokenKey VERTICAL_MARGIN = TokenKey + .define("--lyt-latex-display-vertical-margin", TokenType.DIMENSION, DimensionValue.px(4)); + + private static int verticalMargin() { + return GuideThemeManager.instance() + .active() + .dim(VERTICAL_MARGIN) + .pxInt(); + } @Getter private final String formula; @Getter private final int fillColorArgb; + /** + * DEPRECATED: legacy jlatexmath render size, parsed for backward compatibility and ignored. + * Retained so the flatbuffer passthrough keeps compiling. The active render size is + * {@link #getFontSize()}. + */ @Getter private final float sourceScale; @Getter private final float userScale; + /** Target typeset font size in pixels: {@link GuideText#BASE_FONT_SIZE} × {@code userScale}. */ + @Getter + private final float fontSize; + private final int style; @Nullable private final GuideTooltip tooltip; @Getter @@ -43,12 +75,15 @@ public class LytLatexDisplayBlock extends LytBlock implements InteractiveElement private int formulaDisplayW; /** Cached formula display height (pixels in GUI units), set during layout. */ private int formulaDisplayH; + /** True when lazy computation has been attempted (even if result is 0). */ + private boolean formulaDisplayComputed; public LytLatexDisplayBlock(String formula, int fillColorArgb, float sourceScale, float userScale, @Nullable GuideTooltip tooltip, int offsetX, int offsetY) { this( formula, new LatexRenderOptions( + TeXConstants.STYLE_DISPLAY, fillColorArgb, sourceScale, userScale, @@ -63,31 +98,90 @@ public LytLatexDisplayBlock(String formula, LatexRenderOptions options) { this.fillColorArgb = options.fillColorArgb(); this.sourceScale = options.sourceScale(); this.userScale = options.userScale(); + this.fontSize = options.fontSize(); + this.style = options.style(); this.tooltip = options.tooltip(); this.offsetX = options.offsetX(); this.offsetY = options.offsetY(); } - @Override - protected LytRect computeLayout(LayoutContext context, int x, int y, int availableWidth) { - int[] size = GuideLatexRenderer.INSTANCE.measureSize(formula, fillColorArgb, sourceScale); + /** + * Returns the formula display width, computing it lazily if no layout pass has been run. + * Uses static font metrics via {@link GuideText} so it works without a {@link LayoutContext}. + */ + public int getFormulaDisplayW() { + if (!formulaDisplayComputed) { + computeFormulaDisplay(); + } + return formulaDisplayW; + } + + /** + * Returns the formula display height, computing it lazily if no layout pass has been run. + * Uses static font metrics via {@link GuideText} so it works without a {@link LayoutContext}. + */ + public int getFormulaDisplayH() { + if (!formulaDisplayComputed) { + computeFormulaDisplay(); + } + return formulaDisplayH; + } + + /** Lazy-compute formula display dimensions using static font metrics. */ + private void computeFormulaDisplay() { + formulaDisplayComputed = true; + // Typeset-at-target-size: the icon pixel dimensions ARE the display dimensions + // (no line-height calibration, no legacy padding patch, no texture scaling). + int[] size = GuideLatexRenderer.INSTANCE.measureSize(formula, fillColorArgb, fontSize, style); if (size == null) { formulaDisplayW = 0; formulaDisplayH = 0; - return new LytRect(x, y, availableWidth, 0); + return; } + formulaDisplayW = Math.max(1, size[0]); + formulaDisplayH = Math.max(1, size[1]); + } - int lineHeight = context.getLineHeight(null); - int refH = GuideLatexRenderer.INSTANCE.calibrateRefHeight(sourceScale); + @Override + protected LytRect computeLayout(LayoutContext context, int x, int y, int availableWidth) { + computeFormulaDisplay(); + return new LytRect(x, y, availableWidth, formulaDisplayH + 2 * verticalMargin()); + } - formulaDisplayH = (int) Math.max(1, Math.ceil((double) size[1] * lineHeight * userScale / refH)); - formulaDisplayW = (int) Math.max(1, Math.ceil((double) size[0] * lineHeight * userScale / refH)); + @Override + protected void onLayoutMoved(int deltaX, int deltaY) {} - return new LytRect(x, y, availableWidth, formulaDisplayH + 2 * VERTICAL_MARGIN); + @Override + public boolean usePrimitives() { + return true; } @Override - protected void onLayoutMoved(int deltaX, int deltaY) {} + public void computePrimitives(PrimitiveCollector c) { + if (formulaDisplayW <= 0 || formulaDisplayH <= 0) { + return; + } + + // Deferred rasterization: emit a stable latex blit token; the render + // engine resolves the real texture at draw time at the exact device + // output scale (texture rasterized at fontSize × rasterScale, blit + // quad stays at the document formulaDisplayW/H, 1:1 device pixels). + int token = GuideLatexRenderer.INSTANCE.registerLatexBlit(formula, fillColorArgb, fontSize, style); + + int centeredX = bounds.x() + (bounds.width() - formulaDisplayW) / 2; + int formulaY = bounds.y() + verticalMargin(); + c.emit( + new GuideRenderPrimitive.BlitTexture( + token, + centeredX + offsetX, + formulaY + offsetY, + formulaDisplayW, + formulaDisplayH, + 0f, + 0f, + 1f, + 1f)); + } @Override public void render(RenderContext context) { @@ -95,13 +189,16 @@ public void render(RenderContext context) { return; } - int[] tex = GuideLatexRenderer.INSTANCE.getOrCreateTexture(formula, fillColorArgb, sourceScale); + int token = GuideLatexRenderer.INSTANCE.registerLatexBlit(formula, fillColorArgb, fontSize, style); + // Legacy (non-primitive) render path: no transform stack is available, + // so fall back to the in-game display scale. + int[] tex = GuideLatexRenderer.INSTANCE.resolveLatexTexture(token, DisplayScale.scaleFactor()); if (tex == null) { return; } int centeredX = bounds.x() + (bounds.width() - formulaDisplayW) / 2; - int formulaY = bounds.y() + VERTICAL_MARGIN; + int formulaY = bounds.y() + verticalMargin(); GuideLatexRenderer.INSTANCE .renderLatex(centeredX + offsetX, formulaY + offsetY, formulaDisplayW, formulaDisplayH, tex[0]); } @@ -130,7 +227,7 @@ public LytRect getVisualBounds() { return bounds != null ? bounds : LytRect.empty(); } int centeredX = bounds.x() + (bounds.width() - formulaDisplayW) / 2; - int formulaY = bounds.y() + VERTICAL_MARGIN; + int formulaY = bounds.y() + verticalMargin(); return new LytRect(centeredX + offsetX, formulaY + offsetY, formulaDisplayW, formulaDisplayH); } diff --git a/src/main/java/com/hfstudio/guidenh/guide/document/block/LytList.java b/src/main/java/com/hfstudio/guidenh/guide/document/block/LytList.java index 5f20a29c..e8654220 100644 --- a/src/main/java/com/hfstudio/guidenh/guide/document/block/LytList.java +++ b/src/main/java/com/hfstudio/guidenh/guide/document/block/LytList.java @@ -1,5 +1,9 @@ package com.hfstudio.guidenh.guide.document.block; +import com.hfstudio.guidenh.guide.compiler.PageCompiler; +import com.hfstudio.guidenh.guide.document.LytRect; +import com.hfstudio.guidenh.guide.layout.LayoutContext; + import lombok.Getter; @Getter @@ -11,6 +15,8 @@ public class LytList extends LytVBox { public LytList(boolean ordered, int start) { this.ordered = ordered; this.start = start; + setMarginTop(PageCompiler.DEFAULT_ELEMENT_SPACING); + setMarginBottom(PageCompiler.DEFAULT_ELEMENT_SPACING); } public int getDepth() { @@ -23,4 +29,20 @@ public int getDepth() { return depth; } + @Override + protected LytRect computeBoxLayout(LayoutContext context, int x, int y, int availableWidth) { + // Manual layout path: only reached from layoutContentSubtree for Mermaid + // NodeContent (no Rust pass). Lay out children (LytListItems) vertically + // and return accumulated bounds. Normal document pipeline bypasses this + // (Rust is the authoritative layout engine). + int cursorY = y; + int maxWidth = 0; + for (LytBlock child : children) { + var childBounds = child.layout(context, x, cursorY, availableWidth); + cursorY += childBounds.height(); + maxWidth = Math.max(maxWidth, childBounds.width()); + } + return new LytRect(x, y, maxWidth, Math.max(0, cursorY - y)); + } + } diff --git a/src/main/java/com/hfstudio/guidenh/guide/document/block/LytListItem.java b/src/main/java/com/hfstudio/guidenh/guide/document/block/LytListItem.java index fb234d01..a6235b9a 100644 --- a/src/main/java/com/hfstudio/guidenh/guide/document/block/LytListItem.java +++ b/src/main/java/com/hfstudio/guidenh/guide/document/block/LytListItem.java @@ -1,63 +1,113 @@ package com.hfstudio.guidenh.guide.document.block; import com.hfstudio.guidenh.guide.color.ColorUtils; +import com.hfstudio.guidenh.guide.color.ColorValue; import com.hfstudio.guidenh.guide.document.DefaultStyles; import com.hfstudio.guidenh.guide.document.LytRect; import com.hfstudio.guidenh.guide.layout.LayoutContext; +import com.hfstudio.guidenh.guide.render.GuideText; import com.hfstudio.guidenh.guide.render.RenderContext; import com.hfstudio.guidenh.guide.style.ResolvedTextStyle; public class LytListItem extends LytVBox { public static final int LEVEL_MARGIN = 10; - private static final int BULLET_SIZE = 2; - private static final int BULLET_X_OFFSET = 5; - - private final ResolvedTextStyle style = DefaultStyles.BODY_TEXT.mergeWith(DefaultStyles.BASE_STYLE); /** - * Cached ordered item number from the last layout pass. - * -1 means the item is in an unordered list or has no list parent. - * Updated in {@link #computeBoxLayout} so that {@link #render} avoids an O(N) sibling scan every frame. + * Shared gutter marker tint: muted gray so markers recede from body text. + * Declared into the layout input ({@code ListMarkerData.style.color}); the + * Rust pipeline shapes and positions the marker glyphs and renders them + * through the shared glyph-quad stream. (The legacy bullet and + * ordered-number draw paths in this class are dead.) */ - private int cachedOrderedNumber = -1; + public static final ColorValue COL_MARKER = ColorUtils.MC_GRAY; + + private final ResolvedTextStyle style = DefaultStyles.BODY_TEXT.mergeWith(DefaultStyles.BASE_STYLE); + + public LytListItem() { + // paddingLeft is read by the Rust layout engine and creates the content + // indentation (replaces the legacy computeBoxLayout's x+margin pass). + // Markers are Rust-shaped and right-aligned to the item's own left edge + // (the per-level document text line) in the layout pipeline. + setPaddingLeft(LEVEL_MARGIN); + } @Override protected LytRect computeBoxLayout(LayoutContext context, int x, int y, int availableWidth) { - // Compute and cache the ordered item number once per layout pass. + // Manual layout path: only reached from layoutContentSubtree for Mermaid + // NodeContent (no Rust pass). paddingLeft (LEVEL_MARGIN) is already + // applied by LytBox.computeLayout before this method; the extra margin + // below creates content indentation leaving the bullet/number zone + // visible. Normal document pipeline bypasses this (Rust is authoritative). + var margin = LEVEL_MARGIN; + int cursorY = y; + int contentAvailWidth = Math.max(1, availableWidth - margin); + int maxContentWidth = 0; + for (LytBlock child : children) { + var childBounds = child.layout(context, x + margin, cursorY, contentAvailWidth); + cursorY += childBounds.height(); + maxContentWidth = Math.max(maxContentWidth, childBounds.width()); + } + int contentHeight = Math.max(0, cursorY - y); + return new LytRect(x, y, maxContentWidth + margin, contentHeight); + } + + /** + * The shared gutter marker text declared into the layout input + * ({@code ListMarkerData}) for the Rust pipeline to shape and draw: ordered + * {@code "N."} or unordered {@code "•"}. Returns {@code null} for items + * that draw their own gutter marker ({@link #hasOwnMarker()}, e.g. the task + * checkbox), so the shared slot is never double-declared. Marker glyphs are + * emitted by Rust as ordinary glyph quads (no Java draw path, zero + * special-casing). + * + *

          + * Ordered numbers are computed on demand at call time + * ({@link #computeOrderedNumber()}, an O(N) sibling scan that honours the + * list's start value). Serialization runs before + * {@code afterExternalLayout()}, so a number cached during layout is still + * unavailable on the first serialization pass. + */ + public String getSharedMarkerText() { + if (hasOwnMarker()) { + return null; + } + int number = computeOrderedNumber(); + return number >= 0 ? number + "." : "•"; + } + + /** + * Computes the ordered number on demand: when the parent is a + * {@link LytList} with {@code isOrdered()}, the number is derived by + * counting the parent list's sibling {@link LytListItem} entries from + * {@link LytList#getStart()} up to this item; otherwise (unordered list, or + * no list parent) it returns -1. O(N) in the item count, which is + * acceptable for one pass per layout serialization. + */ + private int computeOrderedNumber() { if (parent instanceof LytList list && list.isOrdered()) { int number = list.getStart(); for (var child : list.getChildren()) { if (child == this) break; if (child instanceof LytListItem) number++; } - cachedOrderedNumber = number; - } else { - cachedOrderedNumber = -1; + return number; } - var margin = LEVEL_MARGIN; - var bounds = super.computeBoxLayout(context, x + margin, y, availableWidth - margin); - return bounds.expand(LEVEL_MARGIN, 0, 0, 0); + return -1; } - @Override - public void render(RenderContext context) { - if (cachedOrderedNumber >= 0) { - String label = cachedOrderedNumber + "."; - var width = context.getWidth(label, style); - var bounds = getBounds(); - var x = bounds.x() + LEVEL_MARGIN - width - 2; - context.drawText(label, x, bounds.y(), style); - } else { - var bounds = getBounds(); - var markerLine = getMarkerLineBounds(context); - int bulletY = markerLine.y() + (markerLine.height() - BULLET_SIZE) / 2; - context.fillRect(bounds.x() + BULLET_X_OFFSET, bulletY, BULLET_SIZE, BULLET_SIZE, ColorUtils.BODY_TEXT); - } - super.render(context); + /** + * Whether this list item draws its own gutter marker (e.g. the task + * checkbox) instead of the shared bullet / ordered number. Subclasses with + * a custom marker must override to return {@code true} so the Rust + * serializer skips the shared marker slot ({@link #getSharedMarkerText()} + * returns null, otherwise both would double-draw). + */ + protected boolean hasOwnMarker() { + return false; } - private LytRect getMarkerLineBounds(RenderContext context) { + protected LytRect getMarkerLineBounds(RenderContext context) { if (!children.isEmpty()) { LytBlock firstChild = children.getFirst(); if (firstChild instanceof LytParagraph paragraph) { @@ -65,7 +115,7 @@ private LytRect getMarkerLineBounds(RenderContext context) { if (firstTextRun != null) { return firstTextRun; } - LytRect firstLine = paragraph.getFirstLineBounds(); + LytRect firstLine = paragraph.getFirstTextRunBounds(); if (firstLine != null) { return new LytRect(firstLine.x(), firstLine.y(), firstLine.width(), context.getLineHeight(style)); } @@ -75,4 +125,24 @@ private LytRect getMarkerLineBounds(RenderContext context) { LytRect bounds = getBounds(); return new LytRect(bounds.x(), bounds.y(), bounds.width(), context.getLineHeight(style)); } + + /** Context-free overload for use in primitive collection. */ + protected LytRect getMarkerLineBounds() { + if (!children.isEmpty()) { + LytBlock firstChild = children.getFirst(); + if (firstChild instanceof LytParagraph paragraph) { + LytRect firstTextRun = paragraph.getFirstTextRunBounds(); + if (firstTextRun != null) { + return firstTextRun; + } + LytRect firstLine = paragraph.getFirstTextRunBounds(); + if (firstLine != null) { + return new LytRect(firstLine.x(), firstLine.y(), firstLine.width(), GuideText.lineHeight(style)); + } + } + return firstChild.getBounds(); + } + LytRect bounds = getBounds(); + return new LytRect(bounds.x(), bounds.y(), bounds.width(), GuideText.lineHeight(style)); + } } diff --git a/src/main/java/com/hfstudio/guidenh/guide/document/block/LytMermaidCanvas.java b/src/main/java/com/hfstudio/guidenh/guide/document/block/LytMermaidCanvas.java index 575b3918..b6f6a3a7 100644 --- a/src/main/java/com/hfstudio/guidenh/guide/document/block/LytMermaidCanvas.java +++ b/src/main/java/com/hfstudio/guidenh/guide/document/block/LytMermaidCanvas.java @@ -1,49 +1,68 @@ package com.hfstudio.guidenh.guide.document.block; +import java.nio.ByteBuffer; import java.util.ArrayList; import java.util.Collections; +import java.util.HashMap; import java.util.IdentityHashMap; import java.util.LinkedHashMap; import java.util.List; import java.util.Map; import java.util.Optional; -import net.minecraft.item.ItemStack; -import net.minecraft.util.ResourceLocation; - import org.jetbrains.annotations.Nullable; -import org.lwjgl.opengl.GL11; import com.hfstudio.guidenh.guide.color.ColorUtils; -import com.hfstudio.guidenh.guide.color.ColorValue; import com.hfstudio.guidenh.guide.color.ConstantColor; import com.hfstudio.guidenh.guide.document.LytRect; -import com.hfstudio.guidenh.guide.document.flow.LytFlowContent; import com.hfstudio.guidenh.guide.document.interaction.DocumentDragTarget; import com.hfstudio.guidenh.guide.document.interaction.FlowInteractionPath; import com.hfstudio.guidenh.guide.document.interaction.GuideTooltip; import com.hfstudio.guidenh.guide.document.interaction.InteractiveElement; import com.hfstudio.guidenh.guide.internal.debug.DebugComponent; import com.hfstudio.guidenh.guide.internal.debug.DebugFlowContainer; -import com.hfstudio.guidenh.guide.internal.recipe.LytNeiRecipeBox; +import com.hfstudio.guidenh.guide.internal.util.DisplayScale; import com.hfstudio.guidenh.guide.internal.util.SmoothFloatState; -import com.hfstudio.guidenh.guide.render.GuiSprite; +import com.hfstudio.guidenh.guide.layout.LayoutBridge; +import com.hfstudio.guidenh.guide.layout.LayoutContext; +import com.hfstudio.guidenh.guide.layout.LayoutTreeSerializer; +import com.hfstudio.guidenh.guide.layout.Layouts; +import com.hfstudio.guidenh.guide.layout.flatbuffers.LayoutResult; +import com.hfstudio.guidenh.guide.render.GlyphRunData; +import com.hfstudio.guidenh.guide.render.GlyphRunGroup; +import com.hfstudio.guidenh.guide.render.GlyphRunHolder; +import com.hfstudio.guidenh.guide.render.GuideGlyphAtlas; +import com.hfstudio.guidenh.guide.render.GuideRenderPrimitive; +import com.hfstudio.guidenh.guide.render.PrimitiveCollector; import com.hfstudio.guidenh.guide.render.RenderContext; +import com.hfstudio.guidenh.guide.scene.support.GuideDebugLog; import com.hfstudio.guidenh.guide.style.ResolvedTextStyle; import com.hfstudio.guidenh.guide.ui.GuideUiHost; -import lombok.Getter; import lombok.Setter; public abstract class LytMermaidCanvas> extends LytBlock implements DocumentDragTarget, InteractiveElement { + private static final boolean HEADLESS = Boolean.getBoolean("guidenh.headlessRender"); + private static final float ZOOM_STEP = 1.1f; private static final float MIN_ZOOM = 0.5f; - private static final float MAX_ZOOM = 2.5f; + private static final float MAX_ZOOM = 5.0f; static final ConstantColor PANEL_BACKGROUND = new ConstantColor(ColorUtils.ARGB_1A0C1117.getColor()); static final ConstantColor PANEL_BORDER = new ConstantColor(ColorUtils.ARGB_66434C57.getColor()); + /** + * Rust layout engine's document content-box padding (layout-engine + * {@code CONTENT_PAD}, layout.rs:17). The engine insets every serialized + * tree by this amount on all sides. A standalone NodeContent subtree + * serialized through {@link #layoutNodeContentWithRust} must therefore + * inflate its requested available width by 2×PAD so the inner content + * width equals {@code contentWidth}, keeping node sizing identical to the + * pre-Rust Java path (LytParagraph used to claim the full availableWidth). + */ + private static final int RUST_CONTENT_PAD = 14; + private int contentOffsetX; private int contentOffsetY; private final SmoothFloatState visualContentOffsetX = new SmoothFloatState(); @@ -66,17 +85,21 @@ public abstract class LytMermaidCanvas> extends Ly private float debugComponentZoom; private List cachedDebugComponents = List.of(); + /** + * Headless render injection, applied by RenderPageService from + * {@code -Dguidenh.renderpage.mermaidzoom} / {@code -Dguidenh.renderpage.mermaidoffset} + * (mirroring the navscroll injection pattern). Zero zoom and zero offsets + * mean "no injection": the {@code HEADLESS} branch then keeps the + * historical fit-to-view + centre behaviour byte-identical. + */ + private float headlessZoomInjection; + private int headlessOffsetXInjection; + private int headlessOffsetYInjection; + // Common interaction state protected Map nodeContentBlocks; protected int preferredWidth; protected int preferredHeight; - protected int lastPickDocX; - protected int lastPickDocY; - protected boolean lastPickValid; - @Nullable - protected LytParagraph lastFlowHoverParagraph; - @Nullable - protected LytFlowContent lastFlowHoverContent; protected void initNodeContentBlocks(@Nullable Map blocks) { this.nodeContentBlocks = blocks == null ? Collections.emptyMap() : new LinkedHashMap<>(blocks); @@ -87,7 +110,29 @@ protected void initNodeContentBlocks(@Nullable Map blocks) { @Override public List getChildren() { - return new ArrayList<>(nodeContentBlocks.values()); + return List.of(); + } + + @Override + protected LytVisitor.Result visitChildren(LytVisitor visitor, boolean includeOutOfTreeContent) { + if (includeOutOfTreeContent && nodeContentBlocks != null) { + for (LytBlock block : nodeContentBlocks.values()) { + if (block.visit(visitor, true) == LytVisitor.Result.STOP) { + return LytVisitor.Result.STOP; + } + } + } + return LytVisitor.Result.CONTINUE; + } + + @Override + public int getExplicitWidth() { + return preferredWidth > 0 ? preferredWidth : -1; + } + + @Override + public int getExplicitHeight() { + return preferredHeight > 0 ? preferredHeight : -1; } protected abstract int canvasPadding(); @@ -102,15 +147,14 @@ public List getChildren() { protected abstract boolean diagramReady(); - protected abstract void renderDiagram(RenderContext context, int baseX, int baseY, float activeZoom); - protected void renderPanel(RenderContext context) { context.fillRect(bounds, PANEL_BACKGROUND); context.drawBorder(bounds, context.resolveColor(PANEL_BORDER), 1); } - protected void onPreRender() { - refreshFlowHover(); + @Override + public void render(RenderContext context) { + // Unused: subclasses use the primitives path (usePrimitives() == true). } @Nullable @@ -124,9 +168,6 @@ public void setPreferredSize(int width, int height) { @Override public LytNode pickNode(int x, int y) { if (!getBounds().contains(x, y)) return null; - lastPickDocX = x; - lastPickDocY = y; - lastPickValid = true; NodeHit hit = pickNodeHit(x, y); return hit != null ? hit.node() : this; } @@ -174,46 +215,68 @@ public Optional getTooltip(float x, float y) { return Optional.empty(); } - protected void refreshFlowHover() { - if (!lastPickValid || !diagramReady()) return; - NodeHit hit = pickNodeHit(lastPickDocX, lastPickDocY); - LytFlowContent hoveredFlow = null; - LytParagraph hoveredParagraph = null; - if (hit != null) { - for (var content : hit.flowPath() - .targets()) { - if (content instanceof InteractiveElement) { - hoveredFlow = content; - break; - } - } - if (hoveredFlow != null) { - for (LytNode node = hit.node(); node != null; node = node.getParent()) { - if (node instanceof LytParagraph p) { - hoveredParagraph = p; - break; - } - } + /** + * Active zoom used for rendering. + *

          + * Upper ceiling: every path (interactive scroll, direct-write + * {@code snapTo}, and the headless injection branch) is bounded by + * {@link #MAX_ZOOM} on every read. The ceiling guards glyph rasterization: + * an unclamped huge zoom would push fontScale to enormous sizes and + * overflow the glyph atlas pages. + *

          + * Lower floor: interactive zoom and headless zoom injection are + * floored at {@link #MIN_ZOOM}. The headless fit-to-view zoom + * (no injection) is exempt from the floor: it must stay at its exact + * computed value so a diagram larger than the viewport always fits + * (byte-identical no-injection regression). It is always {@code <= 1.0} + * by construction, so only the defensive upper bound applies. + *

          + * Tier quantization: the interactive and headless-injection paths + * snap their value to the nearest {@link #ZOOM_STEP}^n tier (scroll steps + * are themselves powers of {@link #ZOOM_STEP}, so targets are natively + * near-tier). Quantizing the easing intermediate values keeps the fontScale + * and therefore the GuideText shape cache key, stable during a zoom + * animation instead of changing every frame (the per-frame re-rasterize / + * per-glyph re-upload churn that collapsed the frame rate). The + * no-injection fit path is exempt: fitZoom is always {@code <= 1.0} and is + * returned unquantized (byte-identical no-injection regression). + */ + public float getActiveZoom() { + if (HEADLESS) { + if (headlessZoomInjection > 0f) { + return quantizeZoom(Math.clamp(zoom, MIN_ZOOM, MAX_ZOOM)); } + // No-injection fit-to-view: exact value, never quantized. + return Math.min(MAX_ZOOM, zoom); } - if (hoveredParagraph != lastFlowHoverParagraph || hoveredFlow != lastFlowHoverContent) { - if (lastFlowHoverParagraph != null) lastFlowHoverParagraph.onMouseLeave(); - if (hoveredParagraph != null) hoveredParagraph.onMouseEnter(hoveredFlow); - lastFlowHoverParagraph = hoveredParagraph; - lastFlowHoverContent = hoveredFlow; - } + float v = visualZoom.value(); + return quantizeZoom(Math.clamp(v > 0f ? v : zoom, MIN_ZOOM, MAX_ZOOM)); } - public float getActiveZoom() { - return visualZoom.value(); + /** + * Quantize a zoom value to the nearest {@code ZOOM_STEP^n} tier (n an + * integer), then clamp the tier back into {@code [MIN_ZOOM, MAX_ZOOM]}. + *

          + * Order is semantically: clamp input, quantize, clamp tier. The final + * clamp guarantees the {@link #MAX_ZOOM} ceiling that guards glyph + * rasterization is never exceeded even when the nearest tier above + * {@code MAX_ZOOM} (1.1^17 ≈ 5.0545) would overshoot it; the returned + * value is always a 1.1^n tier except exactly at the [MIN, MAX] bounds. + */ + private static float quantizeZoom(float value) { + if (value <= 0f) { + return value; + } + double tier = Math.pow(ZOOM_STEP, Math.round(Math.log(value) / Math.log(ZOOM_STEP))); + return (float) Math.clamp(tier, MIN_ZOOM, MAX_ZOOM); } public int getVisualOffsetX() { - return visualContentOffsetX.rounded(); + return HEADLESS ? contentOffsetX : visualContentOffsetX.rounded(); } public int getVisualOffsetY() { - return visualContentOffsetY.rounded(); + return HEADLESS ? contentOffsetY : visualContentOffsetY.rounded(); } public int getScaledOriginX() { @@ -327,6 +390,16 @@ public void setContentOffset(int x, int y) { contentOffsetY = y; } + /** + * Apply headless zoom/offset injection. Zero zoom and zero offsets leave + * the {@code HEADLESS} branch on its historical fit-to-view + centre path. + */ + public void setHeadlessInjection(float zoomInjection, int offsetX, int offsetY) { + this.headlessZoomInjection = zoomInjection; + this.headlessOffsetXInjection = offsetX; + this.headlessOffsetYInjection = offsetY; + } + public int getRawOffsetX() { return contentOffsetX; } @@ -347,32 +420,101 @@ public void clampOffsets() { } @Override - public void render(RenderContext context) { - if (!diagramReady()) return; - onPreRender(); + public void computePrimitives(PrimitiveCollector c) { + // Drive the raw→visual easing chain at the entry of our own primitive + // collection (same pattern as LytCodeBlock driving updateVisualScroll in + // its computePrimitives): wheel-zoom (scroll) and drags write raw zoom / + // contentOffset, and getActiveZoom/getVisualOffsetX/Y read the visual + // side; without a per-frame driver the two stay permanently detached + // and the interaction never reaches the render. HEADLESS mode reads the + // raw values directly and the HEADLESS branch below overrides zoom / + // offset anyway, so this call is a no-op for headless rendering. updateVisualState(); + boolean ready = diagramReady(); + GuideDebugLog.debugAlways("[GuideNH-Mermaid] computePrimitives diagramReady={} bounds={}", ready, bounds); + if (!ready) return; + LytRect b = getBounds(); + if (b == null) return; + + // Panel background and border + c.emit(new GuideRenderPrimitive.FillRect(b.x(), b.y(), b.width(), b.height(), PANEL_BACKGROUND.resolve())); + c.emit( + new GuideRenderPrimitive.DrawBorder( + b.x(), + b.y(), + b.width(), + b.height(), + 1, + 1, + 1, + 1, + PANEL_BORDER.resolve())); float activeZoom = getActiveZoom(); - if (Float.compare(lastScaledStyleZoom, activeZoom) != 0) { - scaledStyleCache.clear(); - lastScaledStyleZoom = activeZoom; - } - - renderPanel(context); - LytRect inner = getInnerViewport(); - int baseX = inner.x() + getVisualOffsetX() - getScaledOriginX(); - int baseY = inner.y() + getVisualOffsetY() - getScaledOriginY(); - - context.pushLocalScissor(inner); - try { - renderDiagram(context, baseX, baseY, activeZoom); - } finally { - context.popScissor(); + int offsetX = getVisualOffsetX(); + int offsetY = getVisualOffsetY(); + if (HEADLESS) { + // Headless: fit diagram in viewport with fit-to-view zoom, unless + // a -Dguidenh.renderpage.mermaidzoom / mermaidoffset injection was + // applied via setHeadlessInjection. Without injection the + // historical fit-to-view + centre behaviour is preserved + // byte-identically. + int contentW = contentWidth(); + int contentH = contentHeight(); + if (contentW > 0 && contentH > 0) { + float fitZoom = Math + .min(1f, Math.min((float) inner.width() / contentW, (float) inner.height() / contentH)); + zoom = headlessZoomInjection > 0f ? headlessZoomInjection : fitZoom; + // Route the injected zoom through getActiveZoom so it shares the + // [MIN_ZOOM, MAX_ZOOM] clamp (an over-ceiling -D injection is + // clamped to MAX_ZOOM instead of overflowing the atlas pages). + // The no-injection fit-to-view value is preserved exactly (the + // diagram must always fit; byte-identical regression). + activeZoom = getActiveZoom(); + if (headlessZoomInjection > 0f) { + GuideDebugLog.infoAlways( + "[GuideNH-Mermaid] headless zoom injection: requested={} quantized={}", + zoom, + activeZoom); + } + } + int scaledContentW = Math.round(contentWidth() * activeZoom); + int scaledContentH = Math.round(contentHeight() * activeZoom); + if (headlessOffsetXInjection != 0 || headlessOffsetYInjection != 0) { + offsetX = headlessOffsetXInjection; + offsetY = headlessOffsetYInjection; + } else { + offsetX = (inner.width() - scaledContentW) / 2; + offsetY = (inner.height() - scaledContentH) / 2; + } } + int baseX = inner.x() + offsetX - getScaledOriginX(); + int baseY = inner.y() + offsetY - getScaledOriginY(); + + // Clip diagram primitives to the inner viewport (prevent overflow to + // subsequent page content). + c.pushScissor(inner.x(), inner.y(), inner.width(), inner.height()); + emitDiagramPrimitives(c, baseX, baseY, activeZoom); + c.popScissor(); } + /** + * Subclasses override to emit diagram-specific primitives (edges, nodes, + * content blocks) after the panel has been emitted. + */ + protected void emitDiagramPrimitives(PrimitiveCollector c, int baseX, int baseY, float activeZoom) {} + protected ResolvedTextStyle getOrScaleStyle(ResolvedTextStyle base, float zoom) { + // The scaled-style cache is keyed by the base style only (not by zoom), + // so it must be invalidated whenever the zoom changes; otherwise the + // text fontScale would keep the stale zoom and text would stop scaling + // in sync with the node boxes. Clearing on zoom change rebuilds the + // cache for the current zoom (cheap: at most a handful of base styles). + if (Float.compare(zoom, lastScaledStyleZoom) != 0) { + lastScaledStyleZoom = zoom; + scaledStyleCache.clear(); + } return MermaidNodeRenderer.getOrScaleStyle(scaledStyleCache, base, zoom); } @@ -413,44 +555,6 @@ public static int scaled(int base, int value, float activeZoom) { return base + Math.round(value * activeZoom); } - protected static boolean usesRawGl(LytBlock block) { - return block instanceof LytLatexBlock || block instanceof LytLatexDisplayBlock - || block instanceof LytItemImage - || block instanceof LytNeiRecipeBox; - } - - protected static void renderContainerDecoration(LytNode container, RenderContext context) { - if (!(container instanceof LytBox box)) return; - LytRect b = container.getBounds(); - if (box.getBackgroundColor() != null) { - context.fillRect(b, box.getBackgroundColor()); - } - int topW = box.getBorderTop() - .width(); - int bottomW = box.getBorderBottom() - .width(); - if (topW > 0) { - context.fillRect( - b.x(), - b.y(), - b.width(), - topW, - context.resolveColor( - box.getBorderTop() - .color())); - } - if (bottomW > 0) { - context.fillRect( - b.x(), - b.bottom() - bottomW, - b.width(), - bottomW, - context.resolveColor( - box.getBorderBottom() - .color())); - } - } - protected static LytRect resolveBlockVisualBounds(LytBlock block) { LytRect[] result = { LytRect.empty() }; block.visit(new LytVisitor() { @@ -500,55 +604,294 @@ protected static int contextLineHeight(ResolvedTextStyle style) { return Math.max(1, Math.round((9 + 1) * style.fontScale())); } - protected void renderNodeContentBlock(LytBlock block, NodeContentRenderContext nodeContext) { - if (block instanceof LytNode container && !container.getChildren() - .isEmpty()) { - for (var child : new ArrayList<>(container.getChildren())) { - if (child instanceof LytBlock childBlock) { - renderNodeContentBlock(childBlock, nodeContext); + protected static LytRect resolveNodeContentRect(NodeContentLayout contentLayout, LytRect nodeRect, int paddingX, + int contentY, float activeZoom) { + int availW = Math.max(1, nodeRect.width() - paddingX * 2); + int availH = Math.max(1, nodeRect.y() + nodeRect.height() - contentY); + return new LytRect( + nodeRect.x() + paddingX, + contentY, + Math.min( + Math.max( + 1, + Math.round( + contentLayout.visualBounds() + .width() * activeZoom)), + availW), + Math.min( + Math.max( + 1, + Math.round( + contentLayout.visualBounds() + .height() * activeZoom)), + availH)); + } + + // Primitives-path helpers for node content blocks. + + /** + * Lay out a Mermaid NodeContent root block through the Rust layout engine + * using the same serialize → measureLayout → writeback pipeline the main + * document uses ({@code LytDocument.createLayout}). This is required + * because NodeContent subtrees live off the document tree + * ({@code nodeContentBlocks}; {@link #getChildren()} is empty), so they + * never reach the document's Rust pass and their inline blocks keep a zero + * x-position (LytItemImage draws at {@code bounds.x()} → line start). A + * dedicated {@link LayoutTreeSerializer} + {@link LayoutBridge#measureLayout} + * pass runs Rust's inline post-pass on the subtree, which anchors each + * inline block at its paragraph marker's pen position and writes the real x + * back into its bounds. + *

          + * Coordinate system: the FlatLayout/glyph coordinates come back + * relative to the subtree's serialized root (the root sits at the + * engine's {@code CONTENT_PAD} inset, i.e. (14,14) for this subtree). Both + * {@link #resolveBlockVisualBounds} and {@link #emitNodeContentPrimitives} + * consume that same shifted space (the viewport origin subtracts + * {@code visualBounds.x()/y()} while the block/glyph coordinates include + * the identical inset), so the existing viewport translation stays valid + * without any extra offset math. + *

          + * Falls back to the Java manual layout ({@link #layoutContentSubtree}) when + * the native bridge is unavailable (font handle 0) or the measure pass + * fails, so NodeContent stays visible in environments without a loaded + * layout engine. Paragraph glyph runs are wiped before the pass so a failed + * pass never renders stale runs at outdated coordinates. + * + * @param context layout context (font metrics + visual scale) + * @param block the NodeContent root block (usually the LytVBox + * produced by {@code compileNodeContentBlock}) + * @param contentWidth the content width used to lay the block out + */ + protected void layoutNodeContentWithRust(LayoutContext context, LytBlock block, int contentWidth) { + LayoutContext localContext = new LayoutContext(context).withVisualScale(context.getVisualScale()); + // Root's own Java bounds are meaningless (LytVBox.computeBoxLayout is a + // stub), but keep the call so the Java fallback sees the same + // preconditions as before. + block.layout(localContext, 0, 0, contentWidth); + clearGlyphRuns(block); + long fontHandle = LayoutBridge.getFontHandle(); + if (fontHandle != 0) { + try { + var serializer = new LayoutTreeSerializer(); + byte[] input = serializer.serialize( + block, + contentWidth + 2 * RUST_CONTENT_PAD, + localContext.getVisualScale(), + DisplayScale.scaleFactor()); + byte[] result = LayoutBridge.measureLayout(fontHandle, input); + if (result.length > 0) { + var flatResult = LayoutResult.getRootAsLayoutResult(ByteBuffer.wrap(result)); + // Upload unique glyph bitmaps to the shared atlas (keys are + // content-stable, so repeated uploads are no-ops). + var atlas = GuideGlyphAtlas.instance(); + int numBitmaps = flatResult.bitmapsLength(); + for (int bi = 0; bi < numBitmaps; bi++) { + var bmp = flatResult.bitmaps(bi); + if (bmp == null) continue; + int w = (int) bmp.w(); + int h = (int) bmp.h(); + if (w <= 0 || h <= 0) continue; + ByteBuffer rgbaBuf = bmp.rgbaAsByteBuffer(); + byte[] rgba = new byte[rgbaBuf.remaining()]; + rgbaBuf.get(rgba); + if (w > 200 || h > 200) { + GuideDebugLog.warnAlways( + "[GuideNH] OVERSIZE glyph upload source=LytMermaidCanvas key={} w={} h={}", + bmp.key(), + w, + h); + } + atlas.upload(bmp.key(), rgba, w, h); + } + // Writeback: every serialized subtree block gets its + // Rust-computed bounds (glyph runs and inline blocks + // included). The vector is index-aligned with the + // serializer's flat nodes. + int numLayouts = flatResult.nodesLength(); + for (int i = 0; i < numLayouts; i++) { + var fl = flatResult.nodes(i); + if (fl == null) continue; + LytNode node = serializer.getNodeByFlatIndex(i); + if (!(node instanceof LytBlock lb)) continue; + lb.applyExternalLayout( + new LytRect( + Math.round(fl.x()), + Math.round(fl.y()), + Math.max(0, Math.round(fl.w())), + Math.max(0, Math.round(fl.h())))); + } + // Inject glyph runs (final subtree-space quads) and span + // decoration rects into the paragraphs so they render rich + // text exactly like the main document pipeline. + Map> runsByNode = new HashMap<>(); + int numRuns = flatResult.glyphRunsLength(); + for (int ri = 0; ri < numRuns; ri++) { + var fbRun = flatResult.glyphRuns(ri); + if (fbRun == null) continue; + int numGlyphs = fbRun.glyphsLength(); + var placed = new ArrayList(numGlyphs); + for (int gi = 0; gi < numGlyphs; gi++) { + var fbg = fbRun.glyphs(gi); + if (fbg != null) { + placed.add( + new GuideRenderPrimitive.PlacedGlyph( + fbg.bitmapKey(), + fbg.x(), + fbg.y(), + fbg.w(), + fbg.h(), + (int) fbg.lineIndex(), + fbg.baseline())); + } + } + runsByNode.computeIfAbsent((int) fbRun.nodeIndex(), k -> new ArrayList<>()) + .add(new GlyphRunGroup(placed, (int) fbRun.argb(), fbRun.shear())); + } + Map> backgroundsByNode = new HashMap<>(); + Map> linesByNode = new HashMap<>(); + Map> separatorsByNode = new HashMap<>(); + int numDecorations = flatResult.decorationsLength(); + for (int di = 0; di < numDecorations; di++) { + var d = flatResult.decorations(di); + if (d == null) continue; + var rect = new GuideRenderPrimitive.FillRect( + Math.round(d.x()), + Math.round(d.y()), + Math.round(d.w()), + Math.round(d.h()), + (int) d.argb()); + if (d.kind() == 3) { + separatorsByNode.computeIfAbsent((int) d.node(), k -> new ArrayList<>()) + .add(rect); + } else if (d.kind() == 0) { + backgroundsByNode.computeIfAbsent((int) d.node(), k -> new ArrayList<>()) + .add(rect); + } else { + linesByNode.computeIfAbsent((int) d.node(), k -> new ArrayList<>()) + .add(rect); + } + } + for (var entry : runsByNode.entrySet()) { + LytNode node = serializer.getNodeByFlatIndex(entry.getKey()); + if (node instanceof GlyphRunHolder holder) { + holder.setGlyphData( + new GlyphRunData( + entry.getValue(), + backgroundsByNode.getOrDefault(entry.getKey(), List.of()), + linesByNode.getOrDefault(entry.getKey(), List.of()), + separatorsByNode.getOrDefault(entry.getKey(), List.of()))); + } + } + // Post pass: blocks that derive state from children's final + // bounds (e.g. ordered-list numbers) re-apply it now. + for (int i = 0; i < numLayouts; i++) { + LytNode node = serializer.getNodeByFlatIndex(i); + if (node instanceof LytBlock lb) { + lb.afterExternalLayout(); + } + } + return; } + } catch (Exception e) { + GuideDebugLog + .warnAlways("[GuideNH-Mermaid] NodeContent Rust layout failed; falling back to Java layout", e); } - renderContainerDecoration(container, nodeContext); - } else if (usesRawGl(block)) { - GL11.glPushMatrix(); - GL11.glTranslatef(nodeContext.getDocumentOriginX(), nodeContext.getDocumentOriginY(), 0f); - GL11.glScalef(nodeContext.getScale(), nodeContext.getScale(), 1f); - try { - block.render(nodeContext); - } finally { - GL11.glPopMatrix(); + } + // Fallback: Java manual layout (the LytVBox stub is not a real pass). + layoutContentSubtree(localContext, block, contentWidth); + } + + /** + * Recursively lay out all LytVBox containers inside {@code block}, + * including nested ones (LytList / LytListItem / LytVBox), so every block + * in the subtree obtains non-empty bounds visible to + * {@link #resolveBlockVisualBounds} and the primitive collector. This is + * the Java fallback used when the Rust layout engine is unavailable; the + * normal document pipeline and the NodeContent Rust pass never reach it. + *

          + * Uses post-order traversal: subtrees are laid out first, then + * siblings are positioned via {@link Layouts#verticalLayout}. This ordering + * is required because {@link LytList} and {@link LytListItem} have real + * {@code computeBoxLayout} that recursively lay out children; a pre-order + * pass would re-layout those children a second time at incorrect + * coordinates (offset relative to 0 instead of the parent's actual Y + * position). + */ + protected static void layoutContentSubtree(LayoutContext context, LytBlock block, int contentWidth) { + if (!(block instanceof LytVBox vbox)) return; + List blockChildren = new ArrayList<>(); + for (LytNode child : vbox.getChildren()) { + if (child instanceof LytBlock b) blockChildren.add(b); + } + if (blockChildren.isEmpty()) return; + // Post-order: lay out child subtrees before positioning siblings. + for (LytBlock child : blockChildren) { + layoutContentSubtree(context, child, contentWidth); + } + // Content VBox created by compileNodeContentBlock has default + // padding (0), gap (0), and alignItems (START). + Layouts.verticalLayout( + context, + blockChildren, + 0, + 0, + contentWidth, + 0, + 0, + 0, + 0, + vbox.getGap(), + vbox.getAlignItems()); + } + + /** + * Recursively wipe glyph runs in the subtree so a failed Rust pass never + * leaves stale runs rendering at outdated coordinates (mirrors + * {@code LytDocument.clearGlyphRuns}). + */ + private static void clearGlyphRuns(LytBlock block) { + if (block instanceof GlyphRunHolder holder) { + holder.setGlyphData(null); + } + for (LytNode child : block.getChildren()) { + if (child instanceof LytBlock childBlock) { + clearGlyphRuns(childBlock); } - } else { - block.render(nodeContext); } } - protected final void renderNodeContent(RenderContext context, LytBlock block, LytRect contentViewport, + /** + * Emit primitives for a node content block using the collector, replacing + * the legacy NodeContentRenderContext path. The block is rendered inside + * a PushTransform/PopTransform frame so its local coordinates map to the + * correct screen position. + */ + protected void emitNodeContentPrimitives(PrimitiveCollector c, LytBlock block, LytRect contentViewport, LytRect visualBounds, float activeZoom) { LytRect innerViewport = getInnerViewport(); LytRect clip = intersect(innerViewport, contentViewport); if (clip == null) return; - context.pushLocalScissor(clip); - try { - int originX = contentViewport.x() - Math.round(visualBounds.x() * activeZoom); - int originY = contentViewport.y() - Math.round(visualBounds.y() * activeZoom); - NodeContentRenderContext nodeContext = new NodeContentRenderContext( - context, - clip, - originX, - originY, - activeZoom); - renderNodeContentBlock(block, nodeContext); - } finally { - context.popScissor(); - } + int originX = contentViewport.x() - Math.round(visualBounds.x() * activeZoom); + int originY = contentViewport.y() - Math.round(visualBounds.y() * activeZoom); + c.pushScissor(clip.x(), clip.y(), clip.width(), clip.height()); + c.pushTransform(originX, originY, activeZoom); + c.collectFrom(block); + c.popTransform(); + c.popScissor(); } - protected static LytRect resolveNodeContentRect(NodeContentLayout contentLayout, LytRect nodeRect, int paddingX, - int contentY, float activeZoom) { - return new LytRect( - nodeRect.x() + paddingX, - contentY, + /** + * Overload that prepares the content viewport from a NodeContentLayout + * and a screen-space content area, then renders the block clipped to + * {@code innerViewport ∩ contentArea} (the node's inner content boundary, + * NOT the centered contentViewport) to prevent text overflow beyond the + * node bounds. + */ + protected void emitNodeContentPrimitives(PrimitiveCollector c, NodeContentLayout contentLayout, LytRect contentArea, + float activeZoom) { + LytRect rawViewport = new LytRect( + contentArea.x(), + contentArea.y(), Math.max( 1, Math.round( @@ -559,6 +902,31 @@ protected static LytRect resolveNodeContentRect(NodeContentLayout contentLayout, Math.round( contentLayout.visualBounds() .height() * activeZoom))); + int cvpX = rawViewport.x(); + int cvpY = rawViewport.y(); + if (rawViewport.width() < contentArea.width()) { + cvpX = contentArea.x() + (contentArea.width() - rawViewport.width()) / 2; + } + if (rawViewport.height() < contentArea.height()) { + cvpY = contentArea.y() + (contentArea.height() - rawViewport.height()) / 2; + } + LytRect contentViewport = new LytRect(cvpX, cvpY, rawViewport.width(), rawViewport.height()); + // Scissor uses node contentArea (not centered contentViewport) to + // prevent text overflow beyond the node's inner boundary. + LytRect innerViewport = getInnerViewport(); + LytRect clip = intersect(innerViewport, contentArea); + if (clip == null) return; + int originX = contentViewport.x() - Math.round( + contentLayout.visualBounds() + .x() * activeZoom); + int originY = contentViewport.y() - Math.round( + contentLayout.visualBounds() + .y() * activeZoom); + c.pushScissor(clip.x(), clip.y(), clip.width(), clip.height()); + c.pushTransform(originX, originY, activeZoom); + c.collectFrom(contentLayout.block()); + c.popTransform(); + c.popScissor(); } protected void collectNodeContentDebugComponents(NodeContentLayout contentLayout, LytRect contentViewport, @@ -654,297 +1022,4 @@ public NodeContentLayout(LytBlock block, LytRect visualBounds) { } } - public static class NodeContentRenderContext implements RenderContext { - - private final RenderContext delegate; - private final LytRect viewport; - private final int originX; - private final int originY; - @Getter - private final float scale; - private final Map scaledStyleCache = new IdentityHashMap<>(); - - public NodeContentRenderContext(RenderContext delegate, LytRect viewport, int originX, int originY, - float scale) { - this.delegate = delegate; - this.viewport = new LytRect( - 0, - 0, - Math.max(1, Math.round(viewport.width() / scale)), - Math.max(1, Math.round(viewport.height() / scale))); - this.originX = originX; - this.originY = originY; - this.scale = Math.max(0.0001f, scale); - } - - @Override - public LytRect viewport() { - return viewport; - } - - @Override - public int getDocumentOriginX() { - return originX; - } - - @Override - public int getDocumentOriginY() { - return originY; - } - - @Override - public LytRect toScreenRect(LytRect rect) { - LytRect s = scaleRect(rect); - return new LytRect( - s.x() + delegate.getDocumentOriginX(), - s.y() + delegate.getDocumentOriginY() - delegate.getScrollOffsetY(), - s.width(), - s.height()); - } - - @Override - public int resolveColor(ColorValue ref) { - return delegate.resolveColor(ref); - } - - @Override - public void fillRect(LytRect rect, int argbColor) { - delegate.fillRect(scaleRect(rect), argbColor); - } - - @Override - public void fillRect(int x, int y, int width, int height, int argbColor) { - delegate.fillRect(scaleX(x), scaleY(y), scaleLength(width), scaleLength(height), argbColor); - } - - @Override - public void drawBorder(LytRect rect, int argbColor, int thickness) { - delegate.drawBorder(scaleRect(rect), argbColor, Math.max(1, scaleLength(thickness))); - } - - @Override - public void drawBorder(int x, int y, int width, int height, int argbColor, int thickness) { - delegate.drawBorder( - scaleX(x), - scaleY(y), - scaleLength(width), - scaleLength(height), - argbColor, - Math.max(1, scaleLength(thickness))); - } - - @Override - public void drawText(String text, int x, int y, ResolvedTextStyle style) { - delegate.drawText(text, scaleX(x), scaleY(y), scaleStyle(style)); - } - - @Override - public int getStringWidth(String text, ResolvedTextStyle style) { - return scaleLength(delegate.getStringWidth(text, style)); - } - - @Override - public int getLineHeight(ResolvedTextStyle style) { - return scaleLength(delegate.getLineHeight(style)); - } - - @Override - public void renderItem(ItemStack stack, int x, int y) { - renderScaledItem(stack, x, y, true); - } - - @Override - public void renderItemIcon(ItemStack stack, int x, int y) { - renderScaledItem(stack, x, y, false); - } - - private void renderScaledItem(ItemStack stack, int x, int y, boolean overlay) { - int screenX = scaleX(x); - int screenY = scaleY(y); - GL11.glPushMatrix(); - try { - GL11.glTranslatef(screenX, screenY, 0f); - GL11.glScalef(scale, scale, 1f); - if (overlay) { - delegate.renderItem(stack, 0, 0); - } else { - delegate.renderItemIcon(stack, 0, 0); - } - } finally { - GL11.glPopMatrix(); - } - } - - @Override - public void blitGuiSprite(LytRect rect, GuiSprite sprite) { - if (sprite == null) return; - int sx = scaleX(rect.x()); - int sy = scaleY(rect.y()); - GL11.glPushMatrix(); - GL11.glTranslatef(sx, sy, 0f); - GL11.glScalef(scale, scale, 1f); - try { - delegate.blitTexture( - sprite.getTexture(), - 0, - 0, - sprite.getU(), - sprite.getV(), - sprite.getWidth(), - sprite.getHeight()); - } finally { - GL11.glPopMatrix(); - } - } - - @Override - public void fillIcon(LytRect rect, GuiSprite sprite, ColorValue color) { - delegate.fillIcon(scaleRect(rect), sprite, color); - } - - @Override - public void blitTexture(ResourceLocation texture, int x, int y, int u, int v, int width, int height) { - delegate.blitTexture(texture, scaleX(x), scaleY(y), u, v, scaleLength(width), scaleLength(height)); - } - - @Override - public void drawLine(float x1, float y1, float x2, float y2, float thickness, int argbColor) { - delegate.drawLine( - scaleFloatX(x1), - scaleFloatY(y1), - scaleFloatX(x2), - scaleFloatY(y2), - Math.max(1f, thickness * scale), - argbColor); - } - - @Override - public void fillTriangle(float x1, float y1, float x2, float y2, float x3, float y3, int argbColor) { - delegate.fillTriangle( - scaleFloatX(x1), - scaleFloatY(y1), - scaleFloatX(x2), - scaleFloatY(y2), - scaleFloatX(x3), - scaleFloatY(y3), - argbColor); - } - - @Override - public void fillPolygon(float[] xs, float[] ys, int argbColor) { - float[] scaledXs = new float[xs.length]; - float[] scaledYs = new float[ys.length]; - for (int i = 0; i < xs.length; i++) { - scaledXs[i] = scaleFloatX(xs[i]); - scaledYs[i] = scaleFloatY(ys[i]); - } - delegate.fillPolygon(scaledXs, scaledYs, argbColor); - } - - @Override - public void fillCircle(float cx, float cy, float radius, int argbColor) { - delegate.fillCircle(scaleFloatX(cx), scaleFloatY(cy), radius * scale, argbColor); - } - - @Override - public void fillEllipse(float cx, float cy, float rx, float ry, int argbColor) { - delegate.fillEllipse(scaleFloatX(cx), scaleFloatY(cy), rx * scale, ry * scale, argbColor); - } - - @Override - public void drawCircleOutline(float cx, float cy, float radius, float thickness, int argbColor) { - delegate.drawCircleOutline( - scaleFloatX(cx), - scaleFloatY(cy), - radius * scale, - Math.max(1f, thickness * scale), - argbColor); - } - - @Override - public void pushScissor(LytRect rect) { - delegate.pushScissor(scaleRect(rect)); - } - - @Override - public void pushLocalScissor(LytRect rect) { - delegate.pushLocalScissor(scaleRect(rect)); - } - - @Override - public LytRect currentScissor() { - return delegate.currentScissor(); - } - - @Override - public void popScissor() { - delegate.popScissor(); - } - - @Override - public void restoreExternalRenderState() { - delegate.restoreExternalRenderState(); - } - - @Override - public void beginLocalView() { - GL11.glPushMatrix(); - GL11.glTranslatef(originX, originY, 0f); - GL11.glScalef(scale, scale, 1f); - } - - @Override - public void endLocalView() { - GL11.glPopMatrix(); - } - - private ResolvedTextStyle scaleStyle(ResolvedTextStyle style) { - return scaledStyleCache.computeIfAbsent( - style, - key -> new ResolvedTextStyle( - key.fontScale() * scale, - key.bold(), - key.italic(), - key.underlined(), - key.wavyUnderline(), - key.dottedUnderline(), - key.strikethrough(), - key.obfuscated(), - key.font(), - key.color(), - key.whiteSpace(), - key.alignment(), - key.dropShadow(), - key.backgroundColor(), - key.inlineCode())); - } - - private LytRect scaleRect(LytRect rect) { - return new LytRect( - scaleX(rect.x()), - scaleY(rect.y()), - scaleLength(rect.width()), - scaleLength(rect.height())); - } - - private int scaleX(int x) { - return originX + Math.round(x * scale); - } - - private int scaleY(int y) { - return originY + Math.round(y * scale); - } - - private int scaleLength(int value) { - return Math.max(1, Math.round(value * scale)); - } - - private float scaleFloatX(float x) { - return originX + x * scale; - } - - private float scaleFloatY(float y) { - return originY + y * scale; - } - } } diff --git a/src/main/java/com/hfstudio/guidenh/guide/document/block/LytMermaidFlowchart.java b/src/main/java/com/hfstudio/guidenh/guide/document/block/LytMermaidFlowchart.java index a19cc870..2d06be70 100644 --- a/src/main/java/com/hfstudio/guidenh/guide/document/block/LytMermaidFlowchart.java +++ b/src/main/java/com/hfstudio/guidenh/guide/document/block/LytMermaidFlowchart.java @@ -23,6 +23,7 @@ public class LytMermaidFlowchart extends LytVBox implements InteractiveElement { private final String sourceText; @Getter private final LytCodeBlockToolbar toolbar = new LytCodeBlockToolbar(); + @Getter private final LytMermaidFlowchartCanvas canvas; @Getter private final Map nodeContent; diff --git a/src/main/java/com/hfstudio/guidenh/guide/document/block/LytMermaidFlowchartCanvas.java b/src/main/java/com/hfstudio/guidenh/guide/document/block/LytMermaidFlowchartCanvas.java index c5557d7c..35ee9203 100644 --- a/src/main/java/com/hfstudio/guidenh/guide/document/block/LytMermaidFlowchartCanvas.java +++ b/src/main/java/com/hfstudio/guidenh/guide/document/block/LytMermaidFlowchartCanvas.java @@ -15,7 +15,6 @@ import com.hfstudio.guidenh.guide.internal.debug.DebugComponent; import com.hfstudio.guidenh.guide.internal.mermaid.MermaidArrowHead; import com.hfstudio.guidenh.guide.internal.mermaid.MermaidEdgeStyle; -import com.hfstudio.guidenh.guide.internal.mermaid.MermaidNodeShape; import com.hfstudio.guidenh.guide.internal.mermaid.flowchart.FlowchartDocument; import com.hfstudio.guidenh.guide.internal.mermaid.flowchart.FlowchartEdge; import com.hfstudio.guidenh.guide.internal.mermaid.flowchart.FlowchartLayoutResult; @@ -25,8 +24,12 @@ import com.hfstudio.guidenh.guide.internal.mermaid.flowchart.FlowchartLayoutStrategy; import com.hfstudio.guidenh.guide.internal.mermaid.flowchart.FlowchartNode; import com.hfstudio.guidenh.guide.internal.mermaid.flowchart.FlowchartSubgraph; +import com.hfstudio.guidenh.guide.layout.FontMetrics; import com.hfstudio.guidenh.guide.layout.LayoutContext; -import com.hfstudio.guidenh.guide.render.RenderContext; +import com.hfstudio.guidenh.guide.render.GuideRenderPrimitive; +import com.hfstudio.guidenh.guide.render.GuideText; +import com.hfstudio.guidenh.guide.render.PrimitiveCollector; +import com.hfstudio.guidenh.guide.scene.support.GuideDebugLog; import com.hfstudio.guidenh.guide.style.ResolvedTextStyle; import com.hfstudio.guidenh.guide.style.TextAlignment; import com.hfstudio.guidenh.guide.style.WhiteSpaceMode; @@ -44,6 +47,24 @@ public class LytMermaidFlowchartCanvas extends LytMermaidCanvas nodeContentLayouts = new LinkedHashMap<>(); private FlowchartLayoutResult layout; + private int precomputedLayoutWidth; public LytMermaidFlowchartCanvas(FlowchartDocument document, Map nodeContentBlocks) { this.document = document; @@ -150,10 +175,16 @@ protected boolean diagramReady() { } @Override - protected void renderDiagram(RenderContext context, int baseX, int baseY, float activeZoom) { - renderSubgraphs(context, baseX, baseY, activeZoom); - renderEdges(context, baseX, baseY, activeZoom); - renderNodes(context, baseX, baseY, activeZoom); + public boolean usePrimitives() { + return true; + } + + @Override + protected void emitDiagramPrimitives(PrimitiveCollector c, int baseX, int baseY, float activeZoom) { + emitSubgraphsPrimitives(c, baseX, baseY, activeZoom); + emitEdgesPrimitives(c, baseX, baseY, activeZoom); + emitNodesPrimitives(c, baseX, baseY, activeZoom); + emitEdgeLabelsPrimitives(c, baseX, baseY, activeZoom); } @Override @@ -214,7 +245,13 @@ private Map computeNodeMinSizes(LayoutContext context) { if (block != null) { LayoutContext localContext = new LayoutContext(context).withVisualScale(context.getVisualScale()); int contentWidth = Math.clamp(maxTextWidth + 60, 96, 240); - block.layout(localContext, 0, 0, contentWidth); + // LytVBox.computeBoxLayout is a stub (Rust is sole layout + // authority for the normal document pipeline), so NodeContent + // subtrees, which never reach the document's Rust pass, used + // to be laid out manually. The Rust engine now lays the subtree + // out directly (including the inline post-pass), with a Java + // fallback for environments without the native bridge. + layoutNodeContentWithRust(localContext, block, contentWidth); LytRect vb = resolveBlockVisualBounds(block); textWidth = vb.width(); textHeight = vb.height(); @@ -250,14 +287,17 @@ private Map computeNodeMinSizes(LayoutContext context) { LytRect minRect = FlowchartShapes .minNodeRect(node.getShape(), contentW, contentH, NODE_PADDING_X, NODE_PADDING_Y); - int width = minRect.width(); - int height = minRect.height(); + int width = minRect.width() + NODE_SIZE_ROUNDING_MARGIN; + int height = minRect.height() + NODE_SIZE_ROUNDING_MARGIN; if (isRoot) { width += 10; height += 4; } + // Clipped shapes (cylinder, diamond, hexagon, circle, ellipse, bang, + // cloud, asymmetric, trapezoid) reserve an edge margin in the layout + // box so their inset outlines keep clearance from their neighbours. if (FlowchartShapes.isShapeClipped(node.getShape())) { width += NODE_EDGE_MARGIN * 2; height += NODE_EDGE_MARGIN * 2; @@ -284,11 +324,242 @@ private void restoreViewportAfterLayout(int previousOffsetX, int previousOffsetY clampOffsets(); } + /** + * Pre-compute diagram layout before the first Rust layout pass and set + * preferredHeight so Rust allocates the correct canvas height immediately. + * Caches the layout result for reuse in afterExternalLayout when the + * actual bounds width matches the pre-computation width. + * + * @param ctx LayoutContext backed by GuideText-based FontMetrics + * @param availableWidth estimated canvas content width (page width or + * placeholder width) + */ + public void precomputeLayout(LayoutContext ctx, int availableWidth) { + int safeWidth = preferredWidth > 0 ? Math.clamp(preferredWidth, 1, availableWidth) + : Math.max(1, availableWidth); + LytRect savedBounds = bounds; + bounds = new LytRect(0, 0, safeWidth, 0); + try { + FlowchartLayoutStrategy strategy = FlowchartLayoutStrategy.forMode(document.getLayoutMode()); + var minSizes = computeNodeMinSizes(ctx); + FlowchartLayoutResult result = strategy.layout(document, minSizes); + if (result != null) { + this.layout = result; + int desiredHeight = result.getHeight() + CANVAS_PADDING * 2; + preferredHeight = preferredHeight > 0 ? Math.max(48, preferredHeight) + : Math.clamp(desiredHeight, MIN_HEIGHT, MAX_HEIGHT); + // Constrain the canvas explicit width to the available page + // width. The unbounded layout width (ELK output) may exceed the + // page 2x for large diagrams; Rust's layout engine then sizes + // the canvas to that explicit width and the page-level clamp in + // computeLayout never runs on the Rust path, pushing the whole + // diagram off-page (fit-to-view centred it inside an oversized + // canvas). Clamping here keeps the canvas inside the page and + // lets fit-to-view centre the diagram in the visible viewport. + int diagramWidth = result.getWidth() + CANVAS_PADDING * 2; + preferredWidth = Math.min(diagramWidth, Math.max(1, safeWidth)); + this.precomputedLayoutWidth = preferredWidth; + GuideDebugLog.debugAlways( + "[GuideNH-Mermaid] precomputeLayout OK layoutHeight={} preferredHeight={} diagramWidth={}", + result.getHeight(), + preferredHeight, + diagramWidth); + GuideDebugLog.debugAlways( + "[GuideNH-Mermaid] precomputeLayout explicitWidth={} safeWidth={}", + preferredWidth, + safeWidth); + } else { + GuideDebugLog + .debugAlways("[GuideNH-Mermaid] precomputeLayout FAILED result=null safeWidth={}", safeWidth); + } + } finally { + bounds = savedBounds; + } + } + + @Override + protected void afterExternalLayout() { + int safeWidth = preferredWidth > 0 ? Math.clamp(preferredWidth, 1, Math.max(1, bounds.width())) + : Math.max(1, bounds.width()); + + GuideDebugLog.debugAlways( + "[GuideNH-Mermaid] afterExternalLayout entered layout={} safeWidth={} precomputedLayoutWidth={} bounds.height={}", + layout != null, + safeWidth, + precomputedLayoutWidth, + bounds.height()); + + // Ensure layout result matches the actual canvas width. + // If actual bounds.width() differs from the width used during + // precompute, the ELK layout may be suboptimal or clipped, so + // recompute at the actual canvas width. + int actualWidth = Math.max(1, bounds.width() - CANVAS_PADDING * 2); + if (layout == null || precomputedLayoutWidth <= 0 || precomputedLayoutWidth != bounds.width()) { + LayoutContext fallbackCtx = new LayoutContext(new FontMetrics() { + + @Override + public float getAdvance(int codePoint, com.hfstudio.guidenh.guide.style.ResolvedTextStyle s) { + return GuideText.measureWidth(new String(Character.toChars(codePoint)), s); + } + + @Override + public int getLineHeight(com.hfstudio.guidenh.guide.style.ResolvedTextStyle s) { + return GuideText.lineHeight(s); + } + }); + FlowchartLayoutStrategy strategy = FlowchartLayoutStrategy.forMode(document.getLayoutMode()); + var minSizes = computeNodeMinSizes(fallbackCtx); + layout = strategy.layout(document, minSizes); + precomputedLayoutWidth = (int) bounds.width(); + GuideDebugLog.debugAlways( + "[GuideNH-Mermaid] afterExternalLayout recomputed ELK at boundsWidth={} layout={}", + bounds.width(), + layout != null); + } + + // With a valid layout, correct the bounds height when it disagrees. This + // is the fallback path for canvases whose height was not fixed up. + if (layout != null) { + int desiredHeight = layout.getHeight() + CANVAS_PADDING * 2; + int expectedHeight = preferredHeight > 0 ? Math.max(48, preferredHeight) + : Math.clamp(desiredHeight, MIN_HEIGHT, MAX_HEIGHT); + if (bounds.height() != expectedHeight) { + GuideDebugLog.debugAlways( + "[GuideNH-Mermaid] afterExternalLayout correcting bounds height {} -> {}", + bounds.height(), + expectedHeight); + bounds = new LytRect(bounds.x(), bounds.y(), bounds.width(), expectedHeight); + } + } + + GuideDebugLog.debugAlways( + "[GuideNH-Mermaid] afterExternalLayout exit layout={} bounds.height={}", + layout != null, + bounds.height()); + } + @Override protected void onLayoutMoved(int deltaX, int deltaY) {} - private void renderEdges(RenderContext context, int baseX, int baseY, float activeZoom) { - int defaultColor = context.resolveColor(EDGE_COLOR); + private @Nullable FlowchartEdge lookupEdge(String fromId, String toId, @Nullable String edgeId) { + if (edgeId != null) { + for (FlowchartEdge e : document.getEdges()) { + if (edgeId.equals(e.getEdgeId())) return e; + } + } + for (FlowchartEdge e : document.getEdges()) { + if (e.getFrom() + .equals(fromId) + && e.getTo() + .equals(toId)) + return e; + } + return null; + } + + @Override + @Nullable + protected NodeHit pickNodeHit(int documentX, int documentY) { + if (layout == null) return null; + LytRect innerViewport = getInnerViewport(); + float activeZoom = getActiveZoom(); + int baseX = innerViewport.x() + getVisualOffsetX() - getScaledOriginX(); + int baseY = innerViewport.y() + getVisualOffsetY() - getScaledOriginY(); + + for (var entry : layout.getNodePositions() + .entrySet()) { + String nodeId = entry.getKey(); + NodeContentLayout contentLayout = nodeContentLayouts.get(nodeId); + if (contentLayout == null) continue; + + NodePosition pos = entry.getValue(); + int sx = scaled(baseX, pos.getX(), activeZoom); + int sy = scaled(baseY, pos.getY(), activeZoom); + int sw = Math.max(1, Math.round(pos.getWidth() * activeZoom)); + int sh = Math.max(1, Math.round(pos.getHeight() * activeZoom)); + LytRect nodeRect = new LytRect(sx, sy, sw, sh); + + int paddingX = Math.max(1, Math.round(NODE_PADDING_X * activeZoom)); + int contentY = nodeRect.y() + Math.max(1, Math.round(NODE_PADDING_Y * activeZoom)) + + resolveNodeBadgeHeight(entry.getKey(), activeZoom); + LytRect contentScreenRect = resolveNodeContentRect(contentLayout, nodeRect, paddingX, contentY, activeZoom); + + if (!contentScreenRect.contains(documentX, documentY)) continue; + + int localX = unscaleCoordinate(documentX - contentScreenRect.x(), activeZoom); + int localY = unscaleCoordinate(documentY - contentScreenRect.y(), activeZoom); + DocumentInteractionSnapshot hit = LytDocument.pick(contentLayout.block(), localX, localY); + if (hit != null) { + return new NodeHit(hit.node(), hit.flowPath(), localX, localY); + } + } + return null; + } + + private int resolveNodeBadgeHeight(String nodeId, float activeZoom) { + FlowchartNode node = document.getNodes() + .get(nodeId); + if (node == null || node.getIcon() == null) return 0; + String badgeText = MermaidNodeRenderer.simplifyIcon(node.getIcon()); + if (badgeText == null) return 0; + ResolvedTextStyle badgeStyle = getOrScaleStyle(ICON_TEXT_STYLE, activeZoom); + int badgePaddingY = Math.max(1, Math.round(2 * activeZoom)); + int iconGapY = Math.max(1, Math.round(ICON_GAP_Y * activeZoom)); + return contextLineHeight(badgeStyle) + badgePaddingY * 2 + iconGapY; + } + + // Primitives pipeline, which replaces render* for the primitives path. + + private void emitSubgraphsPrimitives(PrimitiveCollector c, int baseX, int baseY, float activeZoom) { + if (layout == null) return; + for (var subgraph : document.getSubgraphs()) { + emitSubgraphRecursive(c, subgraph, layout.getNodePositions(), baseX, baseY, activeZoom, 0); + } + } + + private void emitSubgraphRecursive(PrimitiveCollector c, FlowchartSubgraph subgraph, + Map positions, int baseX, int baseY, float activeZoom, int depth) { + LytRect bounds = computeSubgraphBounds(subgraph, positions); + if (bounds == null) return; + + int pad = Math.round(SUBGRAPH_PADDING * activeZoom); + int sx = scaled(baseX, bounds.x() - pad, activeZoom); + int sy = scaled(baseY, bounds.y() - pad, activeZoom); + int sw = Math.max(1, Math.round((bounds.width() + pad * 2) * activeZoom)); + int sh = Math.max(1, Math.round((bounds.height() + pad * 2) * activeZoom)); + LytRect sgRect = new LytRect(sx, sy, sw, sh); + + int bg = SUBGRAPH_BG[depth % SUBGRAPH_BG.length].resolve(); + int border = SUBGRAPH_BORDER[depth % SUBGRAPH_BORDER.length].resolve(); + c.emit(new GuideRenderPrimitive.FillRect(sgRect.x(), sgRect.y(), sgRect.width(), sgRect.height(), bg)); + int borderThickness = Math.max(1, Math.round(1.5f * activeZoom)); + c.emit( + new GuideRenderPrimitive.DrawBorder( + sgRect.x(), + sgRect.y(), + sgRect.width(), + sgRect.height(), + borderThickness, + borderThickness, + borderThickness, + borderThickness, + border)); + + String label = subgraph.getLabel(); + if (label != null && !label.isEmpty()) { + int labelPadX = Math.max(2, Math.round(4 * activeZoom)); + int labelPadY = Math.max(1, Math.round(2 * activeZoom)); + ResolvedTextStyle labelStyle = getOrScaleStyle(NODE_TEXT_STYLE, activeZoom); + GuideText.emitText(c, label, sgRect.x() + labelPadX, sgRect.y() + labelPadY, labelStyle); + } + + for (var child : subgraph.getChildren()) { + emitSubgraphRecursive(c, child, positions, baseX, baseY, activeZoom, depth + 1); + } + } + + private void emitEdgesPrimitives(PrimitiveCollector c, int baseX, int baseY, float activeZoom) { + int defaultColor = EDGE_COLOR.resolve(); for (EdgePath edgePath : layout.getEdgePaths()) { FlowchartEdge flowEdge = lookupEdge(edgePath.getFromId(), edgePath.getToId(), edgePath.getEdgeId()); MermaidEdgeStyle style = flowEdge != null ? flowEdge.getStyle() : MermaidEdgeStyle.SOLID; @@ -326,215 +597,83 @@ private void renderEdges(RenderContext context, int baseX, int baseY, float acti List points = edgePath.getPoints(); if (points.size() < 2) continue; - FlowchartLayoutResult.Point srcBoundary = null; - FlowchartLayoutResult.Point tgtBoundary = null; - NodePosition srcPos = null; - NodePosition tgtPos = null; - - if (arrowFwd) { - tgtPos = layout.getPosition(edgePath.getToId()); - if (tgtPos != null) { - FlowchartNode tgtNode = document.getNodes() - .get(edgePath.getToId()); - MermaidNodeShape tgtShape = tgtNode != null ? tgtNode.getShape() : null; - if (tgtShape != null && FlowchartShapes.isShapeClipped(tgtShape)) { - FlowchartLayoutResult.Point lastPoint = points.get(points.size() - 1); - LytRect tgtRect = new LytRect( - tgtPos.getX() + NODE_EDGE_MARGIN, - tgtPos.getY() + NODE_EDGE_MARGIN, - tgtPos.getWidth() - NODE_EDGE_MARGIN * 2, - tgtPos.getHeight() - NODE_EDGE_MARGIN * 2); - tgtBoundary = FlowchartShapes - .edgeIntersect(tgtRect, tgtShape, lastPoint.getX(), lastPoint.getY()); - } - } - } - if (arrowRev) { - srcPos = layout.getPosition(edgePath.getFromId()); - if (srcPos != null) { - FlowchartNode srcNode = document.getNodes() - .get(edgePath.getFromId()); - MermaidNodeShape srcShape = srcNode != null ? srcNode.getShape() : null; - if (srcShape != null && FlowchartShapes.isShapeClipped(srcShape)) { - FlowchartLayoutResult.Point firstPoint = points.get(0); - LytRect srcRect = new LytRect( - srcPos.getX() + NODE_EDGE_MARGIN, - srcPos.getY() + NODE_EDGE_MARGIN, - srcPos.getWidth() - NODE_EDGE_MARGIN * 2, - srcPos.getHeight() - NODE_EDGE_MARGIN * 2); - srcBoundary = FlowchartShapes - .edgeIntersect(srcRect, srcShape, firstPoint.getX(), firstPoint.getY()); - } - } - } + for (int i = 1; i < points.size(); i++) { + FlowchartLayoutResult.Point from = points.get(i - 1); + FlowchartLayoutResult.Point to = points.get(i); + float x1 = scaled(baseX, from.getX(), activeZoom); + float y1 = scaled(baseY, from.getY(), activeZoom); + float x2 = scaled(baseX, to.getX(), activeZoom); + float y2 = scaled(baseY, to.getY(), activeZoom); - List edgePoints = new ArrayList<>(points); - if (tgtBoundary != null) { - FlowchartLayoutResult.Point lastOrig = points.get(points.size() - 1); - if (tgtBoundary.getX() != lastOrig.getX() || tgtBoundary.getY() != lastOrig.getY()) { - edgePoints.add(tgtBoundary); - } - } - if (srcBoundary != null) { - FlowchartLayoutResult.Point firstOrig = points.get(0); - if (srcBoundary.getX() != firstOrig.getX() || srcBoundary.getY() != firstOrig.getY()) { - edgePoints.add(0, srcBoundary); + if (style == MermaidEdgeStyle.DASHED || style == MermaidEdgeStyle.DOTTED) { + emitDashedLine(c, x1, y1, x2, y2, edgeThickness, edgeColor, style == MermaidEdgeStyle.DOTTED); + } else { + c.emit(new GuideRenderPrimitive.DrawLine(x1, y1, x2, y2, edgeThickness, edgeColor)); } } - drawEdgeLines( - context, - edgePoints, - edgePath.getFromId(), - edgePath.getToId(), - baseX, - baseY, - activeZoom, - style, - edgeThickness, - edgeColor, - srcBoundary, - tgtBoundary); - - if (arrowFwd) { - float tipX, tipY, dirX, dirY; - if (tgtPos != null) { - FlowchartNode tgtNode = document.getNodes() - .get(edgePath.getToId()); - MermaidNodeShape tgtShape = tgtNode != null ? tgtNode.getShape() : null; - if (tgtBoundary != null) { - FlowchartLayoutResult.Point prev = edgePoints.get(edgePoints.size() - 2); - tipX = scaled(baseX, tgtBoundary.getX(), activeZoom); - tipY = scaled(baseY, tgtBoundary.getY(), activeZoom); - dirX = tipX - scaled(baseX, prev.getX(), activeZoom); - dirY = tipY - scaled(baseY, prev.getY(), activeZoom); - } else { - FlowchartLayoutResult.Point lastPoint = points.get(points.size() - 1); - FlowchartLayoutResult.Point prev = points.get(points.size() - 2); - int tx = tgtShape != null ? lastPoint.getX() : tgtPos.getX() + tgtPos.getWidth() / 2; - int ty = tgtShape != null ? lastPoint.getY() : tgtPos.getY() + tgtPos.getHeight() / 2; - tipX = scaled(baseX, tx, activeZoom); - tipY = scaled(baseY, ty, activeZoom); - dirX = tipX - scaled(baseX, prev.getX(), activeZoom); - dirY = tipY - scaled(baseY, prev.getY(), activeZoom); - } - } else { - FlowchartLayoutResult.Point prev = points.get(points.size() - 2); - tipX = scaled(baseX, prev.getX(), activeZoom); - tipY = scaled(baseY, prev.getY(), activeZoom); - dirX = 0; - dirY = 0; - } + if (arrowFwd || arrowRev) { + FlowchartLayoutResult.Point last = points.getLast(); + FlowchartLayoutResult.Point prev = points.size() >= 2 ? points.get(points.size() - 2) : last; + float tipX = scaled(baseX, last.getX(), activeZoom); + float tipY = scaled(baseY, last.getY(), activeZoom); + float dirX = tipX - scaled(baseX, prev.getX(), activeZoom); + float dirY = tipY - scaled(baseY, prev.getY(), activeZoom); float len = (float) Math.sqrt(dirX * dirX + dirY * dirY); if (len > 0.5f) { - drawArrowHeadVariant(context, tipX, tipY, dirX / len, dirY / len, activeZoom, edgeColor, fwdHead); + dirX /= len; + dirY /= len; + if (arrowFwd) { + emitArrowHeadVariant(c, tipX, tipY, dirX, dirY, activeZoom, edgeColor, fwdHead); + } } - } - if (arrowRev) { - float tailX, tailY, dirX, dirY; - if (srcPos != null) { - FlowchartNode srcNode = document.getNodes() - .get(edgePath.getFromId()); - MermaidNodeShape srcShape = srcNode != null ? srcNode.getShape() : null; - if (srcBoundary != null) { - FlowchartLayoutResult.Point next = edgePoints.get(1); - tailX = scaled(baseX, srcBoundary.getX(), activeZoom); - tailY = scaled(baseY, srcBoundary.getY(), activeZoom); - dirX = scaled(baseX, next.getX(), activeZoom) - tailX; - dirY = scaled(baseY, next.getY(), activeZoom) - tailY; - } else { - FlowchartLayoutResult.Point firstPoint = points.get(0); - FlowchartLayoutResult.Point second = points.get(1); - int tx = srcShape != null ? firstPoint.getX() : srcPos.getX() + srcPos.getWidth() / 2; - int ty = srcShape != null ? firstPoint.getY() : srcPos.getY() + srcPos.getHeight() / 2; - tailX = scaled(baseX, tx, activeZoom); - tailY = scaled(baseY, ty, activeZoom); - dirX = scaled(baseX, second.getX(), activeZoom) - tailX; - dirY = scaled(baseY, second.getY(), activeZoom) - tailY; + if (arrowRev) { + FlowchartLayoutResult.Point first = points.get(0); + FlowchartLayoutResult.Point second = points.size() >= 2 ? points.get(1) : first; + float tailX = scaled(baseX, first.getX(), activeZoom); + float tailY = scaled(baseY, first.getY(), activeZoom); + float revDirX = tailX - scaled(baseX, second.getX(), activeZoom); + float revDirY = tailY - scaled(baseY, second.getY(), activeZoom); + float revLen = (float) Math.sqrt(revDirX * revDirX + revDirY * revDirY); + if (revLen > 0.5f) { + revDirX /= revLen; + revDirY /= revLen; + emitArrowHeadVariant(c, tailX, tailY, revDirX, revDirY, activeZoom, edgeColor, revHead); } - } else { - FlowchartLayoutResult.Point second = points.get(1); - tailX = scaled(baseX, second.getX(), activeZoom); - tailY = scaled(baseY, second.getY(), activeZoom); - dirX = 0; - dirY = 0; - } - float len = (float) Math.sqrt(dirX * dirX + dirY * dirY); - if (len > 0.5f) { - drawArrowHeadVariant(context, tailX, tailY, dirX / len, dirY / len, activeZoom, edgeColor, revHead); } } - if (label != null && !label.isEmpty()) { - drawEdgeLabel(context, points, baseX, baseY, activeZoom, label); - } } } - private @Nullable FlowchartEdge lookupEdge(String fromId, String toId, @Nullable String edgeId) { - if (edgeId != null) { - for (FlowchartEdge e : document.getEdges()) { - if (edgeId.equals(e.getEdgeId())) return e; + /** + * Emit edge labels in a separate pass after nodes have been + * drawn, so labels appear on top of node shapes rather than being + * obscured by them. Each label is anchored at the edge path's + * midpoint but clamped into the free span between its endpoint node + * borders. + */ + private void emitEdgeLabelsPrimitives(PrimitiveCollector c, int baseX, int baseY, float activeZoom) { + if (layout == null) return; + for (EdgePath edgePath : layout.getEdgePaths()) { + FlowchartEdge flowEdge = lookupEdge(edgePath.getFromId(), edgePath.getToId(), edgePath.getEdgeId()); + String label = flowEdge != null ? flowEdge.getLabel() : null; + if (label != null && !label.isEmpty()) { + emitEdgeLabelPrimitives( + c, + edgePath.getPoints(), + layout.getPosition(edgePath.getFromId()), + layout.getPosition(edgePath.getToId()), + baseX, + baseY, + activeZoom, + label); } } - for (FlowchartEdge e : document.getEdges()) { - if (e.getFrom() - .equals(fromId) - && e.getTo() - .equals(toId)) - return e; - } - return null; - } - - private void drawEdgeLines(RenderContext context, List points, String fromId, - String toId, int baseX, int baseY, float activeZoom, MermaidEdgeStyle style, int thickness, int color, - @Nullable FlowchartLayoutResult.Point srcBoundary, @Nullable FlowchartLayoutResult.Point tgtBoundary) { - NodePosition srcPos = layout.getPosition(fromId); - NodePosition tgtPos = layout.getPosition(toId); - - float srcCx = srcPos != null ? scaled(baseX, srcPos.getX() + srcPos.getWidth() / 2, activeZoom) : Float.NaN; - float srcCy = srcPos != null ? scaled(baseY, srcPos.getY() + srcPos.getHeight() / 2, activeZoom) : Float.NaN; - float tgtCx = tgtPos != null ? scaled(baseX, tgtPos.getX() + tgtPos.getWidth() / 2, activeZoom) : Float.NaN; - float tgtCy = tgtPos != null ? scaled(baseY, tgtPos.getY() + tgtPos.getHeight() / 2, activeZoom) : Float.NaN; - - int n = points.size(); - - for (int i = 1; i < n; i++) { - FlowchartLayoutResult.Point from = points.get(i - 1); - FlowchartLayoutResult.Point to = points.get(i); - float x1 = scaled(baseX, from.getX(), activeZoom); - float y1 = scaled(baseY, from.getY(), activeZoom); - float x2 = scaled(baseX, to.getX(), activeZoom); - float y2 = scaled(baseY, to.getY(), activeZoom); - drawLine(context, x1, y1, x2, y2, style, thickness, color); - } - - if (!Float.isNaN(srcCx) && srcBoundary == null) { - FlowchartLayoutResult.Point first = points.get(0); - float x1 = scaled(baseX, first.getX(), activeZoom); - float y1 = scaled(baseY, first.getY(), activeZoom); - drawLine(context, srcCx, srcCy, x1, y1, style, thickness, color); - } - if (!Float.isNaN(tgtCx) && tgtBoundary == null) { - FlowchartLayoutResult.Point last = points.get(n - 1); - float x1 = scaled(baseX, last.getX(), activeZoom); - float y1 = scaled(baseY, last.getY(), activeZoom); - drawLine(context, x1, y1, tgtCx, tgtCy, style, thickness, color); - } } - private void drawLine(RenderContext context, float x1, float y1, float x2, float y2, MermaidEdgeStyle style, - int thickness, int color) { - if (style == MermaidEdgeStyle.DASHED || style == MermaidEdgeStyle.DOTTED) { - drawDashedLine(context, x1, y1, x2, y2, thickness, color, style == MermaidEdgeStyle.DOTTED); - } else { - context.drawLine(x1, y1, x2, y2, thickness, color); - } - } - - private void drawDashedLine(RenderContext context, float x1, float y1, float x2, float y2, int thickness, int color, + private void emitDashedLine(PrimitiveCollector c, float x1, float y1, float x2, float y2, int thickness, int color, boolean dotted) { float dx = x2 - x1; float dy = y2 - y1; @@ -553,23 +692,23 @@ private void drawDashedLine(RenderContext context, float x1, float y1, float x2, float ex = x1 + nx * segEnd; float ey = y1 + ny * segEnd; if (draw) { - context.drawLine(sx, sy, ex, ey, thickness, color); + c.emit(new GuideRenderPrimitive.DrawLine(sx, sy, ex, ey, thickness, color)); } drawn = segEnd + gapLen; draw = !draw; } } - private void drawArrowHeadVariant(RenderContext context, float tipX, float tipY, float dirX, float dirY, + private void emitArrowHeadVariant(PrimitiveCollector c, float tipX, float tipY, float dirX, float dirY, float activeZoom, int color, MermaidArrowHead headType) { switch (headType) { - case CIRCLE -> drawCircleHead(context, tipX, tipY, dirX, dirY, activeZoom, color); - case CROSS -> drawCrossHead(context, tipX, tipY, dirX, dirY, activeZoom, color); - default -> drawTriangleHead(context, tipX, tipY, dirX, dirY, activeZoom, color); + case CIRCLE -> emitCircleHead(c, tipX, tipY, dirX, dirY, activeZoom, color); + case CROSS -> emitCrossHead(c, tipX, tipY, dirX, dirY, activeZoom, color); + default -> emitTriangleHead(c, tipX, tipY, dirX, dirY, activeZoom, color); } } - private void drawTriangleHead(RenderContext context, float tipX, float tipY, float dirX, float dirY, + private void emitTriangleHead(PrimitiveCollector c, float tipX, float tipY, float dirX, float dirY, float activeZoom, int color) { float size = Math.max(4f, 8f * activeZoom); float perpX = -dirY; @@ -579,42 +718,58 @@ private void drawTriangleHead(RenderContext context, float tipX, float tipY, flo float leftY = baseY + dirX * size * 0.4f; float rightX = baseX - perpX * size * 0.4f; float rightY = baseY - dirX * size * 0.4f; - context.fillTriangle(tipX, tipY, leftX, leftY, rightX, rightY, color); + c.emit(new GuideRenderPrimitive.DrawTriangle(tipX, tipY, leftX, leftY, rightX, rightY, color)); } - private void drawCircleHead(RenderContext context, float tipX, float tipY, float dirX, float dirY, float activeZoom, + private void emitCircleHead(PrimitiveCollector c, float tipX, float tipY, float dirX, float dirY, float activeZoom, int color) { float radius = Math.max(3f, 5f * activeZoom); float cx = tipX - dirX * radius; float cy = tipY - dirY * radius; - context.fillCircle(cx, cy, radius, color); + c.emit(new GuideRenderPrimitive.DrawCircle(cx, cy, radius, color, true)); } - private void drawCrossHead(RenderContext context, float tipX, float tipY, float dirX, float dirY, float activeZoom, + private void emitCrossHead(PrimitiveCollector c, float tipX, float tipY, float dirX, float dirY, float activeZoom, int color) { float size = Math.max(3f, 5f * activeZoom); float perpX = -dirY; float cx = tipX - dirX * size * 0.5f; float cy = tipY - dirY * size * 0.5f; float thickness = Math.max(1f, 1.5f * activeZoom); - context.drawLine( - cx + perpX * size * 0.7f, - cy + dirX * size * 0.7f, - cx - perpX * size * 0.7f, - cy - dirX * size * 0.7f, - thickness, - color); - context.drawLine( - cx + perpX * size * 0.7f, - cy - dirX * size * 0.7f, - cx - perpX * size * 0.7f, - cy + dirX * size * 0.7f, - thickness, - color); + c.emit( + new GuideRenderPrimitive.DrawLine( + cx + perpX * size * 0.7f, + cy + dirX * size * 0.7f, + cx - perpX * size * 0.7f, + cy - dirX * size * 0.7f, + thickness, + color)); + c.emit( + new GuideRenderPrimitive.DrawLine( + cx + perpX * size * 0.7f, + cy - dirX * size * 0.7f, + cx - perpX * size * 0.7f, + cy + dirX * size * 0.7f, + thickness, + color)); } - private void drawEdgeLabel(RenderContext context, List points, int baseX, int baseY, - float activeZoom, String label) { + /** + * Draw a single edge label. The label is anchored at the edge path's + * midpoint (measured by path length), then its background box is clamped + * into the free span between the two endpoint node borders so it keeps a + * constant {@link #EDGE_LABEL_GAP} clearance from both nodes. Previously + * the box was placed purely at the midpoint with no minimum-gap + * constraint, so a label that nearly filled the gap pressed against a + * node border (right edge 1px at scale2), and integer rounding of the + * midpoint / half-width pushed it off-centre. When the gap cannot + * hold the box at that clearance, the label is word-wrapped (with + * codepoint-level breaking of overlong words) and residual overlong + * fragments are ellipsized, so the box never overflows onto either node. + */ + private void emitEdgeLabelPrimitives(PrimitiveCollector c, List points, + @Nullable FlowchartLayoutResult.NodePosition fromPos, @Nullable FlowchartLayoutResult.NodePosition toPos, + int baseX, int baseY, float activeZoom, String label) { float totalLen = 0f; float[] segLens = new float[points.size() - 1]; for (int i = 1; i < points.size(); i++) { @@ -632,21 +787,21 @@ private void drawEdgeLabel(RenderContext context, List= halfLen) { float frac = (halfLen - accumulated) / Math.max(segLens[i], 0.0001f); - mx = points.get(i) + midDocX = points.get(i) .getX() + (points.get(i + 1) .getX() - points.get(i) .getX()) * frac; - my = points.get(i) + midDocY = points.get(i) .getY() + (points.get(i + 1) .getY() @@ -657,23 +812,165 @@ private void drawEdgeLabel(RenderContext context, List= Math.abs(toCy - fromCy); + if (horizontal) { + if (toCx >= fromCx) { + nearBorder = fromPos.getX() + fromPos.getWidth(); + farBorder = toPos.getX(); + } else { + nearBorder = toPos.getX() + toPos.getWidth(); + farBorder = fromPos.getX(); + } + } else { + if (toCy >= fromCy) { + nearBorder = fromPos.getY() + fromPos.getHeight(); + farBorder = toPos.getY(); + } else { + nearBorder = toPos.getY() + toPos.getHeight(); + farBorder = fromPos.getY(); + } + } + int nearScreen = horizontal ? baseX + Math.round(nearBorder * activeZoom) + : baseY + Math.round(nearBorder * activeZoom); + int farScreen = horizontal ? baseX + Math.round(farBorder * activeZoom) + : baseY + Math.round(farBorder * activeZoom); + constrained = farScreen > nearScreen; + if (constrained) { + nearBorder = nearScreen; + farBorder = farScreen; + } + } + int span = constrained ? farBorder - nearBorder : 0; + + // Wrap the label so its box provably fits the gap with EDGE_LABEL_GAP + // clearance on both sides; a label that fits unchanged stays on a + // single line. Word-first wrapping preserves the full text; residual + // over-budget lines (e.g. a single glyph wider than the budget) are + // clipped so the box stays inside the gap. + // + // The wrap budget is taken from the edge path's usable length (the + // total path length in rendered-logical px), NOT from the straight + // gap between the endpoint node borders. ELK routes edges as + // polylines; for routed edges the path length is far larger than the + // ~nodeSpacing straight gap, and mermaid renders edge labels + // horizontally along the path, so the budget must follow the path. + // Before this fix the budget wrongly used the node straight gap + // (span = nodeSpacing = 20px → budgetPx = 14), forcing every label + // wider than 14px ('Critical', or a wide CJK label) into a per-glyph + // vertical column. + // + // Hard bound: the wrapped box must stay inside the straight + // node gap (span) so the label never presses against either endpoint + // node; the EDGE_LABEL_GAP clearance and the box-clamp below both + // assume boxWidth <= span - 2*EDGE_LABEL_GAP. When the path budget + // yields a box wider than the gap, the label is re-wrapped at the + // (narrower) gap budget so the FULL text is preserved in more lines + // instead of being truncated or overflowing onto the nodes. + List lines = List.of(label); + if (constrained) { + int pathBudgetPx = Math.max(0, (int) Math.floor(totalLen * activeZoom)) - 2 * EDGE_LABEL_GAP - 2 * pad; + int gapBudgetPx = span - 2 * EDGE_LABEL_GAP - 2 * pad; + if (pathBudgetPx >= 1) { + List wrapped = GuideText.wrap(label, pathBudgetPx, labelStyle); + if (!wrapped.isEmpty()) { + lines = wrapped; + int boxWidth = 0; + for (String line : wrapped) { + boxWidth = Math.max(boxWidth, GuideText.measureWidth(line, labelStyle)); + } + // If the path-budget box still cannot fit inside + // the node gap, re-wrap at the gap budget (full text kept). + if (gapBudgetPx >= 1 && boxWidth > gapBudgetPx) { + List gapWrapped = GuideText.wrap(label, gapBudgetPx, labelStyle); + if (!gapWrapped.isEmpty()) { + lines = gapWrapped; + } + } + int hardBudget = Math.max(1, gapBudgetPx); + for (int i = 0; i < lines.size(); i++) { + String line = lines.get(i); + int lineW = GuideText.measureWidth(line, labelStyle); + if (lineW > hardBudget) { + String clipped = GuideText + .clipToWidth(line, hardBudget, labelStyle, GuideText.ClipSuffix.NONE); + if (!clipped.isEmpty()) { + lines.set(i, clipped); + } + } + } + } + } + } + int lineHeight = GuideText.lineHeight(labelStyle); + int textWidth = 0; + for (String line : lines) { + textWidth = Math.max(textWidth, GuideText.measureWidth(line, labelStyle)); + } + int textHeight = lines.size() * lineHeight; + int boxHalfW = textWidth / 2 + pad; + int boxHalfH = textHeight / 2 + pad; + + // Label anchor. The old code rounded the doc-space midpoint to an + // integer before scaling and divided the half-width by integer + // truncation, which shifted the box off-centre by up to 1 logical px + // (2 rendered px at scale2) and pressed its near edge against the + // node border. Clamp the anchor so the box keeps EDGE_LABEL_GAP from + // both endpoint node borders. + float centerX = baseX + midDocX * activeZoom; + float centerY = baseY + midDocY * activeZoom; + if (constrained) { + float low = nearBorder + EDGE_LABEL_GAP + (horizontal ? boxHalfW : boxHalfH); + float high = farBorder - EDGE_LABEL_GAP - (horizontal ? boxHalfW : boxHalfH); + if (low <= high) { + if (horizontal) { + centerX = Math.max(low, Math.min(centerX, high)); + } else { + centerY = Math.max(low, Math.min(centerY, high)); + } + } else { + // Gap still too narrow after wrapping (e.g. a single glyph is + // wider than the budget): keep the label centred between the + // nodes rather than pushing it onto one of them. + float mid = (low + high) / 2f; + if (horizontal) { + centerX = mid; + } else { + centerY = mid; + } + } + } + + int bgX = Math.round(centerX - textWidth / 2f) - pad; + int bgY = Math.round(centerY - textHeight / 2f) - pad; + c.emit(new GuideRenderPrimitive.FillRect(bgX, bgY, textWidth + pad * 2, textHeight + pad * 2, bgColor)); + int textTop = Math.round(centerY - textHeight / 2f); + for (int i = 0; i < lines.size(); i++) { + String line = lines.get(i); + int lineWidth = GuideText.measureWidth(line, labelStyle); + GuideText.emitText(c, line, Math.round(centerX - lineWidth / 2f), textTop + i * lineHeight, labelStyle); + } } - private void renderNodes(RenderContext context, int baseX, int baseY, float activeZoom) { + private void emitNodesPrimitives(PrimitiveCollector c, int baseX, int baseY, float activeZoom) { ResolvedTextStyle badgeStyle = getOrScaleStyle(ICON_TEXT_STYLE, activeZoom); int paddingX = Math.max(1, Math.round(NODE_PADDING_X * activeZoom)); int paddingY = Math.max(1, Math.round(NODE_PADDING_Y * activeZoom)); @@ -699,13 +996,7 @@ private void renderNodes(RenderContext context, int baseX, int baseY, float acti int sy = scaled(baseY, pos.getY(), activeZoom); int sw = Math.max(1, Math.round(pos.getWidth() * activeZoom)); int sh = Math.max(1, Math.round(pos.getHeight() * activeZoom)); - LytRect rect; - if (FlowchartShapes.isShapeClipped(node.getShape())) { - int margin = Math.round(NODE_EDGE_MARGIN * activeZoom); - rect = new LytRect(sx + margin, sy + margin, sw - 2 * margin, sh - 2 * margin); - } else { - rect = new LytRect(sx, sy, sw, sh); - } + LytRect rect = new LytRect(sx, sy, sw, sh); var colors = MermaidNodeRenderer.resolveNodeColors(node.getClasses(), node.getShape(), isRoot); String nodeStyles = node.getStyleOverride(); @@ -723,15 +1014,16 @@ private void renderNodes(RenderContext context, int baseX, int baseY, float acti colors = new MermaidNodeRenderer.NodeColors(colors.background(), strokeColor, colors.accent()); } } - FlowchartShapes.render(context, rect, node.getShape(), colors.background(), colors.border()); + FlowchartShapes.emitShape(c, node.getShape(), rect, colors.background(), colors.border()); if (colors.accent() != MermaidNodeRenderer.DEFAULT_ACCENT && FlowchartShapes.hasAccentBar(node.getShape())) { - MermaidNodeRenderer.renderAccentBar(context, rect, colors.accent()); + c.emit(new GuideRenderPrimitive.FillRect(rect.x(), rect.y(), 3, rect.height(), colors.accent())); } int contentW = rect.width() - 2 * pdX; int contentH = rect.height() - 2 * pdY; - LytRect contentArea = FlowchartShapes.contentBounds(rect, node.getShape(), contentW, contentH, pdX, pdY); + LytRect contentArea = FlowchartShapes + .contentBounds(rect, node.getShape(), contentW, contentH, pdX, pdY, activeZoom); int textY = contentArea.y(); String icon = node.getIcon(); @@ -740,16 +1032,33 @@ private void renderNodes(RenderContext context, int baseX, int baseY, float acti if (badgeText != null) { int badgeWidth = Math.max( 1, - context.getStringWidth(badgeText, badgeStyle) + GuideText.measureWidth(badgeText, badgeStyle) + Math.max(2, Math.round(BADGE_PADDING_X * activeZoom)) * 2); int badgeHeight = Math.max( 1, - context.getLineHeight(badgeStyle) + Math.max(1, Math.round(BADGE_PADDING_Y * activeZoom)) * 2); + GuideText.lineHeight(badgeStyle) + Math.max(1, Math.round(BADGE_PADDING_Y * activeZoom)) * 2); int badgeX = contentArea.x(); LytRect badge = new LytRect(badgeX, textY, badgeWidth, badgeHeight); - context.fillRect(badge, MermaidNodeRenderer.BADGE_BACKGROUND); - context.drawBorder(badge, MermaidNodeRenderer.BADGE_BORDER, 1); - context.drawText( + c.emit( + new GuideRenderPrimitive.FillRect( + badge.x(), + badge.y(), + badge.width(), + badge.height(), + MermaidNodeRenderer.BADGE_BACKGROUND)); + c.emit( + new GuideRenderPrimitive.DrawBorder( + badge.x(), + badge.y(), + badge.width(), + badge.height(), + 1, + 1, + 1, + 1, + MermaidNodeRenderer.BADGE_BORDER)); + GuideText.emitText( + c, badgeText, badge.x() + Math.max(2, Math.round(BADGE_PADDING_X * activeZoom)), badge.y() + Math.max(1, Math.round(BADGE_PADDING_Y * activeZoom)), @@ -763,147 +1072,36 @@ private void renderNodes(RenderContext context, int baseX, int baseY, float acti NodeContentLayout contentLayout = nodeContentLayouts.get(nodeId); if (contentLayout != null) { - renderNodeContent(context, contentLayout, contentArea, activeZoom); + emitNodeContentPrimitives(c, contentLayout, contentArea, activeZoom); } else { String label = node.getLabel(); if (label == null || label.isEmpty()) continue; - List lines = MermaidNodeRenderer.wrapText(context, style, label, visibleWidth); - int lineHeight = context.getLineHeight(style); + List lines = MermaidNodeRenderer.wrapText(new LayoutContext(new FontMetrics() { + + @Override + public float getAdvance(int codePoint, ResolvedTextStyle s) { + return GuideText.measureWidth(new String(Character.toChars(codePoint)), s); + } + + @Override + public int getLineHeight(ResolvedTextStyle s) { + return GuideText.lineHeight(s); + } + }), style, label, visibleWidth); + int lineHeight = GuideText.lineHeight(style); int totalTextHeight = lines.size() * lineHeight; int textAreaHeight = contentArea.y() + visibleHeight - textY; int baseTextY = textY + Math.max(0, (textAreaHeight - totalTextHeight) / 2); for (int i = 0; i < lines.size(); i++) { - int lineWidth = context.getStringWidth(lines.get(i), style); + int lineWidth = GuideText.measureWidth(lines.get(i), style); int textX = contentArea.x() + Math.max(0, (visibleWidth - lineWidth) / 2); - context.drawText(lines.get(i), textX, baseTextY + i * lineHeight, style); + GuideText.emitText(c, lines.get(i), textX, baseTextY + i * lineHeight, style); } } } } - private void renderNodeContent(RenderContext context, NodeContentLayout contentLayout, LytRect contentArea, - float activeZoom) { - LytRect rawViewport = new LytRect( - contentArea.x(), - contentArea.y(), - Math.max( - 1, - Math.round( - contentLayout.visualBounds() - .width() * activeZoom)), - Math.max( - 1, - Math.round( - contentLayout.visualBounds() - .height() * activeZoom))); - - int cvpX = rawViewport.x(); - int cvpY = rawViewport.y(); - if (rawViewport.width() < contentArea.width()) { - cvpX = contentArea.x() + (contentArea.width() - rawViewport.width()) / 2; - } - if (rawViewport.height() < contentArea.height()) { - cvpY = contentArea.y() + (contentArea.height() - rawViewport.height()) / 2; - } - LytRect contentViewport = new LytRect(cvpX, cvpY, rawViewport.width(), rawViewport.height()); - - renderNodeContent(context, contentLayout.block(), contentViewport, contentLayout.visualBounds(), activeZoom); - } - - @Override - @Nullable - protected NodeHit pickNodeHit(int documentX, int documentY) { - if (layout == null) return null; - LytRect innerViewport = getInnerViewport(); - float activeZoom = getActiveZoom(); - int baseX = innerViewport.x() + getVisualOffsetX() - getScaledOriginX(); - int baseY = innerViewport.y() + getVisualOffsetY() - getScaledOriginY(); - - for (var entry : layout.getNodePositions() - .entrySet()) { - String nodeId = entry.getKey(); - NodeContentLayout contentLayout = nodeContentLayouts.get(nodeId); - if (contentLayout == null) continue; - - NodePosition pos = entry.getValue(); - int sx = scaled(baseX, pos.getX(), activeZoom); - int sy = scaled(baseY, pos.getY(), activeZoom); - int sw = Math.max(1, Math.round(pos.getWidth() * activeZoom)); - int sh = Math.max(1, Math.round(pos.getHeight() * activeZoom)); - LytRect nodeRect = new LytRect(sx, sy, sw, sh); - - int paddingX = Math.max(1, Math.round(NODE_PADDING_X * activeZoom)); - int contentY = nodeRect.y() + Math.max(1, Math.round(NODE_PADDING_Y * activeZoom)) - + resolveNodeBadgeHeight(entry.getKey(), activeZoom); - LytRect contentScreenRect = resolveNodeContentRect(contentLayout, nodeRect, paddingX, contentY, activeZoom); - - if (!contentScreenRect.contains(documentX, documentY)) continue; - - int localX = unscaleCoordinate(documentX - contentScreenRect.x(), activeZoom); - int localY = unscaleCoordinate(documentY - contentScreenRect.y(), activeZoom); - DocumentInteractionSnapshot hit = LytDocument.pick(contentLayout.block(), localX, localY); - if (hit != null) { - return new NodeHit(hit.node(), hit.flowPath(), localX, localY); - } - } - return null; - } - - private int resolveNodeBadgeHeight(String nodeId, float activeZoom) { - FlowchartNode node = document.getNodes() - .get(nodeId); - if (node == null || node.getIcon() == null) return 0; - String badgeText = MermaidNodeRenderer.simplifyIcon(node.getIcon()); - if (badgeText == null) return 0; - ResolvedTextStyle badgeStyle = getOrScaleStyle(ICON_TEXT_STYLE, activeZoom); - int badgePaddingY = Math.max(1, Math.round(2 * activeZoom)); - int iconGapY = Math.max(1, Math.round(ICON_GAP_Y * activeZoom)); - return contextLineHeight(badgeStyle) + badgePaddingY * 2 + iconGapY; - } - - private void renderSubgraphs(RenderContext context, int baseX, int baseY, float activeZoom) { - if (layout == null) return; - for (var subgraph : document.getSubgraphs()) { - renderSubgraphRecursive(context, subgraph, layout.getNodePositions(), baseX, baseY, activeZoom, 0); - } - } - - private void renderSubgraphRecursive(RenderContext context, FlowchartSubgraph subgraph, - Map positions, int baseX, int baseY, float activeZoom, int depth) { - LytRect bounds = computeSubgraphBounds(subgraph, positions); - if (bounds == null) return; - - int pad = Math.round(SUBGRAPH_PADDING * activeZoom); - int sx = scaled(baseX, bounds.x() - pad, activeZoom); - int sy = scaled(baseY, bounds.y() - pad, activeZoom); - int sw = Math.max(1, Math.round((bounds.width() + pad * 2) * activeZoom)); - int sh = Math.max(1, Math.round((bounds.height() + pad * 2) * activeZoom)); - LytRect sgRect = new LytRect(sx, sy, sw, sh); - - int bg = context.resolveColor(SUBGRAPH_BG[depth % SUBGRAPH_BG.length]); - int border = context.resolveColor(SUBGRAPH_BORDER[depth % SUBGRAPH_BORDER.length]); - context.fillRect(sgRect, bg); - context.drawBorder(sgRect, border, Math.max(1, Math.round(1.5f * activeZoom))); - - String label = subgraph.getLabel(); - if (label != null && !label.isEmpty()) { - int labelPadX = Math.max(2, Math.round(4 * activeZoom)); - int labelPadY = Math.max(1, Math.round(2 * activeZoom)); - ResolvedTextStyle labelStyle = getOrScaleStyle(NODE_TEXT_STYLE, activeZoom); - LytRect labelRect = new LytRect( - sgRect.x() + labelPadX, - sgRect.y() + labelPadY, - context.getStringWidth(label, labelStyle), - context.getLineHeight(labelStyle)); - context.drawText(label, labelRect.x(), labelRect.y(), labelStyle); - } - - for (var child : subgraph.getChildren()) { - renderSubgraphRecursive(context, child, positions, baseX, baseY, activeZoom, depth + 1); - } - } - @Nullable private static LytRect computeSubgraphBounds(FlowchartSubgraph subgraph, Map positions) { LytRect result = null; diff --git a/src/main/java/com/hfstudio/guidenh/guide/document/block/LytMermaidMindmapCanvas.java b/src/main/java/com/hfstudio/guidenh/guide/document/block/LytMermaidMindmapCanvas.java index 7d245b8f..a14b4a98 100644 --- a/src/main/java/com/hfstudio/guidenh/guide/document/block/LytMermaidMindmapCanvas.java +++ b/src/main/java/com/hfstudio/guidenh/guide/document/block/LytMermaidMindmapCanvas.java @@ -9,15 +9,19 @@ import com.hfstudio.guidenh.guide.color.ColorUtils; import com.hfstudio.guidenh.guide.color.ConstantColor; import com.hfstudio.guidenh.guide.document.LytRect; +import com.hfstudio.guidenh.guide.document.block.shapes.FlowchartShapes; import com.hfstudio.guidenh.guide.document.interaction.DocumentInteractionSnapshot; import com.hfstudio.guidenh.guide.internal.debug.DebugComponent; import com.hfstudio.guidenh.guide.internal.mermaid.MermaidNodeShape; import com.hfstudio.guidenh.guide.internal.mermaid.mindmap.MindmapDocument; import com.hfstudio.guidenh.guide.internal.mermaid.mindmap.MindmapLayoutMode; import com.hfstudio.guidenh.guide.internal.mermaid.mindmap.MindmapNode; +import com.hfstudio.guidenh.guide.layout.FontMetrics; import com.hfstudio.guidenh.guide.layout.LayoutContext; -import com.hfstudio.guidenh.guide.render.RenderContext; -import com.hfstudio.guidenh.guide.scene.LytGuidebookScene; +import com.hfstudio.guidenh.guide.render.GuideRenderPrimitive; +import com.hfstudio.guidenh.guide.render.GuideText; +import com.hfstudio.guidenh.guide.render.PrimitiveCollector; +import com.hfstudio.guidenh.guide.scene.support.GuideDebugLog; import com.hfstudio.guidenh.guide.style.ResolvedTextStyle; import com.hfstudio.guidenh.guide.style.TextAlignment; import com.hfstudio.guidenh.guide.style.WhiteSpaceMode; @@ -55,7 +59,8 @@ public class LytMermaidMindmapCanvas extends LytMermaidCanvas nodeContentBlocks) { this.mindmap = mindmap; @@ -142,10 +150,7 @@ protected LytRect computeLayout(LayoutContext context, int x, int y, int availab layout = buildLayout(context, safeWidth); int desiredHeight = layout.diagramHeight() + CANVAS_PADDING * 2; int viewportHeight = preferredHeight > 0 ? Math.max(48, preferredHeight) - : Math.max(MIN_HEIGHT, Math.min(MAX_HEIGHT, desiredHeight)); - if (preferredHeight > 0 && safeWidth < resolvePreferredViewportWidth()) { - viewportHeight = Math.max(viewportHeight, Math.min(MAX_HEIGHT, desiredHeight)); - } + : Math.clamp(desiredHeight, MIN_HEIGHT, MAX_HEIGHT); int viewportWidth = Math.max(1, safeWidth - CANVAS_PADDING * 2); int innerViewportHeight = Math.max(1, viewportHeight - CANVAS_PADDING * 2); restoreViewportAfterLayout( @@ -159,13 +164,233 @@ protected LytRect computeLayout(LayoutContext context, int x, int y, int availab return new LytRect(x, y, safeWidth, viewportHeight); } + /** + * Pre-compute diagram layout before the first Rust layout pass and set + * preferredHeight so Rust allocates the correct canvas height immediately. + * Caches the layout result for reuse in afterExternalLayout when the + * actual bounds width matches the pre-computation width. + * + * @param ctx LayoutContext backed by GuideText-based FontMetrics + * @param availableWidth estimated canvas content width (page width or + * placeholder width) + */ + public void precomputeLayout(LayoutContext ctx, int availableWidth) { + int safeWidth = preferredWidth > 0 ? Math.max(1, Math.min(preferredWidth, availableWidth)) + : Math.max(1, availableWidth); + this.layout = buildLayout(ctx, safeWidth); + this.precomputedLayoutWidth = safeWidth; + if (layout != null) { + int desiredHeight = layout.diagramHeight() + CANVAS_PADDING * 2; + int newPreferredHeight = preferredHeight > 0 ? Math.max(48, preferredHeight) + : Math.clamp(desiredHeight, MIN_HEIGHT, MAX_HEIGHT); + preferredHeight = newPreferredHeight; + int diagramWidth = layout.diagramWidth() + CANVAS_PADDING * 2; + preferredWidth = diagramWidth; + GuideDebugLog.debugAlways( + "[GuideNH-Mermaid] precomputeLayout OK diagramHeight={} preferredHeight={}", + layout.diagramHeight(), + preferredHeight); + GuideDebugLog.debugAlways( + "[GuideNH-Mermaid] precomputeLayout set explicitWidth={} diagramWidth={} safeWidth={}", + preferredWidth, + layout.diagramWidth(), + safeWidth); + } else { + GuideDebugLog.debugAlways("[GuideNH-Mermaid] precomputeLayout FAILED layout=null safeWidth={}", safeWidth); + } + } + + @Override + protected void afterExternalLayout() { + int safeWidth = preferredWidth > 0 ? Math.max(1, Math.min(preferredWidth, Math.max(1, bounds.width()))) + : Math.max(1, bounds.width()); + + GuideDebugLog.debugAlways( + "[GuideNH-Mermaid] afterExternalLayout entered layout={} safeWidth={} precomputedLayoutWidth={} bounds.height={}", + layout != null, + safeWidth, + precomputedLayoutWidth, + bounds.height()); + + // Ensure layout result matches the actual canvas width. + // Width matches precompute → reuse cached layout (no recompute). + // Width mismatch or no precompute → recompute at the correct width. + if (layout == null || precomputedLayoutWidth <= 0 || precomputedLayoutWidth != safeWidth) { + LayoutContext fallbackCtx = new LayoutContext(new FontMetrics() { + + @Override + public float getAdvance(int codePoint, ResolvedTextStyle s) { + return GuideText.measureWidth(new String(Character.toChars(codePoint)), s); + } + + @Override + public int getLineHeight(ResolvedTextStyle s) { + return GuideText.lineHeight(s); + } + }); + layout = buildLayout(fallbackCtx, safeWidth); + GuideDebugLog.debugAlways("[GuideNH-Mermaid] afterExternalLayout recomputed layout={}", layout != null); + } + + // With a valid layout, correct the bounds height when it disagrees. This + // is the fallback path for canvases whose height was not fixed up. + if (layout != null) { + int desiredHeight = layout.diagramHeight() + CANVAS_PADDING * 2; + int expectedHeight = preferredHeight > 0 ? Math.max(48, preferredHeight) + : Math.clamp(desiredHeight, MIN_HEIGHT, MAX_HEIGHT); + if (bounds.height() != expectedHeight) { + GuideDebugLog.debugAlways( + "[GuideNH-Mermaid] afterExternalLayout correcting bounds height {} -> {}", + bounds.height(), + expectedHeight); + bounds = new LytRect(bounds.x(), bounds.y(), bounds.width(), expectedHeight); + } + } + + GuideDebugLog.debugAlways( + "[GuideNH-Mermaid] afterExternalLayout exit layout={} bounds.height={}", + layout != null, + bounds.height()); + } + @Override protected void onLayoutMoved(int deltaX, int deltaY) {} + // Primitives pipeline, which replaces render* for the primitives path. + @Override - protected void renderDiagram(RenderContext context, int baseX, int baseY, float activeZoom) { - renderConnectors(context, layout.root(), baseX, baseY); - renderNodes(context, layout.root(), baseX, baseY); + public boolean usePrimitives() { + return true; + } + + @Override + protected void emitDiagramPrimitives(PrimitiveCollector c, int baseX, int baseY, float activeZoom) { + emitConnectorsPrimitives(c, layout.root(), baseX, baseY, activeZoom); + emitNodesPrimitives(c, layout.root(), baseX, baseY, activeZoom); + } + + private void emitConnectorsPrimitives(PrimitiveCollector c, NodeLayout node, int baseX, int baseY, + float activeZoom) { + for (NodeLayout child : node.children) { + if (mindmap.getLayoutMode() == MindmapLayoutMode.TIDY_TREE) { + emitVerticalConnector( + c, + scaled(baseX, node.centerX(), activeZoom), + scaled(baseY, node.bottom(), activeZoom), + scaled(baseX, child.centerX(), activeZoom), + scaled(baseY, child.y, activeZoom), + ColorUtils.ARGB_FF5D6C7C.getColor()); + } else { + boolean rightSide = child.centerX() >= node.centerX(); + int parentEdgeX = scaled(baseX, rightSide ? node.right() : node.x, activeZoom); + int childEdgeX = scaled(baseX, rightSide ? child.x : child.right(), activeZoom); + emitHorizontalConnector( + c, + parentEdgeX, + scaled(baseY, node.centerY(), activeZoom), + childEdgeX, + scaled(baseY, child.centerY(), activeZoom), + ColorUtils.ARGB_FF5D6C7C.getColor()); + } + emitConnectorsPrimitives(c, child, baseX, baseY, activeZoom); + } + } + + private void emitHorizontalConnector(PrimitiveCollector c, int startX, int startY, int endX, int endY, int color) { + int midX = (startX + endX) / 2; + emitHorizontalLine(c, startX, midX, startY, color); + emitVerticalLine(c, midX, startY, endY, color); + emitHorizontalLine(c, midX, endX, endY, color); + } + + private void emitVerticalConnector(PrimitiveCollector c, int startX, int startY, int endX, int endY, int color) { + int midY = (startY + endY) / 2; + emitVerticalLine(c, startX, startY, midY, color); + emitHorizontalLine(c, startX, endX, midY, color); + emitVerticalLine(c, endX, midY, endY, color); + } + + private void emitHorizontalLine(PrimitiveCollector c, int startX, int endX, int y, int color) { + int left = Math.min(startX, endX); + int width = Math.abs(endX - startX) + 1; + c.emit(new GuideRenderPrimitive.FillRect(left, y, width, CONNECTOR_THICKNESS, color)); + } + + private void emitVerticalLine(PrimitiveCollector c, int x, int startY, int endY, int color) { + int top = Math.min(startY, endY); + int height = Math.abs(endY - startY) + 1; + c.emit(new GuideRenderPrimitive.FillRect(x, top, CONNECTOR_THICKNESS, height, color)); + } + + private void emitNodesPrimitives(PrimitiveCollector c, NodeLayout node, int baseX, int baseY, float activeZoom) { + LytRect rect = new LytRect( + scaled(baseX, node.x, activeZoom), + scaled(baseY, node.y, activeZoom), + Math.max(1, Math.round(node.width * activeZoom)), + Math.max(1, Math.round(node.height * activeZoom))); + LytRect boxRect = rect; + NodeColors colors = resolveColors(node.node); + MermaidNodeShape shape = node.node.getShape(); + FlowchartShapes.emitShape(c, shape, boxRect, colors.background, colors.border); + if (FlowchartShapes.hasAccentBar(shape)) { + c.emit(new GuideRenderPrimitive.FillRect(boxRect.x(), boxRect.y(), 3, boxRect.height(), colors.accent)); + } + + ResolvedTextStyle style = getOrScaleStyle(node.depth == 0 ? ROOT_TEXT_STYLE : NODE_TEXT_STYLE, activeZoom); + ResolvedTextStyle badgeStyle = getOrScaleStyle(ICON_TEXT_STYLE, activeZoom); + int paddingX = Math.max(1, Math.round(NODE_PADDING_X * activeZoom)); + int paddingY = Math.max(1, Math.round(NODE_PADDING_Y * activeZoom)); + int iconGapY = Math.max(1, Math.round(ICON_GAP_Y * activeZoom)); + int badgePaddingX = Math.max(2, Math.round(4 * activeZoom)); + int badgePaddingY = Math.max(1, Math.round(2 * activeZoom)); + int textY = rect.y() + paddingY; + if (node.showBadge && node.badgeText != null) { + int badgeWidth = Math.max(1, GuideText.measureWidth(node.badgeText, badgeStyle) + badgePaddingX * 2); + int badgeHeight = Math.max(1, GuideText.lineHeight(badgeStyle) + badgePaddingY * 2); + LytRect badge = new LytRect(rect.x() + paddingX, textY, badgeWidth, badgeHeight); + c.emit( + new GuideRenderPrimitive.FillRect( + badge.x(), + badge.y(), + badge.width(), + badge.height(), + MermaidNodeRenderer.BADGE_BACKGROUND)); + c.emit( + new GuideRenderPrimitive.DrawBorder( + badge.x(), + badge.y(), + badge.width(), + badge.height(), + 1, + 1, + 1, + 1, + MermaidNodeRenderer.BADGE_BORDER)); + GuideText.emitText(c, node.badgeText, badge.x() + badgePaddingX, badge.y() + badgePaddingY, badgeStyle); + textY = badge.bottom() + iconGapY; + } + + if (node.contentLayout != null) { + LytRect contentViewport = resolveNodeContentRect(node.contentLayout, rect, paddingX, textY, activeZoom); + emitNodeContentPrimitives( + c, + node.contentLayout.block(), + contentViewport, + node.contentLayout.visualBounds(), + activeZoom); + } else { + int lineHeight = GuideText.lineHeight(style); + for (String line : node.lines) { + int lineWidth = GuideText.measureWidth(line, style); + int textX = rect.x() + Math.max(paddingX, (rect.width() - lineWidth) / 2); + GuideText.emitText(c, line, textX, textY, style); + textY += lineHeight; + } + } + + for (NodeLayout child : node.children) { + emitNodesPrimitives(c, child, baseX, baseY, activeZoom); + } } private DiagramLayout buildLayout(LayoutContext context, int availableWidth) { @@ -350,7 +575,14 @@ private NodeLayout prepareLayout(LayoutContext context, MindmapNode node, int de } LayoutContext localContext = new LayoutContext(context).withVisualScale(context.getVisualScale()); int contentWidth = Math.clamp(maxNodeTextWidth + 60, 96, 240); - block.layout(localContext, 0, 0, contentWidth); + // LytVBox.computeBoxLayout is a stub (Rust is the sole layout authority + // for the normal document pipeline), so NodeContent subtrees, which + // never reach the document's Rust pass, used to be laid out manually. + // The Rust engine now lays the subtree out directly (including the + // inline post-pass that anchors inline ItemImage bounds at their text + // pen position), with a Java fallback for environments without the + // native bridge. + layoutNodeContentWithRust(localContext, block, contentWidth); LytRect visualBounds = resolveBlockVisualBounds(block); return new NodeContentLayout(block, visualBounds); } @@ -444,107 +676,6 @@ private void layoutTopDown(NodeLayout node, int x, int y) { } } - private void renderConnectors(RenderContext context, NodeLayout node, int baseX, int baseY) { - float activeZoom = getActiveZoom(); - for (NodeLayout child : node.children) { - if (mindmap.getLayoutMode() == MindmapLayoutMode.TIDY_TREE) { - drawVerticalConnector( - context, - scaled(baseX, node.centerX(), activeZoom), - scaled(baseY, node.bottom(), activeZoom), - scaled(baseX, child.centerX(), activeZoom), - scaled(baseY, child.y, activeZoom), - ColorUtils.ARGB_FF5D6C7C.getColor()); - } else { - boolean rightSide = child.centerX() >= node.centerX(); - int parentEdgeX = scaled(baseX, rightSide ? node.right() : node.x, activeZoom); - int childEdgeX = scaled(baseX, rightSide ? child.x : child.right(), activeZoom); - drawHorizontalConnector( - context, - parentEdgeX, - scaled(baseY, node.centerY(), activeZoom), - childEdgeX, - scaled(baseY, child.centerY(), activeZoom), - ColorUtils.ARGB_FF5D6C7C.getColor()); - } - renderConnectors(context, child, baseX, baseY); - } - } - - private void renderNodes(RenderContext context, NodeLayout node, int baseX, int baseY) { - float activeZoom = getActiveZoom(); - LytRect rect = new LytRect( - scaled(baseX, node.x, activeZoom), - scaled(baseY, node.y, activeZoom), - Math.max(1, Math.round(node.width * activeZoom)), - Math.max(1, Math.round(node.height * activeZoom))); - LytRect boxRect = rect; - NodeColors colors = resolveColors(node.node); - context.fillRect(boxRect, colors.background); - context.drawBorder(boxRect, colors.border, node.node.getShape() == MermaidNodeShape.BANG ? 2 : 1); - context.fillRect(new LytRect(boxRect.x(), boxRect.y(), 3, boxRect.height()), colors.accent); - - ResolvedTextStyle style = getOrScaleStyle(node.depth == 0 ? ROOT_TEXT_STYLE : NODE_TEXT_STYLE, activeZoom); - ResolvedTextStyle badgeStyle = getOrScaleStyle(ICON_TEXT_STYLE, activeZoom); - int paddingX = Math.max(1, Math.round(NODE_PADDING_X * activeZoom)); - int paddingY = Math.max(1, Math.round(NODE_PADDING_Y * activeZoom)); - int iconGapY = Math.max(1, Math.round(ICON_GAP_Y * activeZoom)); - int badgePaddingX = Math.max(2, Math.round(4 * activeZoom)); - int badgePaddingY = Math.max(1, Math.round(2 * activeZoom)); - int textY = rect.y() + paddingY; - if (node.showBadge && node.badgeText != null) { - int badgeWidth = Math.max(1, context.getStringWidth(node.badgeText, badgeStyle) + badgePaddingX * 2); - LytRect badge = new LytRect( - rect.x() + paddingX, - textY, - badgeWidth, - Math.max(1, context.getLineHeight(badgeStyle) + badgePaddingY * 2)); - context.fillRect(badge, ColorUtils.ARGB_262A3340.getColor()); - context.drawBorder(badge, ColorUtils.ARGB_66434C57.getColor(), 1); - context.drawText(node.badgeText, badge.x() + badgePaddingX, badge.y() + badgePaddingY, badgeStyle); - textY = badge.bottom() + iconGapY; - } - - if (node.contentLayout != null) { - renderNodeContent(context, node, rect, paddingX, textY, activeZoom); - } else { - int lineHeight = context.getLineHeight(style); - for (String line : node.lines) { - int lineWidth = MermaidNodeRenderer.measureText(context, style, line); - int textX = rect.x() + Math.max(paddingX, (rect.width() - lineWidth) / 2); - context.drawText(line, textX, textY, style); - textY += lineHeight; - } - } - - for (NodeLayout child : node.children) { - renderNodes(context, child, baseX, baseY); - } - } - - private void renderNodeContent(RenderContext context, NodeLayout node, LytRect rect, int paddingX, int contentY, - float activeZoom) { - if (node.contentLayout == null) return; - LytRect contentViewport = resolveNodeContentRect(node.contentLayout, rect, paddingX, contentY, activeZoom); - renderNodeContent( - context, - node.contentLayout.block(), - contentViewport, - node.contentLayout.visualBounds(), - activeZoom); - } - - private static boolean containsScene(@Nullable LytBlock block) { - if (block == null) return false; - if (block instanceof LytGuidebookScene) return true; - if (block instanceof LytNode container) { - for (var child : container.getChildren()) { - if (child instanceof LytBlock childBlock && containsScene(childBlock)) return true; - } - } - return false; - } - @Override @Nullable protected NodeHit pickNodeHit(int documentX, int documentY) { @@ -679,50 +810,10 @@ private NodeColors resolveColors(MindmapNode node) { return new NodeColors(background, border, accent); } - private void drawHorizontalConnector(RenderContext context, int startX, int startY, int endX, int endY, int color) { - int midX = (startX + endX) / 2; - fillHorizontalLine(context, startX, midX, startY, color); - fillVerticalLine(context, midX, startY, endY, color); - fillHorizontalLine(context, midX, endX, endY, color); - } - - private void drawVerticalConnector(RenderContext context, int startX, int startY, int endX, int endY, int color) { - int midY = (startY + endY) / 2; - fillVerticalLine(context, startX, startY, midY, color); - fillHorizontalLine(context, startX, endX, midY, color); - fillVerticalLine(context, endX, midY, endY, color); - } - - private void fillHorizontalLine(RenderContext context, int startX, int endX, int y, int color) { - int left = Math.min(startX, endX); - int width = Math.abs(endX - startX) + 1; - context.fillRect(new LytRect(left, y, width, CONNECTOR_THICKNESS), color); - } - - private void fillVerticalLine(RenderContext context, int x, int startY, int endY, int color) { - int top = Math.min(startY, endY); - int height = Math.abs(endY - startY) + 1; - context.fillRect(new LytRect(x, top, CONNECTOR_THICKNESS, height), color); - } - private int resolvePreferredViewportWidth() { return preferredWidth > 0 ? preferredWidth : MIN_WIDTH; } - LytRect getContentBoundsForTesting() { - return layout != null ? layout.contentBounds() : LytRect.empty(); - } - - public interface AdvanceFunction { - - float getAdvance(int codePoint, ResolvedTextStyle style); - } - - private interface WordVisitor { - - boolean accept(String word); - } - public static class DiagramLayout { private final NodeLayout root; diff --git a/src/main/java/com/hfstudio/guidenh/guide/document/block/LytParagraph.java b/src/main/java/com/hfstudio/guidenh/guide/document/block/LytParagraph.java index 34f740b0..6c4d21c2 100644 --- a/src/main/java/com/hfstudio/guidenh/guide/document/block/LytParagraph.java +++ b/src/main/java/com/hfstudio/guidenh/guide/document/block/LytParagraph.java @@ -11,20 +11,286 @@ import com.hfstudio.guidenh.guide.document.LytRect; import com.hfstudio.guidenh.guide.document.flow.LytFlowContainer; import com.hfstudio.guidenh.guide.document.flow.LytFlowContent; +import com.hfstudio.guidenh.guide.document.flow.LytFlowInlineBlock; +import com.hfstudio.guidenh.guide.document.flow.LytFlowSpan; +import com.hfstudio.guidenh.guide.document.flow.LytFlowText; +import com.hfstudio.guidenh.guide.document.flow.LytSpoilerSpan; import com.hfstudio.guidenh.guide.document.interaction.FlowInteractionPath; import com.hfstudio.guidenh.guide.internal.debug.DebugFlowContainer; import com.hfstudio.guidenh.guide.layout.LayoutContext; -import com.hfstudio.guidenh.guide.layout.flow.FlowBuilder; -import com.hfstudio.guidenh.guide.layout.flow.LineElement; +import com.hfstudio.guidenh.guide.render.GlyphRunData; +import com.hfstudio.guidenh.guide.render.GlyphRunGroup; +import com.hfstudio.guidenh.guide.render.GlyphRunHolder; +import com.hfstudio.guidenh.guide.render.GuideRenderPrimitive; +import com.hfstudio.guidenh.guide.render.GuideText; +import com.hfstudio.guidenh.guide.render.PrimitiveCollector; import com.hfstudio.guidenh.guide.render.RenderContext; import com.hfstudio.guidenh.guide.style.TextStyle; import lombok.Getter; import lombok.Setter; -public class LytParagraph extends LytBlock implements LytFlowContainer, DebugFlowContainer { +public class LytParagraph extends LytBlock implements LytFlowContainer, DebugFlowContainer, GlyphRunHolder { - protected final FlowBuilder content = new FlowBuilder(); + private final List flowContent = new ArrayList<>(); + + // Rich glyph output from Rust cosmic-text shaping (per-span runs + decorations) + @Nullable + private GlyphRunData glyphData; + + @Override + public void setGlyphData(@Nullable GlyphRunData data) { + this.glyphData = data; + } + + @Override + public @Nullable GlyphRunData getGlyphData() { + return glyphData; + } + + /** + * Render through the primitive pipeline when a Rust-shaped glyph run is + * available. Opaque paragraphs (§k/obfuscated, float-aligned inline blocks) + * keep legacy HostDraw rendering via {@link #render(RenderContext)}. + */ + @Override + public boolean usePrimitives() { + return !flowContent.isEmpty(); + } + + @Override + public List getChildren() { + // Surface inline blocks (icons, formulas, etc. embedded in the flow + // content) as real tree children: the serializer pairs them with the + // U+FFFC placeholders in the paragraph text, and the render collector + // traverses them like any other child. + return getInlineBlocks(); + } + + /** + * The inner blocks of this paragraph's {@code LytFlowInlineBlock} wrappers, + * in document order. Empty for plain-text paragraphs. + */ + public List getInlineBlocks() { + List out = new ArrayList<>(); + for (LytFlowContent fc : getContent()) { + collectInlineBlocks(fc, out); + } + return out; + } + + private static void collectInlineBlocks(LytFlowContent fc, List out) { + if (fc instanceof LytFlowInlineBlock ib && ib.getBlock() != null) { + out.add(ib.getBlock()); + } else if (fc instanceof LytFlowSpan fs) { + for (LytFlowContent child : fs.getChildren()) { + collectInlineBlocks(child, out); + } + } + } + + @Override + protected void onExternalLayoutApplied(LytRect oldBounds, LytRect newBounds) {} + + @Override + public void computePrimitives(PrimitiveCollector c) { + // Obfuscated (§k) paragraphs: render per-frame random characters via + // GuideText at the Rust-computed paragraph bounds. Layout geometry + // comes from Rust (single authority); animation is a rendering concern. + if (hasObfuscatedStyles(getContent())) { + emitObfuscatedText(c); + return; + } + if (glyphData != null && !glyphData.runs() + .isEmpty()) { + // Span backgrounds (highlight / inline-code) behind the glyphs; + // underline / strikethrough on top. + for (GuideRenderPrimitive.FillRect bg : glyphData.backgrounds()) { + c.emit(bg); + } + List spanOwners = hasSpoiler() ? collectSpanOwners() : null; + List runs = glyphData.runs(); + for (int si = 0; si < runs.size(); si++) { + GlyphRunGroup group = runs.get(si); + if (spanOwners != null && si < spanOwners.size() && isSpoilerHidden(spanOwners.get(si))) { + emitSpoilerMask(c, group); + } else { + c.emit( + new GuideRenderPrimitive.DrawGlyphRun( + group.glyphs(), + group.argb(), + group.shear(), + resolveStyle().dropShadow())); + } + } + for (GuideRenderPrimitive.FillRect line : glyphData.lines()) { + c.emit(line); + } + // Wavy / dotted decorations (kind 4/5) draw on top, after the + // plain underline / strikethrough lines. + for (GuideRenderPrimitive.DrawDecorationLine decoration : glyphData.decorations()) { + c.emit(decoration); + } + return; + } + // Fallback: glyph data unavailable, so emit text through GuideText and the + // paragraph renders as visible text instead of silent blank. + emitTextFallback(c); + } + + private boolean hasSpoiler() { + for (LytFlowContent fc : getContent()) { + if (hasSpoilerIn(fc)) return true; + } + return false; + } + + private static boolean hasSpoilerIn(LytFlowContent fc) { + if (fc instanceof LytSpoilerSpan) return true; + if (fc instanceof LytFlowSpan span) { + for (LytFlowContent child : span.getChildren()) { + if (hasSpoilerIn(child)) return true; + } + } + return false; + } + + private boolean isSpoilerHidden(LytFlowContent owner) { + LytSpoilerSpan spoiler = owner.findAncestor(LytSpoilerSpan.class); + if (spoiler == null) return false; + if (owner instanceof LytSpoilerSpan) spoiler = (LytSpoilerSpan) owner; + boolean hovered = hoveredPath != null && hoveredPath.containsPrimaryOrDescendant(owner); + boolean revealed = revealedPath != null && revealedPath.containsOrAncestors(owner); + return !hovered && !revealed; + } + + private static void emitSpoilerMask(PrimitiveCollector c, GlyphRunGroup group) { + if (group.glyphs() + .isEmpty()) return; + float minX = Float.MAX_VALUE, minY = Float.MAX_VALUE, maxX = 0, maxY = 0; + for (var g : group.glyphs()) { + minX = Math.min(minX, g.x()); + minY = Math.min(minY, g.y()); + maxX = Math.max(maxX, g.x() + g.w()); + maxY = Math.max(maxY, g.y() + g.h()); + } + c.emit( + new GuideRenderPrimitive.FillRect( + Math.round(minX) - 1, + Math.round(minY) - 1, + Math.round(maxX - minX) + 2, + Math.round(maxY - minY) + 2, + 0xFF000000)); + } + + /** + * Emit obfuscated (§k) text via GuideText with per-frame random characters. + * Rust provides layout geometry (paragraph bounds); rendering is handled + * here as a native GuideText call, with no LineBuilder dependency. + */ + private void emitObfuscatedText(PrimitiveCollector c) { + StringBuilder text = new StringBuilder(); + for (LytFlowContent fc : getContent()) { + collectObfuscatedText(fc, text); + } + if (text.isEmpty()) return; + StringBuilder random = new StringBuilder(text.length()); + for (int i = 0; i < text.length(); i++) { + char ch = text.charAt(i); + if (Character.isWhitespace(ch)) { + random.append(ch); + } else { + random.append(randomObfuscatedChar()); + } + } + GuideText.emitText(c, random.toString(), bounds.x(), bounds.y(), resolveStyle()); + } + + private static void collectObfuscatedText(LytFlowContent fc, StringBuilder out) { + if (fc instanceof LytFlowText ft) { + out.append(ft.getText()); + } else if (fc instanceof LytFlowInlineBlock) { + out.append(' '); // placeholder space for inline blocks + } else if (fc instanceof LytFlowSpan fs) { + for (LytFlowContent child : fs.getChildren()) { + collectObfuscatedText(child, out); + } + } + } + + /** + * Fallback emission: collect all plain text from flow content and emit via + * GuideText (atlas-backed glyph run). Used when glyphData is null or empty + * so the paragraph renders visible text instead of silent blank. + */ + private void emitTextFallback(PrimitiveCollector c) { + StringBuilder text = new StringBuilder(); + for (LytFlowContent fc : getContent()) { + collectPlainText(fc, text); + } + if (text.isEmpty()) return; + GuideText.emitText(c, text.toString(), bounds.x(), bounds.y(), resolveStyle()); + } + + /** + * Collect plain (non-obfuscated) text from a flow-content subtree. + */ + private static void collectPlainText(LytFlowContent fc, StringBuilder out) { + if (fc instanceof LytFlowText ft) { + out.append(ft.getText()); + } else if (fc instanceof LytFlowInlineBlock) { + out.append(' '); // placeholder space for inline blocks + } else if (fc instanceof LytFlowSpan fs) { + for (LytFlowContent child : fs.getChildren()) { + collectPlainText(child, out); + } + } + } + + private static char randomObfuscatedChar() { + // Fast per-frame random character from ASCII letters and digits, + // matching Minecraft's §k visual style. + long t = System.nanoTime(); + int idx = (int) (t % 62); + if (idx < 26) return (char) ('A' + idx); + if (idx < 52) return (char) ('a' + idx - 26); + return (char) ('0' + idx - 52); + } + + /** + * Obfuscated-only detection: {@code §k} content cannot be baked into a + * static glyph run (per-frame random animation). Spoiler spans are NOT + * included; they get glyph runs with a render-time overlay. + */ + public static boolean hasObfuscatedStyles(Iterable content) { + for (LytFlowContent fc : content) { + if (hasObfuscatedIn(fc)) { + return true; + } + } + return false; + } + + private static boolean hasObfuscatedIn(LytFlowContent fc) { + if (fc instanceof LytFlowSpan span) { + for (LytFlowContent child : span.getChildren()) { + if (hasObfuscatedIn(child)) { + return true; + } + } + } + if (fc instanceof LytFlowText text) { + if (text.getText() + .contains("§k")) { + return true; + } + if (fc.resolveStyle() + .obfuscated()) { + return true; + } + } + return false; + } @Getter @Setter @@ -46,18 +312,13 @@ public class LytParagraph extends LytBlock implements LytFlowContainer, DebugFlo @Override public void append(LytFlowContent child) { - content.append(child); + flowContent.add(child); child.setParent(this); invalidateLayout(); } @Override public boolean isCulled(LytRect viewport) { - // If we have floating content, account for its bounding box exceeding our content box - if (content.floatsIntersect(viewport)) { - return false; - } - return super.isCulled(viewport); } @@ -68,19 +329,93 @@ public LytRect computeLayout(LayoutContext context, int x, int y, int availableW availableWidth -= paddingLeft + paddingRight; y += paddingTop; - var style = resolveStyle(); - - var bounds = content.computeLayout(context, x, y, availableWidth, style.alignment()); - - if (paddingBottom != 0) { - return bounds.withHeight(bounds.height() + paddingBottom); + // Paragraph geometry is Rust's sole authority: all paragraphs skip + // the expensive LineBuilder pass. Inline block children still need + // their sizes computed here (the serializer reads them before Rust + // takes over); positions are assigned later by the Rust inline post-pass. + for (LytBlock ib : getInlineBlocks()) { + ib.layout(context, 0, 0, availableWidth); } - return bounds; + // The h=10 minimal estimate is retained. Rust applyExternalLayout + // overwrites these bounds in the standard document pipeline, but the + // Mermaid NodeContent manual layout path (LytListItem.computeBoxLayout, + // documented as having no Rust pass) consumes the bounds produced by + // the child layout() calls without any Rust override. Because not every + // reachable path can be statically proven to be covered, removing or + // changing h would alter the serialized geometry along that path. The + // equivalent entry in Layouts.java is left untouched in this wave, as + // its reachability is unproven. + int h = paddingTop + paddingBottom + 10; // minimal estimate + return new LytRect(x - paddingLeft, y - paddingTop, availableWidth, h); } @Override protected void onLayoutMoved(int deltaX, int deltaY) { - content.move(deltaX, deltaY); + // The Rust-baked glyph run uses absolute document coordinates, so it must + // follow the paragraph's bounds (scroll replay, smooth scrolling) or the + // text detaches from the paragraph's background/clip/hover geometry. + if (glyphData != null && !glyphData.runs() + .isEmpty()) { + List movedGroups = new ArrayList<>( + glyphData.runs() + .size()); + for (GlyphRunGroup group : glyphData.runs()) { + List moved = new ArrayList<>( + group.glyphs() + .size()); + for (var g : group.glyphs()) { + moved.add( + new GuideRenderPrimitive.PlacedGlyph( + g.atlasKey(), + g.x() + deltaX, + g.y() + deltaY, + g.w(), + g.h(), + g.lineIndex(), + g.baseline() + deltaY)); + } + movedGroups.add(new GlyphRunGroup(moved, group.argb(), group.shear())); + } + // Decoration rects use absolute document coordinates too, so they must + // follow the same move. + glyphData = new GlyphRunData( + movedGroups, + moveRects(glyphData.backgrounds(), deltaX, deltaY), + moveRects(glyphData.lines(), deltaX, deltaY), + moveRects(glyphData.separators(), deltaX, deltaY), + moveDecorations(glyphData.decorations(), deltaX, deltaY)); + } + } + + private static List moveRects(List rects, int deltaX, + int deltaY) { + if (rects.isEmpty() || (deltaX == 0 && deltaY == 0)) { + return rects; + } + List moved = new ArrayList<>(rects.size()); + for (var r : rects) { + moved.add(new GuideRenderPrimitive.FillRect(r.x() + deltaX, r.y() + deltaY, r.w(), r.h(), r.argb())); + } + return moved; + } + + private static List moveDecorations( + List decorations, int deltaX, int deltaY) { + if (decorations.isEmpty() || (deltaX == 0 && deltaY == 0)) { + return decorations; + } + List moved = new ArrayList<>(decorations.size()); + for (var d : decorations) { + moved.add( + new GuideRenderPrimitive.DrawDecorationLine( + d.x() + deltaX, + d.y() + deltaY, + d.w(), + d.h(), + d.argb(), + d.kind())); + } + return moved; } @Override @@ -104,49 +439,113 @@ public void onMouseLeave() { @Override public @Nullable LytNode pickNode(int x, int y) { - // If we are the host for any floating elements, those can exceed our own bounds - var fl = content.pickFloatingElement(x, y); - if (fl != null) { - return this; - } - return super.pickNode(x, y); } @Override public void render(RenderContext context) { - // Since we overwrite isCulled, we render even if our actual line content is culled, for floats - if (context.intersectsViewport(bounds)) { - content.render(context, hoveredPath, revealedPath); + // All block-tree rendering goes through computePrimitives (usePrimitives + // always returns true when content exists). This legacy-path fallback is + // only reached by direct render() callers outside the document pipeline + // (tooltip / annotation / editor chains, e.g. ContentTooltip content and + // TextAnnotation rich content). + if (flowContent.isEmpty()) return; + if (glyphData != null && !glyphData.runs() + .isEmpty()) return; + StringBuilder text = new StringBuilder(); + for (LytFlowContent fc : getContent()) { + collectPlainText(fc, text); + } + if (!text.isEmpty()) { + context.drawText(text.toString(), bounds.x(), bounds.y(), resolveStyle()); } - - content.renderFloats(context, hoveredPath, revealedPath); } @Override public @Nullable FlowInteractionPath pickContent(int x, int y) { - return content.pickPath(x, y); + if (glyphData != null && !glyphData.runs() + .isEmpty()) { + var hit = pickFromGlyphRuns(x, y); + if (hit != null) { + return hit; + } + } + return null; } - public @Nullable LytRect getFirstLineBounds() { - return content.getFirstLineBounds(); + @Nullable + private FlowInteractionPath pickFromGlyphRuns(int x, int y) { + List spanOwners = collectSpanOwners(); + var runs = glyphData.runs(); + for (int si = 0; si < runs.size(); si++) { + var group = runs.get(si); + for (var g : group.glyphs()) { + if (x >= g.x() && x <= g.x() + g.w() && y >= g.y() && y <= g.y() + g.h()) { + LytFlowContent owner = si < spanOwners.size() ? spanOwners.get(si) : null; + if (owner != null) { + return FlowInteractionPath.fromPrimary(owner); + } + return null; + } + } + } + return null; + } + + private List collectSpanOwners() { + List owners = new ArrayList<>(); + for (LytFlowContent fc : getContent()) { + collectSpanOwnersRecursive(fc, owners); + } + return owners; + } + + private static void collectSpanOwnersRecursive(LytFlowContent fc, List out) { + if (fc instanceof LytFlowText || fc instanceof LytFlowInlineBlock) { + out.add(fc); + } else if (fc instanceof LytFlowSpan span) { + for (LytFlowContent child : span.getChildren()) { + collectSpanOwnersRecursive(child, out); + } + } } public @Nullable LytRect getFirstTextRunBounds() { - return content.getFirstTextRunBounds(); + if (glyphData != null && !glyphData.runs() + .isEmpty()) { + return firstLineBoundsFromGlyphs(); + } + return null; + } + + @Nullable + private LytRect firstLineBoundsFromGlyphs() { + float minX = Float.MAX_VALUE, minY = Float.MAX_VALUE, maxX = 0, maxY = 0; + boolean found = false; + for (GlyphRunGroup group : glyphData.runs()) { + for (var g : group.glyphs()) { + if (g.lineIndex() != 0) continue; + found = true; + minX = Math.min(minX, g.x()); + minY = Math.min(minY, g.y()); + maxX = Math.max(maxX, g.x() + g.w()); + maxY = Math.max(maxY, g.y() + g.h()); + } + } + if (!found) return null; + return new LytRect(Math.round(minX), Math.round(minY), Math.round(maxX - minX), Math.round(maxY - minY)); } @Override public Stream enumerateContentBounds(LytFlowContent content) { - return this.content.enumerateContentBounds(content); + return Stream.empty(); } @Override protected LytVisitor.Result visitChildren(LytVisitor visitor, boolean includeOutOfTreeContent) { - if (super.visitChildren(visitor, includeOutOfTreeContent) == LytVisitor.Result.STOP) { - return LytVisitor.Result.STOP; - } - + // A paragraph is walked through its flow content alone. The blocks getChildren() surfaces are the + // inner blocks of its inline block wrappers, which this walk already reaches through the wrapper, + // so delegating to the tree children as well would report every inline block twice. for (var flowContent : getContent()) { flowContent.visit(visitor); } @@ -155,15 +554,15 @@ protected LytVisitor.Result visitChildren(LytVisitor visitor, boolean includeOut } public Iterable getContent() { - return content.getContent(); + return flowContent; } public boolean isEmpty() { - return content.isEmpty(); + return flowContent.isEmpty(); } public void clearContent() { - content.clear(); + flowContent.clear(); invalidateLayout(); } @@ -228,20 +627,11 @@ public static LytParagraph error(String text) { @Override @Nullable public FlowContentEntry pickFlowContent(int x, int y) { - LineElement element = content.pick(x, y); - if (element != null) { - return new FlowContentEntry(element.getFlowContent(), element.bounds); - } return null; } @Override public List getAllFlowContent() { - List entries = new ArrayList<>(); - for (LytFlowContent flowContent : getContent()) { - content.enumerateContentBounds(flowContent) - .forEach(bounds -> entries.add(new FlowContentEntry(flowContent, bounds))); - } - return entries; + return List.of(); } } diff --git a/src/main/java/com/hfstudio/guidenh/guide/document/block/LytPlaceholderBlock.java b/src/main/java/com/hfstudio/guidenh/guide/document/block/LytPlaceholderBlock.java deleted file mode 100644 index 26d1a96b..00000000 --- a/src/main/java/com/hfstudio/guidenh/guide/document/block/LytPlaceholderBlock.java +++ /dev/null @@ -1,81 +0,0 @@ -package com.hfstudio.guidenh.guide.document.block; - -import java.util.ArrayList; -import java.util.List; -import java.util.concurrent.CompletableFuture; - -import com.hfstudio.guidenh.guide.document.DefaultStyles; -import com.hfstudio.guidenh.guide.document.LytRect; -import com.hfstudio.guidenh.guide.layout.LayoutContext; -import com.hfstudio.guidenh.guide.render.RenderContext; -import com.hfstudio.guidenh.guide.scene.support.GuideDebugLog; - -/** - * This layout block shows a loading indicator and will ultimately replace itself with the final content. - */ -public class LytPlaceholderBlock extends LytBlock { - - private final CompletableFuture future; - - private LytBlock currentBlock; - - private final List currentChildren = new ArrayList<>(1); - - public LytPlaceholderBlock(CompletableFuture future) { - var loading = new LytParagraph(); - loading.appendText("Loading..."); - setCurrent(loading); - - this.future = future; - future.whenCompleteAsync(this::onLoad, Runnable::run); - } - - private void setCurrent(LytBlock block) { - if (currentBlock != block) { - if (currentBlock != null) { - currentBlock.parent = null; - } - currentChildren.clear(); - currentBlock = block; - block.parent = this; - currentChildren.add(block); - invalidateLayout(); - } - } - - private void onLoad(LytBlock element, Throwable error) { - if (error != null || element == null) { - GuideDebugLog.error("[GuideNH] [LytPlaceholderBlock] Failed to load an asynchronous guide element.", error); - var errorParagraph = new LytParagraph(); - errorParagraph.setStyle(DefaultStyles.ERROR_TEXT); - if (error == null) { - errorParagraph.appendText("An unknown error occurred"); - } else { - errorParagraph.appendText(error.toString()); - } - setCurrent(errorParagraph); - } else { - setCurrent(element); - } - } - - @Override - protected LytRect computeLayout(LayoutContext context, int x, int y, int availableWidth) { - return currentBlock.layout(context, x, y, availableWidth); - } - - @Override - protected void onLayoutMoved(int deltaX, int deltaY) { - currentBlock.onLayoutMoved(deltaX, deltaY); - } - - @Override - public void render(RenderContext context) { - currentBlock.render(context); - } - - @Override - public List getChildren() { - return List.copyOf(currentChildren); - } -} diff --git a/src/main/java/com/hfstudio/guidenh/guide/document/block/LytQuoteBox.java b/src/main/java/com/hfstudio/guidenh/guide/document/block/LytQuoteBox.java index 7017433e..663cab09 100644 --- a/src/main/java/com/hfstudio/guidenh/guide/document/block/LytQuoteBox.java +++ b/src/main/java/com/hfstudio/guidenh/guide/document/block/LytQuoteBox.java @@ -10,6 +10,7 @@ import com.hfstudio.guidenh.guide.document.LytRect; import com.hfstudio.guidenh.guide.document.flow.LytFlowContent; import com.hfstudio.guidenh.guide.layout.LayoutContext; +import com.hfstudio.guidenh.guide.render.PrimitiveCollector; import com.hfstudio.guidenh.guide.render.RenderContext; import com.hfstudio.guidenh.guide.style.BorderStyle; @@ -85,6 +86,16 @@ public void append(LytBlock block) { content.append(block); } + /** + * The container holding this quote box's body paragraphs, nested below the + * optional title row. The blockquote compiler clears the body's edge + * paragraph margins against it so the title row's spacing is carried by the + * root's gap instead of the first body paragraph's own top margin. + */ + public LytVBox getBodyContainer() { + return content; + } + @Override public void removeChild(LytNode node) { content.removeChild(node); @@ -110,6 +121,17 @@ protected void onLayoutMoved(int deltaX, int deltaY) { root.moveLayoutPos(deltaX, deltaY); } + @Override + public boolean usePrimitives() { + return true; + } + + @Override + public void computePrimitives(PrimitiveCollector c) { + // No-op: the internal root (LytVBox) is a child returned by getChildren() + // and will be visited by PrimitiveCollector.collectFrom traversal. + } + @Override public void render(RenderContext context) { root.render(context); diff --git a/src/main/java/com/hfstudio/guidenh/guide/document/block/LytSizeBox.java b/src/main/java/com/hfstudio/guidenh/guide/document/block/LytSizeBox.java index 80ba52f1..3034d338 100644 --- a/src/main/java/com/hfstudio/guidenh/guide/document/block/LytSizeBox.java +++ b/src/main/java/com/hfstudio/guidenh/guide/document/block/LytSizeBox.java @@ -8,6 +8,8 @@ import com.hfstudio.guidenh.guide.internal.editor.gui.SceneEditorVerticalScrollbar; import com.hfstudio.guidenh.guide.internal.util.SmoothFloatState; import com.hfstudio.guidenh.guide.layout.LayoutContext; +import com.hfstudio.guidenh.guide.render.GuideRenderPrimitive; +import com.hfstudio.guidenh.guide.render.PrimitiveCollector; import com.hfstudio.guidenh.guide.render.RenderContext; import lombok.Getter; @@ -24,19 +26,42 @@ public class LytSizeBox extends LytVBox implements DocumentDragTarget { private int preferredWidth; @Getter private int preferredHeight; - private int contentHeight; - private int viewportX; - private int viewportY; - private int viewportWidth; - private int viewportHeight; + /** Scrollable viewport wrapping the content; clips children to its bounds. */ + private final LytViewportBox viewport = new LytViewportBox(); + /** Content container: receives all externally appended children. */ + private final LytVBox content = new LytVBox(); private int scrollOffsetY; private int appliedScrollOffsetY; + /** Visual-scroll delta currently baked into the content bounds (see computePrimitives). */ + private int visualDeltaY; private final SmoothFloatState visualScrollOffsetY = new SmoothFloatState(); private boolean draggingContent; private int dragLastDocumentY; private boolean draggingScrollbar; private int scrollbarGrabOffsetY; + public LytSizeBox() { + viewport.setFullWidth(true); + viewport.append(content); + super.append(viewport); + } + + /** External content is appended into the inner content box, inside the viewport. */ + @Override + public void append(LytBlock block) { + content.append(block); + } + + @Override + public void removeChild(LytNode node) { + content.removeChild(node); + } + + @Override + public void clearContent() { + content.clearContent(); + } + public void setPreferredWidth(int preferredWidth) { this.preferredWidth = Math.max(0, preferredWidth); } @@ -49,54 +74,106 @@ public void setPreferredHeight(int preferredHeight) { protected LytRect computeBoxLayout(LayoutContext context, int x, int y, int availableWidth) { int constrainedWidth = preferredWidth > 0 ? Math.min(availableWidth, preferredWidth) : availableWidth; int measuredWidth = Math.max(1, constrainedWidth); - viewportX = x; - viewportY = y; - viewportWidth = measuredWidth; - appliedScrollOffsetY = 0; - - LytRect contentBounds = super.computeBoxLayout(context, x, y, measuredWidth); - contentHeight = contentBounds.height(); - viewportHeight = preferredHeight > 0 ? preferredHeight : contentHeight; - - if (preferredHeight > 0 && contentHeight > viewportHeight) { - viewportWidth = Math.max(1, measuredWidth - SCROLLBAR_WIDTH - SCROLLBAR_GAP); - contentBounds = super.computeBoxLayout(context, x, y, viewportWidth); - contentHeight = contentBounds.height(); + int contentWidth = measuredWidth; + + LytRect contentBounds = content.layout(context, x, y, measuredWidth); + int contentH = contentBounds.height(); + int viewportH = preferredHeight > 0 ? preferredHeight : contentH; + if (preferredHeight > 0 && contentH > viewportH) { + contentWidth = Math.max(1, measuredWidth - SCROLLBAR_WIDTH - SCROLLBAR_GAP); + contentBounds = content.layout(context, x, y, contentWidth); + contentH = contentBounds.height(); + viewportH = preferredHeight; } - viewportHeight = preferredHeight > 0 ? preferredHeight : contentHeight; + viewport.setExplicitHeight(viewportH); + viewport.layout(context, x, y, contentWidth); setScrollOffset(scrollOffsetY); snapVisualScrollToTarget(); int totalWidth = preferredWidth > 0 ? measuredWidth : contentBounds.width() + (hasVerticalScroll() ? SCROLLBAR_WIDTH + SCROLLBAR_GAP : 0); - return new LytRect(x, y, totalWidth, viewportHeight); + return new LytRect(x, y, totalWidth, viewportH); } - @Override - public void render(RenderContext context) { - updateVisualScroll(); - if (!hasVerticalScroll()) { - super.render(context); - return; + // Derived geometry, computed from current bounds with no layout-time fields. + + private int getContentHeight() { + return content.getBounds() + .height(); + } + + private int getViewportHeight() { + return preferredHeight > 0 ? preferredHeight : getContentHeight(); + } + + private LytRect getViewportBounds() { + int x = bounds.x() + getBorderLeft().width() + paddingLeft; + int y = bounds.y() + getBorderTop().width() + paddingTop; + int w = bounds.right() - getBorderRight().width() - paddingRight - x; + if (hasVerticalScroll()) { + w = Math.max(1, w - SCROLLBAR_WIDTH - SCROLLBAR_GAP); } + return new LytRect(x, y, Math.max(0, w), Math.max(0, getViewportHeight())); + } + + private int getMaxScrollOffset() { + return Math.max(0, getContentHeight() - getViewportHeight()); + } + + @Override + public boolean usePrimitives() { + return true; + } - if (getBackgroundColor() != null) { - context.fillRect(bounds, getBackgroundColor()); + @Override + public void computePrimitives(PrimitiveCollector c) { + super.computePrimitives(c); + + // Advance the smooth scroll and bake the visual delta into the content + // bounds (collector traverses the content right after this). + updateVisualScroll(); + int newDelta = appliedScrollOffsetY - visualScrollOffsetY.rounded(); + if (newDelta != visualDeltaY && !content.getBounds() + .isEmpty()) { + content.moveLayoutPos(0, newDelta - visualDeltaY); + visualDeltaY = newDelta; } - LytRect viewportBounds = getViewportBounds(); - context.pushLocalScissor(viewportBounds); - try { - renderChildrenWithVisualOffset(context); - } finally { - context.popScissor(); + LytRect trackBounds = getScrollbarTrackBounds(); + if (!trackBounds.isEmpty()) { + c.emit( + new GuideRenderPrimitive.FillRect( + trackBounds.x(), + trackBounds.y(), + trackBounds.width(), + trackBounds.height(), + 0x30242B33)); + LytRect thumbBounds = getScrollbarThumbBounds(); + if (!thumbBounds.isEmpty()) { + c.emit( + new GuideRenderPrimitive.FillRect( + thumbBounds.x(), + thumbBounds.y(), + thumbBounds.width(), + thumbBounds.height(), + draggingScrollbar ? 0xFFCDD6E1 : 0xA0AAB5C2)); + } } + } - renderScrollbar(context); - renderBorder(context); + @Override + protected void afterExternalLayout() { + // The writeback reset the content to the unscrolled position; re-apply + // the current scroll offset and restart the visual-delta bookkeeping. + content.moveLayoutPos(0, appliedScrollOffsetY - scrollOffsetY); + appliedScrollOffsetY = scrollOffsetY; + visualDeltaY = 0; } + @Override + public void render(RenderContext context) {} + @Override public boolean beginDrag(int documentX, int documentY, int button) { if (!hasVerticalScroll() || button != 0) { @@ -206,9 +283,7 @@ private void setScrollOffset(int scrollOffsetY) { this.scrollOffsetY = SceneEditorVerticalScrollbar.clamp(scrollOffsetY, 0, getMaxScrollOffset()); int deltaY = appliedScrollOffsetY - this.scrollOffsetY; if (deltaY != 0) { - for (LytBlock child : children) { - child.moveLayoutPos(0, deltaY); - } + content.moveLayoutPos(0, deltaY); appliedScrollOffsetY = this.scrollOffsetY; } } @@ -226,27 +301,24 @@ private void updateScrollFromMouseY(int mouseY) { scrollbarGrabOffsetY, trackBounds.y(), trackBounds.height(), - contentHeight, - viewportHeight)); - } - - private int getMaxScrollOffset() { - return Math.max(0, contentHeight - viewportHeight); + getContentHeight(), + getViewportHeight())); } private boolean hasVerticalScroll() { return getMaxScrollOffset() > 0; } - private LytRect getViewportBounds() { - return new LytRect(viewportX, viewportY, viewportWidth, viewportHeight); - } - private LytRect getScrollbarTrackBounds() { if (!hasVerticalScroll()) { return LytRect.empty(); } - return new LytRect(viewportX + viewportWidth + SCROLLBAR_GAP, viewportY, SCROLLBAR_WIDTH, viewportHeight); + LytRect viewportBounds = getViewportBounds(); + return new LytRect( + viewportBounds.right() + SCROLLBAR_GAP, + viewportBounds.y(), + SCROLLBAR_WIDTH, + viewportBounds.height()); } private LytRect getScrollbarThumbBounds() { @@ -258,8 +330,8 @@ private LytRect getScrollbarThumbBounds() { SceneEditorVerticalScrollbar.Thumb thumb = SceneEditorVerticalScrollbar.computeThumb( trackBounds.y(), trackBounds.height(), - contentHeight, - viewportHeight, + getContentHeight(), + getViewportHeight(), visualScrollOffsetY.rounded()); return new LytRect(trackBounds.x(), thumb.start(), trackBounds.width(), thumb.size()); } @@ -269,28 +341,6 @@ private void snapVisualScrollToTarget() { } private void updateVisualScroll() { - visualScrollOffsetY.updateTowards(scrollOffsetY, 28f, 0.25f, 0.01f, Math.max(128f, viewportHeight * 2f)); - } - - private void renderChildrenWithVisualOffset(RenderContext context) { - int renderDeltaY = appliedScrollOffsetY - visualScrollOffsetY.rounded(); - if (renderDeltaY != 0) { - moveChildrenLayoutY(renderDeltaY); - } - try { - for (LytBlock child : children) { - child.render(context); - } - } finally { - if (renderDeltaY != 0) { - moveChildrenLayoutY(-renderDeltaY); - } - } - } - - private void moveChildrenLayoutY(int deltaY) { - for (LytBlock child : children) { - child.moveLayoutPos(0, deltaY); - } + visualScrollOffsetY.updateTowards(scrollOffsetY, 28f, 0.25f, 0.01f, Math.max(128f, getViewportHeight() * 2f)); } } diff --git a/src/main/java/com/hfstudio/guidenh/guide/document/block/LytSlot.java b/src/main/java/com/hfstudio/guidenh/guide/document/block/LytSlot.java index f20ee811..601bd7e2 100644 --- a/src/main/java/com/hfstudio/guidenh/guide/document/block/LytSlot.java +++ b/src/main/java/com/hfstudio/guidenh/guide/document/block/LytSlot.java @@ -13,6 +13,8 @@ import com.hfstudio.guidenh.guide.document.interaction.ItemTooltip; import com.hfstudio.guidenh.guide.internal.item.GuideDisplayItemStacks; import com.hfstudio.guidenh.guide.layout.LayoutContext; +import com.hfstudio.guidenh.guide.render.GuideRenderPrimitive; +import com.hfstudio.guidenh.guide.render.PrimitiveCollector; import com.hfstudio.guidenh.guide.render.RenderContext; import lombok.Getter; @@ -64,6 +66,33 @@ protected LytRect computeLayout(LayoutContext context, int x, int y, int availab @Override protected void onLayoutMoved(int deltaX, int deltaY) {} + @Override + public boolean usePrimitives() { + return true; + } + + @Override + public void computePrimitives(PrimitiveCollector c) { + var x = bounds.x(); + var y = bounds.y(); + int w = bounds.width(); + int h = bounds.height(); + + if (renderSlotBackground) { + c.emit(new GuideRenderPrimitive.FillRect(x, y, w, 1, SLOT_BORDER_DARK)); + c.emit(new GuideRenderPrimitive.FillRect(x, y, 1, h, SLOT_BORDER_DARK)); + c.emit(new GuideRenderPrimitive.FillRect(x, y + h - 1, w, 1, SLOT_BORDER_LIGHT)); + c.emit(new GuideRenderPrimitive.FillRect(x + w - 1, y, 1, h, SLOT_BORDER_LIGHT)); + c.emit(new GuideRenderPrimitive.FillRect(x + 1, y + 1, w - 2, h - 2, SLOT_INNER_BG)); + } + + var padding = largeSlot ? LARGE_PADDING : PADDING; + var stack = getDisplayedStack(); + if (stack != null) { + c.emit(new GuideRenderPrimitive.RenderItem(stack, x + padding, y + padding)); + } + } + @Override public void render(RenderContext context) { var x = bounds.x(); diff --git a/src/main/java/com/hfstudio/guidenh/guide/document/block/LytStructureView.java b/src/main/java/com/hfstudio/guidenh/guide/document/block/LytStructureView.java index 216cae86..015c2125 100644 --- a/src/main/java/com/hfstudio/guidenh/guide/document/block/LytStructureView.java +++ b/src/main/java/com/hfstudio/guidenh/guide/document/block/LytStructureView.java @@ -9,6 +9,8 @@ import com.hfstudio.guidenh.guide.color.ColorUtils; import com.hfstudio.guidenh.guide.document.LytRect; import com.hfstudio.guidenh.guide.layout.LayoutContext; +import com.hfstudio.guidenh.guide.render.GuideRenderPrimitive; +import com.hfstudio.guidenh.guide.render.PrimitiveCollector; import com.hfstudio.guidenh.guide.render.RenderContext; import lombok.Getter; @@ -45,6 +47,14 @@ public BlockEntry(int x, int y, int z, ItemStack stack) { // Invalidated whenever addBlock mutates the underlying list. private List sortedCache; + public int getViewWidth() { + return viewWidth; + } + + public int getViewHeight() { + return viewHeight; + } + public void setViewSize(int width, int height) { this.viewWidth = Math.max(32, width); this.viewHeight = Math.max(32, height); @@ -69,10 +79,31 @@ protected LytRect computeLayout(LayoutContext context, int x, int y, int availab protected void onLayoutMoved(int deltaX, int deltaY) {} @Override - public void render(RenderContext context) { + public boolean usePrimitives() { + return true; + } + + @Override + public void computePrimitives(PrimitiveCollector c) { var bounds = getBounds(); - context.fillRect(bounds, ColorUtils.ARGB_FF1E1E1E.getColor()); - context.drawBorder(bounds, ColorUtils.ARGB_FF555555.getColor(), 1); + c.emit( + new GuideRenderPrimitive.FillRect( + bounds.x(), + bounds.y(), + bounds.width(), + bounds.height(), + ColorUtils.ARGB_FF1E1E1E.getColor())); + c.emit( + new GuideRenderPrimitive.DrawBorder( + bounds.x(), + bounds.y(), + bounds.width(), + bounds.height(), + 1, + 1, + 1, + 1, + ColorUtils.ARGB_FF555555.getColor())); if (blocks.isEmpty()) { return; @@ -105,18 +136,18 @@ public void render(RenderContext context) { sortedCache = sorted; } - context.pushLocalScissor(bounds); - try { - for (BlockEntry b : sorted) { - int px = projectX(b.x, b.z) + offsetX; - int py = projectY(b.x, b.y, b.z) + offsetY; - context.renderItem(b.stack, px, py); - } - } finally { - context.popScissor(); + c.pushScissor(bounds.x(), bounds.y(), bounds.width(), bounds.height()); + for (BlockEntry b : sorted) { + int px = projectX(b.x, b.z) + offsetX; + int py = projectY(b.x, b.y, b.z) + offsetY; + c.emit(new GuideRenderPrimitive.RenderItem(b.stack, px, py)); } + c.popScissor(); } + @Override + public void render(RenderContext context) {} + public static int projectX(int x, int z) { return (x - z) * TILE_W; } diff --git a/src/main/java/com/hfstudio/guidenh/guide/document/block/LytTaskListItem.java b/src/main/java/com/hfstudio/guidenh/guide/document/block/LytTaskListItem.java index 27aac945..c2222ab8 100644 --- a/src/main/java/com/hfstudio/guidenh/guide/document/block/LytTaskListItem.java +++ b/src/main/java/com/hfstudio/guidenh/guide/document/block/LytTaskListItem.java @@ -3,6 +3,8 @@ import com.hfstudio.guidenh.guide.color.ColorUtils; import com.hfstudio.guidenh.guide.document.LytRect; import com.hfstudio.guidenh.guide.layout.LayoutContext; +import com.hfstudio.guidenh.guide.render.GuideRenderPrimitive; +import com.hfstudio.guidenh.guide.render.PrimitiveCollector; import com.hfstudio.guidenh.guide.render.RenderContext; import lombok.Getter; @@ -14,6 +16,12 @@ public class LytTaskListItem extends LytListItem { private boolean checked; + public LytTaskListItem() { + // Extra 4px beyond LytListItem's LEVEL_MARGIN, matching the legacy + // computeBoxLayout's x+4 offset for task item content. + setPaddingLeft(LEVEL_MARGIN + 4); + } + @Override protected LytRect computeBoxLayout(LayoutContext context, int x, int y, int availableWidth) { int margin = LEVEL_MARGIN + 4; @@ -21,12 +29,41 @@ protected LytRect computeBoxLayout(LayoutContext context, int x, int y, int avai return bounds.expand(4, 0, 0, 0); } + @Override + protected boolean hasOwnMarker() { + // The checkbox replaces the shared bullet or ordered number; the + // superclass must not also paint a marker in the gutter. + return true; + } + + @Override + public void computePrimitives(PrimitiveCollector c) { + super.computePrimitives(c); + LytRect bounds = getBounds(); + int boxSize = 7; + int boxX = bounds.x() + 1; + // Vertically center the checkbox on the marker's first-line text run + // (bounds written back by Rust): the checkbox's vertical center lands + // on the first line's vertical center, matching the bullet alignment. + LytRect markerLine = getMarkerLineBounds(); + int boxY = markerLine.y() + (markerLine.height() - boxSize) / 2; + int argb = ColorUtils.BODY_TEXT.resolve(); + c.emit(new GuideRenderPrimitive.DrawBorder(boxX, boxY, boxSize, boxSize, 1, 1, 1, 1, argb)); + if (checked) { + int fillArgb = ColorUtils.LINK.resolve(); + c.emit(new GuideRenderPrimitive.FillRect(boxX + 2, boxY + 2, 3, 3, fillArgb)); + } + } + @Override public void render(RenderContext context) { LytRect bounds = getBounds(); int boxSize = 7; int boxX = bounds.x() + 1; - int boxY = bounds.y() + 1; + // Same marker-line anchor as computePrimitives: center the checkbox on + // the first-line text run bounds (Rust layout authority). + LytRect markerLine = getMarkerLineBounds(context); + int boxY = markerLine.y() + (markerLine.height() - boxSize) / 2; context.drawBorder(new LytRect(boxX, boxY, boxSize, boxSize), context.resolveColor(ColorUtils.BODY_TEXT), 1); if (checked) { context.fillRect(boxX + 2, boxY + 2, 3, 3, ColorUtils.LINK); diff --git a/src/main/java/com/hfstudio/guidenh/guide/document/block/LytThematicBreak.java b/src/main/java/com/hfstudio/guidenh/guide/document/block/LytThematicBreak.java index d9be5dcf..705049fa 100644 --- a/src/main/java/com/hfstudio/guidenh/guide/document/block/LytThematicBreak.java +++ b/src/main/java/com/hfstudio/guidenh/guide/document/block/LytThematicBreak.java @@ -3,6 +3,8 @@ import com.hfstudio.guidenh.guide.color.ColorUtils; import com.hfstudio.guidenh.guide.document.LytRect; import com.hfstudio.guidenh.guide.layout.LayoutContext; +import com.hfstudio.guidenh.guide.render.GuideRenderPrimitive; +import com.hfstudio.guidenh.guide.render.PrimitiveCollector; import com.hfstudio.guidenh.guide.render.RenderContext; public class LytThematicBreak extends LytBlock { @@ -15,6 +17,19 @@ public LytRect computeLayout(LayoutContext context, int x, int y, int availableW @Override protected void onLayoutMoved(int deltaX, int deltaY) {} + @Override + public boolean usePrimitives() { + return true; + } + + @Override + public void computePrimitives(PrimitiveCollector c) { + var line = bounds.withHeight(2) + .centerVerticallyIn(bounds); + int argb = ColorUtils.THEMATIC_BREAK.resolve(); + c.emit(new GuideRenderPrimitive.FillRect(line.x(), line.y(), line.width(), line.height(), argb)); + } + @Override public void render(RenderContext context) { var line = bounds.withHeight(2) diff --git a/src/main/java/com/hfstudio/guidenh/guide/document/block/LytVBox.java b/src/main/java/com/hfstudio/guidenh/guide/document/block/LytVBox.java index aefa1c4d..af965146 100644 --- a/src/main/java/com/hfstudio/guidenh/guide/document/block/LytVBox.java +++ b/src/main/java/com/hfstudio/guidenh/guide/document/block/LytVBox.java @@ -2,28 +2,24 @@ import com.hfstudio.guidenh.guide.document.LytRect; import com.hfstudio.guidenh.guide.layout.LayoutContext; -import com.hfstudio.guidenh.guide.layout.Layouts; /** * Lays out its children vertically. + *

          + * The Java layout pre-pass has been removed; children are laid out by the + * Rust layout engine. This method is retained for compatibility (LytBox's + * final computeLayout calls it) but returns a minimal rect since real + * bounds are applied via {@link #applyExternalLayout}. */ public class LytVBox extends LytAxisBox { @Override protected LytRect computeBoxLayout(LayoutContext context, int x, int y, int availableWidth) { - // Padding is applied through the parent - return Layouts.verticalLayout( - context, - children, - x, - y, - availableWidth, - isFullWidth(), - 0, - 0, - 0, - 0, - getGap(), - getAlignItems()); + // NOTE: The document pipeline no longer calls this method; children + // are laid out by the Rust layout engine which is the authoritative + // source for bounds. This return value is only a fallback for the + // legacy layout() call chain (LytBox.computeLayout(final) expands the + // LytRect(x,y,0,0) with padding/border, producing a non-zero rect). + return new LytRect(x, y, 0, 0); } } diff --git a/src/main/java/com/hfstudio/guidenh/guide/document/block/LytViewportBox.java b/src/main/java/com/hfstudio/guidenh/guide/document/block/LytViewportBox.java new file mode 100644 index 00000000..31fd5541 --- /dev/null +++ b/src/main/java/com/hfstudio/guidenh/guide/document/block/LytViewportBox.java @@ -0,0 +1,39 @@ +package com.hfstudio.guidenh.guide.document.block; + +import org.jetbrains.annotations.Nullable; + +import com.hfstudio.guidenh.guide.document.LytRect; + +/** + * A scroll viewport: a plain vertical container that clips its children to its + * own bounds. Used by scroll containers (code blocks, size boxes, details + * blocks) as the single scrollable region; the framework's children-clip + * semantics then do the clipping, and the owner moves the content child's + * bounds to scroll. + *

          + * When the owner forces a viewport height, it is declared through + * {@link #setExplicitHeight(int)} so the layout engine reserves exactly that + * height; otherwise the viewport grows with its content (no scrolling). + */ +public class LytViewportBox extends LytVBox { + + private int explicitHeight = -1; + + public void setExplicitHeight(int explicitHeight) { + this.explicitHeight = explicitHeight; + } + + @Override + public int getExplicitHeight() { + return explicitHeight; + } + + @Override + public @Nullable LytRect getChildrenClipRect() { + // Clip children to the viewport: the content may be taller and scrolls + // beneath. When not scrollable the bounds equal the content bounds, so + // the clip is a no-op. + var b = getBounds(); + return b == null || b.isEmpty() ? null : b; + } +} diff --git a/src/main/java/com/hfstudio/guidenh/guide/document/block/LytWidthBox.java b/src/main/java/com/hfstudio/guidenh/guide/document/block/LytWidthBox.java index da044a9c..bc717f80 100644 --- a/src/main/java/com/hfstudio/guidenh/guide/document/block/LytWidthBox.java +++ b/src/main/java/com/hfstudio/guidenh/guide/document/block/LytWidthBox.java @@ -12,6 +12,13 @@ public class LytWidthBox extends LytVBox { public void setPreferredWidth(int preferredWidth) { this.preferredWidth = Math.max(0, preferredWidth); + // preferredWidth <= 0 means "no fixed width constraint": signal full + // width through the fullWidth mechanism (same pattern as + // LytDetailsBlock/LytCodeBlock) so LayoutStyleExtractor serializes + // size_w=100% and the authoritative Rust layout stretches this box to + // the available content width. (Rust has no knowledge of preferredWidth + // itself; serializing 0 as auto would shrink-wrap to content width.) + setFullWidth(this.preferredWidth <= 0); } @Override diff --git a/src/main/java/com/hfstudio/guidenh/guide/document/block/MermaidNodeRenderer.java b/src/main/java/com/hfstudio/guidenh/guide/document/block/MermaidNodeRenderer.java index 487b26ad..e4dd359f 100644 --- a/src/main/java/com/hfstudio/guidenh/guide/document/block/MermaidNodeRenderer.java +++ b/src/main/java/com/hfstudio/guidenh/guide/document/block/MermaidNodeRenderer.java @@ -1,16 +1,12 @@ package com.hfstudio.guidenh.guide.document.block; -import java.util.ArrayList; import java.util.List; import java.util.Map; import com.hfstudio.guidenh.guide.color.ColorUtils; -import com.hfstudio.guidenh.guide.document.LytRect; import com.hfstudio.guidenh.guide.internal.mermaid.MermaidNodeShape; -import com.hfstudio.guidenh.guide.internal.util.GuideStringLines; -import com.hfstudio.guidenh.guide.layout.FontMetrics; import com.hfstudio.guidenh.guide.layout.LayoutContext; -import com.hfstudio.guidenh.guide.render.RenderContext; +import com.hfstudio.guidenh.guide.render.GuideText; import com.hfstudio.guidenh.guide.style.ResolvedTextStyle; public final class MermaidNodeRenderer { @@ -73,10 +69,6 @@ public static NodeColors resolveNodeColors(List classes, MermaidNodeShap return new NodeColors(background, accent, accent); } - public static void renderAccentBar(RenderContext context, LytRect boxRect, int accentColor) { - context.fillRect(new LytRect(boxRect.x(), boxRect.y(), 3, boxRect.height()), accentColor); - } - public static ResolvedTextStyle scaleTextStyle(ResolvedTextStyle base, float zoom) { return new ResolvedTextStyle( base.fontScale() * zoom, @@ -93,7 +85,8 @@ public static ResolvedTextStyle scaleTextStyle(ResolvedTextStyle base, float zoo base.alignment(), base.dropShadow(), base.backgroundColor(), - base.inlineCode()); + base.inlineCode(), + base.baselineShift()); } public static ResolvedTextStyle getOrScaleStyle(Map cache, @@ -135,10 +128,6 @@ public static int measureText(LayoutContext context, ResolvedTextStyle style, St return measureTextInternal(style, text, context::getAdvance); } - public static int measureText(RenderContext context, ResolvedTextStyle style, String text) { - return context.getStringWidth(text, style); - } - public static int measureTextInternal(ResolvedTextStyle style, String text, AdvanceFunction advance) { if (text == null || text.isEmpty()) { return 0; @@ -152,105 +141,12 @@ public static int measureTextInternal(ResolvedTextStyle style, String text, Adva return Math.round(width); } - public static List wrapText(RenderContext context, ResolvedTextStyle style, String text, int maxWidth) { - return wrapText(new LayoutContext(new FontMetrics() { - - @Override - public float getAdvance(int codePoint, ResolvedTextStyle s) { - return context.getStringWidth(new String(Character.toChars(codePoint)), s); - } - - @Override - public int getLineHeight(ResolvedTextStyle s) { - return context.getLineHeight(s); - } - }), style, text, maxWidth); - } - public static List wrapText(LayoutContext context, ResolvedTextStyle style, String text, int maxWidth) { - List result = new ArrayList<>(); - GuideStringLines.visitLines(text != null ? text : "", (paragraph, lineIndex) -> { - if (paragraph.isEmpty()) { - result.add(""); - return true; - } - - StringBuilder line = new StringBuilder(); - scanWords(paragraph, word -> appendWrappedWord(result, line, context, style, word, maxWidth)); - if (!line.isEmpty()) { - result.add(line.toString()); - } - return true; - }); - return result; - } - - private static boolean appendWrappedWord(List result, StringBuilder line, LayoutContext context, - ResolvedTextStyle style, String word, int maxWidth) { - if (line.isEmpty()) { - if (measureText(context, style, word) <= maxWidth) { - line.append(word); - } else { - appendBrokenWord(result, line, context, style, word, maxWidth); - } - return true; - } - - String candidate = line + " " + word; - if (measureText(context, style, candidate) <= maxWidth) { - line.append(' ') - .append(word); - return true; - } - - result.add(line.toString()); - line.setLength(0); - if (measureText(context, style, word) <= maxWidth) { - line.append(word); - } else { - appendBrokenWord(result, line, context, style, word, maxWidth); - } - return true; - } - - public static void scanWords(String text, WordVisitor visitor) { - int start = -1; - for (int index = 0, length = text.length(); index <= length; index++) { - char value = index < length ? text.charAt(index) : ' '; - if (Character.isWhitespace(value)) { - if (start >= 0) { - if (!visitor.accept(text.substring(start, index))) { - return; - } - start = -1; - } - } else if (start < 0) { - start = index; - } - } - } - - private static void appendBrokenWord(List result, StringBuilder line, LayoutContext context, - ResolvedTextStyle style, String word, int maxWidth) { - StringBuilder fragment = new StringBuilder(); - for (int offset = 0; offset < word.length();) { - int codePoint = word.codePointAt(offset); - String next = fragment + new String(Character.toChars(codePoint)); - if (!fragment.isEmpty() && measureText(context, style, next) > maxWidth) { - result.add(fragment.toString()); - fragment.setLength(0); - } - fragment.appendCodePoint(codePoint); - offset += Character.charCount(codePoint); - } - if (!fragment.isEmpty()) { - line.append(fragment); - } - } - - @FunctionalInterface - public interface WordVisitor { - - boolean accept(String word); + // Unified text pipeline: word-first wrapping + codepoint-level + // breaking of overlong words, measured by GuideText (Rust font system). + // LayoutContext is no longer used for measurement; GuideText.wrap + // measures with its own GuideText adapters, which are the same source + // as the former LayoutContext-based measurement (measurement-neutral). + return GuideText.wrap(text, maxWidth, style); } } diff --git a/src/main/java/com/hfstudio/guidenh/guide/document/block/RowBaselineAligner.java b/src/main/java/com/hfstudio/guidenh/guide/document/block/RowBaselineAligner.java new file mode 100644 index 00000000..c4ff9bb5 --- /dev/null +++ b/src/main/java/com/hfstudio/guidenh/guide/document/block/RowBaselineAligner.java @@ -0,0 +1,227 @@ +package com.hfstudio.guidenh.guide.document.block; + +import java.util.ArrayList; +import java.util.List; + +import org.jetbrains.annotations.Nullable; + +import com.hfstudio.guidenh.guide.document.LytRect; +import com.hfstudio.guidenh.guide.render.GlyphRunHolder; +import com.hfstudio.guidenh.guide.scene.support.GuideDebugLog; + +/** + * Post-layout baseline alignment for BASELINE-aligned row containers. + * + *

          + * taffy 0.12.1 reports no leaf baselines ({@code first_baselines} is always + * NONE), so {@code AlignItems::BASELINE} degrades to bottom-edge alignment. + * This pass runs on the Java side AFTER the taffy writeback and glyph-run + * injection in {@link LytDocument#createLayout}: for every row container whose + * {@code alignItems == BASELINE}, each direct child's + * first-line text baseline is aggregated from its per-glyph + * baselines ({@link com.hfstudio.guidenh.guide.render.GuideRenderPrimitive.PlacedGlyph#baseline()}, + * {@code lineIndex() == 0}) and the children are translated vertically so all + * baselines land on one line. + * + *

          + * Anchor rule (CSS flexbox baseline semantics): the flex line's + * baseline sits at {@code rowTop + max(ascent_i + marginTop_i)} where + * {@code ascent_i = baseline_i - bounds.y_i} is the distance from the child's + * content-box top to its text baseline. The child whose + * {@code ascent + marginTop} is largest keeps its cross-start (margin-top) + * edge on the line's cross-start and every other child is shifted DOWN to meet + * the shared baseline (never up relative to its own margin edge, so nothing + * overflows the line's top). Children without any text (images and scenes, + * CSS replaced elements) use their bottom edge as the baseline, which keeps + * image-only BASELINE rows exactly where taffy already put them. + * + *

          + * Movement mechanism: translation goes through + * {@link LytBlock#moveLayoutPos}, the same path used by scroll replay and + * smooth scrolling, so {@code onLayoutMoved} shifts glyph quads AND their + * baselines together (baseline - y stays invariant; see + * LytParagraph#onLayoutMoved). + * + *

          + * Zero-behaviour contract: the pass is a pure walk that only mutates + * when a {@code BASELINE} row container is found. Non-BASELINE containers + * (and documents without any BASELINE row) are byte-identical to the taffy + * writeback. The second-pass geometry intentionally deviates from the raw + * taffy output for BASELINE containers only; that deviation is recorded in the + * fixture assertions through the documented bounds exemption protocol. + * + *

          + * Known limitation (in scope for this wave): the per-glyph baseline + * is the line's baseline, and the Rust engine emits baseline for every line of + * every glyph, but the aggregation here uses {@code lineIndex() == 0}, the + * child's first line. Multi-line child blocks whose later-line + * baselines matter (true multi-line baseline alignment) are out of scope; row + * container children are single-line in practice. + */ +public final class RowBaselineAligner { + + private RowBaselineAligner() {} + + /** + * Align the children of every {@code alignItems=BASELINE} row container in + * the subtree. Document-order walk: an outer BASELINE row aligns (and thus + * translates) a nested BASELINE row before the nested one aligns its own + * children relative to its already-final bounds. + * + * @param root the document root (or any subtree root) + */ + public static void align(LytNode root) { + for (var child : root.getChildren()) { + if (child instanceof LytBlock block) { + alignBlock(block); + } + } + } + + private static void alignBlock(LytBlock block) { + if (block instanceof LytHBox hbox && hbox.getAlignItems() == AlignItems.BASELINE) { + alignRow(hbox); + } + for (var child : block.getChildren()) { + if (child instanceof LytBlock childBlock) { + alignBlock(childBlock); + } + } + } + + private static void alignRow(LytHBox row) { + LytRect rowBounds = row.getBounds(); + if (rowBounds == null || rowBounds.height() <= 0) { + return; + } + List children = new ArrayList<>(); + for (var child : row.getChildren()) { + if (child instanceof LytBlock block) { + children.add(block); + } + } + if (children.isEmpty()) { + return; + } + // Wrapped flex rows put each flex line's first item back at the row's + // cross-start (x resets), so non-monotonic x identifies a multi-line + // row. Baseline alignment is a per-LINE operation; this wave only + // handles single-line rows (row children are single-line in practice), + // so a wrapped row is skipped untouched. + for (int i = 1; i < children.size(); i++) { + LytRect prev = children.get(i - 1) + .getBounds(); + LytRect cur = children.get(i) + .getBounds(); + if (prev == null || cur == null) { + continue; + } + if (cur.x() <= prev.x()) { + GuideDebugLog.warnAlways( + "Layout: baseline row at ({}) wrapped to multiple lines - skipped (single-line scope only)", + rowBounds.x() + " " + rowBounds.y()); + return; + } + } + + // Baseline per child: first-line text baseline when the subtree shapes + // text, otherwise the box bottom edge (CSS replaced-element baseline). + // ascentFromCrossStart = baseline - (contentTop - marginTop): the + // distance from the child's margin-box top to its baseline; this is + // what the flex line's baseline position maximizes (CSS flexbox). + float[] baselines = new float[children.size()]; + float[] ascents = new float[children.size()]; + float maxAscent = 0f; + for (int i = 0; i < children.size(); i++) { + LytBlock child = children.get(i); + LytRect cb = child.getBounds(); + if (cb == null || cb.height() <= 0) { + // Degenerate (empty / zero-height) child: no measurable + // baseline, leave it untouched and out of the anchor race. + ascents[i] = Float.NEGATIVE_INFINITY; + baselines[i] = cb != null ? cb.y() : rowBounds.y(); + continue; + } + Float textBaseline = firstLineBaseline(child); + float base = textBaseline != null ? textBaseline : cb.bottom(); + baselines[i] = base; + ascents[i] = (base - cb.y()) + child.getMarginTop(); + if (ascents[i] > maxAscent) { + maxAscent = ascents[i]; + } + } + + if (!(maxAscent > 0f)) { + return; // nothing has a measurable baseline, leave the row untouched + } + float target = rowBounds.y() + maxAscent; + + StringBuilder log = null; + for (int i = 0; i < children.size(); i++) { + if (!(ascents[i] > Float.NEGATIVE_INFINITY)) { + continue; // degenerate child skipped + } + float delta = target - baselines[i]; + int deltaY = Math.round(delta); + if (deltaY != 0) { + children.get(i) + .moveLayoutPos(0, deltaY); + } + if (log == null) { + log = new StringBuilder(); + } + log.append( + children.get(i) + .getClass() + .getSimpleName()) + .append(" baseline=") + .append(Math.round(baselines[i] * 100) / 100.0) + .append(" ascent+margin=") + .append(Math.round(ascents[i] * 100) / 100.0) + .append(" delta=") + .append(deltaY) + .append(' '); + } + // Evidence trail for the visual bounds ratchet: only BASELINE rows reach + // this line, so the log volume is zero for the default corpus. + GuideDebugLog.warnAlways( + "Layout: baseline row ({} {}) targetBaseline={} maxAscent={} -> {}", + rowBounds.x(), + rowBounds.y(), + Math.round(target * 100) / 100.0, + Math.round(maxAscent * 100) / 100.0, + log != null ? log.toString() : "(no translation)"); + } + + /** + * First-line text baseline of {@code block}'s subtree, in document + * coordinates. Walks holder glyphs first (lineIndex == 0), then recurses + * into children in document order. Returns {@code null} when the subtree + * contains no shaped text. + */ + @Nullable + private static Float firstLineBaseline(LytBlock block) { + if (block instanceof GlyphRunHolder holder) { + var data = holder.getGlyphData(); + if (data != null) { + for (var group : data.runs()) { + for (var glyph : group.glyphs()) { + if (glyph.lineIndex() == 0) { + // All glyphs of one line share the same baseline. + return glyph.baseline(); + } + } + } + } + } + for (var child : block.getChildren()) { + if (child instanceof LytBlock childBlock) { + Float baseline = firstLineBaseline(childBlock); + if (baseline != null) { + return baseline; + } + } + } + return null; + } +} diff --git a/src/main/java/com/hfstudio/guidenh/guide/document/block/chart/CartesianChartRenderer.java b/src/main/java/com/hfstudio/guidenh/guide/document/block/chart/CartesianChartRenderer.java index 9d26b13f..77411702 100644 --- a/src/main/java/com/hfstudio/guidenh/guide/document/block/chart/CartesianChartRenderer.java +++ b/src/main/java/com/hfstudio/guidenh/guide/document/block/chart/CartesianChartRenderer.java @@ -2,7 +2,9 @@ import com.hfstudio.guidenh.guide.color.ColorUtils; import com.hfstudio.guidenh.guide.document.LytRect; -import com.hfstudio.guidenh.guide.render.RenderContext; +import com.hfstudio.guidenh.guide.render.GuideRenderPrimitive; +import com.hfstudio.guidenh.guide.render.GuideText; +import com.hfstudio.guidenh.guide.render.PrimitiveCollector; import com.hfstudio.guidenh.guide.style.ResolvedTextStyle; /** @@ -13,10 +15,10 @@ public class CartesianChartRenderer { protected CartesianChartRenderer() {} /** Compute insets reserved for axis labels; returns [left, top, right, bottom] (pixels). */ - public static int[] computeAxisInsets(RenderContext context, ChartAxisOptions xAxis, ChartAxisOptions yAxis, - AxisRange xRange, AxisRange yRange, String[] xCategories, boolean showXTicks, boolean showYTicks) { + public static int[] computeAxisInsets(ChartAxisOptions xAxis, ChartAxisOptions yAxis, AxisRange xRange, + AxisRange yRange, String[] xCategories, boolean showXTicks, boolean showYTicks) { ResolvedTextStyle style = LytChartBase.textStyle(ColorUtils.ARGB_FFCCCCCC.getColor()); - int lineH = context.getLineHeight(style); + int lineH = GuideText.lineHeight(style); int left = 4; int top = 4; int right = 4; @@ -25,7 +27,7 @@ public static int[] computeAxisInsets(RenderContext context, ChartAxisOptions xA int maxLabel = 0; for (double t = yRange.min; t <= yRange.max + 1e-9; t += yRange.step) { String s = yAxis.formatTick(t); - int w = context.getStringWidth(s, style); + int w = GuideText.measureWidth(s, style); if (w > maxLabel) { maxLabel = w; } @@ -46,15 +48,15 @@ public static int[] computeAxisInsets(RenderContext context, ChartAxisOptions xA // Reserve a small right margin so the last X tick label does not overflow the plot. if (xRange != null) { String last = xAxis.formatTick(xRange.max); - right = Math.max(right, context.getStringWidth(last, style) / 2 + 2); + right = Math.max(right, GuideText.measureWidth(last, style) / 2 + 2); } else if (xCategories != null && xCategories.length > 0) { String last = xCategories[xCategories.length - 1]; if (last != null) { - right = Math.max(right, context.getStringWidth(last, style) / 2 + 2); + right = Math.max(right, GuideText.measureWidth(last, style) / 2 + 2); } String first = xCategories[0]; if (first != null) { - left = Math.max(left, context.getStringWidth(first, style) / 2 + 2); + left = Math.max(left, GuideText.measureWidth(first, style) / 2 + 2); } } } @@ -74,7 +76,7 @@ public static float mapY(double value, AxisRange range, LytRect plotRect) { } /** Render axes + grid lines + tick text inside plotRect. */ - public static void drawAxes(RenderContext context, LytRect plotRect, ChartAxisOptions xAxis, ChartAxisOptions yAxis, + public static void drawAxes(PrimitiveCollector c, LytRect plotRect, ChartAxisOptions xAxis, ChartAxisOptions yAxis, AxisRange xRange, AxisRange yRange, String[] xCategories, boolean numericX) { ResolvedTextStyle xLabelStyle = LytChartBase.textStyle(xAxis.getLabelColor()); ResolvedTextStyle yLabelStyle = LytChartBase.textStyle(yAxis.getLabelColor()); @@ -84,12 +86,19 @@ public static void drawAxes(RenderContext context, LytRect plotRect, ChartAxisOp for (double t = yRange.min; t <= yRange.max + 1e-9; t += yRange.step) { float y = mapY(t, yRange, plotRect); if (yAxis.isGridVisible()) { - context.drawLine(plotRect.x(), y, plotRect.right(), y, 1f, yAxis.getGridColor()); + c.emit( + new GuideRenderPrimitive.DrawLine( + plotRect.x(), + y, + plotRect.right(), + y, + 1f, + yAxis.getGridColor())); } String s = yAxis.formatTick(t); - int sw = context.getStringWidth(s, yLabelStyle); - int lh = context.getLineHeight(yLabelStyle); - context.drawText(s, plotRect.x() - sw - 4, (int) y - lh / 2, yLabelStyle); + int sw = GuideText.measureWidth(s, yLabelStyle); + int lh = GuideText.lineHeight(yLabelStyle); + GuideText.emitText(c, s, plotRect.x() - sw - 4, (int) y - lh / 2, yLabelStyle); } } @@ -99,48 +108,68 @@ public static void drawAxes(RenderContext context, LytRect plotRect, ChartAxisOp for (double t = xRange.min; t <= xRange.max + 1e-9; t += xRange.step) { float x = mapX(t, xRange, plotRect); if (xAxis.isGridVisible()) { - context.drawLine(x, plotRect.y(), x, plotRect.bottom(), 1f, xAxis.getGridColor()); + c.emit( + new GuideRenderPrimitive.DrawLine( + x, + plotRect.y(), + x, + plotRect.bottom(), + 1f, + xAxis.getGridColor())); } String s = xAxis.formatTick(t); - int sw = context.getStringWidth(s, xLabelStyle); + int sw = GuideText.measureWidth(s, xLabelStyle); int tx = (int) x - sw / 2; // Allow at most half the label to extend past the plot edge so neighbouring text // does not collide with the chart border. tx = Math.max(plotRect.x() - sw / 2, Math.min(plotRect.right() - sw / 2, tx)); - context.drawText(s, tx, plotRect.bottom() + 3, xLabelStyle); + GuideText.emitText(c, s, tx, plotRect.bottom() + 3, xLabelStyle); } } else if (xCategories != null && xCategories.length > 0) { float step = (float) plotRect.width() / xCategories.length; for (int i = 0; i < xCategories.length; i++) { String label = xCategories[i] != null ? xCategories[i] : ""; - int sw = context.getStringWidth(label, xLabelStyle); + int sw = GuideText.measureWidth(label, xLabelStyle); float cx = plotRect.x() + step * (i + 0.5f); int tx = (int) cx - sw / 2; - context.drawText(label, tx, plotRect.bottom() + 3, xLabelStyle); + GuideText.emitText(c, label, tx, plotRect.bottom() + 3, xLabelStyle); } } // Axis border. - context.drawLine(plotRect.x(), plotRect.y(), plotRect.x(), plotRect.bottom(), 1f, xAxis.getAxisColor()); - context - .drawLine(plotRect.x(), plotRect.bottom(), plotRect.right(), plotRect.bottom(), 1f, xAxis.getAxisColor()); + c.emit( + new GuideRenderPrimitive.DrawLine( + plotRect.x(), + plotRect.y(), + plotRect.x(), + plotRect.bottom(), + 1f, + xAxis.getAxisColor())); + c.emit( + new GuideRenderPrimitive.DrawLine( + plotRect.x(), + plotRect.bottom(), + plotRect.right(), + plotRect.bottom(), + 1f, + xAxis.getAxisColor())); // Axis title. if (xAxis.getLabel() != null && !xAxis.getLabel() .isEmpty()) { - int sw = context.getStringWidth(xAxis.getLabel(), xLabelStyle); - int lh = context.getLineHeight(xLabelStyle); + int sw = GuideText.measureWidth(xAxis.getLabel(), xLabelStyle); + int lh = GuideText.lineHeight(xLabelStyle); // Center the X-axis label below the tick row, but clamp to the plot's horizontal range // so a long label does not bleed past the right edge. int tx = plotRect.x() + Math.max(0, (plotRect.width() - sw) / 2); - context.drawText(xAxis.getLabel(), tx, plotRect.bottom() + 3 + lh + 2, xLabelStyle); + GuideText.emitText(c, xAxis.getLabel(), tx, plotRect.bottom() + 3 + lh + 2, xLabelStyle); } if (yAxis.getLabel() != null && !yAxis.getLabel() .isEmpty()) { - int lh = context.getLineHeight(yLabelStyle); + int lh = GuideText.lineHeight(yLabelStyle); // Place the Y-axis label horizontally above the plot's top-left corner instead of to // its left, so long labels (e.g. "Strength (dB)") never overflow the chart frame. - context.drawText(yAxis.getLabel(), plotRect.x(), plotRect.y() - lh - 2, yLabelStyle); + GuideText.emitText(c, yAxis.getLabel(), plotRect.x(), plotRect.y() - lh - 2, yLabelStyle); } } } diff --git a/src/main/java/com/hfstudio/guidenh/guide/document/block/chart/ChartLegendRenderer.java b/src/main/java/com/hfstudio/guidenh/guide/document/block/chart/ChartLegendRenderer.java index e2e594ab..3cc53afb 100644 --- a/src/main/java/com/hfstudio/guidenh/guide/document/block/chart/ChartLegendRenderer.java +++ b/src/main/java/com/hfstudio/guidenh/guide/document/block/chart/ChartLegendRenderer.java @@ -3,12 +3,16 @@ import java.util.ArrayList; import java.util.List; -import org.lwjgl.opengl.GL11; +import net.minecraft.client.Minecraft; +import net.minecraft.client.renderer.texture.ITextureObject; +import net.minecraft.util.ResourceLocation; import com.hfstudio.guidenh.guide.color.ColorUtils; import com.hfstudio.guidenh.guide.document.LytRect; import com.hfstudio.guidenh.guide.layout.LayoutContext; -import com.hfstudio.guidenh.guide.render.RenderContext; +import com.hfstudio.guidenh.guide.render.GuideRenderPrimitive; +import com.hfstudio.guidenh.guide.render.GuideText; +import com.hfstudio.guidenh.guide.render.PrimitiveCollector; import com.hfstudio.guidenh.guide.style.ResolvedTextStyle; /** @@ -19,6 +23,11 @@ public class ChartLegendRenderer { private static final int SWATCH_TEXT_GAP = 4; private static final int HORIZONTAL_ROW_GAP = 2; + /** Exposed for Rust-side chrome computation. */ + public static int getSwatchTextGap() { + return SWATCH_TEXT_GAP; + } + protected ChartLegendRenderer() {} /** A single legend entry. */ @@ -66,8 +75,8 @@ public Layout(List entries, ChartLegendPosition position, LytRect l } } - public static Layout computeLayout(RenderContext context, List entries, ChartLegendPosition position, - int contentLeft, int contentTop, int contentRight, int contentBottom) { + public static Layout computeLayout(List entries, ChartLegendPosition position, int contentLeft, + int contentTop, int contentRight, int contentBottom) { if (entries == null || entries.isEmpty() || position == null || position == ChartLegendPosition.NONE) { return new Layout( entries != null ? entries : new ArrayList<>(), @@ -80,7 +89,7 @@ public static Layout computeLayout(RenderContext context, List entr } ResolvedTextStyle textStyle = LytChartBase.textStyle(ColorUtils.ARGB_FFCCCCCC.getColor()); - int lineHeight = context.getLineHeight(textStyle); + int lineHeight = GuideText.lineHeight(textStyle); int swatch = LytChartBase.LEGEND_SWATCH_SIZE; int gap = LytChartBase.LEGEND_GAP; @@ -91,7 +100,7 @@ public static Layout computeLayout(RenderContext context, List entr entries, position, Math.max(1, contentRight - contentLeft), - context::getStringWidth, + (text, style) -> GuideText.measureWidth(text, style), lineHeight, swatch, textStyle); @@ -111,7 +120,7 @@ public static Layout computeLayout(RenderContext context, List entr case RIGHT: { int width = 0; for (LegendEntry e : entries) { - int w = swatch + SWATCH_TEXT_GAP + context.getStringWidth(e.name, textStyle); + int w = swatch + SWATCH_TEXT_GAP + GuideText.measureWidth(e.name, textStyle); if (w > width) { width = w; } @@ -141,12 +150,12 @@ public static Layout computeLayout(RenderContext context, List entr } } - public static void render(RenderContext context, Layout layout, ResolvedTextStyle styleTemplate) { + public static void emit(PrimitiveCollector c, Layout layout, ResolvedTextStyle styleTemplate) { if (layout.position == ChartLegendPosition.NONE || layout.entries.isEmpty()) { return; } ResolvedTextStyle textStyle = LytChartBase.textStyle(ColorUtils.ARGB_FFCCCCCC.getColor()); - int lineHeight = context.getLineHeight(textStyle); + int lineHeight = GuideText.lineHeight(textStyle); int swatch = LytChartBase.LEGEND_SWATCH_SIZE; LytRect rect = layout.legendRect; @@ -156,7 +165,7 @@ public static void render(RenderContext context, Layout layout, ResolvedTextStyl int totalWidth = 0; for (int i = 0; i < layout.entries.size(); i++) { LegendEntry e = layout.entries.get(i); - totalWidth += swatch + SWATCH_TEXT_GAP + context.getStringWidth(e.name, textStyle); + totalWidth += swatch + SWATCH_TEXT_GAP + GuideText.measureWidth(e.name, textStyle); if (i < layout.entries.size() - 1) { totalWidth += LytChartBase.LEGEND_ENTRY_GAP; } @@ -167,17 +176,17 @@ public static void render(RenderContext context, Layout layout, ResolvedTextStyl int textY = y + (rowHeight - lineHeight) / 2; int swY = y + (rowHeight - swatch) / 2; for (LegendEntry e : layout.entries) { - int itemWidth = swatch + SWATCH_TEXT_GAP + context.getStringWidth(e.name, textStyle); + int itemWidth = swatch + SWATCH_TEXT_GAP + GuideText.measureWidth(e.name, textStyle); if (x > rect.x() && x + itemWidth > rect.right()) { x = rect.x(); y += rowHeight + HORIZONTAL_ROW_GAP; textY = y + (rowHeight - lineHeight) / 2; swY = y + (rowHeight - swatch) / 2; } - drawSwatch(context, e, x, swY, swatch); + emitSwatch(c, e, x, swY, swatch); x += swatch + SWATCH_TEXT_GAP; - context.drawText(e.name, x, textY, textStyle); - x += context.getStringWidth(e.name, textStyle) + LytChartBase.LEGEND_ENTRY_GAP; + GuideText.emitText(c, e.name, x, textY, textStyle); + x += GuideText.measureWidth(e.name, textStyle) + LytChartBase.LEGEND_ENTRY_GAP; } break; } @@ -187,8 +196,8 @@ public static void render(RenderContext context, Layout layout, ResolvedTextStyl int y = rect.y(); for (LegendEntry e : layout.entries) { int swY = y + (lineHeight - swatch) / 2; - drawSwatch(context, e, x, swY, swatch); - context.drawText(e.name, x + swatch + SWATCH_TEXT_GAP, y, textStyle); + emitSwatch(c, e, x, swY, swatch); + GuideText.emitText(c, e.name, x + swatch + SWATCH_TEXT_GAP, y, textStyle); y += lineHeight + 2; if (y + lineHeight > rect.bottom()) { break; @@ -220,6 +229,30 @@ public static int measureHeight(LayoutContext context, List entries textStyle); } + /** + * Version without {@link LayoutContext}; uses guide-level static font metrics + * ({@link GuideText#lineHeight} / {@link GuideText#measureWidth}) so it can + * be called from a lazy getter when the Java layout pre-pass has not run. + * The result is equivalent to {@link #measureHeight} for serialization purposes. + */ + public static int measureHeightStatic(List entries, ChartLegendPosition position, int availableWidth) { + if (entries == null || entries.isEmpty() || position == null || position == ChartLegendPosition.NONE) { + return 0; + } + if (position != ChartLegendPosition.TOP && position != ChartLegendPosition.BOTTOM) { + return 0; + } + ResolvedTextStyle textStyle = LytChartBase.textStyle(ColorUtils.ARGB_FFCCCCCC.getColor()); + return measureHorizontalLegendHeight( + entries, + position, + Math.max(1, availableWidth), + (text, style) -> GuideText.measureWidth(text, style), + GuideText.lineHeight(textStyle), + LytChartBase.LEGEND_SWATCH_SIZE, + textStyle); + } + private static int measureHorizontalLegendHeight(List entries, ChartLegendPosition position, int availableWidth, TextWidthMeasure textWidthMeasure, int lineHeight, int swatch, ResolvedTextStyle textStyle) { @@ -260,20 +293,37 @@ private interface TextWidthMeasure { int measure(String text, ResolvedTextStyle style); } - private static void drawSwatch(RenderContext context, LegendEntry entry, int x, int y, int size) { + private static void emitSwatch(PrimitiveCollector c, LegendEntry entry, int x, int y, int size) { if (entry.icon != null && entry.icon.hasItemStack()) { float scale = (float) size / 16f; - GL11.glPushMatrix(); - GL11.glTranslatef(x, y, 0f); - GL11.glScalef(scale, scale, 1f); - context.renderItem(entry.icon.getStack(), 0, 0); - GL11.glPopMatrix(); + c.pushTransform(x, y, scale); + c.emit(new GuideRenderPrimitive.RenderItem(entry.icon.getStack(), 0, 0)); + c.popTransform(); return; } if (entry.icon != null && entry.icon.hasImage()) { - context.fillTexturedRect(new LytRect(x, y, size, size), entry.icon.getTexture()); + ResourceLocation res = entry.icon.getTexture() + .getTexture(); + int texId = res != null ? getGlTextureId(res) : -1; + if (texId >= 0) { + // Full texture UV, matching the legacy fillTexturedRect behavior. + c.emit(new GuideRenderPrimitive.BlitTexture(texId, x, y, size, size, 0f, 0f, 1f, 1f)); + } return; } - context.fillRect(new LytRect(x, y, size, size), entry.color); + c.emit(new GuideRenderPrimitive.FillRect(x, y, size, size, entry.color)); + } + + /** Convert a Minecraft ResourceLocation to a GL texture ID for use with BlitTexture. */ + private static int getGlTextureId(ResourceLocation res) { + try { + ITextureObject tex = Minecraft.getMinecraft() + .getTextureManager() + .getTexture(res); + return tex != null ? tex.getGlTextureId() : -1; + } catch (Throwable t) { + // Headless (unit tests) or texture unavailable: skip drawing. + return -1; + } } } diff --git a/src/main/java/com/hfstudio/guidenh/guide/document/block/chart/CornerLegendRenderer.java b/src/main/java/com/hfstudio/guidenh/guide/document/block/chart/CornerLegendRenderer.java index f53dcc97..84318d52 100644 --- a/src/main/java/com/hfstudio/guidenh/guide/document/block/chart/CornerLegendRenderer.java +++ b/src/main/java/com/hfstudio/guidenh/guide/document/block/chart/CornerLegendRenderer.java @@ -4,7 +4,9 @@ import com.hfstudio.guidenh.guide.color.ColorUtils; import com.hfstudio.guidenh.guide.document.LytRect; -import com.hfstudio.guidenh.guide.render.RenderContext; +import com.hfstudio.guidenh.guide.render.GuideRenderPrimitive; +import com.hfstudio.guidenh.guide.render.GuideText; +import com.hfstudio.guidenh.guide.render.PrimitiveCollector; import com.hfstudio.guidenh.guide.style.ResolvedTextStyle; public class CornerLegendRenderer { @@ -25,7 +27,11 @@ public class CornerLegendRenderer { protected CornerLegendRenderer() {} - public static void render(RenderContext context, LytRect plotRect, List entries, + /** + * Emits the corner legend as {@link GuideRenderPrimitive}s into {@code c}, + * measuring text through {@link GuideText}. + */ + public static void emit(PrimitiveCollector c, LytRect plotRect, List entries, CornerLegendPosition position, int maxWidth, int maxHeight, int backgroundColor) { if (position == null || position == CornerLegendPosition.NONE || entries == null @@ -33,7 +39,7 @@ public static void render(RenderContext context, LytRect plotRect, List maxWidth) { - return ""; - } - int end = text.length(); - while (end > 0) { - String candidate = text.substring(0, end) + suffix; - if (context.getStringWidth(candidate, TEXT_STYLE) <= maxWidth) { - return candidate; - } - end--; - } - return suffix; - } - private static int clamp(int value, int min, int max) { return Math.clamp(value, min, max); } diff --git a/src/main/java/com/hfstudio/guidenh/guide/document/block/chart/LytBarChart.java b/src/main/java/com/hfstudio/guidenh/guide/document/block/chart/LytBarChart.java index d4f69b6f..b7cda1cf 100644 --- a/src/main/java/com/hfstudio/guidenh/guide/document/block/chart/LytBarChart.java +++ b/src/main/java/com/hfstudio/guidenh/guide/document/block/chart/LytBarChart.java @@ -8,7 +8,9 @@ import com.hfstudio.guidenh.guide.color.ColorUtils; import com.hfstudio.guidenh.guide.document.LytRect; import com.hfstudio.guidenh.guide.internal.debug.DebugComponent; -import com.hfstudio.guidenh.guide.render.RenderContext; +import com.hfstudio.guidenh.guide.render.GuideRenderPrimitive; +import com.hfstudio.guidenh.guide.render.GuideText; +import com.hfstudio.guidenh.guide.render.PrimitiveCollector; import com.hfstudio.guidenh.guide.style.ResolvedTextStyle; import lombok.Getter; @@ -87,7 +89,7 @@ public void setBarWidthRatio(float r) { } @Override - protected int getExtraPlotWidth() { + public int getExtraPlotWidth() { if (pieInset != null && pieInset.getPosition() == PieInsetSpec.Position.RIGHT_OUTSIDE) { return pieInset.getSize() + PIE_OUTSIDE_GAP; } @@ -107,9 +109,9 @@ protected List collectLegendEntries() { } @Override - protected void renderChart(RenderContext context, LytRect plotRect) { + protected LytRect renderChart(PrimitiveCollector c, LytRect plotRect) { int categoryCount = Math.max(categories.length, maxSeriesLength()); - if (categoryCount == 0 || (series.isEmpty() && lineOverlays.isEmpty())) return; + if (categoryCount == 0 || (series.isEmpty() && lineOverlays.isEmpty())) return plotRect; // Peel off a dedicated right-hand area for the pie inset when configured. LytRect pieArea = null; if (pieInset != null && pieInset.getPosition() == PieInsetSpec.Position.RIGHT_OUTSIDE) { @@ -137,76 +139,124 @@ protected void renderChart(RenderContext context, LytRect plotRect) { if (v > dMax) dMax = v; } } + // BarChart is horizontal: the value axis is the horizontal (X) axis but the author's + // semantic "yAxis" attributes (yAxisMin/yAxisMax/yAxisStep/yAxisTickFormat/yAxisUnit) control + // the numeric axis. Use yAxis for range, tick format, and value label; xAxis remains the + // category (vertical) axis and controls grid appearance. AxisRange xRange = AxisRange - .compute(xAxis.getMin(), xAxis.getMax(), xAxis.getStep(), Math.min(0d, dMin), Math.max(0d, dMax)); + .compute(yAxis.getMin(), yAxis.getMax(), yAxis.getStep(), Math.min(0d, dMin), Math.max(0d, dMax)); xRangeCache = xRange; // Estimate left-side (category) and bottom (value tick) insets. ResolvedTextStyle style = textStyle(ColorUtils.ARGB_FFCCCCCC.getColor()); - int lh = context.getLineHeight(style); + int lh = GuideText.lineHeight(style); int leftInset = 4; for (int i = 0; i < categoryCount; i++) { - String c = i < categories.length ? categories[i] : Integer.toString(i + 1); - int w = context.getStringWidth(c, style); + String cat = i < categories.length ? categories[i] : Integer.toString(i + 1); + int w = GuideText.measureWidth(cat, style); if (w > leftInset) leftInset = w; } leftInset += 6; int bottomInset = lh + 4; - if (xAxis.getLabel() != null && !xAxis.getLabel() + // yAxis label (value axis) goes below the bottom, xAxis label (category) goes on the left. + if (yAxis.getLabel() != null && !yAxis.getLabel() .isEmpty()) { bottomInset += lh + 2; } LytRect inner = plotRect.shrink(leftInset, 4, 4, bottomInset); plotCache = inner; - if (inner.width() <= 4 || inner.height() <= 4) return; + if (inner.width() <= 4 || inner.height() <= 4) return inner; - // Grid (vertical lines correspond to X values). + // Grid (vertical lines correspond to value axis ticks). for (double t = xRange.min; t <= xRange.max + 1e-9; t += xRange.step) { float gx = CartesianChartRenderer.mapX(t, xRange, inner); if (xAxis.isGridVisible()) { - context.drawLine(gx, inner.y(), gx, inner.bottom(), 1f, xAxis.getGridColor()); + c.emit(new GuideRenderPrimitive.DrawLine(gx, inner.y(), gx, inner.bottom(), 1f, xAxis.getGridColor())); } - String s = xAxis.formatTick(t); - int sw = context.getStringWidth(s, style); - context.drawText(s, (int) gx - sw / 2, inner.bottom() + 3, style); + // Use yAxis tick formatting (tickFormat + unit) for the value axis. + String s = yAxis.formatTick(t); + int sw = GuideText.measureWidth(s, style); + GuideText.emitText(c, s, (int) gx - sw / 2, inner.bottom() + 3, style); } - if (xAxis.getLabel() != null && !xAxis.getLabel() + // yAxis label at bottom (value axis label). + if (yAxis.getLabel() != null && !yAxis.getLabel() .isEmpty()) { - int sw = context.getStringWidth(xAxis.getLabel(), style); - context - .drawText(xAxis.getLabel(), inner.x() + (inner.width() - sw) / 2, inner.bottom() + 3 + lh + 2, style); + int sw = GuideText.measureWidth(yAxis.getLabel(), style); + GuideText.emitText( + c, + yAxis.getLabel(), + inner.x() + (inner.width() - sw) / 2, + inner.bottom() + 3 + lh + 2, + style); } // Category ticks. float categoryHeight = (float) inner.height() / categoryCount; for (int i = 0; i < categoryCount; i++) { - String c = i < categories.length ? categories[i] : Integer.toString(i + 1); - int sw = context.getStringWidth(c, style); + String cat = i < categories.length ? categories[i] : Integer.toString(i + 1); + int sw = GuideText.measureWidth(cat, style); float cy = inner.y() + categoryHeight * (i + 0.5f); - context.drawText(c, inner.x() - sw - 4, (int) cy - lh / 2, style); + GuideText.emitText(c, cat, inner.x() - sw - 4, (int) cy - lh / 2, style); + } + // X (category) axis label on the left side, below the last category label. + if (xAxis.getLabel() != null && !xAxis.getLabel() + .isEmpty()) { + int xsw = GuideText.measureWidth(xAxis.getLabel(), style); + int xLabelX = inner.x() - xsw - 4; + float lastCatCy = inner.y() + categoryHeight * (categoryCount - 0.5f); + int xLabelY = (int) lastCatCy + lh / 2 + 2; + GuideText.emitText(c, xAxis.getLabel(), xLabelX, xLabelY, style); } // Border. - context.drawLine(inner.x(), inner.y(), inner.x(), inner.bottom(), 1f, xAxis.getAxisColor()); - context.drawLine(inner.x(), inner.bottom(), inner.right(), inner.bottom(), 1f, xAxis.getAxisColor()); + c.emit( + new GuideRenderPrimitive.DrawLine( + inner.x(), + inner.y(), + inner.x(), + inner.bottom(), + 1f, + xAxis.getAxisColor())); + c.emit( + new GuideRenderPrimitive.DrawLine( + inner.x(), + inner.bottom(), + inner.right(), + inner.bottom(), + 1f, + xAxis.getAxisColor())); int seriesCount = series.size(); + // Detect single-value mode where seriesCount == categoryCount and each series has + // exactly one value. In this mode, map each series directly to its corresponding category row. + boolean singleValueMode = seriesCount == categoryCount && seriesCount > 0; + if (singleValueMode) { + for (ChartSeries s : series) { + if (s.getYs().length != 1) { + singleValueMode = false; + break; + } + } + } float baselineX = CartesianChartRenderer.mapX(0d, xRange, inner); ResolvedTextStyle valueStyle = textStyle(getLabelColor()); if (seriesCount > 0) { float clusterHeight = categoryHeight * barWidthRatio; - float barHeight = clusterHeight / seriesCount; + int effSeriesCount = singleValueMode ? 1 : seriesCount; + float barHeight = clusterHeight / effSeriesCount; for (int ci = 0; ci < categoryCount; ci++) { float clusterCenter = inner.y() + categoryHeight * (ci + 0.5f); float clusterTop = clusterCenter - clusterHeight / 2f; - for (int si = 0; si < seriesCount; si++) { - ChartSeries s = series.get(si); - if (ci >= s.getYs().length) continue; - double v = s.getYs()[ci]; + for (int si = 0; si < effSeriesCount; si++) { + int seriesIdx = singleValueMode ? ci : si; + ChartSeries s = series.get(seriesIdx); + int valueIdx = singleValueMode ? 0 : ci; + if (valueIdx >= s.getYs().length) continue; + double v = s.getYs()[valueIdx]; float endX = CartesianChartRenderer.mapX(v, xRange, inner); float y0 = clusterTop + barHeight * si; float y1 = y0 + barHeight - 0.5f; - int key = encodeKey(si, ci); + int key = encodeKey(singleValueMode ? ci : si, ci); boolean hovered = key == hoveredKey; float xLeft = Math.min(endX, baselineX); float xRight = Math.max(endX, baselineX); @@ -218,11 +268,22 @@ protected void renderChart(RenderContext context, LytRect plotRect) { (int) y0, Math.max(1, (int) (xRight - xLeft)), Math.max(1, (int) (y1 - y0))); - context.fillRect(bar, s.getColor()); + c.emit( + new GuideRenderPrimitive.FillRect(bar.x(), bar.y(), bar.width(), bar.height(), s.getColor())); if (hovered) { - context.drawBorder(bar, ColorUtils.BLACK.getColor(), 1); + c.emit( + new GuideRenderPrimitive.DrawBorder( + bar.x(), + bar.y(), + bar.width(), + bar.height(), + 1, + 1, + 1, + 1, + ColorUtils.BLACK.getColor())); } - drawValueLabel(context, valueStyle, v, bar, endX); + drawValueLabel(c, valueStyle, v, bar, endX); } } } @@ -247,31 +308,38 @@ protected void renderChart(RenderContext context, LytRect plotRect) { if (hoveredLineSeries == li && (hoveredLinePoint == i || hoveredLinePoint == i + 1)) { thick += 1f; } - context.drawLine(px[i], py[i], px[i + 1], py[i + 1], thick, s.getColor()); + c.emit(new GuideRenderPrimitive.DrawLine(px[i], py[i], px[i + 1], py[i + 1], thick, s.getColor())); } for (int i = 0; i < n; i++) { boolean ph = hoveredLineSeries == li && hoveredLinePoint == i; float r = ph ? LINE_POINT_RADIUS + 2f : LINE_POINT_RADIUS; - context.fillCircle(px[i], py[i], r, s.getColor()); + c.emit(new GuideRenderPrimitive.DrawCircle(px[i], py[i], r, s.getColor(), true)); if (ph) { - context.drawCircleOutline(px[i], py[i], r, 1f, ColorUtils.BLACK.getColor()); + c.emit( + new GuideRenderPrimitive.DrawCircleOutline( + px[i], + py[i], + r, + 1f, + ColorUtils.BLACK.getColor())); } } } } if (pieArea != null) { - PieInsetRenderer.drawAt(context, pieArea, pieInset); + PieInsetRenderer.drawAt(c, pieArea, pieInset); } else { - PieInsetRenderer.draw(context, inner, pieInset); + PieInsetRenderer.draw(c, inner, pieInset); } + return inner; } - private void drawValueLabel(RenderContext context, ResolvedTextStyle style, double value, LytRect bar, float endX) { + private void drawValueLabel(PrimitiveCollector c, ResolvedTextStyle style, double value, LytRect bar, float endX) { if (getLabelPosition() == ChartLabelPosition.NONE) return; String text = formatValue(value); - int tw = context.getStringWidth(text, style); - int lh = context.getLineHeight(style); + int tw = GuideText.measureWidth(text, style); + int lh = GuideText.lineHeight(style); int textX; int textY = bar.y() + (bar.height() - lh) / 2; int textX1 = bar.x() + (bar.width() - tw) / 2; @@ -294,7 +362,7 @@ private void drawValueLabel(RenderContext context, ResolvedTextStyle style, doub default: return; } - context.drawText(text, textX, textY, style); + GuideText.emitText(c, text, textX, textY, style); } private int maxSeriesLength() { @@ -355,23 +423,35 @@ protected int hitTest(float x, float y) { } if (series.isEmpty()) return -1; int seriesCount = series.size(); + boolean singleValueMode = seriesCount == categoryCount && seriesCount > 0; + if (singleValueMode) { + for (ChartSeries s : series) { + if (s.getYs().length != 1) { + singleValueMode = false; + break; + } + } + } float clusterHeight = categoryHeight * barWidthRatio; - float barHeight = clusterHeight / seriesCount; + int effSeriesCount = singleValueMode ? 1 : seriesCount; + float barHeight = clusterHeight / effSeriesCount; float baselineX = CartesianChartRenderer.mapX(0d, xRangeCache, plotCache); for (int ci = 0; ci < categoryCount; ci++) { float clusterCenter = plotCache.y() + categoryHeight * (ci + 0.5f); float clusterTop = clusterCenter - clusterHeight / 2f; - for (int si = 0; si < seriesCount; si++) { - ChartSeries s = series.get(si); - if (ci >= s.getYs().length) continue; - double v = s.getYs()[ci]; + for (int si = 0; si < effSeriesCount; si++) { + int seriesIdx = singleValueMode ? ci : si; + ChartSeries s = series.get(seriesIdx); + int valueIdx = singleValueMode ? 0 : ci; + if (valueIdx >= s.getYs().length) continue; + double v = s.getYs()[valueIdx]; float endX = CartesianChartRenderer.mapX(v, xRangeCache, plotCache); float y0 = clusterTop + barHeight * si; float y1 = y0 + barHeight; float xLeft = Math.min(endX, baselineX); float xRight = Math.max(endX, baselineX); if (x >= xLeft && x <= xRight && y >= y0 && y <= y1) { - return encodeKey(si, ci); + return encodeKey(singleValueMode ? ci : si, ci); } } } @@ -467,20 +547,32 @@ public List getDebugComponents() { int categoryCount = Math.max(categories.length, maxSeriesLength()); int seriesCount = series.size(); + boolean singleValueMode = seriesCount == categoryCount && seriesCount > 0; + if (singleValueMode) { + for (ChartSeries s : series) { + if (s.getYs().length != 1) { + singleValueMode = false; + break; + } + } + } float categoryHeight = (float) plotCache.height() / categoryCount; float clusterHeight = categoryHeight * barWidthRatio; - float barHeight = clusterHeight / seriesCount; + int effSeriesCount = singleValueMode ? 1 : seriesCount; + float barHeight = clusterHeight / effSeriesCount; float baselineX = CartesianChartRenderer.mapX(0d, xRangeCache, plotCache); for (int ci = 0; ci < categoryCount; ci++) { float clusterCenter = plotCache.y() + categoryHeight * (ci + 0.5f); float clusterTop = clusterCenter - clusterHeight / 2f; - for (int si = 0; si < seriesCount; si++) { - ChartSeries s = series.get(si); - if (ci >= s.getYs().length) continue; + for (int si = 0; si < effSeriesCount; si++) { + int seriesIdx = singleValueMode ? ci : si; + ChartSeries s = series.get(seriesIdx); + int valueIdx = singleValueMode ? 0 : ci; + if (valueIdx >= s.getYs().length) continue; - double v = s.getYs()[ci]; + double v = s.getYs()[valueIdx]; float endX = CartesianChartRenderer.mapX(v, xRangeCache, plotCache); float y0 = clusterTop + barHeight * si; float y1 = y0 + barHeight - 0.5f; diff --git a/src/main/java/com/hfstudio/guidenh/guide/document/block/chart/LytChartBase.java b/src/main/java/com/hfstudio/guidenh/guide/document/block/chart/LytChartBase.java index 99519821..d2730360 100644 --- a/src/main/java/com/hfstudio/guidenh/guide/document/block/chart/LytChartBase.java +++ b/src/main/java/com/hfstudio/guidenh/guide/document/block/chart/LytChartBase.java @@ -16,6 +16,9 @@ import com.hfstudio.guidenh.guide.document.interaction.TextTooltip; import com.hfstudio.guidenh.guide.internal.tooltip.AppendedItemTooltip; import com.hfstudio.guidenh.guide.layout.LayoutContext; +import com.hfstudio.guidenh.guide.render.GuideRenderPrimitive; +import com.hfstudio.guidenh.guide.render.GuideText; +import com.hfstudio.guidenh.guide.render.PrimitiveCollector; import com.hfstudio.guidenh.guide.render.RenderContext; import com.hfstudio.guidenh.guide.style.ResolvedTextStyle; import com.hfstudio.guidenh.guide.style.TextAlignment; @@ -30,8 +33,8 @@ */ public abstract class LytChartBase extends LytBlock implements InteractiveElement { - protected static final int DEFAULT_WIDTH = 320; - protected static final int DEFAULT_HEIGHT = 200; + public static final int DEFAULT_WIDTH = 320; + public static final int DEFAULT_HEIGHT = 200; protected static final int PADDING = 8; protected static final int TITLE_GAP = 4; protected static final int LEGEND_GAP = 6; @@ -70,22 +73,117 @@ public abstract class LytChartBase extends LytBlock implements InteractiveElemen @Setter private int cornerLegendBackgroundColor = CornerLegendRenderer.DEFAULT_BACKGROUND; - public int getExplicitWidth() { - return explicitWidth; + /** Currently hovered hit key; {@code -1} means none. The exact semantics is decided by each subclass. */ + protected int hoveredKey = -1; + + /** + * Chrome height (title + legend + padding), cached during {@link #computeLayout} + * for serialization into PieChartData. + * Computed lazily when accessed and still zero (no pre-pass), using static + * font metrics via {@link GuideText} instead of {@link LayoutContext}. + */ + private int chromeHeight; + + /** + * Returns the chrome height, computing it lazily if no layout pass has been run. + * Uses {@link GuideText} static font metrics so it works without a {@link LayoutContext}. + *

          + * NOTE: This is only used by the Java render path (computeLayout for scaling). + * The Rust measure path now computes chrome internally from the final width, + * using legend wrapping transplanted from {@link ChartLegendRenderer}. + * See measure.rs for the Rust-side computation. + */ + public int getChromeHeight() { + if (chromeHeight <= 0) { + chromeHeight = computeChromeHeightForWidth(preferredWidth()); + } + return chromeHeight; } - public int getExplicitHeight() { - return explicitHeight; + /** + * Computes chrome height purely from block fields and static font metrics. + * Does NOT require a {@link LayoutContext}, so it works even without the + * Java layout pre-pass having been run. + */ + private int computeChromeHeightForWidth(int width) { + int chrome = PADDING * 2; + if (title != null && !title.isEmpty()) { + chrome += GuideText.lineHeight(textStyle(titleColor)) + TITLE_GAP; + } + int contentWidth = Math.max(1, width - PADDING * 2); + chrome += ChartLegendRenderer.measureHeightStatic(collectLegendEntries(), legendPosition, contentWidth); + if (legendPosition == ChartLegendPosition.TOP || legendPosition == ChartLegendPosition.BOTTOM) { + chrome += legendPosition == ChartLegendPosition.NONE ? 0 : LEGEND_GAP; + } + return chrome; } - /** Currently hovered hit key; {@code -1} means none. The exact semantics is decided by each subclass. */ - protected int hoveredKey = -1; + /** + * Returns the title chrome (lineHeight + TITLE_GAP), width-independent, + * for Rust-side chrome computation. 0 if no title is set. + */ + public float getTitleChromeForRust() { + if (title != null && !title.isEmpty()) { + return GuideText.lineHeight(textStyle(titleColor)) + TITLE_GAP; + } + return 0f; + } + + /** + * Returns the legend position as a byte matching the schema: + * 0=NONE, 1=TOP, 2=BOTTOM, 3=LEFT, 4=RIGHT. + */ + public byte getLegendPositionForRust() { + return switch (legendPosition) { + case TOP -> (byte) 1; + case BOTTOM -> (byte) 2; + case LEFT -> (byte) 3; + case RIGHT -> (byte) 4; + default -> (byte) 0; + }; + } + + /** + * Returns the legend row height (max of swatch size and line height) + * for Rust-side chrome computation. + */ + public float getLegendRowHeightForRust() { + ResolvedTextStyle legendStyle = textStyle(ColorUtils.ARGB_FFCCCCCC.getColor()); + int lineHeight = GuideText.lineHeight(legendStyle); + return Math.max(LEGEND_SWATCH_SIZE, lineHeight); + } + + /** + * Returns per-legend-entry label widths for Rust-side chrome computation. + * Each entry's width = LEGEND_SWATCH_SIZE + SWATCH_TEXT_GAP + measureWidth(label, legendStyle). + */ + public float[] getLegendLabelWidthsForRust() { + List entries = collectLegendEntries(); + ResolvedTextStyle legendStyle = textStyle(ColorUtils.ARGB_FFCCCCCC.getColor()); + float[] widths = new float[entries.size()]; + for (int i = 0; i < entries.size(); i++) { + ChartLegendRenderer.LegendEntry entry = entries.get(i); + int labelW = GuideText.measureWidth(entry.name, legendStyle); + widths[i] = LEGEND_SWATCH_SIZE + ChartLegendRenderer.getSwatchTextGap() + labelW; + } + return widths; + } public void setExplicitSize(int width, int height) { this.explicitWidth = width > 0 ? width : -1; this.explicitHeight = height > 0 ? height : -1; } + @Override + public int getExplicitWidth() { + return explicitWidth; + } + + @Override + public int getExplicitHeight() { + return explicitHeight; + } + public void setLegendPosition(ChartLegendPosition legendPosition) { this.legendPosition = legendPosition != null ? legendPosition : ChartLegendPosition.NONE; } @@ -109,12 +207,10 @@ protected LytRect computeLayout(LayoutContext context, int x, int y, int availab width = ResponsiveVisualSizing.scaleWidth(width, context.getVisualScale(), 64); int height = explicitHeight > 0 ? explicitHeight : DEFAULT_HEIGHT; width = Math.max(1, Math.min(width, availableWidth)); - height = ResponsiveVisualSizing.scaleBodyHeightForWidth( - preferredWidth(), - height, - width, - estimateFixedChromeHeight(context, width), - MIN_PLOT_HEIGHT); + int estimatedChrome = estimateFixedChromeHeight(context, width); + this.chromeHeight = estimatedChrome; + height = ResponsiveVisualSizing + .scaleBodyHeightForWidth(preferredWidth(), height, width, estimatedChrome, MIN_PLOT_HEIGHT); return new LytRect(x, y, width, height); } @@ -140,7 +236,7 @@ private int estimateFixedChromeHeight(LayoutContext context, int width) { * Subclasses override to request additional horizontal space (for example, a side-mounted pie inset). * Default 0. */ - protected int getExtraPlotWidth() { + public int getExtraPlotWidth() { return 0; } @@ -148,9 +244,30 @@ protected int getExtraPlotWidth() { protected void onLayoutMoved(int deltaX, int deltaY) {} @Override - public final void render(RenderContext context) { - context.fillRect(bounds, backgroundColor); - context.drawBorder(bounds, borderColor, 1); + public boolean usePrimitives() { + return true; + } + + @Override + public final void computePrimitives(PrimitiveCollector c) { + c.emit( + new GuideRenderPrimitive.FillRect( + bounds.x(), + bounds.y(), + bounds.width(), + bounds.height(), + backgroundColor)); + c.emit( + new GuideRenderPrimitive.DrawBorder( + bounds.x(), + bounds.y(), + bounds.width(), + bounds.height(), + 1, + 1, + 1, + 1, + borderColor)); ResolvedTextStyle textStyle = textStyle(ColorUtils.WHITE.getColor()); int contentTop = bounds.y() + PADDING; @@ -160,16 +277,16 @@ public final void render(RenderContext context) { if (title != null && !title.isEmpty()) { ResolvedTextStyle titleStyle = textStyle(titleColor); - int titleWidth = context.getStringWidth(title, titleStyle); + int titleWidth = GuideText.measureWidth(title, titleStyle); int titleX = bounds.x() + (bounds.width() - titleWidth) / 2; - context.drawText(title, titleX, contentTop, titleStyle); - contentTop += context.getLineHeight(titleStyle) + TITLE_GAP; + GuideText.emitText(c, title, titleX, contentTop, titleStyle); + contentTop += GuideText.lineHeight(titleStyle) + TITLE_GAP; } // Compute legend area. List legend = collectLegendEntries(); ChartLegendRenderer.Layout legendLayout = ChartLegendRenderer - .computeLayout(context, legend, legendPosition, contentLeft, contentTop, contentRight, contentBottom); + .computeLayout(legend, legendPosition, contentLeft, contentTop, contentRight, contentBottom); int plotLeft = legendLayout.plotLeft; int plotTop = legendLayout.plotTop; @@ -180,23 +297,30 @@ public final void render(RenderContext context) { } LytRect plotRect = new LytRect(plotLeft, plotTop, plotRight - plotLeft, plotBottom - plotTop); - renderChart(context, plotRect); - CornerLegendRenderer.render( - context, - plotRect, + LytRect innerPlotRect = renderChart(c, plotRect); + if (innerPlotRect == null || innerPlotRect.isEmpty()) { + innerPlotRect = plotRect; + } + CornerLegendRenderer.emit( + c, + innerPlotRect, collectCornerLegendEntries(), cornerLegendPosition, cornerLegendWidth, cornerLegendHeight, cornerLegendBackgroundColor); - ChartLegendRenderer.render(context, legendLayout, textStyle); + ChartLegendRenderer.emit(c, legendLayout, textStyle); } + @Override + public final void render(RenderContext context) {} + /** * Subclasses implement the chart-specific drawing; {@code plotRect} has already excluded the space * occupied by the title and legend. */ - protected abstract void renderChart(RenderContext context, LytRect plotRect); + /** @return the inner rectangle actually used for data plotting (may be {@code plotRect} itself) */ + protected abstract LytRect renderChart(PrimitiveCollector c, LytRect plotRect); /** * Collect legend entries; empty by default. Subclasses override as needed. @@ -292,7 +416,8 @@ public static ResolvedTextStyle textStyle(int argb) { TextAlignment.LEFT, false, null, - false); + false, + 0.0f); } public static String formatPercent(double ratio) { diff --git a/src/main/java/com/hfstudio/guidenh/guide/document/block/chart/LytColumnChart.java b/src/main/java/com/hfstudio/guidenh/guide/document/block/chart/LytColumnChart.java index 76d6d6e1..900b0f0d 100644 --- a/src/main/java/com/hfstudio/guidenh/guide/document/block/chart/LytColumnChart.java +++ b/src/main/java/com/hfstudio/guidenh/guide/document/block/chart/LytColumnChart.java @@ -8,7 +8,9 @@ import com.hfstudio.guidenh.guide.color.ColorUtils; import com.hfstudio.guidenh.guide.document.LytRect; import com.hfstudio.guidenh.guide.internal.debug.DebugComponent; -import com.hfstudio.guidenh.guide.render.RenderContext; +import com.hfstudio.guidenh.guide.render.GuideRenderPrimitive; +import com.hfstudio.guidenh.guide.render.GuideText; +import com.hfstudio.guidenh.guide.render.PrimitiveCollector; import com.hfstudio.guidenh.guide.style.ResolvedTextStyle; import lombok.Getter; @@ -87,7 +89,7 @@ public void setBarWidthRatio(float r) { } @Override - protected int getExtraPlotWidth() { + public int getExtraPlotWidth() { if (pieInset != null && pieInset.getPosition() == PieInsetSpec.Position.RIGHT_OUTSIDE) { return pieInset.getSize() + PIE_OUTSIDE_GAP; } @@ -107,10 +109,10 @@ protected List collectLegendEntries() { } @Override - protected void renderChart(RenderContext context, LytRect plotRect) { + protected LytRect renderChart(PrimitiveCollector c, LytRect plotRect) { int categoryCount = Math.max(categories.length, maxSeriesLength()); if (categoryCount == 0 || (series.isEmpty() && lineOverlays.isEmpty())) { - return; + return plotRect; } // If the pie inset uses RIGHT_OUTSIDE, peel off a dedicated right-hand area for it so the // columns/lines do not have to share space with the pie. @@ -146,23 +148,15 @@ protected void renderChart(RenderContext context, LytRect plotRect) { yRangeCache = yRange; // Reserve space along the left/bottom for axis labels and tick text. - int[] insets = CartesianChartRenderer.computeAxisInsets( - context, - xAxis, - yAxis, - null, - yRange, - categories.length > 0 ? categories : null, - true, - true); + int[] insets = CartesianChartRenderer + .computeAxisInsets(xAxis, yAxis, null, yRange, categories.length > 0 ? categories : null, true, true); LytRect inner = plotRect.shrink(insets[0], insets[1], insets[2], insets[3]); plotCache = inner; if (inner.width() <= 4 || inner.height() <= 4) { - return; + return inner; } - CartesianChartRenderer - .drawAxes(context, inner, xAxis, yAxis, null, yRange, ensureCategories(categoryCount), false); + CartesianChartRenderer.drawAxes(c, inner, xAxis, yAxis, null, yRange, ensureCategories(categoryCount), false); float categoryWidth = (float) inner.width() / categoryCount; int seriesCount = series.size(); @@ -193,11 +187,22 @@ protected void renderChart(RenderContext context, LytRect plotRect) { (int) yTop, Math.max(1, (int) (x1 - x0)), Math.max(1, (int) (yBot - yTop))); - context.fillRect(bar, s.getColor()); + c.emit( + new GuideRenderPrimitive.FillRect(bar.x(), bar.y(), bar.width(), bar.height(), s.getColor())); if (hovered) { - context.drawBorder(bar, ColorUtils.BLACK.getColor(), 1); + c.emit( + new GuideRenderPrimitive.DrawBorder( + bar.x(), + bar.y(), + bar.width(), + bar.height(), + 1, + 1, + 1, + 1, + ColorUtils.BLACK.getColor())); } - drawValueLabel(context, valueStyle, v, bar, baselineY, topY); + drawValueLabel(c, valueStyle, v, bar, baselineY, topY); } } } @@ -222,14 +227,20 @@ protected void renderChart(RenderContext context, LytRect plotRect) { if (hoveredLineSeries == li && (hoveredLinePoint == i || hoveredLinePoint == i + 1)) { thick += 1f; } - context.drawLine(px[i], py[i], px[i + 1], py[i + 1], thick, s.getColor()); + c.emit(new GuideRenderPrimitive.DrawLine(px[i], py[i], px[i + 1], py[i + 1], thick, s.getColor())); } for (int i = 0; i < n; i++) { boolean ph = hoveredLineSeries == li && hoveredLinePoint == i; float r = ph ? LINE_POINT_RADIUS + 2f : LINE_POINT_RADIUS; - context.fillCircle(px[i], py[i], r, s.getColor()); + c.emit(new GuideRenderPrimitive.DrawCircle(px[i], py[i], r, s.getColor(), true)); if (ph) { - context.drawCircleOutline(px[i], py[i], r, 1f, ColorUtils.BLACK.getColor()); + c.emit( + new GuideRenderPrimitive.DrawCircleOutline( + px[i], + py[i], + r, + 1f, + ColorUtils.BLACK.getColor())); } } } @@ -237,18 +248,19 @@ protected void renderChart(RenderContext context, LytRect plotRect) { // Pie inset (if configured) goes after columns + lines so it sits on top. if (pieArea != null) { - PieInsetRenderer.drawAt(context, pieArea, pieInset); + PieInsetRenderer.drawAt(c, pieArea, pieInset); } else { - PieInsetRenderer.draw(context, inner, pieInset); + PieInsetRenderer.draw(c, inner, pieInset); } + return inner; } - private void drawValueLabel(RenderContext context, ResolvedTextStyle style, double value, LytRect bar, + private void drawValueLabel(PrimitiveCollector c, ResolvedTextStyle style, double value, LytRect bar, float baselineY, float topY) { if (getLabelPosition() == ChartLabelPosition.NONE) return; String text = formatValue(value); - int tw = context.getStringWidth(text, style); - int lh = context.getLineHeight(style); + int tw = GuideText.measureWidth(text, style); + int lh = GuideText.lineHeight(style); int textX = bar.x() + (bar.width() - tw) / 2; int textY; switch (getLabelPosition()) { @@ -268,7 +280,7 @@ private void drawValueLabel(RenderContext context, ResolvedTextStyle style, doub default: return; } - context.drawText(text, textX, textY, style); + GuideText.emitText(c, text, textX, textY, style); } private String[] ensureCategories(int count) { diff --git a/src/main/java/com/hfstudio/guidenh/guide/document/block/chart/LytLineChart.java b/src/main/java/com/hfstudio/guidenh/guide/document/block/chart/LytLineChart.java index 8f7fc649..abaf237c 100644 --- a/src/main/java/com/hfstudio/guidenh/guide/document/block/chart/LytLineChart.java +++ b/src/main/java/com/hfstudio/guidenh/guide/document/block/chart/LytLineChart.java @@ -7,7 +7,9 @@ import com.hfstudio.guidenh.guide.color.ColorUtils; import com.hfstudio.guidenh.guide.document.LytRect; -import com.hfstudio.guidenh.guide.render.RenderContext; +import com.hfstudio.guidenh.guide.render.GuideRenderPrimitive; +import com.hfstudio.guidenh.guide.render.GuideText; +import com.hfstudio.guidenh.guide.render.PrimitiveCollector; import com.hfstudio.guidenh.guide.style.ResolvedTextStyle; import lombok.Getter; @@ -97,8 +99,8 @@ protected List collectCornerLegendEntries() { } @Override - protected void renderChart(RenderContext context, LytRect plotRect) { - if (series.isEmpty()) return; + protected LytRect renderChart(PrimitiveCollector c, LytRect plotRect) { + if (series.isEmpty()) return plotRect; double yMin = Double.POSITIVE_INFINITY; double yMax = Double.NEGATIVE_INFINITY; @@ -128,7 +130,6 @@ protected void renderChart(RenderContext context, LytRect plotRect) { int categoryCount = Math.max(categories.length, maxSeriesLength()); int[] insets = CartesianChartRenderer.computeAxisInsets( - context, xAxis, yAxis, xRange, @@ -138,10 +139,10 @@ protected void renderChart(RenderContext context, LytRect plotRect) { true); LytRect inner = plotRect.shrink(insets[0], insets[1], insets[2], insets[3]); plotCache = inner; - if (inner.width() <= 4 || inner.height() <= 4) return; + if (inner.width() <= 4 || inner.height() <= 4) return inner; CartesianChartRenderer.drawAxes( - context, + c, inner, xAxis, yAxis, @@ -169,7 +170,7 @@ protected void renderChart(RenderContext context, LytRect plotRect) { if (hoveredKey >= 0 && hoveredSeries == si && (hoveredPoint == i || hoveredPoint == i + 1)) { thick = LINE_THICKNESS + 1f; } - context.drawLine(px[i], py[i], px[i + 1], py[i + 1], thick, s.getColor()); + c.emit(new GuideRenderPrimitive.DrawLine(px[i], py[i], px[i + 1], py[i + 1], thick, s.getColor())); } // Data points. @@ -186,9 +187,9 @@ protected void renderChart(RenderContext context, LytRect plotRect) { y += off[1] * 2f; } float r = hovered ? POINT_RADIUS + 2f : POINT_RADIUS; - context.fillCircle(x, y, r, s.getColor()); + c.emit(new GuideRenderPrimitive.DrawCircle(x, y, r, s.getColor(), true)); if (hovered) { - context.drawCircleOutline(x, y, r, 1f, ColorUtils.BLACK.getColor()); + c.emit(new GuideRenderPrimitive.DrawCircleOutline(x, y, r, 1f, ColorUtils.BLACK.getColor())); } } } @@ -196,10 +197,10 @@ protected void renderChart(RenderContext context, LytRect plotRect) { // Data value labels. if (getLabelPosition() != ChartLabelPosition.NONE) { ResolvedTextStyle style = textStyle(getLabelColor()); - int lh = context.getLineHeight(style); + int lh = GuideText.lineHeight(style); for (int i = 0; i < n; i++) { String text = formatValue(s.getYs()[i]); - int tw = context.getStringWidth(text, style); + int tw = GuideText.measureWidth(text, style); int tx = (int) px[i] - tw / 2; int ty; switch (getLabelPosition()) { @@ -217,10 +218,11 @@ protected void renderChart(RenderContext context, LytRect plotRect) { default: continue; } - context.drawText(text, tx, ty, style); + GuideText.emitText(c, text, tx, ty, style); } } } + return inner; } private static float[] computeOutwardNormal(float[] xs, float[] ys, int i, int n) { diff --git a/src/main/java/com/hfstudio/guidenh/guide/document/block/chart/LytPieChart.java b/src/main/java/com/hfstudio/guidenh/guide/document/block/chart/LytPieChart.java index bfe41ca1..0cd50a0c 100644 --- a/src/main/java/com/hfstudio/guidenh/guide/document/block/chart/LytPieChart.java +++ b/src/main/java/com/hfstudio/guidenh/guide/document/block/chart/LytPieChart.java @@ -7,7 +7,9 @@ import com.hfstudio.guidenh.guide.document.LytRect; import com.hfstudio.guidenh.guide.internal.debug.DebugComponent; -import com.hfstudio.guidenh.guide.render.RenderContext; +import com.hfstudio.guidenh.guide.render.GuideRenderPrimitive; +import com.hfstudio.guidenh.guide.render.GuideText; +import com.hfstudio.guidenh.guide.render.PrimitiveCollector; import com.hfstudio.guidenh.guide.style.ResolvedTextStyle; import lombok.Getter; @@ -55,13 +57,13 @@ protected List collectLegendEntries() { } @Override - protected void renderChart(RenderContext context, LytRect plotRect) { - if (slices.isEmpty()) return; + protected LytRect renderChart(PrimitiveCollector c, LytRect plotRect) { + if (slices.isEmpty()) return plotRect; double total = 0d; for (PieSlice s : slices) { total += Math.max(0d, s.getValue()); } - if (total <= 0d) return; + if (total <= 0d) return plotRect; totalCache = total; float cx = plotRect.x() + plotRect.width() / 2f; @@ -72,7 +74,7 @@ protected void renderChart(RenderContext context, LytRect plotRect) { radiusCache = radius; ResolvedTextStyle labelStyle = textStyle(getLabelColor()); - int lh = context.getLineHeight(labelStyle); + int lh = GuideText.lineHeight(labelStyle); double angle = Math.toRadians(startAngleDeg); double dir = clockwise ? 1d : -1d; for (int i = 0; i < slices.size(); i++) { @@ -83,7 +85,7 @@ protected void renderChart(RenderContext context, LytRect plotRect) { // Hovered slice keeps its apex at (cx, cy); only the outer arc bulges outward by // HOVER_OFFSET so the wedge is emphasized without dislocating its centre. float drawRadius = hovered ? radius + HOVER_OFFSET : radius; - drawSlice(context, cx, cy, drawRadius, angle, sweep, slice.getColor()); + drawSlice(c, cx, cy, drawRadius, angle, sweep, slice.getColor()); // Label. ChartLabelPosition pos = getLabelPosition(); if (pos != ChartLabelPosition.NONE) { @@ -91,21 +93,30 @@ protected void renderChart(RenderContext context, LytRect plotRect) { case OUTSIDE, ABOVE, BELOW -> slice.getLabel() + " " + formatPercent(slice.getValue() / total); default -> formatPercent(slice.getValue() / total); }; - int tw = context.getStringWidth(text, labelStyle); + int tw = GuideText.measureWidth(text, labelStyle); + // OUTSIDE labels use labelR = drawRadius + lh + 4f so text is pushed clear of + // the pie boundary instead of sitting only 4px out. The clamp is relaxed by tw/2 on + // left/right and lh/2 on top/bottom so large-slice labels are not pulled back inside. float labelR = pos == ChartLabelPosition.OUTSIDE || pos == ChartLabelPosition.ABOVE - || pos == ChartLabelPosition.BELOW ? drawRadius + 4f : drawRadius * 0.6f; + || pos == ChartLabelPosition.BELOW ? drawRadius + Math.max(lh + 2f, 8f) : drawRadius * 0.6f; float tx = cx + (float) Math.cos(mid) * labelR - tw / 2f; float ty = cy + (float) Math.sin(mid) * labelR - lh / 2f; - // Clamp label inside the plot rectangle so OUTSIDE labels do not overflow the chart frame. - int clampedTx = Math.max(plotRect.x(), Math.min(plotRect.right() - tw, (int) tx)); - int clampedTy = Math.max(plotRect.y(), Math.min(plotRect.bottom() - lh, (int) ty)); - context.drawText(text, clampedTx, clampedTy, labelStyle); + // Clamp label with relaxed bounds so outside labels project beyond the pie without + // overflowing the chart frame entirely. + int relaxedLeft = plotRect.x() - (pos == ChartLabelPosition.OUTSIDE ? tw / 2 : 0); + int relaxedRight = plotRect.right() - tw + (pos == ChartLabelPosition.OUTSIDE ? tw / 2 : 0); + int clampedTx = Math.max(relaxedLeft, Math.min(relaxedRight, (int) tx)); + int clampedTy = Math.max( + plotRect.y() - (pos == ChartLabelPosition.OUTSIDE ? lh / 2 : 0), + Math.min(plotRect.bottom() - lh + (pos == ChartLabelPosition.OUTSIDE ? lh / 2 : 0), (int) ty)); + GuideText.emitText(c, text, clampedTx, clampedTy, labelStyle); } angle += sweep; } + return plotRect; } - private static void drawSlice(RenderContext context, float cx, float cy, float radius, double startAngle, + private static void drawSlice(PrimitiveCollector c, float cx, float cy, float radius, double startAngle, double sweepAngle, int color) { if (Math.abs(sweepAngle) < 1e-6) return; int segments = Math.max(2, (int) Math.ceil(CIRCLE_SEGMENTS * Math.abs(sweepAngle) / (Math.PI * 2d))); @@ -118,7 +129,7 @@ private static void drawSlice(RenderContext context, float cx, float cy, float r xs[i + 1] = cx + (float) Math.cos(a) * radius; ys[i + 1] = cy + (float) Math.sin(a) * radius; } - context.fillPolygon(xs, ys, color); + c.emit(new GuideRenderPrimitive.DrawPolygon(xs, ys, color)); } @Override diff --git a/src/main/java/com/hfstudio/guidenh/guide/document/block/chart/LytScatterChart.java b/src/main/java/com/hfstudio/guidenh/guide/document/block/chart/LytScatterChart.java index 7d3c13c0..5465cadf 100644 --- a/src/main/java/com/hfstudio/guidenh/guide/document/block/chart/LytScatterChart.java +++ b/src/main/java/com/hfstudio/guidenh/guide/document/block/chart/LytScatterChart.java @@ -7,7 +7,9 @@ import com.hfstudio.guidenh.guide.color.ColorUtils; import com.hfstudio.guidenh.guide.document.LytRect; -import com.hfstudio.guidenh.guide.render.RenderContext; +import com.hfstudio.guidenh.guide.render.GuideRenderPrimitive; +import com.hfstudio.guidenh.guide.render.GuideText; +import com.hfstudio.guidenh.guide.render.PrimitiveCollector; import com.hfstudio.guidenh.guide.style.ResolvedTextStyle; import lombok.Getter; @@ -70,8 +72,8 @@ protected List collectCornerLegendEntries() { } @Override - protected void renderChart(RenderContext context, LytRect plotRect) { - if (series.isEmpty()) return; + protected LytRect renderChart(PrimitiveCollector c, LytRect plotRect) { + if (series.isEmpty()) return plotRect; double xMin = Double.POSITIVE_INFINITY; double xMax = Double.NEGATIVE_INFINITY; double yMin = Double.POSITIVE_INFINITY; @@ -99,16 +101,15 @@ protected void renderChart(RenderContext context, LytRect plotRect) { xRangeCache = xRange; yRangeCache = yRange; - int[] insets = CartesianChartRenderer - .computeAxisInsets(context, xAxis, yAxis, xRange, yRange, null, true, true); + int[] insets = CartesianChartRenderer.computeAxisInsets(xAxis, yAxis, xRange, yRange, null, true, true); LytRect inner = plotRect.shrink(insets[0], insets[1], insets[2], insets[3]); plotCache = inner; - if (inner.width() <= 4 || inner.height() <= 4) return; + if (inner.width() <= 4 || inner.height() <= 4) return inner; - CartesianChartRenderer.drawAxes(context, inner, xAxis, yAxis, xRange, yRange, null, true); + CartesianChartRenderer.drawAxes(c, inner, xAxis, yAxis, xRange, yRange, null, true); ResolvedTextStyle valueStyle = textStyle(getLabelColor()); - int lh = context.getLineHeight(valueStyle); + int lh = GuideText.lineHeight(valueStyle); int hoveredSeries = decodeSeries(hoveredKey); int hoveredPoint = decodePoint(hoveredKey); for (int si = 0; si < series.size(); si++) { @@ -120,13 +121,13 @@ protected void renderChart(RenderContext context, LytRect plotRect) { boolean hovered = hoveredKey >= 0 && hoveredSeries == si && hoveredPoint == i; float r = hovered ? POINT_RADIUS + 2f : POINT_RADIUS; int color = hovered ? brighten(s.getColor()) : s.getColor(); - context.fillCircle(x, y, r, color); + c.emit(new GuideRenderPrimitive.DrawCircle(x, y, r, color, true)); if (hovered) { - context.drawCircleOutline(x, y, r, 1f, ColorUtils.BLACK.getColor()); + c.emit(new GuideRenderPrimitive.DrawCircleOutline(x, y, r, 1f, ColorUtils.BLACK.getColor())); } if (getLabelPosition() != ChartLabelPosition.NONE) { String text = "(" + formatValue(s.getXs()[i]) + "," + formatValue(s.getYs()[i]) + ")"; - int tw = context.getStringWidth(text, valueStyle); + int tw = GuideText.measureWidth(text, valueStyle); int tx = (int) x - tw / 2; int ty; switch (getLabelPosition()) { @@ -140,10 +141,11 @@ protected void renderChart(RenderContext context, LytRect plotRect) { default: continue; } - context.drawText(text, tx, ty, valueStyle); + GuideText.emitText(c, text, tx, ty, valueStyle); } } } + return inner; } private static int brighten(int argb) { diff --git a/src/main/java/com/hfstudio/guidenh/guide/document/block/chart/PieInsetRenderer.java b/src/main/java/com/hfstudio/guidenh/guide/document/block/chart/PieInsetRenderer.java index c046f074..82b33359 100644 --- a/src/main/java/com/hfstudio/guidenh/guide/document/block/chart/PieInsetRenderer.java +++ b/src/main/java/com/hfstudio/guidenh/guide/document/block/chart/PieInsetRenderer.java @@ -2,7 +2,9 @@ import com.hfstudio.guidenh.guide.color.ColorUtils; import com.hfstudio.guidenh.guide.document.LytRect; -import com.hfstudio.guidenh.guide.render.RenderContext; +import com.hfstudio.guidenh.guide.render.GuideRenderPrimitive; +import com.hfstudio.guidenh.guide.render.GuideText; +import com.hfstudio.guidenh.guide.render.PrimitiveCollector; import com.hfstudio.guidenh.guide.style.ResolvedTextStyle; /** @@ -16,7 +18,7 @@ public class PieInsetRenderer { protected PieInsetRenderer() {} - public static void draw(RenderContext context, LytRect plotRect, PieInsetSpec spec) { + public static void draw(PrimitiveCollector c, LytRect plotRect, PieInsetSpec spec) { if (spec == null || spec.getSlices() .isEmpty()) { return; @@ -47,14 +49,14 @@ public static void draw(RenderContext context, LytRect plotRect, PieInsetSpec sp areaY = plotRect.y() + MARGIN; break; } - drawInternal(context, spec, areaX, areaY, size); + drawInternal(c, spec, areaX, areaY, size); } /** * Draw the pie inset filling the provided rectangle entirely; used when the host chart reserves a * dedicated outside area (e.g. {@link PieInsetSpec.Position#RIGHT_OUTSIDE}). */ - public static void drawAt(RenderContext context, LytRect area, PieInsetSpec spec) { + public static void drawAt(PrimitiveCollector c, LytRect area, PieInsetSpec spec) { if (spec == null || area == null || area.width() <= 16 || area.height() <= 16) { return; } @@ -65,10 +67,10 @@ public static void drawAt(RenderContext context, LytRect area, PieInsetSpec spec int size = Math.min(area.width(), area.height()); int areaX = area.x() + (area.width() - size) / 2; int areaY = area.y() + (area.height() - size) / 2; - drawInternal(context, spec, areaX, areaY, size); + drawInternal(c, spec, areaX, areaY, size); } - private static void drawInternal(RenderContext context, PieInsetSpec spec, int areaX, int areaY, int size) { + private static void drawInternal(PrimitiveCollector c, PieInsetSpec spec, int areaX, int areaY, int size) { double total = 0d; for (PieSlice slice : spec.getSlices()) { total += Math.max(0d, slice.getValue()); @@ -80,13 +82,13 @@ private static void drawInternal(RenderContext context, PieInsetSpec spec, int a int titleHeight = 0; if (!spec.getTitle() .isEmpty()) { - titleHeight = context.getLineHeight(titleStyle); - int tw = context.getStringWidth(spec.getTitle(), titleStyle); + titleHeight = GuideText.lineHeight(titleStyle); + int tw = GuideText.measureWidth(spec.getTitle(), titleStyle); // Center within the inset; if the title is wider than the inset, anchor to the inset's // left edge so it does not bleed into the host plot. int tx = tw <= size ? areaX + (size - tw) / 2 : areaX; int ty = areaY; - context.drawText(spec.getTitle(), tx, ty, titleStyle); + GuideText.emitText(c, spec.getTitle(), tx, ty, titleStyle); } int pieSize = size - titleHeight; @@ -99,14 +101,14 @@ private static void drawInternal(RenderContext context, PieInsetSpec spec, int a double dir = spec.isClockwise() ? 1d : -1d; for (PieSlice slice : spec.getSlices()) { double sweep = (slice.getValue() / total) * Math.PI * 2d * dir; - drawSlice(context, cx, cy, radius, angle, sweep, slice.getColor()); + drawSlice(c, cx, cy, radius, angle, sweep, slice.getColor()); angle += sweep; } // Thin outline around the pie to separate it from the host plot. - context.drawCircleOutline(cx, cy, radius, 1f, ColorUtils.ARGB_FF202020.getColor()); + c.emit(new GuideRenderPrimitive.DrawCircleOutline(cx, cy, radius, 1f, ColorUtils.ARGB_FF202020.getColor())); } - private static void drawSlice(RenderContext context, float cx, float cy, float radius, double startAngle, + private static void drawSlice(PrimitiveCollector c, float cx, float cy, float radius, double startAngle, double sweepAngle, int color) { if (Math.abs(sweepAngle) < 1e-6) return; int segments = Math.max(2, (int) Math.ceil(CIRCLE_SEGMENTS * Math.abs(sweepAngle) / (Math.PI * 2d))); @@ -119,7 +121,7 @@ private static void drawSlice(RenderContext context, float cx, float cy, float r xs[i + 1] = cx + (float) Math.cos(a) * radius; ys[i + 1] = cy + (float) Math.sin(a) * radius; } - context.fillPolygon(xs, ys, color); + c.emit(new GuideRenderPrimitive.DrawPolygon(xs, ys, color)); } private static ResolvedTextStyle textStyle(int color) { diff --git a/src/main/java/com/hfstudio/guidenh/guide/document/block/functiongraph/DomainPredicate.java b/src/main/java/com/hfstudio/guidenh/guide/document/block/functiongraph/DomainPredicate.java index 25e8da98..96752486 100644 --- a/src/main/java/com/hfstudio/guidenh/guide/document/block/functiongraph/DomainPredicate.java +++ b/src/main/java/com/hfstudio/guidenh/guide/document/block/functiongraph/DomainPredicate.java @@ -131,12 +131,29 @@ static double parseNumberOrConstant(String text, double fallback) { if (text == null || text.isEmpty()) { return fallback; } - double constant = FunctionLibrary.constant(text); + String trimmed = text.trim(); + if (trimmed.isEmpty()) { + return fallback; + } + // Unary sign before a constant ("-pi", "+tau"): Double.parseDouble + // handles signed numbers itself, but the constant lookup needs the + // sign stripped and re-applied. + int sign = 1; + String body = trimmed; + if (body.startsWith("-")) { + sign = -1; + body = body.substring(1) + .trim(); + } else if (body.startsWith("+")) { + body = body.substring(1) + .trim(); + } + double constant = FunctionLibrary.constant(body); if (!Double.isNaN(constant)) { - return constant; + return sign * constant; } try { - return Double.parseDouble(text); + return Double.parseDouble(trimmed); } catch (NumberFormatException ex) { return fallback; } diff --git a/src/main/java/com/hfstudio/guidenh/guide/document/block/functiongraph/LytFunctionGraph.java b/src/main/java/com/hfstudio/guidenh/guide/document/block/functiongraph/LytFunctionGraph.java index dad1f23c..2ee38b95 100644 --- a/src/main/java/com/hfstudio/guidenh/guide/document/block/functiongraph/LytFunctionGraph.java +++ b/src/main/java/com/hfstudio/guidenh/guide/document/block/functiongraph/LytFunctionGraph.java @@ -4,6 +4,8 @@ import java.util.List; import java.util.Optional; +import org.scilab.forge.jlatexmath.TeXConstants; + import com.hfstudio.guidenh.guide.color.ColorUtils; import com.hfstudio.guidenh.guide.color.ConstantColor; import com.hfstudio.guidenh.guide.document.LytRect; @@ -19,6 +21,9 @@ import com.hfstudio.guidenh.guide.internal.markdown.MarkdownLatexShorthand; import com.hfstudio.guidenh.guide.latex.GuideLatexRenderer; import com.hfstudio.guidenh.guide.layout.LayoutContext; +import com.hfstudio.guidenh.guide.render.GuideRenderPrimitive; +import com.hfstudio.guidenh.guide.render.GuideText; +import com.hfstudio.guidenh.guide.render.PrimitiveCollector; import com.hfstudio.guidenh.guide.render.RenderContext; import com.hfstudio.guidenh.guide.style.ResolvedTextStyle; import com.hfstudio.guidenh.guide.style.TextAlignment; @@ -40,8 +45,8 @@ */ public class LytFunctionGraph extends LytBlock implements InteractiveElement, DocumentDragTarget { - private static final int DEFAULT_WIDTH = 320; - private static final int DEFAULT_HEIGHT = 220; + public static final int DEFAULT_WIDTH = 320; + public static final int DEFAULT_HEIGHT = 220; private static final int PADDING = 8; private static final int TITLE_GAP = 4; private static final int AXIS_LABEL_GAP = 4; @@ -65,13 +70,39 @@ public class LytFunctionGraph extends LytBlock implements InteractiveElement, Do private static final int AUTO_POINT_SOLVE_STEPS = 24; private static final int AUTO_POINT_LABEL_GAP = 3; private static final int MIN_PLOT_HEIGHT = 88; - private static final float LABEL_LATEX_SOURCE_SCALE = 100f; private static final ResolvedTextStyle TITLE_STYLE = makeStyle(ColorUtils.ARGB_FFE6E6E6.getColor(), true); private static final ResolvedTextStyle AXIS_LABEL_STYLE = makeStyle(ColorUtils.CHART_LABEL.getColor(), false); private static final ResolvedTextStyle TOOLTIP_BODY_STYLE = makeStyle(ColorUtils.ARGB_FFD7DEE7.getColor(), false); private static final ResolvedTextStyle LEGEND_LABEL_STYLE = makeStyle(ColorUtils.ARGB_FFD7DEE7.getColor(), false); + // Exposure for serializer precomputation (no flatc available). + + /** @see #TITLE_GAP */ + public static int getTitleGapConstant() { + return TITLE_GAP; + } + + /** @see #TITLE_STYLE */ + public static ResolvedTextStyle getTitleStyle() { + return TITLE_STYLE; + } + + /** @see #LEGEND_LABEL_STYLE */ + public static ResolvedTextStyle getLegendLabelStyle() { + return LEGEND_LABEL_STYLE; + } + + /** @see #LEGEND_SWATCH_SIZE */ + public static int getLegendSwatchSize() { + return LEGEND_SWATCH_SIZE; + } + + /** @see #LEGEND_SWATCH_TEXT_GAP */ + public static int getLegendSwatchTextGap() { + return LEGEND_SWATCH_TEXT_GAP; + } + @Getter private final List plots = new ArrayList<>(); @Getter @@ -220,7 +251,9 @@ protected LytRect computeLayout(LayoutContext context, int x, int y, int availab if (xLabel != null && !xLabel.isEmpty()) { fixedChromeHeight += measureRichTextHeight(context, xLabel, AXIS_LABEL_STYLE) + AXIS_LABEL_GAP; } - int legendHeight = measureLegendHeight(context, plotWidth); + // Skip bottom legend space when corner legend is active. + boolean hasCornerLegend = cornerLegendPosition != CornerLegendPosition.NONE; + int legendHeight = hasCornerLegend ? 0 : measureLegendHeight(plotWidth); if (legendHeight > 0) { fixedChromeHeight += legendHeight + LEGEND_GAP_ABOVE; } @@ -240,9 +273,35 @@ protected void onLayoutMoved(int deltaX, int deltaY) { } @Override - public void render(RenderContext context) { - context.fillRect(bounds, backgroundColor); - context.drawBorder(bounds, borderColor, 1); + protected void onExternalLayoutApplied(LytRect oldBounds, LytRect newBounds) { + invalidateSamples(); + } + + @Override + public boolean usePrimitives() { + return true; + } + + @Override + public void computePrimitives(PrimitiveCollector c) { + c.emit( + new GuideRenderPrimitive.FillRect( + bounds.x(), + bounds.y(), + bounds.width(), + bounds.height(), + backgroundColor)); + c.emit( + new GuideRenderPrimitive.DrawBorder( + bounds.x(), + bounds.y(), + bounds.width(), + bounds.height(), + 1, + 1, + 1, + 1, + borderColor)); int contentTop = bounds.y() + PADDING; int contentBottom = bounds.bottom() - PADDING; @@ -250,25 +309,27 @@ public void render(RenderContext context) { int contentRight = bounds.right() - PADDING; if (title != null && !title.isEmpty()) { - int titleHeight = measureRichTextHeight(context, title, TITLE_STYLE); - int tw = measureRichTextWidth(context, title, TITLE_STYLE); + int titleHeight = measureRichTextHeight(title, TITLE_STYLE); + int tw = measureRichTextWidth(title, TITLE_STYLE); int tx = bounds.x() + (bounds.width() - tw) / 2; - drawRichText(context, title, tx, contentTop, TITLE_STYLE); + drawRichText(c, title, tx, contentTop, TITLE_STYLE); contentTop += titleHeight + TITLE_GAP; } if (yLabel != null && !yLabel.isEmpty()) { - drawRichText(context, yLabel, contentLeft + AXIS_PAD_LEFT, contentTop, AXIS_LABEL_STYLE); - contentTop += measureRichTextHeight(context, yLabel, AXIS_LABEL_STYLE) + AXIS_LABEL_GAP; + drawRichText(c, yLabel, contentLeft + AXIS_PAD_LEFT, contentTop, AXIS_LABEL_STYLE); + contentTop += measureRichTextHeight(yLabel, AXIS_LABEL_STYLE) + AXIS_LABEL_GAP; } int plotLeft = contentLeft + AXIS_PAD_LEFT; int plotRight = contentRight; int plotTop = contentTop; int legendWidth = Math.max(0, plotRight - plotLeft); - int legendHeight = measureLegendHeight(context, legendWidth); + // When a corner legend is active, skip the bottom legend entirely and reserve no space. + boolean hasCornerLegend = cornerLegendPosition != CornerLegendPosition.NONE; + int legendHeight = hasCornerLegend ? 0 : measureLegendHeight(legendWidth); int xLabelHeight = xLabel != null && !xLabel.isEmpty() - ? measureRichTextHeight(context, xLabel, AXIS_LABEL_STYLE) + AXIS_LABEL_GAP + ? measureRichTextHeight(xLabel, AXIS_LABEL_STYLE) + AXIS_LABEL_GAP : 0; int plotBottom = contentBottom - AXIS_PAD_BOTTOM - xLabelHeight @@ -289,37 +350,40 @@ public void render(RenderContext context) { } if (showGrid) { - drawGrid(context, plotRect); + drawGrid(c, plotRect); } if (showAxes) { - drawAxes(context, plotRect); + drawAxes(c, plotRect); } for (int i = 0; i < plots.size(); i++) { - renderPlot(context, plotRect, i); + renderPlot(c, plotRect, i); } - renderMarkedPoints(context, plotRect); - renderAutoPoints(context, plotRect); + renderMarkedPoints(c, plotRect); + renderAutoPoints(c, plotRect); if ((activePlotIndex >= 0 && activePlotIndex < plots.size()) || activeMarkedIndex >= 0 || activeAutoPlotIndex >= 0) { - renderActiveOverlay(context, plotRect); + renderActiveOverlay(c, plotRect); } - renderCornerLegend(context, plotRect); + renderCornerLegend(c, plotRect); if (legendHeight > 0) { int legendTop = plotRect.bottom() + AXIS_PAD_BOTTOM + LEGEND_GAP_ABOVE; - renderLegend(context, plotRect.x(), legendTop, legendWidth); + renderLegend(c, plotRect.x(), legendTop, legendWidth); } if (xLabel != null && !xLabel.isEmpty()) { - int labelWidth = measureRichTextWidth(context, xLabel, AXIS_LABEL_STYLE); + int labelWidth = measureRichTextWidth(xLabel, AXIS_LABEL_STYLE); int labelX = plotRect.x() + Math.max(0, (plotRect.width() - labelWidth) / 2); int labelY = plotRect.bottom() + AXIS_PAD_BOTTOM + (legendHeight > 0 ? legendHeight + LEGEND_GAP_ABOVE : 0); - drawRichText(context, xLabel, labelX, labelY, AXIS_LABEL_STYLE); + drawRichText(c, xLabel, labelX, labelY, AXIS_LABEL_STYLE); } } + @Override + public void render(RenderContext context) {} + @Override public Optional getTooltip(float x, float y) { if (!isDragging) { @@ -541,61 +605,61 @@ private double unmapXToData(double screenX, boolean inverse) { return inverse ? unmapY(screenX) : unmapX(screenX); } - private void drawGrid(RenderContext context, LytRect plotRect) { + private void drawGrid(PrimitiveCollector c, LytRect plotRect) { if (effectiveXStep > 0) { double start = Math.ceil(effectiveXMin / effectiveXStep) * effectiveXStep; for (double v = start; v <= effectiveXMax + 1e-9; v += effectiveXStep) { float x = (float) mapX(v); - context.drawLine(x, plotRect.y(), x, plotRect.bottom(), 1f, gridColor); + c.emit(new GuideRenderPrimitive.DrawLine(x, plotRect.y(), x, plotRect.bottom(), 1f, gridColor)); } } if (effectiveYStep > 0) { double start = Math.ceil(effectiveYMin / effectiveYStep) * effectiveYStep; for (double v = start; v <= effectiveYMax + 1e-9; v += effectiveYStep) { float y = (float) mapY(v); - context.drawLine(plotRect.x(), y, plotRect.right(), y, 1f, gridColor); + c.emit(new GuideRenderPrimitive.DrawLine(plotRect.x(), y, plotRect.right(), y, 1f, gridColor)); } } } - private void drawAxes(RenderContext context, LytRect plotRect) { + private void drawAxes(PrimitiveCollector c, LytRect plotRect) { // Vertical (y) axis pinned to x = 0 when visible, otherwise to plotRect.x. float axisX = (float) mapX(0d); if (axisX < plotRect.x() || axisX > plotRect.right()) { axisX = plotRect.x(); } - context.drawLine(axisX, plotRect.y(), axisX, plotRect.bottom(), 1f, axisColor); + c.emit(new GuideRenderPrimitive.DrawLine(axisX, plotRect.y(), axisX, plotRect.bottom(), 1f, axisColor)); float axisY = (float) mapY(0d); if (axisY < plotRect.y() || axisY > plotRect.bottom()) { axisY = plotRect.bottom(); } - context.drawLine(plotRect.x(), axisY, plotRect.right(), axisY, 1f, axisColor); + c.emit(new GuideRenderPrimitive.DrawLine(plotRect.x(), axisY, plotRect.right(), axisY, 1f, axisColor)); // Y tick labels along left edge of plot rect. if (effectiveYStep > 0) { double start = Math.ceil(effectiveYMin / effectiveYStep) * effectiveYStep; - int lh = context.getLineHeight(AXIS_LABEL_STYLE); + int lh = GuideText.lineHeight(AXIS_LABEL_STYLE); for (double v = start; v <= effectiveYMax + 1e-9; v += effectiveYStep) { String label = formatTick(v); - int sw = context.getStringWidth(label, AXIS_LABEL_STYLE); + int sw = GuideText.measureWidth(label, AXIS_LABEL_STYLE); int ly = (int) mapY(v) - lh / 2; - context.drawText(label, plotRect.x() - sw - AXIS_LABEL_GAP, ly, AXIS_LABEL_STYLE); + GuideText.emitText(c, label, plotRect.x() - sw - AXIS_LABEL_GAP, ly, AXIS_LABEL_STYLE); } } if (effectiveXStep > 0) { double start = Math.ceil(effectiveXMin / effectiveXStep) * effectiveXStep; for (double v = start; v <= effectiveXMax + 1e-9; v += effectiveXStep) { String label = formatTick(v); - int sw = context.getStringWidth(label, AXIS_LABEL_STYLE); + int sw = GuideText.measureWidth(label, AXIS_LABEL_STYLE); int lx = (int) mapX(v) - sw / 2; lx = Math.clamp(lx, plotRect.x() - sw / 2, plotRect.right() - sw / 2); - context.drawText(label, lx, plotRect.bottom() + AXIS_LABEL_GAP, AXIS_LABEL_STYLE); + GuideText.emitText(c, label, lx, plotRect.bottom() + AXIS_LABEL_GAP, AXIS_LABEL_STYLE); } } } - private void renderPlot(RenderContext context, LytRect plotRect, int index) { + private void renderPlot(PrimitiveCollector c, LytRect plotRect, int index) { FunctionPlot plot = plots.get(index); float[] xs = sampleXs[index]; float[] ys = sampleYs[index]; @@ -626,11 +690,37 @@ private void renderPlot(RenderContext context, LytRect plotRect, int index) { if ((x1 < plotRect.x() && x2 < plotRect.x()) || (x1 > plotRect.right() && x2 > plotRect.right())) { continue; } - context.drawLine(x1, y1, x2, y2, thickness, color); + // Quadrant mask clipping: skip segments whose data endpoints are both outside + // the allowed quadrants. + if (explicitQuadrantMask != 0 && explicitQuadrantMask != 0xF) { + double dx1 = unmapX(x1); + double dy1 = unmapY(y1); + double dx2 = unmapX(x2); + double dy2 = unmapY(y2); + if (!isPointInQuadrant(dx1, dy1, explicitQuadrantMask) + && !isPointInQuadrant(dx2, dy2, explicitQuadrantMask)) { + continue; + } + } + c.emit(new GuideRenderPrimitive.DrawLine(x1, y1, x2, y2, thickness, color)); + } + } + + /** + * Check whether a data point falls within the allowed quadrant mask. + * Bits 0-3 correspond to quadrants 1-4 (Q1: x>=0,y>=0, Q2: x<0,y>=0, Q3: x<0,y<0, Q4: x>=0,y<0). + */ + private static boolean isPointInQuadrant(double dataX, double dataY, int mask) { + int q; + if (dataX >= 0d) { + q = dataY >= 0d ? 1 : 4; + } else { + q = dataY >= 0d ? 2 : 3; } + return (mask & (1 << (q - 1))) != 0; } - private void renderMarkedPoints(RenderContext context, LytRect plotRect) { + private void renderMarkedPoints(PrimitiveCollector c, LytRect plotRect) { for (MarkedPoint point : points) { double[] res = resolveMarkedPoint(point); if (res == null) { @@ -647,8 +737,14 @@ private void renderMarkedPoints(RenderContext context, LytRect plotRect) { if (sy < plotRect.y() - POINT_RADIUS || sy > plotRect.bottom() + POINT_RADIUS) { continue; } - context.fillCircle(sx, sy, POINT_RADIUS + POINT_OUTER_RING, ColorUtils.WHITE.getColor()); - context.fillCircle(sx, sy, POINT_RADIUS, color); + c.emit( + new GuideRenderPrimitive.DrawCircle( + sx, + sy, + POINT_RADIUS + POINT_OUTER_RING, + ColorUtils.WHITE.getColor(), + true)); + c.emit(new GuideRenderPrimitive.DrawCircle(sx, sy, POINT_RADIUS, color, true)); } } @@ -697,7 +793,7 @@ private double[] resolveMarkedPoint(MarkedPoint point) { return new double[] { dataX, dataY, (double) color }; } - private void renderAutoPoints(RenderContext context, LytRect plotRect) { + private void renderAutoPoints(PrimitiveCollector c, LytRect plotRect) { autoPointHitCache.clear(); for (int pi = 0; pi < plots.size(); pi++) { FunctionPlot plot = plots.get(pi); @@ -708,19 +804,19 @@ private void renderAutoPoints(RenderContext context, LytRect plotRect) { int color = spec.colorInherit() ? plot.getColor() : spec.color(); int drawn = 0; if (!Double.isNaN(spec.everyX())) { - drawn = renderAutoPointsEveryX(context, plotRect, plot, spec, color, drawn, pi); + drawn = renderAutoPointsEveryX(c, plotRect, plot, spec, color, drawn, pi); } if (!Double.isNaN(spec.everyY()) && drawn < AUTO_POINT_MAX_PER_PLOT) { - renderAutoPointsEveryY(context, plotRect, plot, spec, color, drawn, pi); + renderAutoPointsEveryY(c, plotRect, plot, spec, color, drawn, pi); } } } - private int renderAutoPointsEveryX(RenderContext context, LytRect plotRect, FunctionPlot plot, AutoPointSpec spec, + private int renderAutoPointsEveryX(PrimitiveCollector c, LytRect plotRect, FunctionPlot plot, AutoPointSpec spec, int color, int drawn, int plotIndex) { if (plot.isInverse()) { return renderAutoPointIntersectionsForAxis( - context, + c, plotRect, plot, spec, @@ -740,7 +836,7 @@ private int renderAutoPointsEveryX(RenderContext context, LytRect plotRect, Func while (value <= max + 1e-9 && drawn < AUTO_POINT_MAX_PER_PLOT && targets < AUTO_POINT_MAX_TARGETS_PER_PLOT) { double dataX = value; double dataY = plot.evaluate(value); - if (drawAutoPoint(context, plotRect, dataX, dataY, color, spec.labelMode(), plotIndex)) { + if (drawAutoPoint(c, plotRect, dataX, dataY, color, spec.labelMode(), plotIndex)) { drawn++; } value += step; @@ -749,7 +845,7 @@ private int renderAutoPointsEveryX(RenderContext context, LytRect plotRect, Func return drawn; } - private int renderAutoPointsEveryY(RenderContext context, LytRect plotRect, FunctionPlot plot, AutoPointSpec spec, + private int renderAutoPointsEveryY(PrimitiveCollector c, LytRect plotRect, FunctionPlot plot, AutoPointSpec spec, int color, int drawn, int plotIndex) { if (plot.isInverse()) { double step = spec.everyY(); @@ -759,7 +855,7 @@ private int renderAutoPointsEveryY(RenderContext context, LytRect plotRect, Func && targets < AUTO_POINT_MAX_TARGETS_PER_PLOT) { double dataY = value; double dataX = plot.evaluate(value); - if (drawAutoPoint(context, plotRect, dataX, dataY, color, spec.labelMode(), plotIndex)) { + if (drawAutoPoint(c, plotRect, dataX, dataY, color, spec.labelMode(), plotIndex)) { drawn++; } value += step; @@ -773,7 +869,7 @@ private int renderAutoPointsEveryY(RenderContext context, LytRect plotRect, Func while (value <= effectiveYMax + 1e-9 && drawn < AUTO_POINT_MAX_PER_PLOT && targets < AUTO_POINT_MAX_TARGETS_PER_PLOT) { drawn = renderAutoPointIntersectionsForAxis( - context, + c, plotRect, plot, spec, @@ -790,7 +886,7 @@ private int renderAutoPointsEveryY(RenderContext context, LytRect plotRect, Func return drawn; } - private int renderAutoPointIntersectionsForAxis(RenderContext context, LytRect plotRect, FunctionPlot plot, + private int renderAutoPointIntersectionsForAxis(PrimitiveCollector c, LytRect plotRect, FunctionPlot plot, AutoPointSpec spec, int color, double target, double targetMin, double targetMax, boolean targetX, int drawn, int plotIndex) { double independentMin = plot.isInverse() ? effectiveYMin : effectiveXMin; @@ -818,7 +914,7 @@ private int renderAutoPointIntersectionsForAxis(RenderContext context, LytRect p prevValue = value; continue; } - if (drawAutoPoint(context, plotRect, dataX, dataY, color, spec.labelMode(), plotIndex)) { + if (drawAutoPoint(c, plotRect, dataX, dataY, color, spec.labelMode(), plotIndex)) { drawn++; } } @@ -852,7 +948,7 @@ private double solveIndependentForAxis(FunctionPlot plot, double target, boolean return (lo + hi) * 0.5d; } - private boolean drawAutoPoint(RenderContext context, LytRect plotRect, double dataX, double dataY, int color, + private boolean drawAutoPoint(PrimitiveCollector c, LytRect plotRect, double dataX, double dataY, int color, AutoPointLabelMode labelMode, int plotIndex) { if (!Double.isFinite(dataX) || !Double.isFinite(dataY)) { return false; @@ -866,12 +962,18 @@ private boolean drawAutoPoint(RenderContext context, LytRect plotRect, double da return false; } autoPointHitCache.add(new double[] { sx, sy, dataX, dataY, (double) color, (double) plotIndex }); - context.fillCircle(sx, sy, POINT_RADIUS + POINT_OUTER_RING, ColorUtils.WHITE.getColor()); - context.fillCircle(sx, sy, POINT_RADIUS, color); + c.emit( + new GuideRenderPrimitive.DrawCircle( + sx, + sy, + POINT_RADIUS + POINT_OUTER_RING, + ColorUtils.WHITE.getColor(), + true)); + c.emit(new GuideRenderPrimitive.DrawCircle(sx, sy, POINT_RADIUS, color, true)); if (labelMode != null && labelMode != AutoPointLabelMode.NONE) { String label = autoPointLabel(labelMode, dataX, dataY); - int width = context.getStringWidth(label, TOOLTIP_BODY_STYLE); - int lineHeight = context.getLineHeight(TOOLTIP_BODY_STYLE); + int width = GuideText.measureWidth(label, TOOLTIP_BODY_STYLE); + int lineHeight = GuideText.lineHeight(TOOLTIP_BODY_STYLE); int x = (int) sx + AUTO_POINT_LABEL_GAP; if (x + width > plotRect.right()) { x = (int) sx - width - AUTO_POINT_LABEL_GAP; @@ -880,7 +982,7 @@ private boolean drawAutoPoint(RenderContext context, LytRect plotRect, double da if (y < plotRect.y()) { y = (int) sy + AUTO_POINT_LABEL_GAP; } - context.drawText(label, x, y, TOOLTIP_BODY_STYLE); + GuideText.emitText(c, label, x, y, TOOLTIP_BODY_STYLE); } return true; } @@ -894,7 +996,7 @@ private String autoPointLabel(AutoPointLabelMode labelMode, double dataX, double }; } - private void renderCornerLegend(RenderContext context, LytRect plotRect) { + private void renderCornerLegend(PrimitiveCollector c, LytRect plotRect) { if (cornerLegendPosition == CornerLegendPosition.NONE) { return; } @@ -905,8 +1007,8 @@ private void renderCornerLegend(RenderContext context, LytRect plotRect) { entries.add(new CornerLegendEntry(plot.getLabel(), plot.getColor(), true)); } } - CornerLegendRenderer.render( - context, + CornerLegendRenderer.emit( + c, plotRect, entries, cornerLegendPosition, @@ -915,13 +1017,13 @@ private void renderCornerLegend(RenderContext context, LytRect plotRect) { cornerLegendBackgroundColor); } - private void renderActiveOverlay(RenderContext context, LytRect plotRect) { + private void renderActiveOverlay(PrimitiveCollector c, LytRect plotRect) { if (activeMarkedIndex >= 0) { - renderMarkedPointOverlay(context, plotRect); + renderMarkedPointOverlay(c, plotRect); return; } if (activeAutoPlotIndex >= 0) { - renderAutoPointOverlay(context, plotRect); + renderAutoPointOverlay(c, plotRect); return; } if (activePlotIndex < 0 || activePlotIndex >= plots.size()) { @@ -944,12 +1046,17 @@ private void renderActiveOverlay(RenderContext context, LytRect plotRect) { if (sx < plotRect.x() || sx > plotRect.right() || sy < plotRect.y() || sy > plotRect.bottom()) { return; } - context.fillCircle(sx, sy, POINT_RADIUS + POINT_OUTER_RING, ColorUtils.WHITE.getColor()); - context.fillCircle(sx, sy, POINT_RADIUS, plot.getColor()); - + c.emit( + new GuideRenderPrimitive.DrawCircle( + sx, + sy, + POINT_RADIUS + POINT_OUTER_RING, + ColorUtils.WHITE.getColor(), + true)); + c.emit(new GuideRenderPrimitive.DrawCircle(sx, sy, POINT_RADIUS, plot.getColor(), true)); } - private void renderMarkedPointOverlay(RenderContext context, LytRect plotRect) { + private void renderMarkedPointOverlay(PrimitiveCollector c, LytRect plotRect) { double dataX = activeMarkedDataX; double dataY = activeMarkedDataY; int color = activeMarkedColor; @@ -959,13 +1066,12 @@ private void renderMarkedPointOverlay(RenderContext context, LytRect plotRect) { return; } // Larger highlight for marked points. - context.fillCircle(sx, sy, POINT_RADIUS + 2f, ColorUtils.WHITE.getColor()); - context.drawCircleOutline(sx, sy, POINT_RADIUS + 2f, 1f, ColorUtils.BLACK.getColor()); - context.fillCircle(sx, sy, POINT_RADIUS, color); - + c.emit(new GuideRenderPrimitive.DrawCircle(sx, sy, POINT_RADIUS + 2f, ColorUtils.WHITE.getColor(), true)); + c.emit(new GuideRenderPrimitive.DrawCircleOutline(sx, sy, POINT_RADIUS + 2f, 1f, ColorUtils.BLACK.getColor())); + c.emit(new GuideRenderPrimitive.DrawCircle(sx, sy, POINT_RADIUS, color, true)); } - private void renderAutoPointOverlay(RenderContext context, LytRect plotRect) { + private void renderAutoPointOverlay(PrimitiveCollector c, LytRect plotRect) { double dataX = activeAutoDataX; double dataY = activeAutoDataY; int color = activeAutoColor; @@ -974,10 +1080,9 @@ private void renderAutoPointOverlay(RenderContext context, LytRect plotRect) { if (sx < plotRect.x() || sx > plotRect.right() || sy < plotRect.y() || sy > plotRect.bottom()) { return; } - context.fillCircle(sx, sy, POINT_RADIUS + 2f, ColorUtils.WHITE.getColor()); - context.drawCircleOutline(sx, sy, POINT_RADIUS + 2f, 1f, ColorUtils.BLACK.getColor()); - context.fillCircle(sx, sy, POINT_RADIUS, color); - + c.emit(new GuideRenderPrimitive.DrawCircle(sx, sy, POINT_RADIUS + 2f, ColorUtils.WHITE.getColor(), true)); + c.emit(new GuideRenderPrimitive.DrawCircleOutline(sx, sy, POINT_RADIUS + 2f, 1f, ColorUtils.BLACK.getColor())); + c.emit(new GuideRenderPrimitive.DrawCircle(sx, sy, POINT_RADIUS, color, true)); } private Optional createActiveTooltip() { @@ -1061,22 +1166,12 @@ private Optional coordinateTooltip(String label, double dataX, dou return Optional.of(new TextTooltip(label + "\n(" + formatValue(dataX) + ", " + formatValue(dataY) + ")")); } - private int measureRichTextWidth(RenderContext context, String text, ResolvedTextStyle style) { - int width = 0; - int lineHeight = context.getLineHeight(style); - for (MarkdownLatexShorthand.Segment segment : MarkdownLatexShorthand.split(text)) { - if (segment.isFormula()) { - LatexMetrics metrics = measureLatex(segment.getValue(), lineHeight); - width += metrics != null ? metrics.width - : context.getStringWidth(delimitedFormula(segment.getValue()), style); - } else { - width += context.getStringWidth(segment.getValue(), style); - } - } - return width; - } - - private int measureRichTextHeight(RenderContext context, String text, ResolvedTextStyle style) { + /** + * Layout-time variant of {@link #measureRichTextHeight(String, ResolvedTextStyle)}; the layout + * context supplies the same line height the primitive pass uses, so chrome reserved here matches + * the chrome drawn later. + */ + private static int measureRichTextHeight(LayoutContext context, String text, ResolvedTextStyle style) { int height = context.getLineHeight(style); for (MarkdownLatexShorthand.Segment segment : MarkdownLatexShorthand.split(text)) { if (segment.isFormula()) { @@ -1089,23 +1184,23 @@ private int measureRichTextHeight(RenderContext context, String text, ResolvedTe return height; } - private int measureRichTextWidth(LayoutContext context, String text, ResolvedTextStyle style) { + private static int measureRichTextWidth(String text, ResolvedTextStyle style) { int width = 0; - int lineHeight = context.getLineHeight(style); + int lineHeight = GuideText.lineHeight(style); for (MarkdownLatexShorthand.Segment segment : MarkdownLatexShorthand.split(text)) { if (segment.isFormula()) { LatexMetrics metrics = measureLatex(segment.getValue(), lineHeight); width += metrics != null ? metrics.width - : measureTextWidth(context, style, delimitedFormula(segment.getValue())); + : GuideText.measureWidth(delimitedFormula(segment.getValue()), style); } else { - width += measureTextWidth(context, style, segment.getValue()); + width += GuideText.measureWidth(segment.getValue(), style); } } return width; } - private int measureRichTextHeight(LayoutContext context, String text, ResolvedTextStyle style) { - int height = context.getLineHeight(style); + private static int measureRichTextHeight(String text, ResolvedTextStyle style) { + int height = GuideText.lineHeight(style); for (MarkdownLatexShorthand.Segment segment : MarkdownLatexShorthand.split(text)) { if (segment.isFormula()) { LatexMetrics metrics = measureLatex(segment.getValue(), height); @@ -1117,52 +1212,53 @@ private int measureRichTextHeight(LayoutContext context, String text, ResolvedTe return height; } - private void drawRichText(RenderContext context, String text, int x, int y, ResolvedTextStyle style) { - int lineHeight = context.getLineHeight(style); - int totalHeight = measureRichTextHeight(context, text, style); + private void drawRichText(PrimitiveCollector c, String text, int x, int y, ResolvedTextStyle style) { + int lineHeight = GuideText.lineHeight(style); + int totalHeight = measureRichTextHeight(text, style); int cursorX = x; int fillColor = style == TITLE_STYLE ? ColorUtils.ARGB_FFE6E6E6.getColor() : ColorUtils.CHART_LABEL.getColor(); for (MarkdownLatexShorthand.Segment segment : MarkdownLatexShorthand.split(text)) { if (!segment.isFormula()) { - context.drawText(segment.getValue(), cursorX, y + (totalHeight - lineHeight) / 2, style); - cursorX += context.getStringWidth(segment.getValue(), style); + GuideText.emitText(c, segment.getValue(), cursorX, y + (totalHeight - lineHeight) / 2, style); + cursorX += GuideText.measureWidth(segment.getValue(), style); continue; } LatexMetrics metrics = measureLatex(segment.getValue(), lineHeight); - int[] texture = metrics != null - ? GuideLatexRenderer.INSTANCE - .getOrCreateTexture(segment.getValue(), fillColor, LABEL_LATEX_SOURCE_SCALE) - : null; - if (metrics == null || texture == null) { + if (metrics == null) { String fallback = delimitedFormula(segment.getValue()); - context.drawText(fallback, cursorX, y + (totalHeight - lineHeight) / 2, style); - cursorX += context.getStringWidth(fallback, style); + GuideText.emitText(c, fallback, cursorX, y + (totalHeight - lineHeight) / 2, style); + cursorX += GuideText.measureWidth(fallback, style); continue; } - GuideLatexRenderer.INSTANCE.renderLatex( - cursorX, - y + (totalHeight - metrics.height) / 2, - metrics.width, - metrics.height, - texture[0]); + int token = GuideLatexRenderer.INSTANCE.registerLatexBlit(segment.getValue(), fillColor, lineHeight, 0); + c.emit( + new GuideRenderPrimitive.BlitTexture( + token, + cursorX, + y + (totalHeight - metrics.height) / 2, + metrics.width, + metrics.height, + 0f, + 0f, + 1f, + 1f)); cursorX += metrics.width; } } - private static LatexMetrics measureLatex(String formula, int lineHeight) { - int[] source = GuideLatexRenderer.INSTANCE - .measureSize(formula, ColorUtils.WHITE.getColor(), LABEL_LATEX_SOURCE_SCALE); - if (source == null) { - return null; - } - int referenceHeight = GuideLatexRenderer.INSTANCE.calibrateRefHeight(LABEL_LATEX_SOURCE_SCALE); - if (referenceHeight <= 0) { + /** + * Typesets {@code formula} at {@code fontSize} pixels and returns its display + * size. The renderer typesets at the target size, so the icon dimensions are + * the display dimensions - no reference-height calibration is applied. + */ + private static LatexMetrics measureLatex(String formula, float fontSize) { + int[] size = GuideLatexRenderer.INSTANCE + .measureSize(formula, ColorUtils.WHITE.getColor(), fontSize, TeXConstants.STYLE_TEXT); + if (size == null) { return null; } - int width = Math.max(1, (int) Math.ceil((double) source[0] * lineHeight / referenceHeight)); - int height = Math.max(1, (int) Math.ceil((double) source[1] * lineHeight / referenceHeight)); - return new LatexMetrics(width, height); + return new LatexMetrics(Math.max(1, size[0]), Math.max(1, size[1])); } private static String delimitedFormula(String formula) { @@ -1175,7 +1271,7 @@ private record LatexMetrics(int width, int height) {} * Measure the total height needed to lay out the legend below the plot, given the available * width. Returns {@code 0} when no plot has a label, suppressing the legend area entirely. */ - private int measureLegendHeight(RenderContext context, int availableWidth) { + private int measureLegendHeight(int availableWidth) { if (availableWidth <= 0) { return 0; } @@ -1190,7 +1286,7 @@ private int measureLegendHeight(RenderContext context, int availableWidth) { if (!any) { return 0; } - int rowHeight = Math.max(LEGEND_SWATCH_SIZE, context.getLineHeight(LEGEND_LABEL_STYLE)); + int rowHeight = Math.max(LEGEND_SWATCH_SIZE, GuideText.lineHeight(LEGEND_LABEL_STYLE)); int rows = 1; int rowWidth = 0; for (FunctionPlot plot : plots) { @@ -1199,7 +1295,7 @@ private int measureLegendHeight(RenderContext context, int availableWidth) { continue; } int itemWidth = LEGEND_SWATCH_SIZE + LEGEND_SWATCH_TEXT_GAP - + context.getStringWidth(label, LEGEND_LABEL_STYLE); + + GuideText.measureWidth(label, LEGEND_LABEL_STYLE); int needed = rowWidth == 0 ? itemWidth : rowWidth + LEGEND_ITEM_GAP + itemWidth; if (rowWidth > 0 && needed > availableWidth) { rows++; @@ -1211,64 +1307,15 @@ private int measureLegendHeight(RenderContext context, int availableWidth) { return rows * rowHeight + (rows - 1) * LEGEND_ROW_GAP; } - private int measureLegendHeight(LayoutContext context, int availableWidth) { - if (availableWidth <= 0) { - return 0; - } - boolean any = false; - for (FunctionPlot plot : plots) { - if (plot.getLabel() != null && !plot.getLabel() - .isEmpty()) { - any = true; - break; - } - } - if (!any) { - return 0; - } - int rowHeight = Math.max(LEGEND_SWATCH_SIZE, context.getLineHeight(LEGEND_LABEL_STYLE)); - int rows = 1; - int rowWidth = 0; - for (FunctionPlot plot : plots) { - String label = plot.getLabel(); - if (label == null || label.isEmpty()) { - continue; - } - int itemWidth = LEGEND_SWATCH_SIZE + LEGEND_SWATCH_TEXT_GAP - + measureTextWidth(context, LEGEND_LABEL_STYLE, label); - int needed = rowWidth == 0 ? itemWidth : rowWidth + LEGEND_ITEM_GAP + itemWidth; - if (rowWidth > 0 && needed > availableWidth) { - rows++; - rowWidth = itemWidth; - } else { - rowWidth = needed; - } - } - return rows * rowHeight + (rows - 1) * LEGEND_ROW_GAP; - } - - private int measureTextWidth(LayoutContext context, ResolvedTextStyle style, String text) { - if (text == null || text.isEmpty()) { - return 0; - } - float width = 0f; - for (int offset = 0; offset < text.length();) { - int codePoint = text.codePointAt(offset); - width += context.getAdvance(codePoint, style); - offset += Character.charCount(codePoint); - } - return Math.round(width); - } - /** * Render the legend at {@code (left, top)}. Items flow left-to-right and wrap onto a new row * once the next item would exceed {@code availableWidth}. */ - private void renderLegend(RenderContext context, int left, int top, int availableWidth) { + private void renderLegend(PrimitiveCollector c, int left, int top, int availableWidth) { if (availableWidth <= 0) { return; } - int rowHeight = Math.max(LEGEND_SWATCH_SIZE, context.getLineHeight(LEGEND_LABEL_STYLE)); + int rowHeight = Math.max(LEGEND_SWATCH_SIZE, GuideText.lineHeight(LEGEND_LABEL_STYLE)); int x = left; int y = top; boolean firstInRow = true; @@ -1278,7 +1325,7 @@ private void renderLegend(RenderContext context, int left, int top, int availabl continue; } int itemWidth = LEGEND_SWATCH_SIZE + LEGEND_SWATCH_TEXT_GAP - + context.getStringWidth(label, LEGEND_LABEL_STYLE); + + GuideText.measureWidth(label, LEGEND_LABEL_STYLE); int needed = firstInRow ? itemWidth : (x - left) + LEGEND_ITEM_GAP + itemWidth; if (!firstInRow && needed > availableWidth) { y += rowHeight + LEGEND_ROW_GAP; @@ -1289,11 +1336,21 @@ private void renderLegend(RenderContext context, int left, int top, int availabl x += LEGEND_ITEM_GAP; } int swatchY = y + (rowHeight - LEGEND_SWATCH_SIZE) / 2; - LytRect swatch = new LytRect(x, swatchY, LEGEND_SWATCH_SIZE, LEGEND_SWATCH_SIZE); - context.fillRect(swatch, plot.getColor()); - context.drawBorder(swatch, ColorUtils.BLACK.getColor(), 1); - int textY = y + (rowHeight - context.getLineHeight(LEGEND_LABEL_STYLE)) / 2; - context.drawText(label, x + LEGEND_SWATCH_SIZE + LEGEND_SWATCH_TEXT_GAP, textY, LEGEND_LABEL_STYLE); + c.emit( + new GuideRenderPrimitive.FillRect(x, swatchY, LEGEND_SWATCH_SIZE, LEGEND_SWATCH_SIZE, plot.getColor())); + c.emit( + new GuideRenderPrimitive.DrawBorder( + x, + swatchY, + LEGEND_SWATCH_SIZE, + LEGEND_SWATCH_SIZE, + 1, + 1, + 1, + 1, + ColorUtils.BLACK.getColor())); + int textY = y + (rowHeight - GuideText.lineHeight(LEGEND_LABEL_STYLE)) / 2; + GuideText.emitText(c, label, x + LEGEND_SWATCH_SIZE + LEGEND_SWATCH_TEXT_GAP, textY, LEGEND_LABEL_STYLE); x += itemWidth; firstInRow = false; } @@ -1548,6 +1605,7 @@ private static ResolvedTextStyle makeStyle(int argb, boolean bold) { TextAlignment.LEFT, false, null, - false); + false, + 0.0f); } } diff --git a/src/main/java/com/hfstudio/guidenh/guide/document/block/recipes/LytRecipeGalleryRow.java b/src/main/java/com/hfstudio/guidenh/guide/document/block/recipes/LytRecipeGalleryRow.java new file mode 100644 index 00000000..0a714fdf --- /dev/null +++ b/src/main/java/com/hfstudio/guidenh/guide/document/block/recipes/LytRecipeGalleryRow.java @@ -0,0 +1,29 @@ +package com.hfstudio.guidenh.guide.document.block.recipes; + +import com.hfstudio.guidenh.guide.document.block.LytBlock; +import com.hfstudio.guidenh.guide.document.block.LytHBox; +import com.hfstudio.guidenh.guide.internal.recipe.LytNeiRecipeBox; + +/** + * A wrapping horizontal row that groups consecutive recipe boxes into a + * "gallery" so multiple recipes share a row when the available width allows. + * Created by {@code LytDocument}'s recipe-gallery grouping pass; also the + * marker type used to recognize (and extend) existing galleries. + */ +public class LytRecipeGalleryRow extends LytHBox { + + /** Matches {@code RecipeCompiler.MULTI_GAP} (kept local to avoid a compiler dependency). */ + public static final int GAP = 4; + + public LytRecipeGalleryRow() { + setWrap(true); + setGap(GAP); + // Full width so the flex row wraps at the document's content edge. + setFullWidth(true); + } + + /** Recipe box block types eligible for gallery grouping. */ + public static boolean isRecipeBox(LytBlock block) { + return block instanceof LytNeiRecipeBox || block instanceof LytStandardRecipeBox; + } +} diff --git a/src/main/java/com/hfstudio/guidenh/guide/document/block/recipes/LytStandardRecipeBox.java b/src/main/java/com/hfstudio/guidenh/guide/document/block/recipes/LytStandardRecipeBox.java index 7235d0b4..f63c8011 100644 --- a/src/main/java/com/hfstudio/guidenh/guide/document/block/recipes/LytStandardRecipeBox.java +++ b/src/main/java/com/hfstudio/guidenh/guide/document/block/recipes/LytStandardRecipeBox.java @@ -11,6 +11,8 @@ import com.hfstudio.guidenh.guide.document.block.LytSlot; import com.hfstudio.guidenh.guide.document.block.LytSlotGrid; import com.hfstudio.guidenh.guide.layout.LayoutContext; +import com.hfstudio.guidenh.guide.render.GuideRenderPrimitive; +import com.hfstudio.guidenh.guide.render.PrimitiveCollector; import com.hfstudio.guidenh.guide.render.RenderContext; import lombok.Getter; @@ -36,11 +38,27 @@ public LytStandardRecipeBox(LytSlotGrid inputs, ItemStack resultStack, boolean s this.inputs = inputs; this.output = new LytSlot(resultStack); this.output.setLargeSlot(true); + // Reserve the crafting-arrow gap as the output slot's own left margin, + // so the flex-row layout (Rust) leaves room for the arrow between the + // inputs grid and the output, declared by the block rather than the compiler. + this.output.setMarginLeft(GAP * 2 + ARROW_W); this.shapeless = shapeless; append(inputs); append(output); } + private static int getGlTextureId(ResourceLocation res) { + try { + var tex = net.minecraft.client.Minecraft.getMinecraft() + .getTextureManager() + .getTexture(res); + return tex != null ? tex.getGlTextureId() : -1; + } catch (Throwable t) { + // Headless (unit tests) or texture unavailable: skip drawing. + return -1; + } + } + public static LytStandardRecipeBox shaped3x3(List stacks, ItemStack result) { var grid = new LytSlotGrid(3, 3); int n = Math.min(9, stacks == null ? 0 : stacks.size()); @@ -85,6 +103,32 @@ protected LytRect computeBoxLayout(LayoutContext context, int x, int y, int avai return new LytRect(x, y, totalW, totalH); } + @Override + public void computePrimitives(PrimitiveCollector c) { + super.computePrimitives(c); + + int inW = inputs.getWidth() * LytSlot.OUTER_SIZE; + int inH = inputs.getHeight() * LytSlot.OUTER_SIZE; + int arrowX = bounds.x() + inW + GAP; + int arrowY = bounds.y() + (inH - ARROW_H) / 2; + int texId = getGlTextureId(CRAFTING_TEXTURE); + if (texId >= 0) { + // Vanilla container GUI textures are 256x256 (same assumption as + // VanillaRenderContext.blitTexture). + c.emit( + new GuideRenderPrimitive.BlitTexture( + texId, + arrowX, + arrowY, + ARROW_W, + ARROW_H, + ARROW_U / 256f, + ARROW_V / 256f, + (ARROW_U + ARROW_W) / 256f, + (ARROW_V + ARROW_H) / 256f)); + } + } + @Override public void render(RenderContext context) { super.render(context); diff --git a/src/main/java/com/hfstudio/guidenh/guide/document/block/shapes/AsymmetricShape.java b/src/main/java/com/hfstudio/guidenh/guide/document/block/shapes/AsymmetricShape.java index bfe24a2e..167a70dd 100644 --- a/src/main/java/com/hfstudio/guidenh/guide/document/block/shapes/AsymmetricShape.java +++ b/src/main/java/com/hfstudio/guidenh/guide/document/block/shapes/AsymmetricShape.java @@ -1,30 +1,13 @@ package com.hfstudio.guidenh.guide.document.block.shapes; import com.hfstudio.guidenh.guide.document.LytRect; -import com.hfstudio.guidenh.guide.internal.mermaid.flowchart.FlowchartLayoutResult.Point; -import com.hfstudio.guidenh.guide.render.RenderContext; +import com.hfstudio.guidenh.guide.render.GuideRenderPrimitive; +import com.hfstudio.guidenh.guide.render.PrimitiveCollector; public class AsymmetricShape implements ShapeRenderer { @Override - public boolean isClipped() { - return true; - } - - @Override - public Point edgeIntersect(LytRect nodeRect, int ex, int ey) { - int x = nodeRect.x(), y = nodeRect.y(), w = nodeRect.width(), h = nodeRect.height(); - int r = x + w, b = y + h, cy = y + h / 2; - int inset = Math.max(2, h / 4); - return FlowchartShapes.intersectPolygon( - nodeRect, - new int[][] { { r, y }, { r, b }, { x + inset, b }, { x, cy }, { x + inset, y } }, - ex, - ey); - } - - @Override - public void render(RenderContext context, LytRect rect, int backgroundColor, int borderColor) { + public void emitPrimitives(PrimitiveCollector c, LytRect rect, int backgroundColor, int borderColor) { int x = rect.x(), y = rect.y(), w = rect.width(), h = rect.height(); int r = rect.right(), b = rect.bottom(), cy = y + h / 2; int inset = Math.max(2, h / 4); @@ -48,8 +31,8 @@ public void render(RenderContext context, LytRect rect, int backgroundColor, int shrunkYs[i] = ys[i]; } } - context.fillPolygon(xs, ys, borderColor); - context.fillPolygon(shrunkXs, shrunkYs, backgroundColor); + c.emit(new GuideRenderPrimitive.DrawPolygon(xs, ys, borderColor)); + c.emit(new GuideRenderPrimitive.DrawPolygon(shrunkXs, shrunkYs, backgroundColor)); } @Override @@ -73,7 +56,7 @@ public String renderSvg(int x, int y, int w, int h, String fill, String stroke) } @Override - public LytRect contentBounds(LytRect nodeRect, int cw, int ch, int padX, int padY) { + public LytRect contentBounds(LytRect nodeRect, int cw, int ch, int padX, int padY, float zoom) { int w = nodeRect.width(); int h = nodeRect.height(); int cx = nodeRect.x() + w / 2; diff --git a/src/main/java/com/hfstudio/guidenh/guide/document/block/shapes/BangShape.java b/src/main/java/com/hfstudio/guidenh/guide/document/block/shapes/BangShape.java index b9c1738a..d4fd9ed7 100644 --- a/src/main/java/com/hfstudio/guidenh/guide/document/block/shapes/BangShape.java +++ b/src/main/java/com/hfstudio/guidenh/guide/document/block/shapes/BangShape.java @@ -4,27 +4,12 @@ import java.util.List; import com.hfstudio.guidenh.guide.document.LytRect; -import com.hfstudio.guidenh.guide.internal.mermaid.flowchart.FlowchartLayoutResult.Point; -import com.hfstudio.guidenh.guide.render.RenderContext; +import com.hfstudio.guidenh.guide.render.PrimitiveCollector; public class BangShape implements ShapeRenderer { @Override - public boolean isClipped() { - return true; - } - - @Override - public Point edgeIntersect(LytRect nodeRect, int ex, int ey) { - int w = nodeRect.width(), h = nodeRect.height(); - float[][] arcs = getBangArcs(w, h); - float[] raw = buildBangPolygon(w, h); - float[] bounds = ShapeUtils.computeBounds(raw); - return FlowchartShapes.intersectArcs(nodeRect, bounds, arcs, ex, ey); - } - - @Override - public void render(RenderContext context, LytRect rect, int backgroundColor, int borderColor) { + public void emitPrimitives(PrimitiveCollector c, LytRect rect, int backgroundColor, int borderColor) { int x = rect.x(), y = rect.y(), w = rect.width(), h = rect.height(); float[] raw = buildBangPolygon(w, h); int n = raw.length / 2; @@ -57,8 +42,8 @@ public void render(RenderContext context, LytRect rect, int backgroundColor, int } } - ShapeUtils.fillPolygonCentered(context, xs, ys, borderColor); - ShapeUtils.fillPolygonCentered(context, ixs, iys, backgroundColor); + ShapeUtils.emitPolygonCentered(c, xs, ys, borderColor); + ShapeUtils.emitPolygonCentered(c, ixs, iys, backgroundColor); } private static float[][] getBangArcs(float w, float h) { @@ -131,7 +116,7 @@ public static String generateSvgPoints(int x, int y, int w, int h) { } @Override - public LytRect contentBounds(LytRect nodeRect, int cw, int ch, int padX, int padY) { + public LytRect contentBounds(LytRect nodeRect, int cw, int ch, int padX, int padY, float zoom) { int w = nodeRect.width(); int h = nodeRect.height(); int cx = nodeRect.x() + w / 2; diff --git a/src/main/java/com/hfstudio/guidenh/guide/document/block/shapes/CircleShape.java b/src/main/java/com/hfstudio/guidenh/guide/document/block/shapes/CircleShape.java index 10744569..205aa412 100644 --- a/src/main/java/com/hfstudio/guidenh/guide/document/block/shapes/CircleShape.java +++ b/src/main/java/com/hfstudio/guidenh/guide/document/block/shapes/CircleShape.java @@ -1,29 +1,19 @@ package com.hfstudio.guidenh.guide.document.block.shapes; import com.hfstudio.guidenh.guide.document.LytRect; -import com.hfstudio.guidenh.guide.internal.mermaid.flowchart.FlowchartLayoutResult.Point; -import com.hfstudio.guidenh.guide.render.RenderContext; +import com.hfstudio.guidenh.guide.render.GuideRenderPrimitive; +import com.hfstudio.guidenh.guide.render.PrimitiveCollector; public class CircleShape implements ShapeRenderer { @Override - public boolean isClipped() { - return true; - } - - @Override - public Point edgeIntersect(LytRect nodeRect, int ex, int ey) { - return FlowchartShapes.intersectCircle(nodeRect, ex, ey); - } - - @Override - public void render(RenderContext context, LytRect rect, int backgroundColor, int borderColor) { + public void emitPrimitives(PrimitiveCollector c, LytRect rect, int backgroundColor, int borderColor) { int cx = rect.x() + rect.width() / 2; int cy = rect.y() + rect.height() / 2; int r = Math.min(rect.width(), rect.height()) / 2; if (r > 0) { - context.fillCircle(cx, cy, r, borderColor); - context.fillCircle(cx, cy, Math.max(r - 1, 1), backgroundColor); + c.emit(new GuideRenderPrimitive.DrawCircle(cx, cy, r, borderColor, true)); + c.emit(new GuideRenderPrimitive.DrawCircle(cx, cy, Math.max(r - 1, 1), backgroundColor, true)); } } @@ -41,11 +31,11 @@ public String renderSvg(int x, int y, int w, int h, String fill, String stroke) } @Override - public LytRect contentBounds(LytRect nodeRect, int cw, int ch, int padX, int padY) { + public LytRect contentBounds(LytRect nodeRect, int cw, int ch, int padX, int padY, float zoom) { int cx = nodeRect.x() + nodeRect.width() / 2; int cy = nodeRect.y() + nodeRect.height() / 2; - int r = Math.min(nodeRect.width(), nodeRect.height()) / 2; - int insSide = (int) (r * Math.sqrt(2)); + double r = Math.min(nodeRect.width(), nodeRect.height()) / 2.0; + int insSide = (int) Math.ceil(r * Math.sqrt(2)); return new LytRect(cx - insSide / 2, cy - insSide / 2, insSide, insSide); } diff --git a/src/main/java/com/hfstudio/guidenh/guide/document/block/shapes/CloudShape.java b/src/main/java/com/hfstudio/guidenh/guide/document/block/shapes/CloudShape.java index a5d430b5..65adf2a4 100644 --- a/src/main/java/com/hfstudio/guidenh/guide/document/block/shapes/CloudShape.java +++ b/src/main/java/com/hfstudio/guidenh/guide/document/block/shapes/CloudShape.java @@ -4,27 +4,12 @@ import java.util.List; import com.hfstudio.guidenh.guide.document.LytRect; -import com.hfstudio.guidenh.guide.internal.mermaid.flowchart.FlowchartLayoutResult.Point; -import com.hfstudio.guidenh.guide.render.RenderContext; +import com.hfstudio.guidenh.guide.render.PrimitiveCollector; public class CloudShape implements ShapeRenderer { @Override - public boolean isClipped() { - return true; - } - - @Override - public Point edgeIntersect(LytRect nodeRect, int ex, int ey) { - int w = nodeRect.width(), h = nodeRect.height(); - float[][] arcs = getCloudArcs(w, h); - float[] raw = buildCloudPolygon(w, h); - float[] bounds = ShapeUtils.computeBounds(raw); - return FlowchartShapes.intersectArcs(nodeRect, bounds, arcs, ex, ey); - } - - @Override - public void render(RenderContext context, LytRect rect, int backgroundColor, int borderColor) { + public void emitPrimitives(PrimitiveCollector c, LytRect rect, int backgroundColor, int borderColor) { int x = rect.x(), y = rect.y(), w = rect.width(), h = rect.height(); float[] raw = buildCloudPolygon(w, h); int n = raw.length / 2; @@ -57,8 +42,8 @@ public void render(RenderContext context, LytRect rect, int backgroundColor, int } } - ShapeUtils.fillPolygonCentered(context, xs, ys, borderColor); - ShapeUtils.fillPolygonCentered(context, ixs, iys, backgroundColor); + ShapeUtils.emitPolygonCentered(c, xs, ys, borderColor); + ShapeUtils.emitPolygonCentered(c, ixs, iys, backgroundColor); } private static float[][] getCloudArcs(float w, float h) { @@ -133,11 +118,14 @@ public static String generateSvgPoints(int x, int y, int w, int h) { } @Override - public LytRect contentBounds(LytRect nodeRect, int cw, int ch, int padX, int padY) { + public LytRect contentBounds(LytRect nodeRect, int cw, int ch, int padX, int padY, float zoom) { int cx = nodeRect.x() + nodeRect.width() / 2; int cy = nodeRect.y() + nodeRect.height() / 2; - int r = Math.min(nodeRect.width(), nodeRect.height()) / 3; - int insSide = (int) (r * Math.sqrt(2)); + // Compute the inscribed-side inset in float and round UP so the + // content rect never shrinks below the scaled text width through + // truncation ((int) casts were losing ~1px on the zoomed path). + double r = Math.min(nodeRect.width(), nodeRect.height()) / 3.0; + int insSide = (int) Math.ceil(r * Math.sqrt(2)); int availW = Math.max(insSide - 2 * padX, 1); int availH = Math.max(insSide - 2 * padY, 1); int contentW = Math.min(availW, cw); diff --git a/src/main/java/com/hfstudio/guidenh/guide/document/block/shapes/CylinderShape.java b/src/main/java/com/hfstudio/guidenh/guide/document/block/shapes/CylinderShape.java index 9f119d3b..ea0946d5 100644 --- a/src/main/java/com/hfstudio/guidenh/guide/document/block/shapes/CylinderShape.java +++ b/src/main/java/com/hfstudio/guidenh/guide/document/block/shapes/CylinderShape.java @@ -2,12 +2,13 @@ import com.hfstudio.guidenh.guide.document.LytRect; import com.hfstudio.guidenh.guide.internal.mermaid.flowchart.FlowchartLayoutResult.Point; -import com.hfstudio.guidenh.guide.render.RenderContext; +import com.hfstudio.guidenh.guide.render.GuideRenderPrimitive; +import com.hfstudio.guidenh.guide.render.PrimitiveCollector; public class CylinderShape implements ShapeRenderer { @Override - public void render(RenderContext context, LytRect rect, int backgroundColor, int borderColor) { + public void emitPrimitives(PrimitiveCollector c, LytRect rect, int backgroundColor, int borderColor) { int x = rect.x(), y = rect.y(), w = rect.width(), h = rect.height(); int cx = x + w / 2; int rx = w / 2; @@ -54,27 +55,24 @@ public void render(RenderContext context, LytRect rect, int backgroundColor, int } } - ShapeUtils.fillPolygonCentered(context, oxs, oys, borderColor); - ShapeUtils.fillPolygonCentered(context, ixs, iys, backgroundColor); + ShapeUtils.emitPolygonCentered(c, oxs, oys, borderColor); + ShapeUtils.emitPolygonCentered(c, ixs, iys, backgroundColor); - drawEllipseFrontArc(context, cx, bodyTop, rx, ry, borderColor); - } - - private static void drawEllipseFrontArc(RenderContext context, float cx, float cy, float rx, float ry, int color) { - int segments = 20; - for (int i = 0; i < segments; i++) { - double a1 = Math.PI * i / segments; - double a2 = Math.PI * (i + 1) / segments; + // Emit the 20 line segments for the ellipse front arc + int arcSegments = 20; + for (int i = 0; i < arcSegments; i++) { + double a1 = Math.PI * i / arcSegments; + double a2 = Math.PI * (i + 1) / arcSegments; float x1 = cx + (float) (Math.cos(a1) * rx); - float y1 = cy + (float) (Math.sin(a1) * ry); + float y1 = bodyTop + (float) (Math.sin(a1) * ry); float x2 = cx + (float) (Math.cos(a2) * rx); - float y2 = cy + (float) (Math.sin(a2) * ry); - context.drawLine(x1, y1, x2, y2, 1, color); + float y2 = bodyTop + (float) (Math.sin(a2) * ry); + c.emit(new GuideRenderPrimitive.DrawLine(x1, y1, x2, y2, 1, borderColor)); } } @Override - public LytRect contentBounds(LytRect nodeRect, int cw, int ch, int padX, int padY) { + public LytRect contentBounds(LytRect nodeRect, int cw, int ch, int padX, int padY, float zoom) { int rx = nodeRect.width() / 2; int ry = Math.max(1, rx / 3); int extraV = Math.max(2, ry / 3); @@ -88,12 +86,11 @@ public String renderSvg(int x, int y, int w, int h, String fill, String stroke) int cx = x + w / 2, rx = w / 2, r = Math.max(3, h / 4); int top = y + r, bot = y + h - r; return String.format( - """ - - - - - """, + "\n" + + "\n" + + "\n" + + "\n" + + "", x, top, rx, diff --git a/src/main/java/com/hfstudio/guidenh/guide/document/block/shapes/DiamondShape.java b/src/main/java/com/hfstudio/guidenh/guide/document/block/shapes/DiamondShape.java index e32f2d9f..0451b088 100644 --- a/src/main/java/com/hfstudio/guidenh/guide/document/block/shapes/DiamondShape.java +++ b/src/main/java/com/hfstudio/guidenh/guide/document/block/shapes/DiamondShape.java @@ -1,26 +1,13 @@ package com.hfstudio.guidenh.guide.document.block.shapes; import com.hfstudio.guidenh.guide.document.LytRect; -import com.hfstudio.guidenh.guide.internal.mermaid.flowchart.FlowchartLayoutResult.Point; -import com.hfstudio.guidenh.guide.render.RenderContext; +import com.hfstudio.guidenh.guide.render.GuideRenderPrimitive; +import com.hfstudio.guidenh.guide.render.PrimitiveCollector; public class DiamondShape implements ShapeRenderer { @Override - public boolean isClipped() { - return true; - } - - @Override - public Point edgeIntersect(LytRect nodeRect, int ex, int ey) { - int x = nodeRect.x(), y = nodeRect.y(), w = nodeRect.width(), h = nodeRect.height(); - int cx = x + w / 2, cy = y + h / 2; - return FlowchartShapes - .intersectPolygon(nodeRect, new int[][] { { cx, y }, { x + w, cy }, { cx, y + h }, { x, cy } }, ex, ey); - } - - @Override - public void render(RenderContext context, LytRect rect, int backgroundColor, int borderColor) { + public void emitPrimitives(PrimitiveCollector c, LytRect rect, int backgroundColor, int borderColor) { int cx = rect.x() + rect.width() / 2; int cy = rect.y() + rect.height() / 2; int r = rect.right(); @@ -32,8 +19,8 @@ public void render(RenderContext context, LytRect rect, int backgroundColor, int float[] shrunkXs = new float[4]; float[] shrunkYs = new float[4]; shrinkPoly(xs, ys, shrunkXs, shrunkYs, cx, cy); - context.fillPolygon(xs, ys, borderColor); - context.fillPolygon(shrunkXs, shrunkYs, backgroundColor); + c.emit(new GuideRenderPrimitive.DrawPolygon(xs, ys, borderColor)); + c.emit(new GuideRenderPrimitive.DrawPolygon(shrunkXs, shrunkYs, backgroundColor)); } @Override @@ -54,7 +41,7 @@ public String renderSvg(int x, int y, int w, int h, String fill, String stroke) } @Override - public LytRect contentBounds(LytRect nodeRect, int cw, int ch, int padX, int padY) { + public LytRect contentBounds(LytRect nodeRect, int cw, int ch, int padX, int padY, float zoom) { int cx = nodeRect.x() + nodeRect.width() / 2; int cy = nodeRect.y() + nodeRect.height() / 2; int insW = nodeRect.width() / 2; diff --git a/src/main/java/com/hfstudio/guidenh/guide/document/block/shapes/DoubleCircleShape.java b/src/main/java/com/hfstudio/guidenh/guide/document/block/shapes/DoubleCircleShape.java index dcd96d94..5dbe6d21 100644 --- a/src/main/java/com/hfstudio/guidenh/guide/document/block/shapes/DoubleCircleShape.java +++ b/src/main/java/com/hfstudio/guidenh/guide/document/block/shapes/DoubleCircleShape.java @@ -1,34 +1,24 @@ package com.hfstudio.guidenh.guide.document.block.shapes; import com.hfstudio.guidenh.guide.document.LytRect; -import com.hfstudio.guidenh.guide.internal.mermaid.flowchart.FlowchartLayoutResult.Point; -import com.hfstudio.guidenh.guide.render.RenderContext; +import com.hfstudio.guidenh.guide.render.GuideRenderPrimitive; +import com.hfstudio.guidenh.guide.render.PrimitiveCollector; public class DoubleCircleShape implements ShapeRenderer { private static final int GAP = 5; @Override - public boolean isClipped() { - return true; - } - - @Override - public Point edgeIntersect(LytRect nodeRect, int ex, int ey) { - return FlowchartShapes.intersectCircle(nodeRect, ex, ey); - } - - @Override - public void render(RenderContext context, LytRect rect, int backgroundColor, int borderColor) { + public void emitPrimitives(PrimitiveCollector c, LytRect rect, int backgroundColor, int borderColor) { int cx = rect.x() + rect.width() / 2; int cy = rect.y() + rect.height() / 2; int outerR = Math.min(rect.width(), rect.height()) / 2; int innerR = Math.max(outerR - GAP, 1); if (outerR > 0) { - context.fillCircle(cx, cy, outerR, borderColor); - context.fillCircle(cx, cy, Math.max(outerR - 1, 1), backgroundColor); - context.fillCircle(cx, cy, innerR, borderColor); - context.fillCircle(cx, cy, Math.max(innerR - 1, 0), backgroundColor); + c.emit(new GuideRenderPrimitive.DrawCircle(cx, cy, outerR, borderColor, true)); + c.emit(new GuideRenderPrimitive.DrawCircle(cx, cy, Math.max(outerR - 1, 1), backgroundColor, true)); + c.emit(new GuideRenderPrimitive.DrawCircle(cx, cy, innerR, borderColor, true)); + c.emit(new GuideRenderPrimitive.DrawCircle(cx, cy, Math.max(innerR - 1, 0), backgroundColor, true)); } } @@ -51,11 +41,11 @@ public String renderSvg(int x, int y, int w, int h, String fill, String stroke) } @Override - public LytRect contentBounds(LytRect nodeRect, int cw, int ch, int padX, int padY) { + public LytRect contentBounds(LytRect nodeRect, int cw, int ch, int padX, int padY, float zoom) { int cx = nodeRect.x() + nodeRect.width() / 2; int cy = nodeRect.y() + nodeRect.height() / 2; - int r = Math.min(nodeRect.width(), nodeRect.height()) / 2; - int insSide = (int) (r * Math.sqrt(2)); + double r = Math.min(nodeRect.width(), nodeRect.height()) / 2.0; + int insSide = (int) Math.ceil(r * Math.sqrt(2)); return new LytRect(cx - insSide / 2, cy - insSide / 2, insSide, insSide); } diff --git a/src/main/java/com/hfstudio/guidenh/guide/document/block/shapes/EllipseShape.java b/src/main/java/com/hfstudio/guidenh/guide/document/block/shapes/EllipseShape.java index fb761af4..4f20e7b9 100644 --- a/src/main/java/com/hfstudio/guidenh/guide/document/block/shapes/EllipseShape.java +++ b/src/main/java/com/hfstudio/guidenh/guide/document/block/shapes/EllipseShape.java @@ -2,10 +2,12 @@ import com.hfstudio.guidenh.guide.document.LytRect; import com.hfstudio.guidenh.guide.internal.mermaid.flowchart.FlowchartLayoutResult.Point; -import com.hfstudio.guidenh.guide.render.RenderContext; +import com.hfstudio.guidenh.guide.render.PrimitiveCollector; public class EllipseShape implements ShapeRenderer { + private static final int SEGMENTS = 32; + @Override public boolean isClipped() { return true; @@ -17,13 +19,27 @@ public Point edgeIntersect(LytRect nodeRect, int ex, int ey) { } @Override - public void render(RenderContext context, LytRect rect, int backgroundColor, int borderColor) { + public void emitPrimitives(PrimitiveCollector c, LytRect rect, int backgroundColor, int borderColor) { int cx = rect.x() + rect.width() / 2; int cy = rect.y() + rect.height() / 2; float rx = rect.width() / 2f; float ry = rect.height() / 2f; - context.fillEllipse(cx, cy, rx, ry, borderColor); - context.fillEllipse(cx, cy, Math.max(rx - 1, 0.5f), Math.max(ry - 1, 0.5f), backgroundColor); + if (rx <= 0f || ry <= 0f) return; + + float[] borderXs = new float[SEGMENTS]; + float[] borderYs = new float[SEGMENTS]; + float[] fillXs = new float[SEGMENTS]; + float[] fillYs = new float[SEGMENTS]; + for (int i = 0; i < SEGMENTS; i++) { + double a = Math.PI * 2.0 * i / SEGMENTS; + borderXs[i] = cx + (float) (Math.cos(a) * rx); + borderYs[i] = cy + (float) (Math.sin(a) * ry); + fillXs[i] = cx + (float) (Math.cos(a) * Math.max(rx - 1f, 0.5f)); + fillYs[i] = cy + (float) (Math.sin(a) * Math.max(ry - 1f, 0.5f)); + } + + ShapeUtils.emitPolygonCentered(c, borderXs, borderYs, borderColor); + ShapeUtils.emitPolygonCentered(c, fillXs, fillYs, backgroundColor); } @Override @@ -40,7 +56,7 @@ public String renderSvg(int x, int y, int w, int h, String fill, String stroke) } @Override - public LytRect contentBounds(LytRect nodeRect, int cw, int ch, int padX, int padY) { + public LytRect contentBounds(LytRect nodeRect, int cw, int ch, int padX, int padY, float zoom) { int cx = nodeRect.x() + nodeRect.width() / 2; int cy = nodeRect.y() + nodeRect.height() / 2; int insW = (int) (nodeRect.width() / Math.sqrt(2)); diff --git a/src/main/java/com/hfstudio/guidenh/guide/document/block/shapes/FlowchartShapes.java b/src/main/java/com/hfstudio/guidenh/guide/document/block/shapes/FlowchartShapes.java index ba36df7a..f1146ce1 100644 --- a/src/main/java/com/hfstudio/guidenh/guide/document/block/shapes/FlowchartShapes.java +++ b/src/main/java/com/hfstudio/guidenh/guide/document/block/shapes/FlowchartShapes.java @@ -6,7 +6,7 @@ import com.hfstudio.guidenh.guide.document.LytRect; import com.hfstudio.guidenh.guide.internal.mermaid.MermaidNodeShape; import com.hfstudio.guidenh.guide.internal.mermaid.flowchart.FlowchartLayoutResult.Point; -import com.hfstudio.guidenh.guide.render.RenderContext; +import com.hfstudio.guidenh.guide.render.PrimitiveCollector; public final class FlowchartShapes { @@ -32,17 +32,21 @@ public final class FlowchartShapes { private FlowchartShapes() {} - public static void render(RenderContext context, LytRect rect, MermaidNodeShape shape, int backgroundColor, + public static void emitShape(PrimitiveCollector c, MermaidNodeShape shape, LytRect rect, int backgroundColor, int borderColor) { ShapeRenderer renderer = RENDERERS.get(shape); if (renderer != null) { - renderer.render(context, rect, backgroundColor, borderColor); + renderer.emitPrimitives(c, rect, backgroundColor, borderColor); + } else { + RectShape fallback = new RectShape(); + fallback.emitPrimitives(c, rect, backgroundColor, borderColor); } } - public static LytRect contentBounds(LytRect nodeRect, MermaidNodeShape shape, int cw, int ch, int padX, int padY) { + public static LytRect contentBounds(LytRect nodeRect, MermaidNodeShape shape, int cw, int ch, int padX, int padY, + float zoom) { ShapeRenderer renderer = RENDERERS.get(shape); - return renderer != null ? renderer.contentBounds(nodeRect, cw, ch, padX, padY) : nodeRect; + return renderer != null ? renderer.contentBounds(nodeRect, cw, ch, padX, padY, zoom) : nodeRect; } public static LytRect minNodeRect(MermaidNodeShape shape, int cw, int ch, int padX, int padY) { diff --git a/src/main/java/com/hfstudio/guidenh/guide/document/block/shapes/HexagonShape.java b/src/main/java/com/hfstudio/guidenh/guide/document/block/shapes/HexagonShape.java index 36d14f55..282c9bdd 100644 --- a/src/main/java/com/hfstudio/guidenh/guide/document/block/shapes/HexagonShape.java +++ b/src/main/java/com/hfstudio/guidenh/guide/document/block/shapes/HexagonShape.java @@ -1,31 +1,13 @@ package com.hfstudio.guidenh.guide.document.block.shapes; import com.hfstudio.guidenh.guide.document.LytRect; -import com.hfstudio.guidenh.guide.internal.mermaid.flowchart.FlowchartLayoutResult.Point; -import com.hfstudio.guidenh.guide.render.RenderContext; +import com.hfstudio.guidenh.guide.render.GuideRenderPrimitive; +import com.hfstudio.guidenh.guide.render.PrimitiveCollector; public class HexagonShape implements ShapeRenderer { @Override - public boolean isClipped() { - return true; - } - - @Override - public Point edgeIntersect(LytRect nodeRect, int ex, int ey) { - int x = nodeRect.x(), y = nodeRect.y(), w = nodeRect.width(), h = nodeRect.height(); - int r = x + w, b = y + h, cy = y + h / 2; - int inset = Math.max(1, h / 4); - return FlowchartShapes.intersectPolygon( - nodeRect, - new int[][] { { x + inset, y }, { r - inset, y }, { r, cy }, { r - inset, b }, { x + inset, b }, - { x, cy } }, - ex, - ey); - } - - @Override - public void render(RenderContext context, LytRect rect, int backgroundColor, int borderColor) { + public void emitPrimitives(PrimitiveCollector c, LytRect rect, int backgroundColor, int borderColor) { int x = rect.x(), y = rect.y(), w = rect.width(), h = rect.height(); int r = rect.right(), b = rect.bottom(), cy = y + h / 2; int inset = Math.max(1, h / 4); @@ -49,8 +31,8 @@ public void render(RenderContext context, LytRect rect, int backgroundColor, int shrunkYs[i] = ys[i]; } } - context.fillPolygon(xs, ys, borderColor); - context.fillPolygon(shrunkXs, shrunkYs, backgroundColor); + c.emit(new GuideRenderPrimitive.DrawPolygon(xs, ys, borderColor)); + c.emit(new GuideRenderPrimitive.DrawPolygon(shrunkXs, shrunkYs, backgroundColor)); } @Override @@ -76,7 +58,7 @@ public String renderSvg(int x, int y, int w, int h, String fill, String stroke) } @Override - public LytRect contentBounds(LytRect nodeRect, int cw, int ch, int padX, int padY) { + public LytRect contentBounds(LytRect nodeRect, int cw, int ch, int padX, int padY, float zoom) { int w = nodeRect.width(); int h = nodeRect.height(); int cx = nodeRect.x() + w / 2; diff --git a/src/main/java/com/hfstudio/guidenh/guide/document/block/shapes/RectShape.java b/src/main/java/com/hfstudio/guidenh/guide/document/block/shapes/RectShape.java index 201ce7e8..3c56a7c0 100644 --- a/src/main/java/com/hfstudio/guidenh/guide/document/block/shapes/RectShape.java +++ b/src/main/java/com/hfstudio/guidenh/guide/document/block/shapes/RectShape.java @@ -1,18 +1,29 @@ package com.hfstudio.guidenh.guide.document.block.shapes; import com.hfstudio.guidenh.guide.document.LytRect; -import com.hfstudio.guidenh.guide.render.RenderContext; +import com.hfstudio.guidenh.guide.render.GuideRenderPrimitive; +import com.hfstudio.guidenh.guide.render.PrimitiveCollector; public class RectShape implements ShapeRenderer { @Override - public void render(RenderContext context, LytRect rect, int backgroundColor, int borderColor) { - context.fillRect(rect, backgroundColor); - context.drawBorder(rect, borderColor, 1); + public void emitPrimitives(PrimitiveCollector c, LytRect rect, int backgroundColor, int borderColor) { + c.emit(new GuideRenderPrimitive.FillRect(rect.x(), rect.y(), rect.width(), rect.height(), backgroundColor)); + c.emit( + new GuideRenderPrimitive.DrawBorder( + rect.x(), + rect.y(), + rect.width(), + rect.height(), + 1, + 1, + 1, + 1, + borderColor)); } @Override - public LytRect contentBounds(LytRect nodeRect, int cw, int ch, int padX, int padY) { + public LytRect contentBounds(LytRect nodeRect, int cw, int ch, int padX, int padY, float zoom) { return nodeRect.shrink(padX, padY, padX, padY); } diff --git a/src/main/java/com/hfstudio/guidenh/guide/document/block/shapes/RoundedRectShape.java b/src/main/java/com/hfstudio/guidenh/guide/document/block/shapes/RoundedRectShape.java index e50261ca..e40c9a4f 100644 --- a/src/main/java/com/hfstudio/guidenh/guide/document/block/shapes/RoundedRectShape.java +++ b/src/main/java/com/hfstudio/guidenh/guide/document/block/shapes/RoundedRectShape.java @@ -1,29 +1,30 @@ package com.hfstudio.guidenh.guide.document.block.shapes; import com.hfstudio.guidenh.guide.document.LytRect; -import com.hfstudio.guidenh.guide.render.RenderContext; +import com.hfstudio.guidenh.guide.render.GuideRenderPrimitive; +import com.hfstudio.guidenh.guide.render.PrimitiveCollector; public class RoundedRectShape implements ShapeRenderer { @Override - public void render(RenderContext context, LytRect rect, int backgroundColor, int borderColor) { + public void emitPrimitives(PrimitiveCollector c, LytRect rect, int backgroundColor, int borderColor) { int x = rect.x(), y = rect.y(), w = rect.width(), h = rect.height(); int r = Math.clamp(Math.min(w, h) / 5, 2, 12); - context.fillRect(rect, borderColor); - context.fillCircle(x + r, y + r, r, borderColor); - context.fillCircle(x + w - r, y + r, r, borderColor); - context.fillCircle(x + r, y + h - r, r, borderColor); - context.fillCircle(x + w - r, y + h - r, r, borderColor); + c.emit(new GuideRenderPrimitive.FillRect(x, y, w, h, borderColor)); + c.emit(new GuideRenderPrimitive.DrawCircle(x + r, y + r, r, borderColor, true)); + c.emit(new GuideRenderPrimitive.DrawCircle(x + w - r, y + r, r, borderColor, true)); + c.emit(new GuideRenderPrimitive.DrawCircle(x + r, y + h - r, r, borderColor, true)); + c.emit(new GuideRenderPrimitive.DrawCircle(x + w - r, y + h - r, r, borderColor, true)); int inset = 1; int ir = Math.max(r - inset, 1); int ix = x + inset, iy = y + inset, iw = w - inset * 2, ih = h - inset * 2; - context.fillRect(ix, iy, iw, ih, backgroundColor); - context.fillCircle(ix + ir, iy + ir, ir, backgroundColor); - context.fillCircle(ix + iw - ir, iy + ir, ir, backgroundColor); - context.fillCircle(ix + ir, iy + ih - ir, ir, backgroundColor); - context.fillCircle(ix + iw - ir, iy + ih - ir, ir, backgroundColor); + c.emit(new GuideRenderPrimitive.FillRect(ix, iy, iw, ih, backgroundColor)); + c.emit(new GuideRenderPrimitive.DrawCircle(ix + ir, iy + ir, ir, backgroundColor, true)); + c.emit(new GuideRenderPrimitive.DrawCircle(ix + iw - ir, iy + ir, ir, backgroundColor, true)); + c.emit(new GuideRenderPrimitive.DrawCircle(ix + ir, iy + ih - ir, ir, backgroundColor, true)); + c.emit(new GuideRenderPrimitive.DrawCircle(ix + iw - ir, iy + ih - ir, ir, backgroundColor, true)); } @Override @@ -42,7 +43,7 @@ public String renderSvg(int x, int y, int w, int h, String fill, String stroke) } @Override - public LytRect contentBounds(LytRect nodeRect, int cw, int ch, int padX, int padY) { + public LytRect contentBounds(LytRect nodeRect, int cw, int ch, int padX, int padY, float zoom) { return nodeRect.shrink(padX, padY, padX, padY); } diff --git a/src/main/java/com/hfstudio/guidenh/guide/document/block/shapes/ShapeRenderer.java b/src/main/java/com/hfstudio/guidenh/guide/document/block/shapes/ShapeRenderer.java index 4ee5721e..72626700 100644 --- a/src/main/java/com/hfstudio/guidenh/guide/document/block/shapes/ShapeRenderer.java +++ b/src/main/java/com/hfstudio/guidenh/guide/document/block/shapes/ShapeRenderer.java @@ -2,13 +2,23 @@ import com.hfstudio.guidenh.guide.document.LytRect; import com.hfstudio.guidenh.guide.internal.mermaid.flowchart.FlowchartLayoutResult.Point; -import com.hfstudio.guidenh.guide.render.RenderContext; +import com.hfstudio.guidenh.guide.render.PrimitiveCollector; public interface ShapeRenderer { - void render(RenderContext context, LytRect rect, int backgroundColor, int borderColor); - - LytRect contentBounds(LytRect nodeRect, int contentW, int contentH, int padX, int padY); + /** Emit primitives for this shape into the collector. */ + void emitPrimitives(PrimitiveCollector c, LytRect rect, int backgroundColor, int borderColor); + + /** + * Compute the content rect (text/badge area) inside a rendered node rect. + * {@code nodeRect} is in the scaled render coordinate space (already + * multiplied by the active zoom); {@code zoom} lets shapes that consume + * fixed logical insets (subprocess frame, circular insets) scale those + * insets consistently, so the content rect stays self-consistent with + * the scaled text width at any zoom (otherwise the content area shrinks + * faster than the text and spurious word-wrap / overflow appears). + */ + LytRect contentBounds(LytRect nodeRect, int contentW, int contentH, int padX, int padY, float zoom); LytRect minNodeRect(int contentW, int contentH, int padX, int padY); diff --git a/src/main/java/com/hfstudio/guidenh/guide/document/block/shapes/ShapeUtils.java b/src/main/java/com/hfstudio/guidenh/guide/document/block/shapes/ShapeUtils.java index 07b62bec..f5dd0c52 100644 --- a/src/main/java/com/hfstudio/guidenh/guide/document/block/shapes/ShapeUtils.java +++ b/src/main/java/com/hfstudio/guidenh/guide/document/block/shapes/ShapeUtils.java @@ -3,18 +3,15 @@ import java.util.ArrayList; import java.util.List; -import com.hfstudio.guidenh.guide.render.RenderContext; +import com.hfstudio.guidenh.guide.render.GuideRenderPrimitive; +import com.hfstudio.guidenh.guide.render.PrimitiveCollector; final class ShapeUtils { private ShapeUtils() {} - /** - * Fills a polygon using a triangle fan from the centroid. - * This avoids artifacts from using a boundary vertex as the fan origin - * (which happens with plain {@link RenderContext#fillPolygon(float[], float[], int)}). - */ - static void fillPolygonCentered(RenderContext context, float[] xs, float[] ys, int color) { + /** Same as fillPolygonCentered but emits a DrawPolygon primitive. */ + static void emitPolygonCentered(PrimitiveCollector c, float[] xs, float[] ys, int color) { int n = xs.length; if (n < 3) return; float cx = 0, cy = 0; @@ -32,7 +29,7 @@ static void fillPolygonCentered(RenderContext context, float[] xs, float[] ys, i System.arraycopy(ys, 0, fanYs, 1, n); fanXs[n + 1] = xs[0]; fanYs[n + 1] = ys[0]; - context.fillPolygon(fanXs, fanYs, color); + c.emit(new GuideRenderPrimitive.DrawPolygon(fanXs, fanYs, color)); } static List arcToPoints(float cx, float cy, float rx, float ry, float startAngle, float endAngle, diff --git a/src/main/java/com/hfstudio/guidenh/guide/document/block/shapes/StadiumShape.java b/src/main/java/com/hfstudio/guidenh/guide/document/block/shapes/StadiumShape.java index dacc38ed..01598326 100644 --- a/src/main/java/com/hfstudio/guidenh/guide/document/block/shapes/StadiumShape.java +++ b/src/main/java/com/hfstudio/guidenh/guide/document/block/shapes/StadiumShape.java @@ -1,24 +1,25 @@ package com.hfstudio.guidenh.guide.document.block.shapes; import com.hfstudio.guidenh.guide.document.LytRect; -import com.hfstudio.guidenh.guide.render.RenderContext; +import com.hfstudio.guidenh.guide.render.GuideRenderPrimitive; +import com.hfstudio.guidenh.guide.render.PrimitiveCollector; public class StadiumShape implements ShapeRenderer { @Override - public void render(RenderContext context, LytRect rect, int backgroundColor, int borderColor) { + public void emitPrimitives(PrimitiveCollector c, LytRect rect, int backgroundColor, int borderColor) { int x = rect.x(), y = rect.y(), w = rect.width(), h = rect.height(); int r = h / 2; float cy = y + h / 2f; - context.fillRect(x + r, y, w - r * 2, h, borderColor); - context.fillCircle(x + r, cy, r, borderColor); - context.fillCircle(x + w - r, cy, r, borderColor); + c.emit(new GuideRenderPrimitive.FillRect(x + r, y, w - r * 2, h, borderColor)); + c.emit(new GuideRenderPrimitive.DrawCircle(x + r, cy, r, borderColor, true)); + c.emit(new GuideRenderPrimitive.DrawCircle(x + w - r, cy, r, borderColor, true)); int ir = Math.max(r - 1, 0); - context.fillRect(x + r, y + 1, w - r * 2, h - 2, backgroundColor); - context.fillCircle(x + r, cy, ir, backgroundColor); - context.fillCircle(x + w - r, cy, ir, backgroundColor); + c.emit(new GuideRenderPrimitive.FillRect(x + r, y + 1, w - r * 2, h - 2, backgroundColor)); + c.emit(new GuideRenderPrimitive.DrawCircle(x + r, cy, ir, backgroundColor, true)); + c.emit(new GuideRenderPrimitive.DrawCircle(x + w - r, cy, ir, backgroundColor, true)); } @Override @@ -37,7 +38,7 @@ public String renderSvg(int x, int y, int w, int h, String fill, String stroke) } @Override - public LytRect contentBounds(LytRect nodeRect, int cw, int ch, int padX, int padY) { + public LytRect contentBounds(LytRect nodeRect, int cw, int ch, int padX, int padY, float zoom) { return nodeRect.shrink(padX, padY, padX, padY); } diff --git a/src/main/java/com/hfstudio/guidenh/guide/document/block/shapes/SubprocessShape.java b/src/main/java/com/hfstudio/guidenh/guide/document/block/shapes/SubprocessShape.java index ab223197..d7f35d8f 100644 --- a/src/main/java/com/hfstudio/guidenh/guide/document/block/shapes/SubprocessShape.java +++ b/src/main/java/com/hfstudio/guidenh/guide/document/block/shapes/SubprocessShape.java @@ -1,23 +1,23 @@ package com.hfstudio.guidenh.guide.document.block.shapes; import com.hfstudio.guidenh.guide.document.LytRect; -import com.hfstudio.guidenh.guide.render.RenderContext; +import com.hfstudio.guidenh.guide.render.GuideRenderPrimitive; +import com.hfstudio.guidenh.guide.render.PrimitiveCollector; public class SubprocessShape implements ShapeRenderer { private static final int FRAME_WIDTH = 8; @Override - public void render(RenderContext context, LytRect rect, int backgroundColor, int borderColor) { + public void emitPrimitives(PrimitiveCollector c, LytRect rect, int backgroundColor, int borderColor) { int x = rect.x(), y = rect.y(), w = rect.width(), h = rect.height(); int innerX = x + FRAME_WIDTH; int innerW = w - FRAME_WIDTH * 2; - context.fillRect(x, y, w, h, borderColor); - context.fillRect(innerX, y, innerW, h, backgroundColor); - - context.drawLine(innerX, y, innerX, y + h, 1, borderColor); - context.drawLine(innerX + innerW, y, innerX + innerW, y + h, 1, borderColor); + c.emit(new GuideRenderPrimitive.FillRect(x, y, w, h, borderColor)); + c.emit(new GuideRenderPrimitive.FillRect(innerX, y, innerW, h, backgroundColor)); + c.emit(new GuideRenderPrimitive.DrawLine(innerX, y, innerX, y + h, 1, borderColor)); + c.emit(new GuideRenderPrimitive.DrawLine(innerX + innerW, y, innerX + innerW, y + h, 1, borderColor)); } @Override @@ -45,9 +45,15 @@ public String renderSvg(int x, int y, int w, int h, String fill, String stroke) } @Override - public LytRect contentBounds(LytRect nodeRect, int cw, int ch, int padX, int padY) { - int innerX = nodeRect.x() + FRAME_WIDTH; - int innerW = nodeRect.width() - FRAME_WIDTH * 2; + public LytRect contentBounds(LytRect nodeRect, int cw, int ch, int padX, int padY, float zoom) { + // The frame inset must scale with zoom: the node rect is already in + // scaled render space, so an unscaled FRAME_WIDTH would carve a fixed + // logical-8px frame out of a scaled rect and shrink the content area + // faster than the (scaled) text, forcing spurious word-wrap/overflow + // on the zoomed path. + int frame = Math.max(1, Math.round(FRAME_WIDTH * zoom)); + int innerX = nodeRect.x() + frame; + int innerW = nodeRect.width() - frame * 2; return new LytRect(innerX + padX, nodeRect.y() + padY, innerW - 2 * padX, nodeRect.height() - 2 * padY); } diff --git a/src/main/java/com/hfstudio/guidenh/guide/document/block/shapes/TrapezoidShape.java b/src/main/java/com/hfstudio/guidenh/guide/document/block/shapes/TrapezoidShape.java index 33eef07f..644000f8 100644 --- a/src/main/java/com/hfstudio/guidenh/guide/document/block/shapes/TrapezoidShape.java +++ b/src/main/java/com/hfstudio/guidenh/guide/document/block/shapes/TrapezoidShape.java @@ -1,27 +1,13 @@ package com.hfstudio.guidenh.guide.document.block.shapes; import com.hfstudio.guidenh.guide.document.LytRect; -import com.hfstudio.guidenh.guide.internal.mermaid.flowchart.FlowchartLayoutResult.Point; -import com.hfstudio.guidenh.guide.render.RenderContext; +import com.hfstudio.guidenh.guide.render.GuideRenderPrimitive; +import com.hfstudio.guidenh.guide.render.PrimitiveCollector; public class TrapezoidShape implements ShapeRenderer { @Override - public boolean isClipped() { - return true; - } - - @Override - public Point edgeIntersect(LytRect nodeRect, int ex, int ey) { - int x = nodeRect.x(), y = nodeRect.y(), w = nodeRect.width(), h = nodeRect.height(); - int r = x + w, b = y + h; - int inset = Math.max(1, h / 4); - return FlowchartShapes - .intersectPolygon(nodeRect, new int[][] { { x + inset, y }, { r - inset, y }, { r, b }, { x, b } }, ex, ey); - } - - @Override - public void render(RenderContext context, LytRect rect, int backgroundColor, int borderColor) { + public void emitPrimitives(PrimitiveCollector c, LytRect rect, int backgroundColor, int borderColor) { int x = rect.x(), y = rect.y(), w = rect.width(), h = rect.height(); int r = rect.right(), b = rect.bottom(); int inset = Math.max(1, h / 4); @@ -45,8 +31,8 @@ public void render(RenderContext context, LytRect rect, int backgroundColor, int shrunkYs[i] = ys[i]; } } - context.fillPolygon(xs, ys, borderColor); - context.fillPolygon(shrunkXs, shrunkYs, backgroundColor); + c.emit(new GuideRenderPrimitive.DrawPolygon(xs, ys, borderColor)); + c.emit(new GuideRenderPrimitive.DrawPolygon(shrunkXs, shrunkYs, backgroundColor)); } @Override @@ -68,7 +54,7 @@ public String renderSvg(int x, int y, int w, int h, String fill, String stroke) } @Override - public LytRect contentBounds(LytRect nodeRect, int cw, int ch, int padX, int padY) { + public LytRect contentBounds(LytRect nodeRect, int cw, int ch, int padX, int padY, float zoom) { int w = nodeRect.width(); int h = nodeRect.height(); int cx = nodeRect.x() + w / 2; diff --git a/src/main/java/com/hfstudio/guidenh/guide/document/block/table/LytTable.java b/src/main/java/com/hfstudio/guidenh/guide/document/block/table/LytTable.java index 5fa1a8f2..2e76dc60 100644 --- a/src/main/java/com/hfstudio/guidenh/guide/document/block/table/LytTable.java +++ b/src/main/java/com/hfstudio/guidenh/guide/document/block/table/LytTable.java @@ -7,6 +7,8 @@ import com.hfstudio.guidenh.guide.document.LytRect; import com.hfstudio.guidenh.guide.document.block.LytBlock; import com.hfstudio.guidenh.guide.layout.LayoutContext; +import com.hfstudio.guidenh.guide.render.GuideRenderPrimitive; +import com.hfstudio.guidenh.guide.render.PrimitiveCollector; import com.hfstudio.guidenh.guide.render.RenderContext; import lombok.Getter; @@ -17,6 +19,13 @@ public class LytTable extends LytBlock { * Width of border around cells. */ public static final int CELL_BORDER = 1; + + /** + * Thickness of the horizontal separator drawn below the header row. + * Deliberately thicker than the 1px data-row separators so the header row + * is visually distinct. + */ + public static final int HEADER_SEPARATOR_THICKNESS = 2; private final List rows = new ArrayList<>(); @Getter @@ -30,18 +39,11 @@ protected LytRect computeLayout(LayoutContext context, int x, int y, int availab layoutColumns(x, availableWidth); - // Layout each row + // Layout each row (rows lay out their own cells against the column model) var currentY = y + CELL_BORDER; for (var row : rows) { - var rowTop = currentY; - var rowBottom = currentY; - for (var cell : row.getChildren()) { - var column = cell.column; - var cellBounds = cell.layout(context, column.x, currentY, column.width); - rowBottom = Math.max(rowBottom, cellBounds.bottom()); - } - row.bounds = new LytRect(x, rowTop, availableWidth, rowBottom - rowTop); - currentY = rowBottom + CELL_BORDER; + var rowBounds = row.layout(context, x, currentY, availableWidth); + currentY = rowBounds.bottom() + CELL_BORDER; } return new LytRect(x, y, availableWidth, currentY - y); @@ -53,37 +55,139 @@ protected void onLayoutMoved(int deltaX, int deltaY) { col.x += deltaX; } for (var row : rows) { - row.bounds = row.bounds.move(deltaX, deltaY); - for (var cell : row.getChildren()) { - cell.moveLayoutPos(deltaX, deltaY); - } + row.moveLayoutPos(deltaX, deltaY); + } + } + + @Override + public boolean usePrimitives() { + return true; + } + + @Override + public void computePrimitives(PrimitiveCollector c) { + var bounds = getBounds(); + // Column border lines (vertical lines between columns). X comes from the + // Rust-written cell bounds of the row with the most cells (Java + // must not compute geometry). column.x/column.width are x=0 + // serialization-time declarations and must not drive drawing. + var sourceRow = widestRow(); + for (int i = 0; i < columns.size() - 1; i++) { + c.emit( + new GuideRenderPrimitive.FillRect( + columnSeparatorX(sourceRow, i), + bounds.y(), + 1, + bounds.height(), + ColorUtils.TABLE_BORDER.resolve())); + } + // Row border lines (horizontal lines between rows) + for (int i = 0; i < rows.size() - 1; i++) { + var row = rows.get(i); + c.emit( + new GuideRenderPrimitive.FillRect( + bounds.x(), + row.getBounds() + .bottom(), + bounds.width(), + row.isHeader() ? HEADER_SEPARATOR_THICKNESS : 1, + ColorUtils.TABLE_BORDER.resolve())); } + // Cells are children - collectFrom traversal handles them } @Override public void render(RenderContext context) { // Render the table cell borders var bounds = getBounds(); + var sourceRow = widestRow(); for (int i = 0; i < columns.size() - 1; i++) { - var column = columns.get(i); - var colRight = column.x + column.width; - context.fillRect(colRight, bounds.y(), 1, bounds.height(), ColorUtils.TABLE_BORDER); + context.fillRect(columnSeparatorX(sourceRow, i), bounds.y(), 1, bounds.height(), ColorUtils.TABLE_BORDER); } for (int i = 0; i < rows.size() - 1; i++) { var row = rows.get(i); - context.fillRect(bounds.x(), row.bounds.bottom(), bounds.width(), 1, ColorUtils.TABLE_BORDER); + context.fillRect( + bounds.x(), + row.getBounds() + .bottom(), + bounds.width(), + row.isHeader() ? HEADER_SEPARATOR_THICKNESS : 1, + ColorUtils.TABLE_BORDER); } for (var row : rows) { - for (var cell : row.getChildren()) { - cell.render(context); + row.render(context); + } + } + + /** + * The row with the most cells (first row wins ties). Its Rust-written cell + * bounds are the source for vertical separator positions; all rows share + * the table's column x-structure, so one row's cell boundaries stand in + * for every row. {@code null} when the table has no rows (then no + * vertical separators can be derived; column model is the fallback). + */ + private LytTableRow widestRow() { + LytTableRow widest = null; + for (var row : rows) { + if (widest == null || row.getChildren() + .size() + > widest.getChildren() + .size()) { + widest = row; + } + } + return widest; + } + + /** + * Document-space x of the vertical separator between column {@code i} and + * {@code i + 1}, derived from the Rust-written cell bounds of + * {@code sourceRow} (the line positions must come from Rust layout + * data, never from Java-computed column geometry). + *

          + * Primary reference: {@code cell[i].getBounds().right()}, the left edge of + * the 1px CELL_BORDER gutter between adjacent cells. This mirrors the + * horizontal separators, which are drawn at {@code row.getBounds().bottom()} + * (the top edge of the row gutter), so vertical and horizontal lines meet at + * the cell corners. The alternative {@code cell[i+1].getBounds().x()} is the + * gutter's right edge, exactly 1px right of {@code right()} while the gutter + * is CELL_BORDER wide, and their integer midpoint degenerates to the left + * edge, so {@code right()} is the stable choice. When cell {@code i}'s + * bounds are missing, the boundary is recovered from the right neighbour's + * {@code cell[i+1].x() - CELL_BORDER}. + *

          + * Fallback: when the widest row has no usable Rust bounds for this boundary + * (no rows, fewer cells than columns, or bounds not written back), the + * legacy column-model position {@code column.x + column.width} is used. This + * only guards degenerate or pre-layout paths; after a Rust layout pass every + * flat node (cells included) receives a written-back rect. + */ + private int columnSeparatorX(LytTableRow sourceRow, int i) { + if (sourceRow != null && i + 1 < sourceRow.getChildren() + .size()) { + var cells = sourceRow.getChildren(); + LytRect left = cells.get(i) + .getBounds(); + LytRect right = cells.get(i + 1) + .getBounds(); + if (!left.isEmpty()) { + return left.right(); + } + if (!right.isEmpty()) { + return right.x() - CELL_BORDER; } } + return columns.get(i).x + columns.get(i).width; } public LytTableRow appendRow() { var row = new LytTableRow(this); + if (rows.isEmpty()) { + row.setMarginTop(CELL_BORDER); + } + row.setMarginBottom(CELL_BORDER); rows.add(row); return row; } @@ -95,7 +199,13 @@ public LytTableColumn getOrCreateColumn(int index) { return columns.get(index); } - private void layoutColumns(int x, int availableWidth) { + /** + * Distribute available width among columns. Called by the serializer + * (no longer by the Java pre-pass) so column widths are set before + * serialization. {@code x} is the table's left edge in document coords. + */ + public void layoutColumns(int x, int availableWidth) { + if (columns.isEmpty()) return; int innerWidth = Math.max(0, availableWidth - (columns.size() + 1) * CELL_BORDER); int totalPreferredWidth = 0; int flexibleColumns = 0; @@ -119,9 +229,16 @@ private void layoutColumns(int x, int availableWidth) { colX += column.width + CELL_BORDER; } - if (assignedWidth < innerWidth) { + // Only distribute remainder to flexible (undeclared) columns. + // When all columns have declared widths, the table stays at the + // sum of declared widths (natural width), and the last column must + // NOT absorb the leftover space. + if (flexibleColumns > 0 && assignedWidth < innerWidth) { + int leftover = innerWidth - assignedWidth; var lastCol = columns.getLast(); - lastCol.width += innerWidth - assignedWidth; + if (lastCol.preferredWidth == 0) { + lastCol.width += leftover; + } } return; } diff --git a/src/main/java/com/hfstudio/guidenh/guide/document/block/table/LytTableColumn.java b/src/main/java/com/hfstudio/guidenh/guide/document/block/table/LytTableColumn.java index 12c21a49..ecc12983 100644 --- a/src/main/java/com/hfstudio/guidenh/guide/document/block/table/LytTableColumn.java +++ b/src/main/java/com/hfstudio/guidenh/guide/document/block/table/LytTableColumn.java @@ -4,7 +4,9 @@ public class LytTableColumn { + @Getter int x; + @Getter int width; @Getter int preferredWidth; diff --git a/src/main/java/com/hfstudio/guidenh/guide/document/block/table/LytTableRow.java b/src/main/java/com/hfstudio/guidenh/guide/document/block/table/LytTableRow.java index c8771376..f1e6313d 100644 --- a/src/main/java/com/hfstudio/guidenh/guide/document/block/table/LytTableRow.java +++ b/src/main/java/com/hfstudio/guidenh/guide/document/block/table/LytTableRow.java @@ -4,33 +4,87 @@ import java.util.List; import com.hfstudio.guidenh.guide.document.LytRect; +import com.hfstudio.guidenh.guide.document.block.LytBlock; import com.hfstudio.guidenh.guide.document.block.LytNode; +import com.hfstudio.guidenh.guide.layout.LayoutContext; +import com.hfstudio.guidenh.guide.render.RenderContext; + +import lombok.Getter; +import lombok.Setter; /** * A row in {@link LytTable}. Contains {@link LytTableCell}. + *

          + * A real block (not an eliminated wrapper): the Rust layout lays rows out as + * flex Row containers: row height follows the tallest cell's content, so + * wrapped text can no longer overflow a Java-pinned height. */ -public class LytTableRow extends LytNode { +public class LytTableRow extends LytBlock { private final LytTable table; private final List cells = new ArrayList<>(); - LytRect bounds = LytRect.empty(); + + /** + * Whether this row is the table's header row. Set by the table compilers + * (GFM markdown tables treat the first row as the header). The table + * renderer draws a thicker separator below it to visually distinguish the + * header from data rows. + */ + @Getter + @Setter + private boolean header; public LytTableRow(LytTable table) { this.table = table; this.parent = table; } - @Override - public LytRect getBounds() { - return bounds; - } - public LytTableCell appendCell() { var cell = new LytTableCell(table, this, table.getOrCreateColumn(cells.size())); + cell.setMarginLeft(LytTable.CELL_BORDER); + if (!cells.isEmpty()) { + // The closing 1px border moves to the new last cell. + cells.getLast() + .setMarginRight(0); + } + cell.setMarginRight(LytTable.CELL_BORDER); cells.add(cell); return cell; } + @Override + public boolean usePrimitives() { + // Rows have no visuals of their own (borders are the table's); the + // collector descends to the cells. Without this the whole row would + // fall back to a legacy HostDraw subtree. + return true; + } + + @Override + protected LytRect computeLayout(LayoutContext context, int x, int y, int availableWidth) { + var rowBottom = y; + for (var cell : cells) { + var column = cell.column; + var cellBounds = cell.layout(context, column.x, y, column.width); + rowBottom = Math.max(rowBottom, cellBounds.bottom()); + } + return new LytRect(x, y, availableWidth, rowBottom - y); + } + + @Override + protected void onLayoutMoved(int deltaX, int deltaY) { + for (var cell : cells) { + cell.moveLayoutPos(deltaX, deltaY); + } + } + + @Override + public void render(RenderContext context) { + for (var cell : cells) { + cell.render(context); + } + } + @Override public List getChildren() { return cells; diff --git a/src/main/java/com/hfstudio/guidenh/guide/document/flow/LytFlowInlineBlock.java b/src/main/java/com/hfstudio/guidenh/guide/document/flow/LytFlowInlineBlock.java index b239c9b4..2c79e22e 100644 --- a/src/main/java/com/hfstudio/guidenh/guide/document/flow/LytFlowInlineBlock.java +++ b/src/main/java/com/hfstudio/guidenh/guide/document/flow/LytFlowInlineBlock.java @@ -11,7 +11,6 @@ import com.hfstudio.guidenh.guide.document.interaction.GuideTooltip; import com.hfstudio.guidenh.guide.document.interaction.InteractiveElement; import com.hfstudio.guidenh.guide.layout.LayoutContext; -import com.hfstudio.guidenh.guide.layout.MinecraftFontMetrics; import com.hfstudio.guidenh.guide.ui.GuideUiHost; import lombok.Getter; @@ -20,9 +19,6 @@ @Getter public class LytFlowInlineBlock extends LytFlowContent implements InteractiveElement { - private static final ThreadLocal MEASURE_LAYOUT_CONTEXT = ThreadLocal - .withInitial(() -> new LayoutContext(new MinecraftFontMetrics())); - private LytBlock block; @Setter @@ -39,22 +35,17 @@ public void setBlock(@Nullable LytBlock block) { invalidateLayout(); } - public LytSize getPreferredSize(int lineWidth) { - return measurePreferredBounds(lineWidth).size(); - } - - public LytRect getPreferredBounds(int lineWidth) { - return measurePreferredBounds(lineWidth); + public LytSize getPreferredSize(LayoutContext context, int lineWidth) { + return getPreferredBounds(context, lineWidth).size(); } - private LytRect measurePreferredBounds(int lineWidth) { + public LytRect getPreferredBounds(LayoutContext context, int lineWidth) { if (block == null) { return LytRect.empty(); } - - var layoutContext = MEASURE_LAYOUT_CONTEXT.get() - .resetTransientState(); - return block.layout(layoutContext, 0, 0, lineWidth); + // Measure with the ambient layout context (same font metrics as the + // rest of the pass, and is headless-safe in tests). + return block.layout(context, 0, 0, lineWidth); } @Override diff --git a/src/main/java/com/hfstudio/guidenh/guide/document/flow/LytFlowLink.java b/src/main/java/com/hfstudio/guidenh/guide/document/flow/LytFlowLink.java index c3ba65fe..a460fb9c 100644 --- a/src/main/java/com/hfstudio/guidenh/guide/document/flow/LytFlowLink.java +++ b/src/main/java/com/hfstudio/guidenh/guide/document/flow/LytFlowLink.java @@ -30,7 +30,9 @@ public class LytFlowLink extends LytTooltipSpan { private boolean playedCustomClickSound; public LytFlowLink() { - modifyStyle(style -> style.color(ColorUtils.LINK)); + modifyStyle( + style -> style.color(ColorUtils.LINK) + .underlined(true)); modifyHoverStyle(style -> style.underlined(true)); } diff --git a/src/main/java/com/hfstudio/guidenh/guide/indices/OreIndex.java b/src/main/java/com/hfstudio/guidenh/guide/indices/OreIndex.java index 5f3c3e58..0c34585d 100644 --- a/src/main/java/com/hfstudio/guidenh/guide/indices/OreIndex.java +++ b/src/main/java/com/hfstudio/guidenh/guide/indices/OreIndex.java @@ -26,7 +26,7 @@ * {@code ingotIron}). When an item stack is registered against any of those ore names the page * is considered a match. *

          - * At rebuild time a precomputed {@code ItemId 閳?PageAnchor} map is built by expanding each + * At rebuild time a precomputed {@code ItemId -> PageAnchor} map is built by expanding each * indexed ore name through {@link OreDictionary#getOres(String)}. This makes * {@link #findByStack(ItemStack)} an O(1) hash lookup instead of requiring a per-call scan of all * ore-dictionary IDs assigned to the hovered item, which can be expensive in large modpacks. @@ -34,7 +34,7 @@ public class OreIndex extends UniqueIndex { /** - * Precomputed item (item + damage) 閳?page anchor map built during {@link #rebuild} / + * Precomputed item (item + damage) to page anchor map built during {@link #rebuild} / * {@link #update}. Keyed by exact damage value and also by {@link OreDictionary#WILDCARD_VALUE} * for stacks registered with wildcard meta, matching the two-step lookup used by * {@link ItemIndex#findByItem}. @@ -72,7 +72,7 @@ private Map buildItemCache(List pages) { String oreName = entry.getKey(); PageAnchor anchor = get(oreName); if (anchor == null) { - // ore name was rejected (e.g. duplicate) 閳?skip + // ore name was rejected (e.g. duplicate), so skip it continue; } for (ItemStack stack : OreDictionary.getOres(oreName)) { diff --git a/src/main/java/com/hfstudio/guidenh/guide/internal/GuideLightweightReloadService.java b/src/main/java/com/hfstudio/guidenh/guide/internal/GuideLightweightReloadService.java index ff63e5e1..4946e584 100644 --- a/src/main/java/com/hfstudio/guidenh/guide/internal/GuideLightweightReloadService.java +++ b/src/main/java/com/hfstudio/guidenh/guide/internal/GuideLightweightReloadService.java @@ -7,7 +7,6 @@ import java.util.List; import java.util.Map; import java.util.function.Function; -import java.util.function.Supplier; import net.minecraft.client.Minecraft; import net.minecraft.client.resources.IResourceManager; @@ -17,7 +16,6 @@ import org.jetbrains.annotations.Nullable; import com.hfstudio.guidenh.ClientProxy; -import com.hfstudio.guidenh.guide.compiler.PageCompiler; import com.hfstudio.guidenh.guide.compiler.ParsedGuidePage; import com.hfstudio.guidenh.guide.internal.datadriven.DataDrivenGuideLoader; import com.hfstudio.guidenh.guide.internal.datadriven.GuidePageResourceSelector; @@ -53,7 +51,6 @@ public static void reloadDevelopmentGuides() { public static void reloadGuides(IResourceManager resourceManager) { var activeResourcePacks = DataDrivenGuideLoader.getActiveResourcePacks(resourceManager); - LazyParsedGuidePage.clearResidentPages(); DataDrivenGuideLoader.clearCaches(); RecipeCache.clear(); SnbtPreParseCache.clear(); @@ -248,37 +245,15 @@ private static ParsedGuidePage tryParsePageCandidate(String sourcePack, String l if (selected == null) return null; byte[] bytes = DataDrivenGuideLoader.readBytes(selected.pack(), sourceId); if (bytes == null) return null; - return parsePageBytes(sourcePack, language, contentRootFolder, pageId, sourceId, bytes, selected); + return parsePageBytes(sourcePack, selected.pack(), language, contentRootFolder, pageId, sourceId, bytes); } @Nullable - private static ParsedGuidePage parsePageBytes(String sourcePack, String language, String contentRootFolder, - ResourceLocation pageId, ResourceLocation sourceId, byte[] bytes, - GuidePageResourceSelector.SelectedPack selected) { + private static ParsedGuidePage parsePageBytes(String sourcePack, IResourcePack sourceResourcePack, String language, + String contentRootFolder, ResourceLocation pageId, ResourceLocation sourceId, byte[] bytes) { try { - GuideLocalizedPageSourceResolver.ResolvedGuidePageSource resolved = GuideLocalizedPageSourceResolver - .resolveFrontmatterOnly(language, contentRootFolder, pageId, bytes); - ParsedGuidePage frontmatter = PageCompiler - .parseFrontmatterOnly(sourcePack, language, pageId, resolved.source()); - Supplier sourceLoader = () -> { - byte[] currentBytes = DataDrivenGuideLoader.readBytes(selected.pack(), sourceId); - if (currentBytes == null) { - return ""; - } - return GuideLocalizedPageSourceResolver.resolve(language, contentRootFolder, pageId, currentBytes) - .source(); - }; - return new LazyParsedGuidePage( - sourcePack, - selected.pack(), - pageId, - frontmatter.getFrontmatter(), - frontmatter.getLanguage(), - frontmatter.getParseFailureMessage(), - frontmatter.getParseFailureFrom(), - frontmatter.getParseFailureTo(), - sourceLoader, - resolved.contentFingerprint()); + return GuideLocalizedPageSourceResolver + .parseFrontmatterOnly(sourcePack, sourceResourcePack, language, contentRootFolder, pageId, bytes); } catch (Exception ex) { GuideDebugLog .warn("[GuideNH] [GuideLightweightReloadService] Error parsing page {} from {}", pageId, sourceId, ex); @@ -335,7 +310,7 @@ private static ParsedGuidePage tryParsePage(IResourceManager resourceManager, St if (selected == null) return null; byte[] bytes = DataDrivenGuideLoader.readBytes(selected.pack(), sourceId); if (bytes == null) return null; - return parsePageBytes(sourcePack, language, contentRootFolder, pageId, sourceId, bytes, selected); + return parsePageBytes(sourcePack, selected.pack(), language, contentRootFolder, pageId, sourceId, bytes); } static byte @Nullable [] selectPageCandidate(ResourceLocation sourceId) { diff --git a/src/main/java/com/hfstudio/guidenh/guide/internal/GuideScreen.java b/src/main/java/com/hfstudio/guidenh/guide/internal/GuideScreen.java index 475e11cf..7ff6981b 100644 --- a/src/main/java/com/hfstudio/guidenh/guide/internal/GuideScreen.java +++ b/src/main/java/com/hfstudio/guidenh/guide/internal/GuideScreen.java @@ -62,7 +62,6 @@ import com.hfstudio.guidenh.guide.compiler.FrontmatterPageMeta; import com.hfstudio.guidenh.guide.compiler.PageCompiler; import com.hfstudio.guidenh.guide.compiler.ParsedGuidePage; -import com.hfstudio.guidenh.guide.document.DefaultStyles; import com.hfstudio.guidenh.guide.document.LytRect; import com.hfstudio.guidenh.guide.document.block.LytDocument; import com.hfstudio.guidenh.guide.document.block.LytHeading; @@ -126,6 +125,8 @@ import com.hfstudio.guidenh.guide.internal.search.GuideSearchPage; import com.hfstudio.guidenh.guide.internal.search.GuideSearchResultDocumentBuilder; import com.hfstudio.guidenh.guide.internal.search.GuideSearchSnippetFormatter; +import com.hfstudio.guidenh.guide.internal.settings.GuideSettingsDocumentBuilder; +import com.hfstudio.guidenh.guide.internal.settings.GuideSettingsPage; import com.hfstudio.guidenh.guide.internal.structure.GuideStructureData; import com.hfstudio.guidenh.guide.internal.tooltip.GuideItemTooltipLines; import com.hfstudio.guidenh.guide.internal.tooltip.GuideItemTooltipRenderSupport; @@ -133,8 +134,11 @@ import com.hfstudio.guidenh.guide.internal.util.LangUtil; import com.hfstudio.guidenh.guide.internal.welcome.GuideWelcomeContent; import com.hfstudio.guidenh.guide.internal.welcome.GuideWelcomeScreen; +import com.hfstudio.guidenh.guide.layout.FontProvider; +import com.hfstudio.guidenh.guide.layout.LayoutBridge; import com.hfstudio.guidenh.guide.layout.LayoutContext; -import com.hfstudio.guidenh.guide.layout.MinecraftFontMetrics; +import com.hfstudio.guidenh.guide.layout.RustFontMetrics; +import com.hfstudio.guidenh.guide.layout.SystemFontProvider; import com.hfstudio.guidenh.guide.mediawiki.MediaWikiExternalLinkSupport; import com.hfstudio.guidenh.guide.mediawiki.MediaWikiPageIds; import com.hfstudio.guidenh.guide.mediawiki.MediaWikiSpecialCatalog; @@ -145,6 +149,8 @@ import com.hfstudio.guidenh.guide.navigation.NavigationNode; import com.hfstudio.guidenh.guide.navigation.NavigationTree; import com.hfstudio.guidenh.guide.render.GuideFontCompat; +import com.hfstudio.guidenh.guide.render.GuideText; +import com.hfstudio.guidenh.guide.render.PrimitiveCollector; import com.hfstudio.guidenh.guide.render.VanillaRenderContext; import com.hfstudio.guidenh.guide.scene.LytGuidebookScene; import com.hfstudio.guidenh.guide.scene.annotation.DiamondAnnotation; @@ -210,6 +216,19 @@ public class GuideScreen extends GuiContainer private float currentVisualScale = 1.0f; private int lastLayoutWidth = -1; private int lastLayoutVisualScalePermille = -1; + /** + * Last base font size used for layout; a readerFontSize config change + * flips this and auto-triggers a re-layout (mirrors the contentZoom precedent). + */ + private float lastLayoutBaseFontSize = -1f; + /** + * Last resolved base/code family used for layout; a readerFontFamily or + * readerCodeFontFamily config change flips these and auto-triggers a + * re-layout (null-safe; the default "system"/"mono" resolve to + * null/"monospace", so no extra re-layouts are added). + */ + private String lastLayoutBaseFamily = null; + private String lastLayoutCodeFamily = null; private long lastPageWheelScrollAtMillis; private int lastPanelX = Integer.MIN_VALUE; private int lastPanelY = Integer.MIN_VALUE; @@ -236,10 +255,24 @@ public class GuideScreen extends GuiContainer private int dragLastMouseY = 0; private GuideIconButton btnSearch, btnHomePage, btnBack, btnForward, btnFullWidth, btnClose; + private GuideIconButton btnSettings; private GuideIconButton btnGuideEditorToggle, btnGuideEditorAutosave, btnGuideEditorSave, btnGuideEditorLayoutSplit, btnGuideEditorLayoutEditorOnly, btnGuideEditorLayoutPreviewOnly, btnGuideEditorAdvancedToggle; public static final int TOOLBAR_H = 16; public static final int TOOLBAR_GAP = 3; + + /** + * Toolbar page-title style: fontScale 0.8 → line height round(17×0.8) = 14 + * < TOOLBAR_H, mirroring {@code GuideNavBar.TITLE_FONT_SCALE}. The laid-out + * bounds height is the LytParagraph minimal estimate (10), so the centered + * titleY = (16-10)/2 + 2 = 5; glyph ink stays inside the 16px toolbar band. + */ + public static final TextStyle TOOLBAR_TITLE_STYLE = TextStyle.builder() + .fontScale(0.8f) + .bold(true) + .font(null) + .color(ColorUtils.MC_WHITE) + .build(); private static final int GUIDE_EDITOR_TOOLBAR_H = 16; private static final int GUIDE_EDITOR_MIN_SPLIT_PANE_W = 15; private static final int SCROLLBAR_W = SceneEditorMultilineTextArea.SCROLLBAR_SIZE; @@ -279,7 +312,7 @@ private static LytDocument buildLoadingDocument() { private final GuideScreenHomeHistory homeHistory = GuideScreenHomeHistory.shared(); private final HomePageDataBuilder homePageDataBuilder = new HomePageDataBuilder(); private final HomePageController homePageController = new HomePageController(); - private final MinecraftFontMetrics layoutFontMetrics = new MinecraftFontMetrics(); + private final RustFontMetrics layoutFontMetrics = new RustFontMetrics(); private final CodeBlockClipboardService codeBlockClipboardService = new CodeBlockClipboardService(); private final GuideDebugOverlay debugOverlay = new GuideDebugOverlay(); private final GuideScreenScrollbarOutline scrollbarOutline = new GuideScreenScrollbarOutline(); @@ -311,6 +344,8 @@ private static LytDocument buildLoadingDocument() { @Nullable private LytDocument searchDocument; @Nullable + private LytDocument settingsDocument; + @Nullable private String cachedSearchQuery; private long cachedSearchIndexRevision = -1L; private long searchDocumentRebuildAfterNanos; @@ -532,7 +567,7 @@ private GuideScreen(GuideScreenRoute route, @Nullable GuideScreenViewState resto this.parentScreen = parentScreen; applyRoute(route); pageTitle = new LytParagraph(); - pageTitle.setStyle(DefaultStyles.HEADING1); + pageTitle.setStyle(TOOLBAR_TITLE_STYLE); try { this.fullWidth = ModConfig.ui.fullWidth; } catch (Throwable ignored) { @@ -743,7 +778,7 @@ private void applyRoute(GuideScreenRoute route) { } } else { guide = null; - currentAnchor = currentRoute.isHomeSearch() ? currentRoute.anchor() : null; + currentAnchor = currentRoute.isHomeSearch() || currentRoute.isSettings() ? currentRoute.anchor() : null; pendingAnchorScroll = false; } } @@ -1035,6 +1070,7 @@ private boolean isNarrowReadingModeActive() { return fullWidth && !isHomeRoute() && !isSearchPage() && !isItemLinksPage() + && !isSettingsPage() && !isGuideEditorActive() && ModConfig.ui.fullWidthNarrowReadingMarginRatio > NARROW_READING_DISABLED_RATIO; } @@ -1091,8 +1127,8 @@ private boolean isGuideEditorActive() { private boolean hasEditableContentRoute() { // A special page is generated from the guide's own data and has no source file, so there is nothing to // edit: the editor pane stays out of the way rather than offering to change a page that cannot be - // written. The same applies to the two built-in synthetic views. - return hasContentRoute() && !isSearchPage() && !isItemLinksPage() && !isSpecialPage(); + // written. The same applies to the two built-in synthetic views and to the virtual settings page. + return hasContentRoute() && !isSearchPage() && !isItemLinksPage() && !isSettingsPage() && !isSpecialPage(); } private void syncGuideEditorStateFromConfig() { @@ -2291,6 +2327,12 @@ private void rebuildToolbar() { getRightToolbarButtonX(isSearchPage() ? 2 : 4), btnY, fullWidth ? GuideIconButton.Role.CLOSE_FULL_WIDTH_VIEW : GuideIconButton.Role.OPEN_FULL_WIDTH_VIEW); + btnSettings = reuseToolbarButton( + btnSettings, + 6, + getRightToolbarButtonX(isSearchPage() ? -1 : 5), + btnY, + GuideIconButton.Role.SETTINGS); btnClose = reuseToolbarButton( btnClose, 0, @@ -2303,6 +2345,9 @@ private void rebuildToolbar() { this.buttonList.add(btnBack); this.buttonList.add(btnForward); this.buttonList.add(btnFullWidth); + if (!isSearchPage()) { + this.buttonList.add(btnSettings); + } this.buttonList.add(btnClose); updateToolbarButtonState(); } @@ -2476,12 +2521,19 @@ protected void actionPerformed(GuiButton btn) { ensureLayout(); clampScroll(); } else if (btn == btnSearch) { - if (isSearchPage()) {} else if (currentRoute != null && currentRoute.isHome()) { - restoreViewState(GuideScreenViewState.of(GuideScreenRoute.homeSearch(""), 0)); - focusSearchField(); + if (isSearchPage()) {} else + if (currentRoute != null && (currentRoute.isHome() || currentRoute.isSettings())) { + restoreViewState(GuideScreenViewState.of(GuideScreenRoute.homeSearch(""), 0)); + focusSearchField(); + } else { + navigateTo(GuideSearchPage.anchorForQuery("")); + focusSearchField(); + } + } else if (btn == btnSettings) { + if (isSettingsPage()) {} else if (currentRoute != null && currentRoute.isHome()) { + restoreViewState(GuideScreenViewState.of(GuideScreenRoute.settings(), 0)); } else { - navigateTo(GuideSearchPage.anchorForQuery("")); - focusSearchField(); + navigateTo(GuideSettingsPage.anchor()); } } else if (btn == btnHomePage) { if (currentRoute != null && currentRoute.isHome()) { @@ -2629,6 +2681,7 @@ private void loadCurrentPage() { currentPage = null; document = null; searchDocument = null; + settingsDocument = null; searchField = null; specialSearchField = null; } else if (isSearchPage()) { @@ -2636,6 +2689,13 @@ private void loadCurrentPage() { document = null; specialSearchField = null; rebuildSearchDocumentIfNeeded(true); + } else if (isSettingsPage()) { + currentPage = null; + document = null; + searchField = null; + specialSearchField = null; + searchDocument = null; + settingsDocument = GuideSettingsDocumentBuilder.buildDocument(); } else if (isItemLinksPage()) { currentPage = null; searchField = null; @@ -2779,7 +2839,7 @@ private void completePendingContentPageLoadIfNeeded() { } if (loadedPage != null) { - // Cache hit — mount immediately + // Cache hit: mount immediately loadedPage.prepareForDisplay(); LytHost lytHost = ClientProxy.getLytHost(); if (!pageLoadInProgress || requestId != pendingPageLoadRequestId) return; @@ -2810,7 +2870,7 @@ private void completePendingContentPageLoadIfNeeded() { return; } - // 3. Not yet compiled — prioritize and show loading + // 3. Not yet compiled, so prioritize it and show the loading document if (worker != null) { worker.prioritize(pageId); } @@ -2819,7 +2879,7 @@ private void completePendingContentPageLoadIfNeeded() { lytHost.setCurrentPageId(pageIdStr); lytHost.setCurrentPageCollection(guide); lytHost.mountDocument(LOADING_DOCUMENT); - // pageLoadInProgress stays true — retry next tick + // pageLoadInProgress stays true, so retry on the next tick } /** Register scenes created at MOUNT time into GuidePage.scenes() for tick dispatch. */ @@ -2871,22 +2931,68 @@ private LayoutContext createLayoutContext(int actualWidth, int referenceWidth) { } private void ensureLayout() { + // Lazy-init the Rust font system handle with system CJK font + if (LayoutBridge.getFontHandle() == 0) { + FontProvider fontProvider = new SystemFontProvider(); + byte[] fontData = fontProvider.getFontData("zh_CN"); + GuideDebugLog.warnAlways( + "GuideScreen: initializing Rust font system from {} ({} bytes)", + fontProvider.getFontPath(), + fontData.length); + long handle = LayoutBridge.init(fontData, "zh_CN"); + LayoutBridge.setFontHandle(handle); + loadFallbackSymbolFont(fontProvider, handle); + } + var activeDocument = getActiveDocument(); if (activeDocument == null) return; int layoutWidth = Math.max(1, Math.round(contentW / currentZoom)); int layoutVisualScalePermille = visualScalePermille(currentVisualScale); + // Compare baseFontSize on every frame (mirroring the contentZoom + // precedent): a config change triggers a re-layout on the next frame + // without any explicit event, and under the default value it is always + // BASE_FONT_SIZE, so no extra re-layout is introduced. + float layoutBaseFontSize = GuideText.resolveBaseFontSize(); + // The resolved base/code families join the same per-frame comparison + // (null-safe; the default "system"/"mono" resolve to null/"monospace", + // so no extra re-layout is introduced). + String layoutBaseFamily = GuideText.resolveBaseFontFamily(); + String layoutCodeFamily = GuideText.resolveCodeFontFamily(); if (!activeDocument.hasLayout() || layoutDocument != activeDocument || lastLayoutWidth != layoutWidth - || lastLayoutVisualScalePermille != layoutVisualScalePermille) { + || lastLayoutVisualScalePermille != layoutVisualScalePermille + || lastLayoutBaseFontSize != layoutBaseFontSize + || !Objects.equals(lastLayoutBaseFamily, layoutBaseFamily) + || !Objects.equals(lastLayoutCodeFamily, layoutCodeFamily)) { clearInteractionState(); activeDocument.updateLayout(createLayoutContext(contentW, getVisualReferenceContentWidth()), layoutWidth); layoutDocument = activeDocument; lastLayoutWidth = layoutWidth; lastLayoutVisualScalePermille = layoutVisualScalePermille; + lastLayoutBaseFontSize = layoutBaseFontSize; + lastLayoutBaseFamily = layoutBaseFamily; + lastLayoutCodeFamily = layoutCodeFamily; invalidateScrollbarOutline(); } } + /** + * Best-effort fallback symbol font registration (seguisym.ttf covers the + * callout icons ⓘ ✦ ➤ ⚠ ☢ that msyh.ttc lacks). Runs once right after + * font init; empty data and stale native libs are skipped/ignored. + */ + private void loadFallbackSymbolFont(FontProvider fontProvider, long handle) { + if (handle == 0) return; + byte[] fallbackData = fontProvider.getFallbackFontData("zh_CN"); + if (fallbackData.length == 0) return; + try { + LayoutBridge.loadFallbackFont(handle, fallbackData); + } catch (UnsatisfiedLinkError e) { + GuideDebugLog + .warnAlways("GuideScreen: loadFallbackFont unavailable (stale native lib?): {}", e.getMessage()); + } + } + private void scrollToCurrentAnchor() { if (isHomeRoute()) return; if (!pendingAnchorScroll) return; @@ -2937,6 +3043,11 @@ private void refreshCurrentPageTitle() { return; } + if (isSettingsPage()) { + currentPageTitle = GuidebookText.Settings.text(); + return; + } + if (isItemLinksPage()) { ItemStack stack = GuideItemLinksPage.stackFromAnchor(currentAnchor); String itemName = stack != null ? stack.getDisplayName() : currentAnchor.anchor(); @@ -3687,26 +3798,17 @@ private void renderGuideEditorPreview(int x, int y, int width, int height) { renderHeight); cachedPreviewScissor = cachedRect(cachedPreviewScissor, x, y, renderWidth, renderHeight); reusableRenderCtx.setViewport(cachedPreviewViewport); + reusableRenderCtx.setScreenViewport(cachedPreviewScissor); reusableRenderCtx.setScreenHeight(this.height); reusableRenderCtx.setDocumentOrigin(x, y); reusableRenderCtx.setScrollOffsetY(guideEditorPreviewScrollY); reusableRenderCtx.setZoom(1.0f); - reusableRenderCtx.pushScissor(cachedPreviewScissor); - GL11.glPushMatrix(); - GL11.glTranslatef(x, y, 0f); - GL11.glTranslatef(0f, -(float) guideEditorPreviewScrollY, 0f); + // No GL matrix or context scissor here: the primitive pipeline's render + // engine owns the document->screen transform and the viewport clip. try { previewDocument.render(reusableRenderCtx); } catch (Throwable t) { GuideDebugLog.warn("Failed to render guide editor preview", t); - } finally { - GL11.glPopMatrix(); - reusableRenderCtx.restoreExternalRenderState(); - reusableRenderCtx.popScissor(); - reusableRenderCtx.restoreExternalRenderState(); - GL11.glDisable(GL11.GL_SCISSOR_TEST); - GL11.glEnable(GL11.GL_TEXTURE_2D); - ColorUtils.applyGlColor(ColorUtils.WHITE.getColor()); } drawGuideEditorPreviewScrollbar( x + renderWidth - SCROLLBAR_W, @@ -4468,8 +4570,9 @@ private String buildBottomBarText(@Nullable FrontmatterPageMeta meta) { } /** - * Builds the bottom-bar string with §o italic formatting around each placeholder value. - * §r resets to white; §7 restores dark gray (#AAAAAA) to match the draw color. + * Builds the bottom-bar string with the italic format code around each placeholder value. + * Each value is followed by the reset code and the dark-gray code (#AAAAAA) that matches + * the draw color. */ private static String formatBottomBar(String sourceDisplay, String authorsStr, @Nullable String dateVal, @Nullable String updatedVal) { @@ -4533,6 +4636,12 @@ private void drawPageTitle() { if (pageTitle.isEmpty()) return; int reservedRight = (16 + TOOLBAR_GAP) * 5 + PANEL_PADDING + 4; + // Ordinary toolbar title (user decision): placed naturally from the + // toolbar band's left edge (panelX + PANEL_PADDING), unrelated to the + // content column, no longer aligned to contentX. The navbar sits + // below the toolbar band (navY = panelY + TOOLBAR_H + 1), so the title + // has no navbar conflict and may fill the toolbar band; the reserved + // right-side icon area is kept. int availableW = Math.max(20, panelW - PANEL_PADDING - reservedRight); int titleX = panelX + PANEL_PADDING; @@ -4548,17 +4657,34 @@ private void drawPageTitle() { int titleY = Math.max(0, (TOOLBAR_H - titleH) / 2) + panelY + 2; var ctx = reusableContentTooltipCtx; - cachedTitleViewport = cachedRect(cachedTitleViewport, 0, 0, availableW, Math.max(titleH, TOOLBAR_H)); + // Defensive clip for the legacy RenderContext fallback path only: the + // atlas-backed glyph runs (DrawGlyphRun) are NOT clipped by this + // viewport. The title stays inside the toolbar band because + // TOOLBAR_TITLE_STYLE's line height (round(17×0.8) = 14) fits under + // TOOLBAR_H; that style, not this clamp, is the actual constraint. + cachedTitleViewport = cachedRect(cachedTitleViewport, 0, 0, availableW, Math.min(titleH, TOOLBAR_H)); ctx.setViewport(cachedTitleViewport); ctx.setScreenHeight(this.height); ctx.setDocumentOrigin(titleX, titleY); ctx.setScrollOffsetY(0); - GL11.glPushMatrix(); - GL11.glTranslatef(titleX, titleY, 0f); try { - pageTitle.render(ctx); + // Use the primitive pipeline instead of the legacy render() call: + // pageTitle now renders through computePrimitives() (usePrimitives() + // always returns true when content exists), which emits atlas-backed + // glyph runs when glyphData is available, or a GuideText fallback + // (DrawGlyphRun / DrawText) when glyphData is null/empty. This + // eliminates the silent blank from the previous empty render() path. + var engine = LytDocument.getRenderEngine(); + LytRect titleScreenVp = new LytRect(titleX, titleY, availableW, Math.max(titleH, TOOLBAR_H)); + var pc = new PrimitiveCollector(titleScreenVp, ctx); + pc.pushTransform(titleX, titleY, 1.0f); + pc.collectFrom(pageTitle); + pc.popTransform(); + var prims = pc.result(); + if (!prims.isEmpty()) { + engine.execute(prims); + } } finally { - GL11.glPopMatrix(); ctx.restoreExternalRenderState(); GL11.glDisable(GL11.GL_SCISSOR_TEST); GL11.glEnable(GL11.GL_TEXTURE_2D); @@ -5050,27 +5176,19 @@ private void renderDocument(int mouseX, int mouseY) { cachedViewportRect = cachedRect(cachedViewportRect, 0, viewportTopInDocument, contentW, documentH); cachedScissorRect = cachedRect(cachedScissorRect, contentX, documentY, contentW, documentH); ctx.setViewport(cachedViewportRect); + ctx.setScreenViewport(cachedScissorRect); ctx.setScreenHeight(this.height); int documentRenderY = getDocumentViewportY() + documentRenderOffsetY; ctx.setDocumentOrigin(contentX, documentRenderY); ctx.setScrollOffsetY(renderedScrollY); ctx.setPreciseScrollOffsetY(visualScrollY); ctx.setZoom(currentZoom); - ctx.pushScissor(cachedScissorRect); - GL11.glPushMatrix(); - GL11.glTranslatef(contentX, documentRenderY, 0f); - if (currentZoom != 1.0f) { - GL11.glScalef(currentZoom, currentZoom, 1f); - } - GL11.glTranslatef(0f, -visualScrollY, 0f); + // No GL matrix or context scissor here: the primitive pipeline's render + // engine owns the document->screen transform and the viewport clip. try { activeDocument.render(ctx); } catch (Throwable t) { GuideDebugLog.error("Error rendering guide document {}", currentAnchor.pageId(), t); - } finally { - GL11.glPopMatrix(); - ctx.restoreExternalRenderState(); - ctx.popScissor(); } } @@ -5244,6 +5362,10 @@ private void updateToolbarButtonState() { btnSearch.enabled = canSearchCurrentView(); btnSearch.visible = true; } + if (btnSettings != null) { + btnSettings.enabled = true; + btnSettings.visible = true; + } if (btnGuideEditorAutosave != null) { btnGuideEditorAutosave.setActive(GuideScreenEditorState.isAutosaveEnabled()); } @@ -6894,6 +7016,10 @@ private boolean isSearchPage() { return GuideSearchPage.isSearchAnchor(currentAnchor); } + private boolean isSettingsPage() { + return GuideSettingsPage.isSettingsAnchor(currentAnchor); + } + private boolean isSpecialPage() { return currentAnchor != null && currentAnchor.pageId() != null && MediaWikiPageIds.isSpecialPage(currentAnchor.pageId()); @@ -7064,7 +7190,7 @@ private int getRightToolbarButtonsLeft() { } private int getRightToolbarButtonCount() { - return isSearchPage() ? 4 : 6; + return isSearchPage() ? 4 : 7; } private int getRightToolbarButtonX(int index) { @@ -7091,7 +7217,7 @@ private void focusSearchField() { @Nullable private LytDocument getActiveDocument() { - return isSearchPage() ? searchDocument : document; + return isSearchPage() ? searchDocument : isSettingsPage() ? settingsDocument : document; } private String queryFromCurrentAnchor() { @@ -7104,6 +7230,24 @@ private String queryFromCurrentAnchor() { return ""; } + /** + * Rebuilds the settings page document in place (after a reader + * config change from the settings UI). Only the document tree is replaced + * here; relayout is handled by the {@link #ensureLayout()} invalidation + * chain, which compares {@code GuideText.resolveBaseFontSize()} + * plus the resolved base/code families every frame, so no explicit + * invalidation is introduced. + */ + public void rebuildSettingsDocument() { + if (!isSettingsPage()) { + return; + } + clearInteractionState(); + settingsDocument = GuideSettingsDocumentBuilder.buildDocument(); + layoutDocument = null; + lastLayoutWidth = -1; + } + private void rebuildSearchDocumentIfNeeded(boolean force) { if (!isSearchPage()) { return; diff --git a/src/main/java/com/hfstudio/guidenh/guide/internal/GuideScreenMemory.java b/src/main/java/com/hfstudio/guidenh/guide/internal/GuideScreenMemory.java index fcbd3a1f..5485d87a 100644 --- a/src/main/java/com/hfstudio/guidenh/guide/internal/GuideScreenMemory.java +++ b/src/main/java/com/hfstudio/guidenh/guide/internal/GuideScreenMemory.java @@ -8,6 +8,7 @@ import com.hfstudio.guidenh.guide.internal.screen.GuideNavBarState; import com.hfstudio.guidenh.guide.internal.search.GuideItemLinksPage; import com.hfstudio.guidenh.guide.internal.search.GuideSearchPage; +import com.hfstudio.guidenh.guide.internal.settings.GuideSettingsPage; public class GuideScreenMemory { @@ -74,7 +75,8 @@ public static boolean isRememberable(@Nullable GuideScreenViewState state) { public static boolean isSupportedContentAnchor(@Nullable PageAnchor anchor) { return anchor != null && !GuideSearchPage.isSearchAnchor(anchor) - && !GuideItemLinksPage.isItemLinksAnchor(anchor); + && !GuideItemLinksPage.isItemLinksAnchor(anchor) + && !GuideSettingsPage.isSettingsAnchor(anchor); } public static boolean isValidContentRoute(@Nullable GuideScreenRoute route) { diff --git a/src/main/java/com/hfstudio/guidenh/guide/internal/GuideScreenRoute.java b/src/main/java/com/hfstudio/guidenh/guide/internal/GuideScreenRoute.java index 2b66b7a9..5fd3a446 100644 --- a/src/main/java/com/hfstudio/guidenh/guide/internal/GuideScreenRoute.java +++ b/src/main/java/com/hfstudio/guidenh/guide/internal/GuideScreenRoute.java @@ -8,13 +8,15 @@ import com.hfstudio.guidenh.guide.PageAnchor; import com.hfstudio.guidenh.guide.internal.search.GuideSearchPage; +import com.hfstudio.guidenh.guide.internal.settings.GuideSettingsPage; public class GuideScreenRoute { public enum Kind { HOME, HOME_SEARCH, - CONTENT + CONTENT, + SETTINGS } private static final GuideScreenRoute HOME = new GuideScreenRoute(Kind.HOME, null, null); @@ -39,6 +41,10 @@ public static GuideScreenRoute homeSearch(@Nullable String query) { return new GuideScreenRoute(Kind.HOME_SEARCH, null, GuideSearchPage.anchorForQuery(query)); } + public static GuideScreenRoute settings() { + return new GuideScreenRoute(Kind.SETTINGS, null, GuideSettingsPage.anchor()); + } + public static GuideScreenRoute content(ResourceLocation guideId, PageAnchor anchor) { return new GuideScreenRoute( Kind.CONTENT, @@ -54,6 +60,10 @@ public boolean isHomeSearch() { return kind == Kind.HOME_SEARCH; } + public boolean isSettings() { + return kind == Kind.SETTINGS; + } + public boolean isContent() { return kind == Kind.CONTENT; } diff --git a/src/main/java/com/hfstudio/guidenh/guide/internal/GuidebookText.java b/src/main/java/com/hfstudio/guidenh/guide/internal/GuidebookText.java index b2b2f4ee..3103d222 100644 --- a/src/main/java/com/hfstudio/guidenh/guide/internal/GuidebookText.java +++ b/src/main/java/com/hfstudio/guidenh/guide/internal/GuidebookText.java @@ -273,6 +273,7 @@ public enum GuidebookText implements LocalizationEnum { ResetView, Search, HomePage, + Settings, HomePageSpecialPages, NavBarSpecialPages, NavBarExpandAll, diff --git a/src/main/java/com/hfstudio/guidenh/guide/internal/LazyParsedGuidePage.java b/src/main/java/com/hfstudio/guidenh/guide/internal/LazyParsedGuidePage.java deleted file mode 100644 index e90002ac..00000000 --- a/src/main/java/com/hfstudio/guidenh/guide/internal/LazyParsedGuidePage.java +++ /dev/null @@ -1,164 +0,0 @@ -package com.hfstudio.guidenh.guide.internal; - -import java.util.LinkedHashMap; -import java.util.Map; -import java.util.Objects; -import java.util.function.Supplier; - -import net.minecraft.client.resources.IResourcePack; -import net.minecraft.util.ResourceLocation; - -import org.jetbrains.annotations.Nullable; - -import com.hfstudio.guidenh.guide.compiler.Frontmatter; -import com.hfstudio.guidenh.guide.compiler.PageCompiler; -import com.hfstudio.guidenh.guide.compiler.ParsedGuidePage; -import com.hfstudio.guidenh.libs.mdast.model.MdAstRoot; -import com.hfstudio.guidenh.libs.unist.UnistPoint; - -/** - * Parsed page shell that keeps frontmatter resident while deferring Markdown source and AST loading. - */ -public final class LazyParsedGuidePage extends ParsedGuidePage { - - private static final int MAX_RESIDENT_PAGES = 48; - private static final Map RESIDENT_PAGES = new LinkedHashMap<>( - MAX_RESIDENT_PAGES, - 0.75f, - true) { - - @Override - protected boolean removeEldestEntry(Map.Entry eldest) { - if (size() <= MAX_RESIDENT_PAGES) { - return false; - } - eldest.getKey() - .discardLoadedContent(); - return true; - } - }; - - private final Supplier sourceLoader; - private final @Nullable String sourceFingerprint; - private volatile String loadedSource; - private volatile MdAstRoot loadedAst; - - public LazyParsedGuidePage(String sourcePack, ResourceLocation id, Frontmatter frontmatter, String language, - @Nullable String parseFailureMessage, @Nullable UnistPoint parseFailureFrom, - @Nullable UnistPoint parseFailureTo, Supplier sourceLoader) { - this( - sourcePack, - id, - frontmatter, - language, - parseFailureMessage, - parseFailureFrom, - parseFailureTo, - sourceLoader, - null); - } - - public LazyParsedGuidePage(String sourcePack, ResourceLocation id, Frontmatter frontmatter, String language, - @Nullable String parseFailureMessage, @Nullable UnistPoint parseFailureFrom, - @Nullable UnistPoint parseFailureTo, Supplier sourceLoader, @Nullable String sourceFingerprint) { - this( - sourcePack, - null, - id, - frontmatter, - language, - parseFailureMessage, - parseFailureFrom, - parseFailureTo, - sourceLoader, - sourceFingerprint); - } - - public LazyParsedGuidePage(String sourcePack, @Nullable IResourcePack sourceResourcePack, ResourceLocation id, - Frontmatter frontmatter, String language, @Nullable String parseFailureMessage, - @Nullable UnistPoint parseFailureFrom, @Nullable UnistPoint parseFailureTo, Supplier sourceLoader, - @Nullable String sourceFingerprint) { - super( - sourcePack, - sourceResourcePack, - id, - "", - null, - frontmatter, - language, - parseFailureMessage, - parseFailureFrom, - parseFailureTo); - this.sourceLoader = Objects.requireNonNull(sourceLoader, "sourceLoader"); - this.sourceFingerprint = sourceFingerprint; - } - - @Override - public String getSource() { - String source = loadedSource; - if (source != null) { - markResident(); - return source; - } - synchronized (this) { - source = loadedSource; - if (source == null) { - source = sourceLoader.get(); - if (source == null) { - source = ""; - } - loadedSource = source; - } - } - markResident(); - return source; - } - - @Override - public MdAstRoot getAstRoot() { - MdAstRoot ast = loadedAst; - if (ast != null) { - markResident(); - return ast; - } - String source = getSource(); - synchronized (this) { - ast = loadedAst; - if (ast == null) { - ParsedGuidePage parsed = PageCompiler.parse(getSourcePack(), getLanguage(), getId(), source); - ast = parsed.getAstRoot(); - // Preserve body parse diagnostics after the AST is evicted; lint and failure views - // must not mistake a deferred parse failure for a successful page. - adoptParseFailure(parsed); - loadedAst = ast; - } - } - markResident(); - return ast; - } - - @Override - public String getSourceFingerprint() { - return sourceFingerprint != null ? sourceFingerprint : super.getSourceFingerprint(); - } - - public static void clearResidentPages() { - synchronized (RESIDENT_PAGES) { - for (LazyParsedGuidePage page : RESIDENT_PAGES.keySet()) { - page.discardLoadedContent(); - } - RESIDENT_PAGES.clear(); - } - } - - private void markResident() { - synchronized (RESIDENT_PAGES) { - RESIDENT_PAGES.put(this, Boolean.TRUE); - } - } - - private synchronized void discardLoadedContent() { - loadedAst = null; - loadedSource = null; - } -} diff --git a/src/main/java/com/hfstudio/guidenh/guide/internal/MutableGuide.java b/src/main/java/com/hfstudio/guidenh/guide/internal/MutableGuide.java index b4dd9eab..97c3ba91 100644 --- a/src/main/java/com/hfstudio/guidenh/guide/internal/MutableGuide.java +++ b/src/main/java/com/hfstudio/guidenh/guide/internal/MutableGuide.java @@ -238,7 +238,7 @@ public byte[] loadAsset(ResourceLocation id) { var language = LangUtil.getCurrentLanguage(); // Candidate order: current-language variant, default-language variant, plain path. - // The first one that resolves wins. Intermediate probes must NOT log — the language + // The first one that resolves wins. Intermediate probes must NOT log - the language // variants are expected to miss for locale-independent assets (kept under the guide // content root rather than under a `_/` folder), so we only report a failure // once every candidate has been tried. @@ -259,7 +259,7 @@ public byte[] loadAsset(ResourceLocation id) { /** * Reads the bytes for one concrete asset id. Performs no language fallback and does not log - * a "miss" — callers own the policy (which candidates to try and when to report failure). + * a "miss" - callers own the policy (which candidates to try and when to report failure). */ private byte @Nullable [] readAssetPath(ResourceLocation id) { String normalizedPath = IdUtils.normalizeAssetPath(id.getResourcePath()); diff --git a/src/main/java/com/hfstudio/guidenh/guide/internal/compile/CompileWorker.java b/src/main/java/com/hfstudio/guidenh/guide/internal/compile/CompileWorker.java index ced26903..8a976d27 100644 --- a/src/main/java/com/hfstudio/guidenh/guide/internal/compile/CompileWorker.java +++ b/src/main/java/com/hfstudio/guidenh/guide/internal/compile/CompileWorker.java @@ -63,7 +63,7 @@ protected boolean removeEldestEntry(Map.Entry eldes }; /** - * Main thread sets this to force a page to compile next (priority). + * Main thread sets this to force a page to compile next, jumping the queue. * Worker clears it after processing. */ private volatile ResourceLocation priorityId; @@ -250,7 +250,7 @@ private void runLoop() { } } - // 3. Nothing to do — sleep briefly, then check again + // 3. Nothing to do, so sleep briefly and check again if (target == null) { try { Thread.sleep(50); @@ -295,7 +295,7 @@ private void compileOne(ResourceLocation pageId) { } long t0 = System.nanoTime(); - // Parse (no-op if already parsed — getAstRoot is lazy + cached) + // Parse (no-op if already parsed: getAstRoot is lazy and cached) parsed.getAstRoot(); long tParsed = System.nanoTime(); diff --git a/src/main/java/com/hfstudio/guidenh/guide/internal/datadriven/DataDrivenGuideLoader.java b/src/main/java/com/hfstudio/guidenh/guide/internal/datadriven/DataDrivenGuideLoader.java index 0ce6c967..a9b6ed38 100644 --- a/src/main/java/com/hfstudio/guidenh/guide/internal/datadriven/DataDrivenGuideLoader.java +++ b/src/main/java/com/hfstudio/guidenh/guide/internal/datadriven/DataDrivenGuideLoader.java @@ -27,13 +27,16 @@ import org.jetbrains.annotations.Nullable; import com.hfstudio.guidenh.guide.Guide; +import com.hfstudio.guidenh.guide.internal.AsyncWorker; import com.hfstudio.guidenh.guide.internal.DirectoryResourcePack; import com.hfstudio.guidenh.guide.internal.GuideDevelopmentResourcePacks; import com.hfstudio.guidenh.guide.internal.MutableGuide; import com.hfstudio.guidenh.guide.internal.datadriven.GuideResourcePackScanner.GuideLanguage; import com.hfstudio.guidenh.guide.internal.datadriven.GuideResourcePackScanner.PackEntry; import com.hfstudio.guidenh.guide.internal.datadriven.GuideResourcePackScanner.PackScan; +import com.hfstudio.guidenh.guide.internal.localization.GuideLanguageIndex; import com.hfstudio.guidenh.guide.internal.resource.GuideResourceAccess; +import com.hfstudio.guidenh.guide.internal.util.LangUtil; import com.hfstudio.guidenh.guide.scene.support.GuideDebugLog; import com.hfstudio.guidenh.mixins.early.fml.AccessorFMLClientHandler; import com.hfstudio.guidenh.mixins.early.minecraft.AccessorAbstractResourcePack; @@ -52,6 +55,11 @@ public record PackCandidate(IResourcePack pack, ResourceLocation resourceLocatio public record ScanResult(Map guides, Map> pagePaths, Map> discoveredLanguages) {} + /** + * Identity of a scanned archive. A value key is required because every reload builds new + * resource-pack instances, and the pack view matters as well: one archive can expose several + * packs rooted below different folders of the same file. + */ private record ZipCacheKey(String folder, ResourcePackViewKey view) {} private record CachedZipScan(long size, long lastModified, PackScan scan) { @@ -131,9 +139,37 @@ public static ScanResult scanAndBuildAll(String folder, Iterable packs = getLastActiveResourcePacks(); + if (packs.isEmpty()) { + return; + } + LinkedHashSet languages = new LinkedHashSet<>(); + String current = LangUtil.getCurrentLanguage(); + if (current != null && !current.isEmpty()) { + languages.add(LangUtil.normalizeLanguage(current)); + } + languages.add(DEFAULT_LANGUAGE); + if (languages.isEmpty()) { + return; + } + AsyncWorker.submit("guidenh:resourceLangIndexWarmup", () -> { + for (String language : languages) { + GuideLanguageIndex.indexLanguage(language); + } + }); + } + public static @Nullable List getCandidatesFor(ResourceLocation pageLocation) { return pagePackIndex.get(pageLocation); } @@ -168,6 +204,13 @@ public static void clearCaches() { // Keep immutable ZIP scans; archive size + timestamp cheaply validates them next time. } + private static File normalizePackRoot(File resourcePackRoot) { + return resourcePackRoot.toPath() + .toAbsolutePath() + .normalize() + .toFile(); + } + private static void applyPackScan(IResourcePack resourcePack, String folder, PackScan scan, LinkedHashMap> pagePaths, Map> discoveredLanguages) { @@ -231,13 +274,6 @@ private static ResourcePackViewKey resourcePackViewKey(IResourcePack resourcePac resourcePack.getPackName()); } - private static File normalizePackRoot(File resourcePackFile) { - return resourcePackFile.toPath() - .toAbsolutePath() - .normalize() - .toFile(); - } - public static Set discoverPagePaths(ResourceLocation guideId, String folder) { return discoverPagePaths(guideId, folder, getActiveResourcePacks()); } diff --git a/src/main/java/com/hfstudio/guidenh/guide/internal/datadriven/GuidePageResourceSelector.java b/src/main/java/com/hfstudio/guidenh/guide/internal/datadriven/GuidePageResourceSelector.java index 3c0f4e0d..d7ff2743 100644 --- a/src/main/java/com/hfstudio/guidenh/guide/internal/datadriven/GuidePageResourceSelector.java +++ b/src/main/java/com/hfstudio/guidenh/guide/internal/datadriven/GuidePageResourceSelector.java @@ -62,12 +62,12 @@ private GuidePageResourceSelector() {} return new SelectedPack(sourceId, best.pack()); } - // Index says it doesn't exist — fast null + // The index says it does not exist, so return null without scanning if (DataDrivenGuideLoader.isIndexPopulated()) { return null; } - // Index not built yet — emergency full scan + // The index is not built yet, so fall back to a full scan return selectFullScan(sourceId, resourcePacks); } diff --git a/src/main/java/com/hfstudio/guidenh/guide/internal/debug/DebugControlPanel.java b/src/main/java/com/hfstudio/guidenh/guide/internal/debug/DebugControlPanel.java index 41cff606..903c6803 100644 --- a/src/main/java/com/hfstudio/guidenh/guide/internal/debug/DebugControlPanel.java +++ b/src/main/java/com/hfstudio/guidenh/guide/internal/debug/DebugControlPanel.java @@ -86,6 +86,10 @@ private void initializeMenu() { new DebugMenuItem("guidenh.debug.menu.display_options.show_memory", DebugMenuAction.TOGGLE_MEMORY)); displayOptions.addSubmenuItem( new DebugMenuItem("guidenh.debug.menu.display_options.show_mouse", DebugMenuAction.TOGGLE_MOUSE_POSITION)); + displayOptions.addSubmenuItem( + new DebugMenuItem( + "guidenh.debug.menu.display_options.layout_overlay", + DebugMenuAction.TOGGLE_LAYOUT_OVERLAY)); menuItems.add(displayOptions); DebugMenuItem recompile = new DebugMenuItem( @@ -283,6 +287,7 @@ private void executeAction(DebugMenuAction action) { case TOGGLE_FPS -> ModConfig.debug.showFps = !ModConfig.debug.showFps; case TOGGLE_MEMORY -> ModConfig.debug.showMemory = !ModConfig.debug.showMemory; case TOGGLE_MOUSE_POSITION -> ModConfig.debug.showMousePosition = !ModConfig.debug.showMousePosition; + case TOGGLE_LAYOUT_OVERLAY -> ModConfig.debug.layoutOverlay = !ModConfig.debug.layoutOverlay; case RECOMPILE_PAGE -> recompilePage(); case EXPORT_DEBUG_DATA -> exportDebugData(); } @@ -305,6 +310,7 @@ private boolean getCheckState(DebugMenuAction action) { case TOGGLE_FPS -> ModConfig.debug.showFps; case TOGGLE_MEMORY -> ModConfig.debug.showMemory; case TOGGLE_MOUSE_POSITION -> ModConfig.debug.showMousePosition; + case TOGGLE_LAYOUT_OVERLAY -> ModConfig.debug.layoutOverlay; default -> false; }; } diff --git a/src/main/java/com/hfstudio/guidenh/guide/internal/debug/DebugMenuAction.java b/src/main/java/com/hfstudio/guidenh/guide/internal/debug/DebugMenuAction.java index 5c437c69..2e3da099 100644 --- a/src/main/java/com/hfstudio/guidenh/guide/internal/debug/DebugMenuAction.java +++ b/src/main/java/com/hfstudio/guidenh/guide/internal/debug/DebugMenuAction.java @@ -20,6 +20,7 @@ public enum DebugMenuAction { TOGGLE_FPS, TOGGLE_MEMORY, TOGGLE_MOUSE_POSITION, + TOGGLE_LAYOUT_OVERLAY, RECOMPILE_PAGE, EXPORT_DEBUG_DATA } diff --git a/src/main/java/com/hfstudio/guidenh/guide/internal/editor/autocomplete/resolver/MdxSyntaxResolver.java b/src/main/java/com/hfstudio/guidenh/guide/internal/editor/autocomplete/resolver/MdxSyntaxResolver.java index cd4903be..61f2e3e8 100644 --- a/src/main/java/com/hfstudio/guidenh/guide/internal/editor/autocomplete/resolver/MdxSyntaxResolver.java +++ b/src/main/java/com/hfstudio/guidenh/guide/internal/editor/autocomplete/resolver/MdxSyntaxResolver.java @@ -585,7 +585,7 @@ public TextSyntaxContext resolveTagStart(String text, int cursorIndex, @Nullable char atCursor = text.charAt(cursorIndex - 1); - // Case 1: cursor immediately after '<' — start of a new tag + // Case 1: cursor immediately after '<', at the start of a new tag if (atCursor == '<') { if (cursorIndex < text.length()) { char next = text.charAt(cursorIndex); @@ -616,7 +616,7 @@ public TextSyntaxContext resolveTagStart(String text, int cursorIndex, @Nullable return null; } int tagStart = nameStart - 1; - // Closing tag: tagStart + 1 && text.charAt(tagStart + 1) == '/') { return null; } diff --git a/src/main/java/com/hfstudio/guidenh/guide/internal/editor/model/SceneEditorSceneNodeType.java b/src/main/java/com/hfstudio/guidenh/guide/internal/editor/model/SceneEditorSceneNodeType.java index 60cafb82..11975777 100644 --- a/src/main/java/com/hfstudio/guidenh/guide/internal/editor/model/SceneEditorSceneNodeType.java +++ b/src/main/java/com/hfstudio/guidenh/guide/internal/editor/model/SceneEditorSceneNodeType.java @@ -7,6 +7,6 @@ public enum SceneEditorSceneNodeType { REMOVE_BLOCKS, BLOCK_ANNOTATION_TEMPLATE, ANNOTATION, - /** Raw MDX text preserved verbatim — for tags the editor doesn't recognise but GameScene does. */ + /** Raw MDX text preserved verbatim for tags the editor does not recognise but GameScene does. */ OPAQUE } diff --git a/src/main/java/com/hfstudio/guidenh/guide/internal/extensions/DefaultExtensions.java b/src/main/java/com/hfstudio/guidenh/guide/internal/extensions/DefaultExtensions.java index 2c92b174..6cdde75b 100644 --- a/src/main/java/com/hfstudio/guidenh/guide/internal/extensions/DefaultExtensions.java +++ b/src/main/java/com/hfstudio/guidenh/guide/internal/extensions/DefaultExtensions.java @@ -39,6 +39,7 @@ import com.hfstudio.guidenh.guide.compiler.tags.ListItemCompiler; import com.hfstudio.guidenh.guide.compiler.tags.MarkTagCompiler; import com.hfstudio.guidenh.guide.compiler.tags.MermaidCompiler; +import com.hfstudio.guidenh.guide.compiler.tags.NodeContentTagCompiler; import com.hfstudio.guidenh.guide.compiler.tags.ParagraphCompiler; import com.hfstudio.guidenh.guide.compiler.tags.PlayerNameTagCompiler; import com.hfstudio.guidenh.guide.compiler.tags.PreCompiler; @@ -159,6 +160,7 @@ public static List tagCompilers() { new FootnoteListCompiler(), new StructureViewCompiler(), new MermaidCompiler(), + new NodeContentTagCompiler(), new CsvTableCompiler(), new ColumnChartCompiler(), new BarChartCompiler(), @@ -168,7 +170,7 @@ public static List tagCompilers() { new FunctionGraphTagCompiler(), new FunctionTagCompiler(), new LatexTagCompiler(), - // Phase 2A: block-level compilers + // Block-level compilers new ParagraphCompiler(), new HeadingCompiler(), new ListCompiler(), @@ -177,7 +179,7 @@ public static List tagCompilers() { new BlockquoteCompiler(), new TableCompiler(), new HrCompiler(), - // Phase 2A: inline compilers + // Inline compilers new StrongCompiler(), new EmphasisCompiler(), new DelUWaveMarkCompiler(), diff --git a/src/main/java/com/hfstudio/guidenh/guide/internal/headless/DocumentOffscreenFramebuffer.java b/src/main/java/com/hfstudio/guidenh/guide/internal/headless/DocumentOffscreenFramebuffer.java new file mode 100644 index 00000000..30eeb612 --- /dev/null +++ b/src/main/java/com/hfstudio/guidenh/guide/internal/headless/DocumentOffscreenFramebuffer.java @@ -0,0 +1,277 @@ +package com.hfstudio.guidenh.guide.internal.headless; + +import java.awt.Color; +import java.awt.Graphics2D; +import java.awt.RenderingHints; +import java.awt.image.BufferedImage; +import java.nio.ByteBuffer; +import java.util.ArrayList; +import java.util.List; + +import net.minecraft.client.Minecraft; +import net.minecraft.client.shader.Framebuffer; + +import org.lwjgl.BufferUtils; +import org.lwjgl.opengl.GL11; + +import com.hfstudio.guidenh.guide.document.LytRect; +import com.hfstudio.guidenh.guide.render.GuideGlyphAtlas; +import com.hfstudio.guidenh.guide.render.GuideRenderEngine; +import com.hfstudio.guidenh.guide.render.GuideRenderPrimitive; +import com.hfstudio.guidenh.guide.render.GuidebookSceneRenderer; +import com.hfstudio.guidenh.guide.render.VanillaRenderContext; +import com.hfstudio.guidenh.guide.scene.GuidebookLevelRenderer; + +/** + * Renders a list of already-laid-out document primitives to an offscreen FBO and + * reads back a {@link BufferedImage}. When the total height exceeds + * {@code GL_MAX_TEXTURE_SIZE}, the document is split into tiles and composited. + * + *

          + * This class does not perform layout; the caller is responsible for + * laying out the document at the full render resolution and collecting primitives + * via {@link com.hfstudio.guidenh.guide.render.PrimitiveCollector#result()}. + * Each tile translates the viewport origin (camera pan equivalent) so that + * the portion of the document starting at {@code (tileX, tileY)} is rendered + * into the tile's framebuffer. + */ +public final class DocumentOffscreenFramebuffer { + + private static final int MAX_TILE_SIZE_CAP = 4096; + /** 16384 * 4 */ + private static final int MAX_TOTAL_DIMENSION = 65536; + + private DocumentOffscreenFramebuffer() {} + + /** + * Renders the given primitives at the specified total dimensions, tiling + * transparently when the document exceeds {@code GL_MAX_TEXTURE_SIZE}. + * + * @param primitives the already-collected primitives in document coordinates. + * @param context the render context whose document origin is adjusted per + * tile (affects HostDraw callbacks). + * @param totalWidth full document width in document units (must be positive). + * @param totalHeight full document height in document units (must be positive). + * @param backgroundRgb opaque background colour packed as 0xRRGGBB. + * @param scale pixel-density multiplier (1-4). Output dimensions are + * totalWidth × scale by totalHeight × scale. + * @return a fully composited opaque {@code BufferedImage} of the document. + * @throws IllegalArgumentException if dimensions are out of range. + * @throws IllegalStateException if the Minecraft client is not ready. + * @throws RuntimeException if any tile fails to render (FBO is cleaned + * up before rethrowing). + */ + public static BufferedImage renderAll(List primitives, VanillaRenderContext context, + int totalWidth, int totalHeight, int backgroundRgb, int scale) { + + // Validate the requested document dimensions. + if (totalWidth <= 0 || totalHeight <= 0) { + throw new IllegalArgumentException( + "totalWidth and totalHeight must be positive: " + totalWidth + " x " + totalHeight); + } + + // Scale pixel dimensions + int pxWidth = totalWidth * scale; + int pxHeight = totalHeight * scale; + + if (pxWidth > MAX_TOTAL_DIMENSION || pxHeight > MAX_TOTAL_DIMENSION) { + throw new IllegalArgumentException( + "Pixel dimensions exceed maximum " + MAX_TOTAL_DIMENSION + ": " + pxWidth + " x " + pxHeight); + } + + Minecraft minecraft = Minecraft.getMinecraft(); + if (minecraft == null || minecraft.gameSettings == null) { + throw new IllegalStateException("Minecraft client is not ready for offscreen rendering."); + } + + // Tile size is capped by the driver's maximum texture size. + int maxFboSize = GL11.glGetInteger(GL11.GL_MAX_TEXTURE_SIZE); + if (maxFboSize <= 0) { + maxFboSize = 8192; // safe fallback + } + int tileSize = Math.min(maxFboSize, MAX_TILE_SIZE_CAP); + + // Render engine, shared across every tile. + GuideRenderEngine engine = new GuideRenderEngine(GuideGlyphAtlas.instance(), new GuidebookSceneRenderer()); + + // Output image, sized in scaled pixel dimensions. + BufferedImage output = new BufferedImage(pxWidth, pxHeight, BufferedImage.TYPE_INT_ARGB); + Graphics2D outputG = output.createGraphics(); + outputG.setRenderingHint(RenderingHints.KEY_INTERPOLATION, RenderingHints.VALUE_INTERPOLATION_NEAREST_NEIGHBOR); + + boolean prevSkipLightmap = GuidebookLevelRenderer.skipLightmapForOffscreen; + GuidebookLevelRenderer.skipLightmapForOffscreen = true; + + try { + int prevDisplayWidth = minecraft.displayWidth; + int prevDisplayHeight = minecraft.displayHeight; + int prevGuiScale = minecraft.gameSettings.guiScale; + context.setZoom(scale); + + // Tile loop, in scaled pixel space. + for (int tileY = 0; tileY < pxHeight; tileY += tileSize) { + int tileH = Math.min(tileSize, pxHeight - tileY); + for (int tileX = 0; tileX < pxWidth; tileX += tileSize) { + int tileW = Math.min(tileSize, pxWidth - tileX); + + Framebuffer fb = null; + boolean projectionPushed = false; + boolean modelviewPushed = false; + + try { + fb = new Framebuffer(tileW, tileH, true); + fb.setFramebufferColor(0f, 0f, 0f, 0f); + + minecraft.displayWidth = tileW; + minecraft.displayHeight = tileH; + minecraft.gameSettings.guiScale = 1; + + fb.bindFramebuffer(true); + + // Set up 2D orthographic projection for the tile dimensions + // (Vanilla GUI rendering relies on this, just like ItemPreviewService). + GL11.glMatrixMode(GL11.GL_PROJECTION); + GL11.glPushMatrix(); + projectionPushed = true; + GL11.glLoadIdentity(); + GL11.glOrtho(0.0D, tileW, tileH, 0.0D, 1000.0D, 3000.0D); + + GL11.glMatrixMode(GL11.GL_MODELVIEW); + GL11.glPushMatrix(); + modelviewPushed = true; + GL11.glLoadIdentity(); + GL11.glTranslatef(0.0F, 0.0F, -2000.0F); + + // Clear with the opaque background colour + float r = ((backgroundRgb >> 16) & 0xFF) / 255f; + float g = ((backgroundRgb >> 8) & 0xFF) / 255f; + float b = (backgroundRgb & 0xFF) / 255f; + GL11.glClearColor(r, g, b, 1f); + GL11.glClear(GL11.GL_COLOR_BUFFER_BIT | GL11.GL_DEPTH_BUFFER_BIT); + + // Shift document origin so HostDraw callbacks see the tile offset + // Document-origin callback: pixel units (-tileX, -tileY) + context.setDocumentOrigin(-tileX, -tileY); + + // Wrap primitives with scale + tile-offset transforms: + // outer: PushTransform(0, 0, scale) scales doc coords by N + // inner: PushTransform(-tileX/scale, -tileY/scale, 1.0f) is the tile offset + // in doc space, which after parent-scaling becomes -tileX, -tileY + // Combined: screen = doc * scale + (-tileX, -tileY) + List wrapped = wrapForTile(primitives, tileX, tileY, scale); + + engine.beginFrame(new LytRect(0, 0, tileW, tileH), 1.0f); + engine.execute(wrapped); + engine.endFrame(); + + // Read pixels (Y-flip to Java top-down) + BufferedImage tile = readPixels(tileW, tileH); + outputG.drawImage(tile, tileX, tileY, null); + + } catch (RuntimeException e) { + throw e; + } catch (Exception e) { + throw new RuntimeException("Tile rendering failed at offset (" + tileX + ", " + tileY + ")", e); + } finally { + // Restore matrices in reverse order + if (modelviewPushed) { + GL11.glMatrixMode(GL11.GL_MODELVIEW); + GL11.glPopMatrix(); + } + if (projectionPushed) { + GL11.glMatrixMode(GL11.GL_PROJECTION); + GL11.glPopMatrix(); + GL11.glMatrixMode(GL11.GL_MODELVIEW); + } + // Release FBO resources + if (fb != null) { + fb.unbindFramebuffer(); + fb.deleteFramebuffer(); + } + // Restore original display dimensions + minecraft.displayWidth = prevDisplayWidth; + minecraft.displayHeight = prevDisplayHeight; + minecraft.gameSettings.guiScale = prevGuiScale; + GL11.glViewport(0, 0, prevDisplayWidth, prevDisplayHeight); + } + } + } + } finally { + GuidebookLevelRenderer.skipLightmapForOffscreen = prevSkipLightmap; + outputG.dispose(); + } + + // Composite the tiled result onto an opaque background. + return compositeOpaque(output, backgroundRgb); + } + + // Helper: wrap the primitive list for a single tile. + + /** + * Wraps the original primitive list with: + *

            + *
          1. Outer {@link PushTransform}(0, 0, scale) scales document coordinates + * by N for pixel-density amplification.
          2. + *
          3. Inner {@link PushTransform}(-tileX/scale, -tileY/scale, 1.0f) is the tile + * offset in document space. The engine's composition applies parent + * scale to child translation, yielding effective offset + * {@code (-tileX, -tileY)} in the scaled pixel space.
          4. + *
          + * + *

          + * Combined effective transform: + * {@code screen = doc * scale + (-tileX, -tileY)}. + * + *

          + * Tile coordinates are in pixel (scaled) space; the inner translation + * divides by scale so the tile-border crossing matches document units. + */ + private static List wrapForTile(List primitives, int tileX, int tileY, + int scale) { + List wrapped = new ArrayList<>(primitives.size() + 4); + // Outer: scale document coordinates + wrapped.add(new GuideRenderPrimitive.PushTransform(0f, 0f, (float) scale)); + // Inner: tile offset in document space + wrapped.add(new GuideRenderPrimitive.PushTransform((float) -tileX / scale, (float) -tileY / scale, 1.0f)); + wrapped.addAll(primitives); + wrapped.add(new GuideRenderPrimitive.PopTransform()); + wrapped.add(new GuideRenderPrimitive.PopTransform()); + return wrapped; + } + + // Helper: read pixels from the currently bound framebuffer. + + private static BufferedImage readPixels(int width, int height) { + ByteBuffer buffer = BufferUtils.createByteBuffer(width * height * 4); + GL11.glReadPixels(0, 0, width, height, GL11.GL_RGBA, GL11.GL_UNSIGNED_BYTE, buffer); + + BufferedImage image = new BufferedImage(width, height, BufferedImage.TYPE_INT_ARGB); + for (int y = 0; y < height; y++) { + int flippedY = height - 1 - y; + for (int x = 0; x < width; x++) { + int index = (x + y * width) * 4; + int r = buffer.get(index) & 0xFF; + int g = buffer.get(index + 1) & 0xFF; + int b = buffer.get(index + 2) & 0xFF; + int a = buffer.get(index + 3) & 0xFF; + image.setRGB(x, flippedY, (a << 24) | (r << 16) | (g << 8) | b); + } + } + return image; + } + + // Helper: composite the source image onto an opaque background. + + private static BufferedImage compositeOpaque(BufferedImage source, int backgroundRgb) { + BufferedImage image = new BufferedImage(source.getWidth(), source.getHeight(), BufferedImage.TYPE_INT_RGB); + Graphics2D g = image.createGraphics(); + try { + g.setColor(new Color(backgroundRgb)); + g.fillRect(0, 0, source.getWidth(), source.getHeight()); + g.drawImage(source, 0, 0, null); + } finally { + g.dispose(); + } + return image; + } +} diff --git a/src/main/java/com/hfstudio/guidenh/guide/internal/headless/GuideNhHeadlessRenderDriver.java b/src/main/java/com/hfstudio/guidenh/guide/internal/headless/GuideNhHeadlessRenderDriver.java new file mode 100644 index 00000000..67d231cb --- /dev/null +++ b/src/main/java/com/hfstudio/guidenh/guide/internal/headless/GuideNhHeadlessRenderDriver.java @@ -0,0 +1,757 @@ +package com.hfstudio.guidenh.guide.internal.headless; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.InvalidPathException; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.util.ArrayList; +import java.util.Collection; +import java.util.Collections; +import java.util.List; +import java.util.stream.Collectors; + +import net.minecraft.client.Minecraft; +import net.minecraft.client.gui.GuiMainMenu; +import net.minecraft.util.ResourceLocation; +import net.minecraft.world.WorldSettings; +import net.minecraft.world.WorldType; + +import org.jetbrains.annotations.Nullable; + +import com.hfstudio.guidenh.guide.compiler.ParsedGuidePage; +import com.hfstudio.guidenh.guide.internal.GuideRegistry; +import com.hfstudio.guidenh.guide.internal.MutableGuide; +import com.hfstudio.guidenh.guide.mediawiki.MediaWikiListContext; +import com.hfstudio.guidenh.guide.mediawiki.MediaWikiSpecialDataIndex; +import com.hfstudio.guidenh.guide.scene.support.GuideDebugLog; + +import cpw.mods.fml.common.FMLCommonHandler; +import cpw.mods.fml.common.eventhandler.SubscribeEvent; +import cpw.mods.fml.common.gameevent.TickEvent; + +/** + * Headless screenshot render driver activated by {@code -Dguidenh.headlessRender=true}. + * + *

          + * State machine (driven by {@link TickEvent.ClientTickEvent}): + *

            + *
          1. {@code IDLE}: wait for main menu, then close screen, launch integrated server
          2. + *
          3. {@code LOADING_WORLD}: poll until {@code theWorld / thePlayer / netHandler} are non-null
          4. + *
          5. {@code WORLD_STABLE}: wait 20 ticks, then start rendering
          6. + *
          7. {@code RENDERING}: render page(s), in single page, route or batch loop + *
              + *
            • Single-page mode ({@code --page} / {@code --md}): render + {@code exitJava(0|1)}
            • + *
            • Route mode ({@code -Dguidenh.renderpage.route=home_search|settings}): synthetic UI-route render + * (e.g. the search-results page or the reader-settings page) + {@code exitJava(0|1)}
            • + *
            • Batch mode ({@code --allPages} / {@code --list}): loop all pages, then summary + {@code exitJava(0|1)}
            • + *
            + *
          8. + *
          9. {@code DONE}: guard against re-entry
          10. + *
          + * + *

          + * Batch mode properties (mutually exclusive with {@code --page} / {@code --md} / + * route mode): + *

            + *
          • {@code -Dguidenh.renderpage.allPages=true}: render every page of the guide in sorted order
          • + *
          • {@code -Dguidenh.renderpage.list=<txt-path>}: one pageId per line; empty lines and + * {@code #}-prefixed lines are skipped
          • + *
          + * + *

          + * Batch summary is printed to stdout and log: {@code total / ok / failed} plus failed-page list. + * Exit code: {@code 0} when all succeeded, {@code 1} on any failure. + * + *

          + * Watchdog: default 360 s timeout for single-page mode; for batch mode the timeout is + * {@code 360 + 120 × pageCount} seconds, computed once when the page list is known. + * Limitation: rendering executes synchronously on the client tick thread. If a single page + * render hangs, the watchdog cannot interrupt it; it can only fire before or after that page + * completes. + * + *

          + * All errors → {@code exitJava(1)}. Never swallow exceptions. + * + * @see RenderPageService + */ +public class GuideNhHeadlessRenderDriver { + + // Configuration. + + /** + * Max time (ms) to block before the first headless page render for the MediaWiki + * special-data index warmup (scheduled asynchronously by MutableGuide) to complete. + * Bounded so a stuck warmup worker can never hang the headless render forever; + * on timeout we log a warning and continue. + */ + private static final long MEDIA_WIKI_WARMUP_TIMEOUT_MILLIS = 30_000L; + + /** Poll interval (ms) while waiting for the MediaWiki warmup worker. */ + private static final long MEDIA_WIKI_WARMUP_POLL_MILLIS = 25L; + + /** Immutable snapshot of JVM property configuration. */ + public record HeadlessRenderConfig(String guideId, @Nullable String pageId, @Nullable Path mdFile, boolean allPages, + @Nullable Path listPath, int width, Path outDir, String language, boolean emitBoundsJson, + boolean emitDebugOverlay, String worldName, int scale, boolean chrome, @Nullable String route, + @Nullable String query) {} + + // State machine. + + private enum State { + IDLE, + LOADING_WORLD, + WORLD_STABLE, + RENDERING, + DONE + } + + private State state = State.IDLE; + private final HeadlessRenderConfig config; + private long watchdogDeadlineNanos; + private int stableTickCount = 0; + private boolean renderPending = false; + + // Batch state. + + /** Ordered list of page IDs to render (batch mode). */ + private List batchPageIds = Collections.emptyList(); + private int pageIndex = 0; + private int okCount = 0; + private int failCount = 0; + private final List failedPageIds = new ArrayList<>(); + + // Construction. + + public GuideNhHeadlessRenderDriver(HeadlessRenderConfig config) { + this.config = config; + this.watchdogDeadlineNanos = System.nanoTime() + 360_000_000_000L; + if (config.chrome()) { + GuideDebugLog + .infoAlways("[GuideNH] [HeadlessRender] chrome pass enabled: nav bar will be appended to page renders"); + } + } + + // Property parsing. + + /** + * Parse all relevant JVM properties into a {@link HeadlessRenderConfig}. + * + * @return parsed config, or {@code null} if any required property is missing or invalid + */ + @Nullable + public static HeadlessRenderConfig parseConfig() { + String guideId = System.getProperty("guidenh.renderpage.guide"); + if (guideId == null || guideId.isEmpty()) { + logError("Missing required property: -Dguidenh.renderpage.guide="); + return null; + } + + String pageId = System.getProperty("guidenh.renderpage.page"); + String mdProp = System.getProperty("guidenh.renderpage.md"); + String allPagesProp = System.getProperty("guidenh.renderpage.allPages"); + String listProp = System.getProperty("guidenh.renderpage.list"); + String routeProp = System.getProperty("guidenh.renderpage.route"); + String queryProp = System.getProperty("guidenh.renderpage.query"); + + boolean hasPage = pageId != null && !pageId.isEmpty(); + boolean hasMd = mdProp != null && !mdProp.isEmpty(); + boolean allPages = "true".equalsIgnoreCase(allPagesProp); + boolean hasList = listProp != null && !listProp.isEmpty(); + boolean hasRoute = routeProp != null && !routeProp.isEmpty(); + boolean hasQuery = queryProp != null && !queryProp.isEmpty(); + + // Mutually exclusive groups: single-page (page/md) vs route vs batch (allPages/list) + int singleModes = (hasPage ? 1 : 0) + (hasMd ? 1 : 0); + int batchModes = (allPages ? 1 : 0) + (hasList ? 1 : 0); + int routeModes = hasRoute ? 1 : 0; + + if (singleModes > 0 && batchModes > 0) { + logError( + "Batch mode (-Dguidenh.renderpage.allPages / --list) and single-page mode " + + "(-Dguidenh.renderpage.page / --md) are mutually exclusive"); + return null; + } + if (singleModes > 0 && routeModes > 0) { + logError( + "Route mode (-Dguidenh.renderpage.route) and single-page mode " + + "(-Dguidenh.renderpage.page / --md) are mutually exclusive"); + return null; + } + if (batchModes > 0 && routeModes > 0) { + logError( + "Route mode (-Dguidenh.renderpage.route) and batch mode " + + "(-Dguidenh.renderpage.allPages / --list) are mutually exclusive"); + return null; + } + if (batchModes > 1) { + logError( + "Only one batch mode allowed: -Dguidenh.renderpage.allPages OR " + + "-Dguidenh.renderpage.list, not both"); + return null; + } + if (batchModes == 0 && singleModes == 0 && routeModes == 0) { + logError( + "Specify single-page mode (-Dguidenh.renderpage.page= or " + + "-Dguidenh.renderpage.md=), route mode (-Dguidenh.renderpage.route=home_search" + + "|settings) or batch mode " + + "(-Dguidenh.renderpage.allPages=true or -Dguidenh.renderpage.list=)"); + return null; + } + if (hasRoute && !"home_search".equals(routeProp) && !"settings".equals(routeProp)) { + logError("Invalid -Dguidenh.renderpage.route value: " + routeProp + " (supported: home_search, settings)"); + return null; + } + if (hasRoute && "home_search".equals(routeProp) && !hasQuery) { + logError( + "-Dguidenh.renderpage.query= is required when " + + "-Dguidenh.renderpage.route=home_search is set"); + return null; + } + + int width; + try { + width = Integer.parseInt(System.getProperty("guidenh.renderpage.width", "900")); + } catch (NumberFormatException e) { + logError("Invalid width value: " + System.getProperty("guidenh.renderpage.width")); + return null; + } + if (width < 100 || width > 4096) { + logError("Width must be between 100 and 4096, got: " + width); + return null; + } + + String outProp = System.getProperty("guidenh.renderpage.out"); + Path outDir; + if (outProp != null && !outProp.isEmpty()) { + outDir = Paths.get(outProp); + } else { + Minecraft mc = Minecraft.getMinecraft(); + if (mc == null || mc.mcDataDir == null) { + logError("Cannot determine default output directory: Minecraft instance not available"); + return null; + } + outDir = mc.mcDataDir.toPath() + .resolve("screenshots"); + } + + String lang = System.getProperty("guidenh.renderpage.lang", "en_us"); + boolean bounds = Boolean.parseBoolean(System.getProperty("guidenh.renderpage.bounds", "false")); + boolean overlay = Boolean.parseBoolean(System.getProperty("guidenh.renderpage.overlay", "false")); + String worldName = System.getProperty("guidenh.renderpage.world", "screenshot-world"); + + int scale; + try { + scale = Integer.parseInt(System.getProperty("guidenh.renderpage.scale", "1")); + } catch (NumberFormatException e) { + logError("Invalid scale value: " + System.getProperty("guidenh.renderpage.scale")); + return null; + } + if (scale < 1 || scale > 4) { + logError("Scale must be between 1 and 4, got: " + scale); + return null; + } + + boolean chrome = Boolean.parseBoolean(System.getProperty("guidenh.renderpage.chrome", "false")); + + Path mdPath = null; + Path listPath = null; + try { + mdPath = hasMd ? Paths.get(mdProp) : null; + listPath = hasList ? Paths.get(listProp) : null; + } catch (InvalidPathException e) { + // NOTE: --md / --list expect FILE PATHS (list = file with one pageId per line), + // not page ids. Page ids contain ':' which is an illegal Windows path char and + // previously blew up here as an unlogged InvalidPathException that FML's state + // event dispatch swallowed silently, hanging the client at the main menu. + logError( + "Invalid file path for -Dguidenh.renderpage.md / --list: " + e.getMessage() + + " (note: --list expects a path to a file containing one pageId per line)"); + return null; + } + + return new HeadlessRenderConfig( + guideId, + hasPage ? pageId : null, + mdPath, + allPages, + listPath, + width, + outDir, + lang, + bounds, + overlay, + worldName, + scale, + chrome, + hasRoute ? routeProp : null, + hasQuery ? queryProp : null); + } + + private static void logError(String message) { + GuideDebugLog.error("[GuideNH] [HeadlessRender] {}", message); + System.err.println("[GuideNH] [HeadlessRender] " + message); + } + + // Tick handler. + + @SubscribeEvent + public void onClientTick(TickEvent.ClientTickEvent event) { + if (event.phase != TickEvent.Phase.END) { + return; + } + + // Watchdog: default 360s; batch mode recalculates when page list is known + if (System.nanoTime() > watchdogDeadlineNanos) { + GuideDebugLog.error("[GuideNH] [HeadlessRender] headless render timeout exceeded"); + FMLCommonHandler.instance() + .exitJava(2, false); + return; + } + + if (state == State.DONE) { + return; + } + + try { + tick(); + } catch (Throwable t) { + GuideDebugLog.error("[GuideNH] [HeadlessRender] Unhandled exception in state machine", t); + System.err.println("[GuideNH] [HeadlessRender] Unhandled exception: " + t.getMessage()); + FMLCommonHandler.instance() + .exitJava(1, false); + } + } + + private void tick() { + Minecraft mc = Minecraft.getMinecraft(); + if (mc == null) { + return; + } + + switch (state) { + case IDLE -> handleIdle(mc); + case LOADING_WORLD -> handleLoadingWorld(mc); + case WORLD_STABLE -> handleWorldStable(mc); + case RENDERING -> { + // Rendering is performed in onRenderTick; ClientTick only handles watchdog. + } + default -> {} + } + } + + // State handlers. + + private void handleIdle(Minecraft mc) { + if (!(mc.currentScreen instanceof GuiMainMenu)) { + return; + } + + state = State.LOADING_WORLD; + GuideDebugLog + .infoAlways("[GuideNH] [HeadlessRender] Starting integrated server (world: {})", config.worldName()); + + mc.displayGuiScreen(null); + WorldSettings settings = new WorldSettings(0L, WorldSettings.GameType.CREATIVE, false, false, WorldType.FLAT); + mc.launchIntegratedServer(config.worldName(), config.worldName(), settings); + } + + private void handleLoadingWorld(Minecraft mc) { + if (mc.theWorld != null && mc.thePlayer != null && mc.getNetHandler() != null) { + state = State.WORLD_STABLE; + stableTickCount = 0; + GuideDebugLog.infoAlways("[GuideNH] [HeadlessRender] World loaded, waiting 20 ticks for stability"); + } + } + + private void handleWorldStable(Minecraft mc) { + stableTickCount++; + if (stableTickCount < 20) { + return; + } + + // Both batch and single-page modes must not render before the async MediaWiki + // special-data warmup is ready: Special pages compile against the guide's + // MediaWikiListContext, and a not-yet-warmed guide serves an empty fallback + // (MediaWikiSpecialDataIndex.empty()), which renders as an empty 96 px page. + awaitMediaWikiWarmup(); + + if (config.allPages() || config.listPath() != null) { + // Batch mode: prepare the page list, then let handleRendering loop. + List ids; + try { + ids = collectBatchPageIds(); + } catch (Exception e) { + logError("Failed to collect batch page IDs: " + e.getMessage()); + FMLCommonHandler.instance() + .exitJava(1, false); + return; + } + + if (ids.isEmpty()) { + logError("No pages to render in batch mode"); + FMLCommonHandler.instance() + .exitJava(1, false); + return; + } + + // One-time watchdog recalculation based on page count + watchdogDeadlineNanos = System.nanoTime() + 360_000_000_000L + (long) ids.size() * 120_000_000_000L; + + batchPageIds = ids; + pageIndex = 0; + okCount = 0; + failCount = 0; + failedPageIds.clear(); + + state = State.RENDERING; + renderPending = true; + GuideDebugLog.infoAlways( + "[GuideNH] [HeadlessRender] Batch render start: {} pages (watchdog {}s), deferred to RenderTickEvent", + ids.size(), + 360L + ids.size() * 120L); + System.out.println( + "[GuideNH] [HeadlessRender] Batch render start: " + ids.size() + " pages, deferred to RenderTickEvent"); + + } else { + // Single-page mode: defer the render to RenderTickEvent. + state = State.RENDERING; + renderPending = true; + GuideDebugLog.infoAlways("[GuideNH] [HeadlessRender] Single-page render deferred to RenderTickEvent"); + } + } + + // MediaWiki warmup gate. + + /** + * Block until the target guide's MediaWiki special-data index warmup has completed, + * or until {@link #MEDIA_WIKI_WARMUP_TIMEOUT_MILLIS} elapses (timeout → warning log + * and continue; the affected Special pages may render as empty fallbacks). + * + *

          + * In {@link MutableGuide}, the async warmup is triggered by the first + * {@link MutableGuide#getMediaWikiListContext()} call; until it finishes the guide + * serves a fallback context whose {@link MediaWikiSpecialDataIndex} is the empty + * singleton. Therefore the first call here both schedules the warmup and inspects + * it, and subsequent polls detect completion once the real index replaces the empty + * fallback. When the guide type has no async warmup (or it is already complete) + * this returns immediately. + */ + private void awaitMediaWikiWarmup() { + ResourceLocation guideId = new ResourceLocation(config.guideId()); + MutableGuide guide = GuideRegistry.getById(guideId); + if (guide == null) { + return; + } + MediaWikiListContext context = guide.getMediaWikiListContext(); + if (context == null || context.specialDataIndex() != MediaWikiSpecialDataIndex.empty()) { + return; // no async warmup pending, or already complete + } + GuideDebugLog.infoAlways( + "[GuideNH] [HeadlessRender] Waiting for MediaWiki special-data warmup of guide {} (up to {} ms)", + guideId, + MEDIA_WIKI_WARMUP_TIMEOUT_MILLIS); + + long deadlineNanos = System.nanoTime() + MEDIA_WIKI_WARMUP_TIMEOUT_MILLIS * 1_000_000L; + boolean interrupted = false; + while (System.nanoTime() < deadlineNanos) { + try { + Thread.sleep(MEDIA_WIKI_WARMUP_POLL_MILLIS); + } catch (InterruptedException e) { + interrupted = true; + GuideDebugLog.warnAlways( + "[GuideNH] [HeadlessRender] Interrupted while waiting for MediaWiki special-data warmup: {}", + e.getMessage()); + break; + } + context = guide.getMediaWikiListContext(); + if (context != null && context.specialDataIndex() != MediaWikiSpecialDataIndex.empty()) { + long waitedMillis = (MEDIA_WIKI_WARMUP_TIMEOUT_MILLIS * 1_000_000L + - (deadlineNanos - System.nanoTime())) / 1_000_000L; + GuideDebugLog.infoAlways( + "[GuideNH] [HeadlessRender] MediaWiki special-data warmup complete for guide {} " + + "(waited {} ms of {} ms budget)", + guideId, + waitedMillis, + MEDIA_WIKI_WARMUP_TIMEOUT_MILLIS); + break; + } + } + if (interrupted) { + Thread.currentThread() + .interrupt(); + return; + } + if (context == null || context.specialDataIndex() == MediaWikiSpecialDataIndex.empty()) { + GuideDebugLog.warnAlways( + "[GuideNH] [HeadlessRender] MediaWiki special-data warmup for guide {} not complete within {} ms; " + + "proceeding; Special pages may render empty fallbacks", + guideId, + MEDIA_WIKI_WARMUP_TIMEOUT_MILLIS); + } + } + + // Batch rendering. + + /** + * Collect page IDs for batch mode either from the guide ({@code allPages}) or from the list + * file ({@code listPath}). + */ + private List collectBatchPageIds() { + if (config.allPages()) { + ResourceLocation guideId = new ResourceLocation(config.guideId()); + MutableGuide guide = GuideRegistry.getById(guideId); + if (guide == null) { + throw new IllegalStateException("Guide not found: " + config.guideId()); + } + Collection pages = guide.getPages(); + return pages.stream() + .map( + p -> p.getId() + .toString()) + .sorted() + .collect(Collectors.toList()); + } else { + return readPageIdList(config.listPath()); + } + } + + /** + * Read a page-ID list file: one pageId per line; empty lines and lines starting with + * {@code #} are skipped. Non-empty lines that do not start with {@code #} are treated as + * page IDs without further validation; invalid IDs will fail during render and be + * reported there. + */ + private List readPageIdList(Path listPath) { + List result = new ArrayList<>(); + try { + List lines = Files.readAllLines(listPath, StandardCharsets.UTF_8); + for (String line : lines) { + String trimmed = line.trim(); + if (trimmed.isEmpty() || trimmed.startsWith("#")) { + continue; + } + result.add(trimmed); + } + } catch (IOException e) { + logError("Failed to read page list file: " + listPath + " (" + e.getMessage() + ")"); + } + GuideDebugLog + .infoAlways("[GuideNH] [HeadlessRender] Read {} page IDs from list file: {}", result.size(), listPath); + return result; + } + + /** + * Render one page per invocation. Called from {@link #onRenderTick(TickEvent.RenderTickEvent)} + * when {@code state == RENDERING} in batch mode. + */ + private void handleRendering(Minecraft mc) { + if (pageIndex >= batchPageIds.size()) { + finishBatch(); + return; + } + + String pageIdStr = batchPageIds.get(pageIndex); + GuideDebugLog.infoAlways( + "[GuideNH] [HeadlessRender] Rendering page [{}/{}]: {}", + pageIndex + 1, + batchPageIds.size(), + pageIdStr); + System.out.println( + "[GuideNH] [HeadlessRender] Rendering page [" + (pageIndex + 1) + + "/" + + batchPageIds.size() + + "]: " + + pageIdStr); + + try { + RenderPageService.ensureFontEngineReady(); + var req = new RenderPageService.RenderPageRequest( + config.guideId(), + pageIdStr, + null, // no mdFile in batch mode + config.language(), + config.width(), + config.outDir(), + config.emitBoundsJson(), + config.emitDebugOverlay(), + config.scale(), + config.chrome(), + config.route(), // null in batch mode (mutually exclusive) + config.query() // null in batch mode (mutually exclusive) + ); + RenderPageService.RenderPageResult result = RenderPageService.render(req); + + String okMsg = "[GuideNH] [HeadlessRender] Page OK [" + (pageIndex + 1) + + "/" + + batchPageIds.size() + + "]: " + + pageIdStr + + " -> " + + result.pngPath() + + " (" + + result.widthPx() + + "x" + + result.heightPx() + + ")"; + GuideDebugLog.infoAlways(okMsg); + System.out.println(okMsg); + okCount++; + } catch (RenderPageService.RenderPageException e) { + String failMsg = "[GuideNH] [HeadlessRender] Page FAILED [" + (pageIndex + 1) + + "/" + + batchPageIds.size() + + "]: " + + pageIdStr + + " at stage " + + e.getStage() + + ": " + + e.getMessage(); + GuideDebugLog.error(failMsg); + System.err.println(failMsg); + failCount++; + failedPageIds.add(pageIdStr); + } catch (Throwable t) { + String failMsg = "[GuideNH] [HeadlessRender] Page FAILED [" + (pageIndex + 1) + + "/" + + batchPageIds.size() + + "]: " + + pageIdStr + + " with exception: " + + t.getMessage(); + GuideDebugLog.error(failMsg, t); + System.err.println(failMsg); + failCount++; + failedPageIds.add(pageIdStr); + } + + pageIndex++; + + if (pageIndex >= batchPageIds.size()) { + finishBatch(); + } + } + + /** + * Print batch summary to stdout and log, then exit with the appropriate code. + */ + private void finishBatch() { + state = State.DONE; + + int total = okCount + failCount; + String summary = "[GuideNH] [HeadlessRender] Batch complete: total=" + total + + " ok=" + + okCount + + " failed=" + + failCount; + GuideDebugLog.infoAlways(summary); + System.out.println(summary); + + if (!failedPageIds.isEmpty()) { + String failedSummary = "[GuideNH] [HeadlessRender] Failed pages (" + failCount + + "): " + + String.join(", ", failedPageIds); + GuideDebugLog.error(failedSummary); + System.err.println(failedSummary); + } + + FMLCommonHandler.instance() + .exitJava(failCount > 0 ? 1 : 0, false); + } + + // Render-tick handler (frame rendering cycle). + + /** + * Execute deferred rendering inside the frame rendering cycle (RenderTickEvent.END). + * + *

          + * Angelica's Tessellator mixins route draws through VBO/VAO paths whose internal state + * is tied to the frame rendering pass. Running {@link RenderPageService#render} inside + * {@link TickEvent.ClientTickEvent} (outside frame) caused silent zero-fragment output. + * This handler shifts execution into the frame cycle to validate that hypothesis. + * + *

          + * Both single-page and batch modes are handled here. Batch mode renders all remaining + * pages in one go (equivalent to the original per-tick loop, just inside the frame render + * cycle instead of client tick). + */ + @SubscribeEvent + public void onRenderTick(TickEvent.RenderTickEvent event) { + if (event.phase != TickEvent.Phase.END) { + return; + } + if (!renderPending) { + return; + } + renderPending = false; + + if (state != State.RENDERING) { + return; + } + + Minecraft mc = Minecraft.getMinecraft(); + if (mc == null) { + return; + } + + if (config.allPages() || config.listPath() != null) { + // Batch mode: render all remaining pages in this render frame + while (pageIndex < batchPageIds.size()) { + handleRendering(mc); + } + // finishBatch is called by handleRendering when all pages are done (JVM exits) + } else { + // Single-page mode + renderSinglePage(); + } + } + + /** + * Execute a single-page render (or a single route render) and exit the JVM with + * the appropriate code. + * + *

          + * Extracted from the old {@code handleWorldStable} single-page path. + */ + private void renderSinglePage() { + GuideDebugLog.infoAlways("[GuideNH] [HeadlessRender] Rendering page..."); + try { + RenderPageService.ensureFontEngineReady(); + var req = new RenderPageService.RenderPageRequest( + config.guideId(), + config.pageId(), + config.mdFile(), + config.language(), + config.width(), + config.outDir(), + config.emitBoundsJson(), + config.emitDebugOverlay(), + config.scale(), + config.chrome(), + config.route(), + config.query()); + RenderPageService.RenderPageResult result = RenderPageService.render(req); + + String message = "[GuideNH] [HeadlessRender] Screenshot written: " + result + .pngPath() + " (" + result.widthPx() + "x" + result.heightPx() + ")"; + GuideDebugLog.infoAlways(message); + System.out.println(message); + + FMLCommonHandler.instance() + .exitJava(0, false); + } catch (RenderPageService.RenderPageException e) { + GuideDebugLog + .error("[GuideNH] [HeadlessRender] Render failed at stage {}: {}", e.getStage(), e.getMessage()); + System.err + .println("[GuideNH] [HeadlessRender] Render failed at stage " + e.getStage() + ": " + e.getMessage()); + FMLCommonHandler.instance() + .exitJava(1, false); + } catch (Throwable t) { + GuideDebugLog.error("[GuideNH] [HeadlessRender] Unhandled exception during render", t); + System.err.println("[GuideNH] [HeadlessRender] Unhandled exception: " + t.getMessage()); + FMLCommonHandler.instance() + .exitJava(1, false); + } + } +} diff --git a/src/main/java/com/hfstudio/guidenh/guide/internal/headless/GuideNhHeadlessWindow.java b/src/main/java/com/hfstudio/guidenh/guide/internal/headless/GuideNhHeadlessWindow.java new file mode 100644 index 00000000..ea1999ed --- /dev/null +++ b/src/main/java/com/hfstudio/guidenh/guide/internal/headless/GuideNhHeadlessWindow.java @@ -0,0 +1,132 @@ +package com.hfstudio.guidenh.guide.internal.headless; + +import java.lang.reflect.InvocationHandler; +import java.lang.reflect.Method; +import java.lang.reflect.Proxy; +import java.util.function.Consumer; + +import com.hfstudio.guidenh.guide.scene.support.GuideDebugLog; + +/** + * Handles hiding the client window for headless rendering (screenshots, etc.). + * + *

          + * Two-tier strategy: + *

            + *
          1. installEarly(): if {@code DisplayEvents} exists (lwjgl3ify ≥ 3.0.21), + * registers a pre-window-create listener that sets + * {@code SDL_PROP_WINDOW_CREATE_HIDDEN_BOOLEAN} so the window is never shown. + *
          2. hideNow(): after {@code Display.create()} has occurred (the 3.0.20 + * fallback, or if the early listener was missed), calls + * {@code SDL_HideWindow(Display.sdlWindow)} directly. + *
          + * + *

          + * All failures are logged as warnings only; window hiding must never crash the + * screenshot pipeline. When JVM property {@code -Dguidenh.headlessRender} is not + * {@code true}, every method returns immediately with zero side effects. + * + *

          + * All LWJGL3 / lwjgl3ify classes are accessed reflectively to avoid compile-time + * dependency on jars that are only present at runtime. + */ +public final class GuideNhHeadlessWindow { + + private static final String PROPERTY_NAME = "guidenh.headlessRender"; + private static final String TAG = "[GuideNhHeadlessWindow]"; + private static final boolean HEADLESS = Boolean.getBoolean(PROPERTY_NAME); + + /** Guard against redundant hide attempts; set to true once SDL_HideWindow succeeds. */ + private static volatile boolean hidden = false; + + private GuideNhHeadlessWindow() {} + + /** + * Earliest hook; call it from {@code FMLPreInitializationEvent}. + * + *

          + * If {@code -Dguidenh.headlessRender=true} and the {@code DisplayEvents} API is + * present at runtime (lwjgl3ify ≥ 3.0.21), dynamically registers a + * pre-window-create listener that makes the window invisible from birth. + */ + public static void installEarly() { + if (!HEADLESS) { + return; + } + + try { + Class displayEventsClass = Class.forName("me.eigenraven.lwjgl3ify.api.DisplayEvents"); + Method addListener = displayEventsClass.getMethod("addPreWindowCreateListener", Consumer.class); + + // Resolve SDL bindings reflectively + Class sdlVideo = Class.forName("org.lwjgl.sdl.SDLVideo"); + Class sdlProps = Class.forName("org.lwjgl.sdl.SDLProperties"); + String hiddenKey = (String) sdlVideo.getField("SDL_PROP_WINDOW_CREATE_HIDDEN_BOOLEAN") + .get(null); + Method setBoolProp = sdlProps + .getMethod("SDL_SetBooleanProperty", int.class, CharSequence.class, boolean.class); + + Object consumerProxy = Proxy.newProxyInstance( + GuideNhHeadlessWindow.class.getClassLoader(), + new Class[] { Consumer.class }, + new InvocationHandler() { + + @Override + public Object invoke(Object proxy, Method method, Object[] args) throws Throwable { + if ("accept".equals(method.getName()) && args != null && args.length == 1) { + Object ctx = args[0]; + int props = (int) ctx.getClass() + .getMethod("props") + .invoke(ctx); + setBoolProp.invoke(null, props, hiddenKey, true); + GuideDebugLog.infoAlways( + "{} Set SDL_PROP_WINDOW_CREATE_HIDDEN_BOOLEAN via DisplayEvents listener (props={})", + TAG, + props); + } + return null; + } + }); + + addListener.invoke(null, consumerProxy); + GuideDebugLog.infoAlways("{} Registered pre-window-create listener via DisplayEvents API", TAG); + } catch (ClassNotFoundException e) { + GuideDebugLog + .warnAlways("{} DisplayEvents not found (lwjgl3ify <= 3.0.20), will use fallback hideNow()", TAG); + hideNow(); // preInit attempt: sdlWindow is already available on MC 1.7.10 + } catch (Exception e) { + GuideDebugLog.warnAlways("{} Failed to register pre-window-create listener: {}", TAG, e.getMessage()); + hideNow(); // fallback: try a direct hide in case the DisplayEvents path partially failed + } + } + + /** + * Fallback / second-chance hook; call it after {@code Display.create()} has + * occurred (FML init phase). Hides the SDL window directly. + */ + public static void hideNow() { + if (!HEADLESS) { + return; + } + if (hidden) { + return; + } + + try { + Class displayClass = Class.forName("org.lwjglx.opengl.Display"); + long window = displayClass.getField("sdlWindow") + .getLong(null); + if (window != 0L) { + Class sdlVideo = Class.forName("org.lwjgl.sdl.SDLVideo"); + sdlVideo.getMethod("SDL_HideWindow", long.class) + .invoke(null, window); + GuideDebugLog.infoAlways("{} Window hidden via SDL_HideWindow (sdlWindow={})", TAG, window); + hidden = true; + } else { + GuideDebugLog.warnAlways("{} sdlWindow is 0, cannot hide window", TAG); + } + } catch (Exception e) { + GuideDebugLog.warnAlways("{} Failed to hide window: {}", TAG, e.getMessage()); + } + } +} diff --git a/src/main/java/com/hfstudio/guidenh/guide/internal/headless/RenderPageService.java b/src/main/java/com/hfstudio/guidenh/guide/internal/headless/RenderPageService.java new file mode 100644 index 00000000..700d7aa4 --- /dev/null +++ b/src/main/java/com/hfstudio/guidenh/guide/internal/headless/RenderPageService.java @@ -0,0 +1,1355 @@ +package com.hfstudio.guidenh.guide.internal.headless; + +import java.awt.Color; +import java.awt.Graphics2D; +import java.awt.RenderingHints; +import java.awt.image.BufferedImage; +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.time.LocalDateTime; +import java.time.format.DateTimeFormatter; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.Locale; +import java.util.stream.Collectors; + +import javax.imageio.ImageIO; + +import net.minecraft.client.Minecraft; +import net.minecraft.client.gui.FontRenderer; +import net.minecraft.util.ResourceLocation; + +import org.jetbrains.annotations.Nullable; + +import com.google.gson.GsonBuilder; +import com.google.gson.JsonArray; +import com.google.gson.JsonObject; +import com.hfstudio.guidenh.ClientProxy; +import com.hfstudio.guidenh.guide.GuidePage; +import com.hfstudio.guidenh.guide.GuidePageIcon; +import com.hfstudio.guidenh.guide.PageAnchor; +import com.hfstudio.guidenh.guide.compiler.PageCompiler; +import com.hfstudio.guidenh.guide.compiler.ParsedGuidePage; +import com.hfstudio.guidenh.guide.document.DefaultStyles; +import com.hfstudio.guidenh.guide.document.LytRect; +import com.hfstudio.guidenh.guide.document.block.LytBlock; +import com.hfstudio.guidenh.guide.document.block.LytDocument; +import com.hfstudio.guidenh.guide.document.block.LytHeading; +import com.hfstudio.guidenh.guide.document.block.LytMermaidCanvas; +import com.hfstudio.guidenh.guide.document.block.LytNode; +import com.hfstudio.guidenh.guide.document.block.LytParagraph; +import com.hfstudio.guidenh.guide.document.flow.LytFlowContent; +import com.hfstudio.guidenh.guide.internal.GuideBookmarkState; +import com.hfstudio.guidenh.guide.internal.GuideME; +import com.hfstudio.guidenh.guide.internal.GuideRegistry; +import com.hfstudio.guidenh.guide.internal.GuideScreen; +import com.hfstudio.guidenh.guide.internal.GuidebookText; +import com.hfstudio.guidenh.guide.internal.MutableGuide; +import com.hfstudio.guidenh.guide.internal.host.LytHost; +import com.hfstudio.guidenh.guide.internal.screen.GuideNavBar; +import com.hfstudio.guidenh.guide.internal.screen.GuideNavBarState; +import com.hfstudio.guidenh.guide.internal.search.GuideSearch; +import com.hfstudio.guidenh.guide.internal.search.GuideSearchPage; +import com.hfstudio.guidenh.guide.internal.search.GuideSearchResultDocumentBuilder; +import com.hfstudio.guidenh.guide.internal.search.GuideSearchSnippetFormatter; +import com.hfstudio.guidenh.guide.internal.settings.GuideSettingsDocumentBuilder; +import com.hfstudio.guidenh.guide.internal.settings.GuideSettingsPage; +import com.hfstudio.guidenh.guide.internal.util.DisplayScale; +import com.hfstudio.guidenh.guide.layout.FontProvider; +import com.hfstudio.guidenh.guide.layout.LayoutBridge; +import com.hfstudio.guidenh.guide.layout.LayoutContext; +import com.hfstudio.guidenh.guide.layout.LayoutTreeSerializer; +import com.hfstudio.guidenh.guide.layout.RustFontMetrics; +import com.hfstudio.guidenh.guide.layout.SystemFontProvider; +import com.hfstudio.guidenh.guide.navigation.NavigationTree; +import com.hfstudio.guidenh.guide.render.GuideRenderPrimitive; +import com.hfstudio.guidenh.guide.render.GuideText; +import com.hfstudio.guidenh.guide.render.PrimitiveCollector; +import com.hfstudio.guidenh.guide.render.VanillaRenderContext; +import com.hfstudio.guidenh.guide.scene.support.GuideDebugLog; + +/** + * Core orchestration service for "page → long screenshot PNG + optional bounds + * JSON / debug overlay". + * + *

          + * Called inside the real Minecraft client (command or startup hook) after + * fonts, resources, and the Guide registry are ready (post-completeInit). + * Does not depend on {@code Minecraft.theWorld / thePlayer / currentScreen}. + * + *

          + * Layout is performed at {@code guiScale = 1}; the visual scale is fixed at + * {@code 1.0} (no zoom). + */ +public final class RenderPageService { + + private static final DateTimeFormatter FILE_NAME_FORMAT = DateTimeFormatter + .ofPattern("yyyy-MM-dd_HHmmss", Locale.ROOT); + + /** Route id for the synthetic search-results page ({@code -Dguidenh.renderpage.route}). */ + private static final String ROUTE_HOME_SEARCH = "home_search"; + + /** Route id for the synthetic reader-settings page ({@code -Dguidenh.renderpage.route}). */ + private static final String ROUTE_SETTINGS = "settings"; + + /** + * Horizontal document edge padding (px per side) applied by the Rust layout + * engine before any content: {@code CONTENT_PAD = 14.0} in + * {@code src/rust/layout-engine/src/layout.rs}, which places content at + * {@code x = 14} with width {@code avail_width - 2 * 14}. A 900 px headless + * page thus lays document content out at 900 - 28 = 872 px wide + * (bounds-verified: search-result row content x=14 w=872). The search-row + * column width must use the same metric; clipping snippet/title/path + * against {@code req.width()} (900) would over-budget the real 872 px + * column by 28 px. + */ + private static final int DOCUMENT_CONTENT_EDGE_PAD = 14; + + /** Six cycling colours for overlay borders/labels, keyed by depth % 6. */ + private static final int[] OVERLAY_FILL_COLORS = { 0x44FF0000, 0x4400FF00, 0x440000FF, 0x44FFFF00, 0x44FF00FF, + 0x4400FFFF }; + private static final int[] OVERLAY_BORDER_COLORS = { 0xFFFF0000, 0xFF00FF00, 0xFF0000FF, 0xFFFFFF00, 0xFFFF00FF, + 0xFF00FFFF }; + + private RenderPageService() {} + + // Data types. + + /** + * @param guideId host guide identifier (always required, used as resource context) + * @param pageId registered page id (non-null → registered-page path) + * @param mdFile arbitrary markdown file (non-null → raw-md path) + * @param language language code, e.g. "en_us" or "zh_cn" + * @param width layout width in document units (GUI pixels) + * @param outDir output directory for generated files + * @param emitBoundsJson if true, write a block-bounds JSON sidecar + * @param emitDebugOverlay if true, write a debug overlay PNG + * @param scale render pixel-density multiplier (1-4; 1 = 1×, no scaling) + * @param chrome if true, append the GuideNavBar chrome pass to the + * headless render. The nav + * bar occupies the left {@link #navBarWidth(int)} + * logical px and the document is shifted right; the + * bounds JSON stays in document coordinates. + * @param route synthetic UI-route id (non-null → route path). + * Supported values: {@code "home_search"} (search-results page) + * and {@code "settings"} (reader-settings page, no query needed); + * mutually exclusive with pageId/mdFile. + * @param query search query, required when {@code route = "home_search"} + */ + public record RenderPageRequest(String guideId, String pageId, Path mdFile, String language, int width, Path outDir, + boolean emitBoundsJson, boolean emitDebugOverlay, int scale, boolean chrome, String route, String query) { + + /** Legacy 9-arg construction (in-game command path); chrome defaults off. */ + public RenderPageRequest(String guideId, String pageId, Path mdFile, String language, int width, Path outDir, + boolean emitBoundsJson, boolean emitDebugOverlay, int scale) { + this( + guideId, + pageId, + mdFile, + language, + width, + outDir, + emitBoundsJson, + emitDebugOverlay, + scale, + false, + null, + null); + } + } + + /** + * @param pngPath path of the written PNG + * @param boundsJsonPath path of the bounds JSON (null when not emitted) + * @param widthPx actual image width in pixels + * @param heightPx actual image height in pixels + * @param blockCount total number of LytBlock instances in the document + */ + public record RenderPageResult(Path pngPath, Path boundsJsonPath, int widthPx, int heightPx, int blockCount) {} + + /** + * Checked exception that wraps all failures inside {@link #render}. + * The {@link Stage} indicates which phase the error occurred in. + */ + public static final class RenderPageException extends Exception { + + public enum Stage { + COMPILE, + LAYOUT, + RENDER, + IO + } + + private final Stage stage; + + public RenderPageException(Stage stage, String message) { + super(message); + this.stage = stage; + } + + public RenderPageException(Stage stage, String message, Throwable cause) { + super(message, cause); + this.stage = stage; + } + + public Stage getStage() { + return stage; + } + } + + // Public API. + + /** + * Force-initialise the Rust font engine if not already done. + * + *

          + * Equivalent to the font-initialisation portion of + * {@code GuideScreen.ensureLayout()}: checks {@link LayoutBridge#getFontHandle()}, + * loads system CJK font data via {@link SystemFontProvider}, and calls + * {@link LayoutBridge#init(byte[], String)} followed by + * {@link LayoutBridge#setFontHandle(long)}. + * + *

          + * Idempotent: subsequent calls are no-ops once the font handle is non-zero. + */ + public static void ensureFontEngineReady() { + if (LayoutBridge.getFontHandle() == 0) { + var fontProvider = new SystemFontProvider(); + byte[] fontData = fontProvider.getFontData("zh_CN"); + GuideDebugLog.warnAlways( + "RenderPageService: initializing Rust font system from {} ({} bytes)", + fontProvider.getFontPath(), + fontData.length); + long handle = LayoutBridge.init(fontData, "zh_CN"); + LayoutBridge.setFontHandle(handle); + loadFallbackSymbolFont(fontProvider, handle); + } + } + + /** + * Best-effort fallback symbol font registration (seguisym.ttf covers the + * callout icons ⓘ ✦ ➤ ⚠ ☢ that msyh.ttc lacks). Runs once right after + * font init; empty data and stale native libs are skipped/ignored. + */ + private static void loadFallbackSymbolFont(FontProvider fontProvider, long handle) { + if (handle == 0) return; + byte[] fallbackData = fontProvider.getFallbackFontData("zh_CN"); + if (fallbackData.length == 0) return; + try { + LayoutBridge.loadFallbackFont(handle, fallbackData); + } catch (UnsatisfiedLinkError e) { + GuideDebugLog + .warnAlways("RenderPageService: loadFallbackFont unavailable (stale native lib?): {}", e.getMessage()); + } + } + + /** + * Orchestrate the full render pipeline. + * + *

            + *
          1. Ensure font engine ready
          2. + *
          3. Compile the page (registered-page or raw-md path)
          4. + *
          5. Layout the document at the requested width
          6. + *
          7. Collect render primitives
          8. + *
          9. Render to offscreen FBO (tiled if necessary)
          10. + *
          11. Write PNG (with collision-safe naming)
          12. + *
          13. Optionally write bounds JSON
          14. + *
          15. Optionally write debug-overlay PNG
          16. + *
          + * + *

          + * Intentional deviation from {@code GuideScreen.renderDocument}: + * This method fixes {@code visualScale = 1.0f} (in the layout context) and + * {@code zoom = 1.0f} (in the render context) to produce a full-resolution + * screenshot. The screenshot is defined as the geometric layout at 1.0× + * scale; it does not simulate the user's current zoom or visual + * scale. {@code GuideScreen.renderDocument} applies the user's dynamic + * {@code currentZoom} and {@code visualScrollY} instead. + */ + public static RenderPageResult render(RenderPageRequest req) throws RenderPageException { + // Step 1: font engine. + ensureFontEngineReady(); + + // Step 2: resolve the host guide. + ResourceLocation guideId = new ResourceLocation(req.guideId()); + MutableGuide guide = GuideRegistry.getById(guideId); + if (guide == null) { + throw new RenderPageException(RenderPageException.Stage.COMPILE, "Guide not found: " + req.guideId()); + } + + // Step 3: compile. + GuidePage compiledPage; + try { + if (req.route() != null) { + if (ROUTE_SETTINGS.equals(req.route())) { + compiledPage = compileSettingsRoute(guide, req); + } else if (ROUTE_HOME_SEARCH.equals(req.route())) { + compiledPage = compileSearchRoute(guide, req); + } else { + throw new RenderPageException( + RenderPageException.Stage.COMPILE, + "Unsupported route: " + req + .route() + " (supported: " + ROUTE_HOME_SEARCH + ", " + ROUTE_SETTINGS + ")"); + } + } else if (req.pageId() != null && !req.pageId() + .isEmpty()) { + compiledPage = compileRegisteredPage(guide, req); + } else if (req.mdFile() != null) { + compiledPage = compileMdFile(guide, req); + } else { + throw new RenderPageException( + RenderPageException.Stage.COMPILE, + "Either pageId, mdFile or route must be provided"); + } + } catch (RenderPageException e) { + throw e; + } catch (Exception e) { + throw new RenderPageException(RenderPageException.Stage.COMPILE, "Compilation failed", e); + } + + LytDocument document = compiledPage.document(); + + // Step 3a: mount the document (dispatch MOUNT events for SceneScript etc.). + String mountPageId = compiledPage.id() + .toString(); + LytHost lytHost = ClientProxy.getLytHost(); + try { + lytHost.setCurrentPageId(mountPageId); + lytHost.setCurrentPageCollection(guide); + lytHost.mountDocument(document); + + // Drive async scripts (SceneScript: doInit → doAwaitSnbt → doBuild) + // to convergence using the host's step mechanism. + long deadline = System.nanoTime() + 10_000_000_000L; // 10 seconds + while (lytHost.hasWork() && System.nanoTime() < deadline) { + lytHost.step(deadline); + } + if (lytHost.hasWork()) { + GuideDebugLog.warnAlways( + "RenderPageService: page {} mount timed out after 10s, {} tasks still pending", + mountPageId, + lytHost.pendingTaskCount()); + } + } catch (Exception e) { + throw new RenderPageException(RenderPageException.Stage.LAYOUT, "Mount failed for page " + mountPageId, e); + } + + // Steps 4 and 5: layout and primitive collection. + // Headless guiscale injection (-Dguidenh.renderpage.guiscale=0|1|2|3|4, + // default 4 when absent): temporarily set gameSettings.guiScale so that + // DisplayScale.scaleFactor() (whose cache key includes guiScale) + // drives the Rust glyph rasterization scale like the live client's + // auto render_scale. The headless render baseline is guiscale=4, so an + // absent property injects 4 by default; without injection, headless + // renders run at scaleFactor=1 (11 ppem glyphs) and can never cover + // the capacity surface that breaks at render_scale=4. Explicit 0 + // disables the injection (layout stays byte-identical). The property + // is read once, the value is applied before layout and restored + // afterwards. + int contentHeight; + List primitives; + VanillaRenderContext renderCtx; + Minecraft mc = Minecraft.getMinecraft(); + int guiscaleInjection = parseGuiscaleInjection(); + int previousGuiScale = 0; + if (guiscaleInjection > 0 && mc != null) { + previousGuiScale = mc.gameSettings.guiScale; + mc.gameSettings.guiScale = guiscaleInjection; + // DisplayScale override: bypasses the ScaledResolution geometric + // cap (sf = min(guiScale, floor(dw/320), floor(dh/240))) that + // otherwise clamps effective scaleFactor to 2 on the 854×480 + // headless window even with guiScale=4 injected. Cleared in the + // finally block below. + DisplayScale.setInjectedScaleFactor(guiscaleInjection); + GuideDebugLog.infoAlways( + "RenderPageService: guiscale injection active: {} (layout render_scale simulation; " + + "restored after layout/collect)", + guiscaleInjection); + } + try { + try { + var layoutCtx = new LayoutContext(new RustFontMetrics()).withVisualScale(1.0f); + document.updateLayout(layoutCtx, req.width()); + contentHeight = document.getContentHeight(); + if (contentHeight <= 0) { + throw new RenderPageException( + RenderPageException.Stage.LAYOUT, + "Document content height must be positive, got: " + contentHeight); + } + } catch (Exception e) { + throw new RenderPageException(RenderPageException.Stage.LAYOUT, "Layout failed", e); + } + + try { + var fullViewport = new LytRect(0, 0, req.width(), contentHeight); + renderCtx = new VanillaRenderContext(fullViewport, contentHeight); + renderCtx.setDocumentOrigin(0, 0); + renderCtx.setScrollOffsetY(0); + renderCtx.setPreciseScrollOffsetY(0); + renderCtx.setZoom(1.0f); + renderCtx.setScreenViewport(fullViewport); + + var pc = new PrimitiveCollector(fullViewport, renderCtx); + applyMermaidInjection(document); + pc.collectFrom(document); + primitives = pc.result(); + // Headless toolbar-title injection (-Dguidenh.renderpage.title=true): + // overlay the page-title glyph run collected through the drawPageTitle + // equivalent path on top of the document primitives. Absent or any + // non-"true" value is a strict no-op; the primitive list stays + // byte-identical, so the default render output is unchanged. + if (isPageTitleInjectionEnabled()) { + List titlePrims = collectToolbarTitlePrimitives(req, guide, compiledPage); + if (!titlePrims.isEmpty()) { + List merged = new ArrayList<>(primitives.size() + titlePrims.size()); + merged.addAll(primitives); + merged.addAll(titlePrims); + primitives = merged; + } + } + } catch (Exception e) { + throw new RenderPageException(RenderPageException.Stage.RENDER, "Primitive collection failed", e); + } + } finally { + if (guiscaleInjection > 0 && mc != null) { + mc.gameSettings.guiScale = previousGuiScale; + // Clear the DisplayScale override exactly where the guiscale + // injection window ends (same condition as the injection). + DisplayScale.setInjectedScaleFactor(0); + } + } + + // Step 6: render. + int scale = req.scale(); + int renderedWidth = req.width(); + BufferedImage image; + try { + if (req.chrome()) { + // Chrome pass: the document renders byte-identically to the + // chrome=false path (own renderAll call), and the GuideNavBar + // renders in a second offscreen pass at the left. The two are + // composited side by side (nav left, document right), mirroring + // the real GuideScreen layout (nav sidebar + content area). + BufferedImage docImage = DocumentOffscreenFramebuffer + .renderAll(primitives, renderCtx, req.width(), contentHeight, 0x121216, scale); + List navPrims = new ArrayList<>(); + VanillaRenderContext navCtx = collectNavBarPrimitives( + req, + guide, + compiledPage, + contentHeight, + navPrims); + int navW = navBarWidth(req.width()); + BufferedImage navImage = DocumentOffscreenFramebuffer + .renderAll(navPrims, navCtx, navW, contentHeight, 0x121216, scale); + image = composeChrome(docImage, navImage, navW * scale, 0x121216); + renderedWidth = req.width() + navW; + GuideDebugLog.infoAlways( + "RenderPageService: chrome pass composed {} nav primitives into {}x{} output " + + "(nav width {} logical px, scale {})", + navPrims.size(), + image.getWidth(), + image.getHeight(), + navW, + scale); + } else { + image = DocumentOffscreenFramebuffer + .renderAll(primitives, renderCtx, req.width(), contentHeight, 0x121216, scale); + } + } catch (Exception e) { + throw new RenderPageException(RenderPageException.Stage.RENDER, "Offscreen rendering failed", e); + } finally { + // Reset the document origin as required by DocumentOffscreenFramebuffer's contract + renderCtx.setDocumentOrigin(0, 0); + } + + // Step 7: write the PNG. + Path pngPath; + try { + Files.createDirectories(req.outDir()); + String baseName = buildBaseName(req); + pngPath = resolveTargetPath(req.outDir(), baseName, "png"); + ImageIO.write(image, "png", pngPath.toFile()); + GuideDebugLog + .infoAlways("RenderPageService: wrote PNG {} ({}x{})", pngPath, image.getWidth(), image.getHeight()); + } catch (IOException e) { + throw new RenderPageException(RenderPageException.Stage.IO, "Failed to write PNG", e); + } + + // Step 8: bounds JSON (optional). + Path boundsJsonPath = null; + if (req.emitBoundsJson()) { + try { + boundsJsonPath = resolveTargetPath(req.outDir(), buildBaseName(req), "json"); + writeBoundsJson(document, boundsJsonPath); + GuideDebugLog.infoAlways("RenderPageService: wrote bounds JSON {}", boundsJsonPath); + } catch (IOException e) { + throw new RenderPageException(RenderPageException.Stage.IO, "Failed to write bounds JSON", e); + } + } + + // Step 9: debug overlay (optional). + if (req.emitDebugOverlay()) { + try { + Path overlayPath = req.outDir() + .resolve(buildBaseName(req) + "_overlay.png"); + drawDebugOverlay(image, document, overlayPath, scale); + GuideDebugLog.infoAlways("RenderPageService: wrote overlay PNG {}", overlayPath); + } catch (IOException e) { + throw new RenderPageException(RenderPageException.Stage.IO, "Failed to write overlay PNG", e); + } + } + + // Step 10: unmount the document from LytHost so the static host does not leak it. + try { + // mountDocument(null) detaches the current doc (setLive(false)) and clears + // the task queue. LytHost has no explicit unmount/release method beyond this. + lytHost.mountDocument(null); + } catch (Exception e) { + GuideDebugLog.warnAlways("RenderPageService: cleanup unmount failed for page {}", mountPageId, e); + } + + int blockCount = countBlocks(document); + return new RenderPageResult(pngPath, boundsJsonPath, renderedWidth * scale, contentHeight * scale, blockCount); + } + + // Compilation helpers. + + private static GuidePage compileRegisteredPage(MutableGuide guide, RenderPageRequest req) + throws RenderPageException { + ResourceLocation pageId = new ResourceLocation(req.pageId()); + ParsedGuidePage parsed = guide.getParsedPage(pageId); + if (parsed == null) { + throw new RenderPageException( + RenderPageException.Stage.COMPILE, + buildPageNotFoundMessage(guide, req.pageId())); + } + try { + return PageCompiler.compile(guide, guide.getExtensions(), parsed); + } catch (Exception e) { + throw new RenderPageException( + RenderPageException.Stage.COMPILE, + "Failed to compile registered page " + req.pageId(), + e); + } + } + + private static GuidePage compileMdFile(MutableGuide guide, RenderPageRequest req) throws RenderPageException { + Path mdFile = req.mdFile(); + if (!Files.isRegularFile(mdFile)) { + throw new RenderPageException( + RenderPageException.Stage.COMPILE, + "mdFile does not exist or is not a regular file: " + mdFile); + } + String content; + try { + content = Files.readString(mdFile, StandardCharsets.UTF_8); + } catch (IOException e) { + throw new RenderPageException(RenderPageException.Stage.COMPILE, "Failed to read mdFile: " + mdFile, e); + } + + String fileName = mdFile.getFileName() + .toString(); + if (fileName.endsWith(".md")) { + fileName = fileName.substring(0, fileName.length() - 3); + } + // Replace characters invalid in ResourceLocation path + String safeName = fileName.replaceAll("[^a-zA-Z0-9._-]", "_"); + ResourceLocation syntheticId = new ResourceLocation(guide.getDefaultNamespace(), safeName); + + String sourcePack = guide.getDefaultNamespace(); + try { + ParsedGuidePage parsed = PageCompiler.parse(sourcePack, req.language(), syntheticId, content); + return PageCompiler.compile(guide, guide.getExtensions(), parsed); + } catch (Exception e) { + throw new RenderPageException(RenderPageException.Stage.COMPILE, "Failed to compile mdFile " + mdFile, e); + } + } + + // Route compilation helpers (synthetic UI routes). + + /** + * Compile the {@code settings} route: build the reader-settings document with + * {@link GuideSettingsDocumentBuilder#buildDocument()}, the exact same single + * builder the live screen uses ({@code GuideScreen.rebuildSettingsDocument} / + * {@code GuideScreen} line 2405), so the headless render shares one source of + * truth with the live page. Unlike the search route (which approximates its + * rows headlessly), the settings route adds no headless-side reconstruction. + * The page is wrapped with the synthetic id {@link GuideSettingsPage#PAGE_ID} + * ({@code guidenh:settings}) so the mount / chrome / naming pipeline can reuse + * the registered-page path unchanged. No query parameter is needed. + */ + private static GuidePage compileSettingsRoute(MutableGuide guide, RenderPageRequest req) { + LytDocument document = GuideSettingsDocumentBuilder.buildDocument(); + return new GuidePage(guide.getDefaultNamespace(), GuideSettingsPage.PAGE_ID, document); + } + + /** + * Compile the {@code home_search} route: run a real guide search + * ({@link GuideME#getSearch()} → {@link GuideSearch#searchGuide(String, Guide)}), + * map every hit into a {@link GuideSearchResultDocumentBuilder.SearchPageResult} + * and build the search-results document exactly like the live screen + * ({@code GuideScreen.buildSearchDocument}). The page is wrapped with the + * synthetic id {@link GuideSearchPage#PAGE_ID} ({@code guidenh:search}) so the + * mount / chrome / naming pipeline can reuse the registered-page path unchanged. + * + *

          + * Headless side builds the {@code SearchPageResult} rows itself instead of + * reaching into {@code GuideScreen}'s private helpers. Title/path clipping is + * approximated from {@code req.width()} using {@link FontRenderer} string + * metrics; the snippet is clipped to at most two {@code textColumnWidth} lines + * plus an ellipsis using {@link GuideText#measureWidth} (the same Rust metric + * the headless render uses), mirroring {@code GuideScreen.clipSnippetForWidth}. + */ + private static GuidePage compileSearchRoute(MutableGuide guide, RenderPageRequest req) { + String query = req.query() == null ? "" : req.query(); + var results = new ArrayList(); + String normalizedQuery = GuideSearchPage.normalizeQuery(query); + try { + for (var result : GuideME.getSearch() + .searchGuide(normalizedQuery, null)) { + results.add(buildSearchRouteResult(req, result)); + } + } catch (Throwable t) { + // Mirrors GuideScreen.buildSearchDocument: a failed search degrades to + // the empty state instead of failing the whole render. + GuideDebugLog.warnAlways("Search failed", t); + } + LytDocument document = GuideSearchResultDocumentBuilder + .buildDocument(query, results, GuidebookText.SearchNoQuery.text(), GuidebookText.SearchNoResults.text()); + return new GuidePage(guide.getDefaultNamespace(), GuideSearchPage.PAGE_ID, document); + } + + /** + * Map one {@link GuideSearch.SearchResult} into a + * {@link GuideSearchResultDocumentBuilder.SearchPageResult}, mirroring the + * live screen's row structure but approximating the text-column widths + * from {@code req.width()} with the Rust document-edge inset deducted: + * the headless document is laid out at the full page width with content + * starting 14 px in on each side (the layout engine's {@code CONTENT_PAD}), + * so a 900 px page renders row content at 872 px, instead of the live panel + * minus nav reservations. + */ + private static GuideSearchResultDocumentBuilder.SearchPageResult buildSearchRouteResult(RenderPageRequest req, + GuideSearch.SearchResult result) { + GuidePageIcon icon = null; + var navNode = GuideRegistry.getMergedNavigationTree() + .getNodeById(result.pageId()); + if (navNode != null) { + icon = navNode.icon(); + } + Minecraft mc = Minecraft.getMinecraft(); + FontRenderer font = mc != null ? mc.fontRenderer : null; + int iconGap = icon != null ? GuideScreen.SEARCH_RESULT_ICON_AND_GAP : 0; + // Column budget mirrors the rendered row-content width: the Rust layout + // insets the whole document by DOCUMENT_CONTENT_EDGE_PAD per side + // (the layout engine's CONTENT_PAD), so a 900 px page renders content at 872 px; + // an icon row further deducts SEARCH_RESULT_ICON_AND_GAP on the same + // basis as GuideScreen.getSearchTextColumnWidth. + int textColumnWidth = Math.max(80, req.width() - DOCUMENT_CONTENT_EDGE_PAD * 2 - iconGap); + int pathWidth = Math.max(192, textColumnWidth / 3); + int titleWidth = Math.max(60, textColumnWidth - pathWidth - GuideScreen.SEARCH_RESULT_TITLE_GAP); + + String rawTitle = result.pageTitle() + .isEmpty() + ? result.pageId() + .toString() + : result.pageTitle(); + String title = clipRightForWidth(font, rawTitle, titleWidth); + String pagePath = clipSearchPath(font, resolveSearchPath(result.pageId()), pathWidth); + LytFlowContent snippet = clipSnippetForWidth(result.text(), textColumnWidth); + return new GuideSearchResultDocumentBuilder.SearchPageResult( + result.guideId(), + PageAnchor.page(result.pageId()), + icon, + title, + pagePath, + snippet); + } + + /** + * Breadcrumb path for a search result, mirroring + * {@code GuideScreen.resolveSearchResultPath} against the merged navigation + * tree; falls back to the resource path's parent segment. + */ + private static String resolveSearchPath(ResourceLocation pageId) { + var path = GuideRegistry.getMergedNavigationTree() + .getPathTo(pageId); + if (path.size() > 1) { + var breadcrumb = new StringBuilder(); + for (int i = 0; i < path.size() - 1; i++) { + var node = path.get(i); + if (node.title() == null || node.title() + .isEmpty()) { + continue; + } + if (!breadcrumb.isEmpty()) { + breadcrumb.append(" / "); + } + breadcrumb.append(node.title()); + } + if (!breadcrumb.isEmpty()) { + return breadcrumb.toString(); + } + } + String resourcePath = pageId.getResourcePath(); + int slashIndex = resourcePath.lastIndexOf('/'); + return slashIndex > 0 ? resourcePath.substring(0, slashIndex) : ""; + } + + /** + * Font-metric right-clip with ellipsis, mirroring + * {@code GuideScreen.clipRightForWidth} using the vanilla {@link FontRenderer}. + * {@code null} font (no client instance) passes the value through un-clipped. + */ + private static String clipRightForWidth(@Nullable FontRenderer font, String value, int maxWidth) { + if (font == null || value == null + || value.isEmpty() + || maxWidth <= 0 + || font.getStringWidth(value) <= maxWidth) { + return value; + } + String unclippedValue = value.endsWith(GuideScreen.ASCII_ELLIPSIS) + && value.length() > GuideScreen.ASCII_ELLIPSIS.length() + ? value.substring(0, value.length() - GuideScreen.ASCII_ELLIPSIS.length()) + : value; + int ellipsisWidth = font.getStringWidth(GuideScreen.ASCII_ELLIPSIS); + if (ellipsisWidth >= maxWidth) { + return font.trimStringToWidth(GuideScreen.ASCII_ELLIPSIS, maxWidth); + } + return font.trimStringToWidth(unclippedValue, maxWidth - ellipsisWidth) + GuideScreen.ASCII_ELLIPSIS; + } + + /** + * Character-count right-clip with ellipsis, mirroring + * {@code GuideScreen.clipRightForChars}. + */ + private static String clipRightForChars(String value, int maxChars) { + if (value == null || value.isEmpty() || maxChars <= 0 || value.length() <= maxChars) { + return value; + } + if (maxChars <= GuideScreen.ASCII_ELLIPSIS.length()) { + return GuideScreen.ASCII_ELLIPSIS.substring(0, maxChars); + } + return value.substring(0, maxChars - GuideScreen.ASCII_ELLIPSIS.length()) + GuideScreen.ASCII_ELLIPSIS; + } + + /** + * Path clip (char cap then width), mirroring {@code GuideScreen.clipSearchPath}. + */ + private static String clipSearchPath(@Nullable FontRenderer font, String value, int pathWidth) { + return clipRightForWidth(font, clipRightForChars(value, GuideScreen.SEARCH_PATH_MAX_CHARS), pathWidth); + } + + /** + * Snippet clip mirroring {@code GuideScreen.clipSnippetForWidth} with the + * headless measurement basis, made line-aware for the Rust renderer's + * hard-break semantics. + * + *

          + * Why line-splitting is mandatory: {@link GuideText#measureWidth} + * returns the widest single line of the shaped text (the Rust + * {@code shape_text_cmd} width uses a max_x semantic; see + * {@code src/rust/layout-engine/src/parley_text.rs}). A snippet containing {@code \n} (the Lucene highlighter + * extracts multi-line fragments; a stress/mixed page snippet carries 4 + * newlines) would therefore pass a whole-text fits check + * ({@link GuideText#clipToWidth} compares {@code measureWidth(text)}, the + * widest single line, against the budget) and be passed through un-clipped, + * then blow the row height up in layout where every {@code \n} is a hard + * break. + * The snippet is therefore split on {@code \n} first, each line measured + * independently, and each newline itself consumes a line slot of the + * two-line budget. + * + *

          + * Budget: the snippet is clipped to at most two rendered lines + * (total budget 2; the first line is measured against the full + * {@code lineWidth}, the second line (where the ellipsis lands) against + * {@code lineWidth - ellipsisWidth} so the ellipsis itself fits inside the + * second line without spilling a third). Width is measured with + * {@link GuideText#measureWidth}, the same Rust metric the headless render + * uses, against {@link DefaultStyles#BASE_STYLE} (fontScale 1, non-bold, + * matching the snippet paragraph's inherited body style). The character + * budget passed to + * {@link GuideSearchSnippetFormatter#clipToVisibleCharsWithEllipsis} is the + * codepoint sum of the kept characters (line 1 + newline + line 2 prefix), + * mirroring the real path's budget structure; the formatter reserves the + * ellipsis inside that budget. + * + *

          + * Fallbacks mirror the real path: a non-positive {@code lineWidth} + * degrades to {@code clipToVisibleChars(snippet, 0)}; an empty snippet is + * passed through untouched; a snippet that fits entirely within the + * two-line budget is passed through un-clipped (no ellipsis appended). + * When {@link GuideText#isAvailable()} is false no measurement is possible; + * the snippet must NOT silently pass through as if it fit, so a WARN is + * logged and the snippet is clipped to empty rather than allowed to + * overflow. + */ + private static LytFlowContent clipSnippetForWidth(LytFlowContent snippet, int lineWidth) { + if (lineWidth <= 0) { + return GuideSearchSnippetFormatter.clipToVisibleChars(snippet, 0); + } + + String plainText = GuideSearchSnippetFormatter.toPlainText(snippet); + if (plainText.isEmpty()) { + return snippet; + } + + if (!GuideText.isAvailable()) { + GuideDebugLog.warnAlways( + "clipSnippetForWidth: GuideText metrics unavailable, clipping snippet to empty rather than overflowing"); + return GuideSearchSnippetFormatter.clipToVisibleChars(snippet, 0); + } + + int ellipsisWidth = GuideText.measureWidth(GuideSearchSnippetFormatter.ELLIPSIS, DefaultStyles.BASE_STYLE); + int lineWithEllipsisWidth = Math.max(0, lineWidth - ellipsisWidth); + + // Hard breaks consume line slots: split first, measure each line + // independently against the same Rust metric the renderer uses. + String[] lines = plainText.split("\n", -1); + + String firstClipped = GuideText + .clipToWidth(lines[0], lineWidth, DefaultStyles.BASE_STYLE, GuideText.ClipSuffix.NONE); + if (lines.length == 1) { + // Single line: passes the whole first line → it truly fits. + if (firstClipped.length() >= lines[0].length()) { + return snippet; + } + // Single overflowing line: clip to one line + ellipsis (the + // ellipsis width is deducted from the line budget). The budget is + // the kept codepoint count; the formatter reserves its own + // 3-char ellipsis inside it, so the result is strictly narrower + // than the measured prefix and the snippet can never overflow. + String headClipped = GuideText + .clipToWidth(lines[0], lineWithEllipsisWidth, DefaultStyles.BASE_STYLE, GuideText.ClipSuffix.NONE); + return GuideSearchSnippetFormatter + .clipToVisibleCharsWithEllipsis(snippet, headClipped.codePointCount(0, headClipped.length())); + } + + // Multi-line snippet: the first line overflowing already exhausts the + // line-1 slot: keep only the first line's prefix plus the ellipsis. + if (firstClipped.length() < lines[0].length()) { + String headClipped = GuideText + .clipToWidth(lines[0], lineWithEllipsisWidth, DefaultStyles.BASE_STYLE, GuideText.ClipSuffix.NONE); + return GuideSearchSnippetFormatter + .clipToVisibleCharsWithEllipsis(snippet, headClipped.codePointCount(0, headClipped.length())); + } + + // Line 1 fits whole; line 2 (ellipsis target) is measured with the + // ellipsis width deducted so "line-2 prefix + ..." stays inside the + // column and never wraps to a third line. + String secondClipped = GuideText + .clipToWidth(lines[1], lineWithEllipsisWidth, DefaultStyles.BASE_STYLE, GuideText.ClipSuffix.NONE); + boolean secondFitsWhole = secondClipped.length() >= lines[1].length(); + if (lines.length == 2 && secondFitsWhole) { + return snippet; + } + // More than two lines, or line 2 overflowing: clip to two lines. The + // budget keeps line 1 + the hard break + line 2's prefix (codepoints); + // the formatter subtracts its 3-char ellipsis and appends "...". + int budget = lines[0].codePointCount(0, lines[0].length()) + 1 + + secondClipped.codePointCount(0, secondClipped.length()); + return GuideSearchSnippetFormatter.clipToVisibleCharsWithEllipsis(snippet, budget); + } + + /** + * Build a descriptive "page not found" message including the list of available page keys. + */ + private static String buildPageNotFoundMessage(MutableGuide guide, String pageId) { + try { + var pages = guide.getPages(); + String keyList = pages.stream() + .limit(30) + .map( + p -> p.getId() + .toString()) + .collect(Collectors.joining(", ", "[", "]")); + return "Page not found: " + pageId + ". Available pages (" + pages.size() + " total): " + keyList; + } catch (IllegalStateException e) { + // pages collection is not loaded yet + return "Page not found: " + pageId + " (pages not loaded yet)"; + } + } + + // Chrome pass helpers for the nav bar overlay. + + /** + * Nav bar open width for the headless chrome pass, mirroring + * {@code GuideScreen.resolveNavigationOpenWidth} under the full-width + * assumption (panelX = 0, panelW = page width): 18 % of the page width, + * floored at {@link GuideNavBar#MIN_DYNAMIC_OPEN_WIDTH} and capped by the + * panel minus padding. For the default 900 px page width this yields + * {@code max(110, 162) = 162} logical px. + */ + private static int navBarWidth(int pageWidth) { + int requested = Math + .max(GuideNavBar.MIN_DYNAMIC_OPEN_WIDTH, pageWidth * GuideNavBar.OPEN_WIDTH_SCREEN_PERCENT / 100); + int maxWidth = Math.max(GuideNavBar.WIDTH_CLOSED, pageWidth - 16 - 40); + return Math.min(requested, maxWidth); + } + + /** + * Build a fresh GuideNavBar for the current guide, drive it to the same + * state the live screen would reach (open/pinned, current page's ancestors + * expanded) and collect its render primitives via + * {@link GuideNavBar#collectPrimitives}. The nav bar spans the full + * document height at x = 0; the returned context backs the second offscreen + * pass. Headless-only; never touches the live GuideScreen's nav bar. + */ + private static VanillaRenderContext collectNavBarPrimitives(RenderPageRequest req, MutableGuide guide, + GuidePage compiledPage, int contentHeight, List target) { + int navW = navBarWidth(req.width()); + GuideNavBar navBar = new GuideNavBar(); + navBar.setBounds(0, 0, contentHeight); + navBar.setOpenWidth(navW); + GuideBookmarkState bookmarkState = GuideBookmarkState.getSharedInstance(); + NavigationTree tree = guide.getNavigationTree(); + navBar.activateGuide( + guide.getId(), + GuideNavBarState.defaultState(), + tree, + bookmarkState, + compiledPage.id(), + Collections.emptySet()); + navBar.setPinned(true); + navBar.update(-1, -1, tree, bookmarkState); + // Headless scroll injection: -Dguidenh.renderpage.navscroll= renders + // this frame at the given scroll offset so the chrome pass can reproduce + // and regression-test the sticky/scroll overlap. Default 0 (absent) + // keeps the existing behaviour byte-identical. + String navScrollProp = System.getProperty("guidenh.renderpage.navscroll"); + if (navScrollProp != null && !navScrollProp.isEmpty()) { + try { + int navScroll = Integer.parseInt(navScrollProp); + navBar.setScrollY(navScroll); + GuideDebugLog.infoAlways( + "RenderPageService: nav bar scroll injected: {} px (guidenh.renderpage.navscroll)", + navScroll); + } catch (NumberFormatException e) { + GuideDebugLog + .warnAlways("RenderPageService: ignoring invalid -Dguidenh.renderpage.navscroll={}", navScrollProp); + } + } + VanillaRenderContext navCtx = new VanillaRenderContext(new LytRect(0, 0, navW, contentHeight), contentHeight); + var navCollector = new PrimitiveCollector(new LytRect(0, 0, navW, contentHeight), navCtx); + navBar.collectPrimitives(guide.getId(), compiledPage.id(), guide, bookmarkState, false, navCollector); + target.addAll(navCollector.result()); + return navCtx; + } + + /** + * Headless mermaid canvas injection, mirroring the navscroll injection + * pattern above. Reads {@code -Dguidenh.renderpage.mermaidzoom} (double, + * 0 = no zoom injection) and {@code -Dguidenh.renderpage.mermaidoffset} + * ({@code "x,y"}, {@code "0,0"} = no offset injection) and applies them to + * every {@link LytMermaidCanvas} instance in the document before primitive + * collection, so the {@code HEADLESS} render branch can be verified for the + * zoom / drag paths without a live client. Absent or zero values are a + * strict no-op: the canvases keep their historical fit-to-view + centre + * behaviour byte-identical. + */ + private static void applyMermaidInjection(LytDocument document) { + String zoomProp = System.getProperty("guidenh.renderpage.mermaidzoom"); + String offsetProp = System.getProperty("guidenh.renderpage.mermaidoffset"); + boolean zoomAbsent = zoomProp == null || zoomProp.isEmpty(); + boolean offsetAbsent = offsetProp == null || offsetProp.isEmpty(); + if (zoomAbsent && offsetAbsent) { + return; + } + float zoom = 0f; + if (!zoomAbsent) { + try { + zoom = (float) Double.parseDouble(zoomProp); + } catch (NumberFormatException e) { + GuideDebugLog + .warnAlways("RenderPageService: ignoring invalid -Dguidenh.renderpage.mermaidzoom={}", zoomProp); + return; + } + } + int offsetX = 0; + int offsetY = 0; + if (!offsetAbsent) { + String[] parts = offsetProp.split(",", -1); + if (parts.length != 2) { + GuideDebugLog.warnAlways( + "RenderPageService: ignoring invalid -Dguidenh.renderpage.mermaidoffset={} (expected x,y)", + offsetProp); + return; + } + try { + offsetX = Integer.parseInt(parts[0].trim()); + offsetY = Integer.parseInt(parts[1].trim()); + } catch (NumberFormatException e) { + GuideDebugLog.warnAlways( + "RenderPageService: ignoring invalid -Dguidenh.renderpage.mermaidoffset={}", + offsetProp); + return; + } + } + if (zoom <= 0f && offsetX == 0 && offsetY == 0) { + return; + } + GuideDebugLog + .infoAlways("RenderPageService: mermaid canvas injection zoom={} offset=({},{})", zoom, offsetX, offsetY); + applyMermaidInjectionRecursive(document, zoom, offsetX, offsetY); + } + + private static void applyMermaidInjectionRecursive(LytNode node, float zoom, int offsetX, int offsetY) { + if (node instanceof LytMermaidCanvascanvas) { + canvas.setHeadlessInjection(zoom, offsetX, offsetY); + } + for (var child : node.getChildren()) { + applyMermaidInjectionRecursive(child, zoom, offsetX, offsetY); + } + } + + // Toolbar page-title injection (equivalent of the live drawPageTitle pass). + + /** + * Headless toolbar page-title injection, mirroring the navscroll / mermaid + * injection pattern above. Reads {@code -Dguidenh.renderpage.title=true} + * (absent or any other value = strict no-op: the collected primitive list + * stays byte-identical) and, when enabled, overlays the toolbar page-title + * glyph run onto the document render via the same layout the live screen + * uses in {@code GuideScreen.drawPageTitle}: ordinary toolbar title placed + * from the toolbar band's left edge (panelX = 0, panelY = 0, panelW = page + * width, narrow-reading inset 0). This gives the toolbar title a headless + * verification channel: previously the title band could only be judged by + * live eyesight. + */ + private static boolean isPageTitleInjectionEnabled() { + return Boolean.parseBoolean(System.getProperty("guidenh.renderpage.title")); + } + + /** + * Build the toolbar title paragraph the same way the live screen does + * ({@code GuideScreen.refreshCurrentPageTitle}'s document-title branch): + * the page's extracted H1 heading flow content when present, otherwise the + * navigation-tree node title, otherwise the page id. The paragraph is + * styled with {@link GuideScreen#TOOLBAR_TITLE_STYLE}. + */ + private static LytParagraph buildToolbarPageTitle(MutableGuide guide, GuidePage page) { + LytParagraph title = new LytParagraph(); + title.setStyle(GuideScreen.TOOLBAR_TITLE_STYLE); + LytHeading extracted = page.titleHeading(); + if (extracted != null) { + for (LytFlowContent flowContent : extracted.getContent()) { + title.append(flowContent); + } + } else { + String resolvedTitle = null; + try { + var node = guide.getNavigationTree() + .getNodeById(page.id()); + if (node != null) { + resolvedTitle = node.title(); + } + } catch (Throwable ignored) {} + if (resolvedTitle == null || resolvedTitle.isEmpty()) { + resolvedTitle = page.id() + .toString(); + } + title.appendText(resolvedTitle); + } + return title; + } + + /** + * Collect the toolbar title paragraph as render primitives positioned at + * its live-screen slot. Mirrors {@code GuideScreen.drawPageTitle}: same + * ordinary-toolbar titleX (toolbar band left edge + padding) / titleY + * formulas, same available-width reserve for the toolbar icon row, same + * title-screen viewport for culling. The primitives are emitted under + * {@code pushTransform(titleX, titleY, 1.0f)} so the glyphs land at the + * toolbar-band position in the final output. + * + * @return collected primitives; empty when the page carries no title text + */ + private static List collectToolbarTitlePrimitives(RenderPageRequest req, MutableGuide guide, + GuidePage page) { + LytParagraph titlePara = buildToolbarPageTitle(guide, page); + if (titlePara.isEmpty()) { + return List.of(); + } + + int panelX = 0; + int panelY = 0; + int panelW = req.width(); + // Ordinary toolbar title (mirrors GuideScreen.drawPageTitle after the + // toolbar-title semantic change): placed from the toolbar band's left + // edge, no navbar/content-column avoidance; the reserved right-side + // icon area is kept. + int reservedRight = (16 + GuideScreen.TOOLBAR_GAP) * 5 + GuideScreen.PANEL_PADDING + 4; + int availableW = Math.max(20, panelW - GuideScreen.PANEL_PADDING - reservedRight); + int titleX = panelX + GuideScreen.PANEL_PADDING; + + // Single-pass layout at (0, 0): position is applied via the GL + // translate (pushTransform), matching GuideScreen.drawPageTitle. + var layoutCtx = new LayoutContext(new RustFontMetrics()); + titlePara.layout(layoutCtx, 0, 0, availableW); + int titleH = titlePara.getBounds() + .height(); + int titleY = Math.max(0, (GuideScreen.TOOLBAR_H - titleH) / 2) + panelY + 2; + + LytRect titleScreenVp = new LytRect(titleX, titleY, availableW, Math.max(titleH, GuideScreen.TOOLBAR_H)); + var titleCtx = new VanillaRenderContext(titleScreenVp, titleY + titleScreenVp.height()); + var pc = new PrimitiveCollector(titleScreenVp, titleCtx); + pc.pushTransform(titleX, titleY, 1.0f); + pc.collectFrom(titlePara); + pc.popTransform(); + GuideDebugLog.infoAlways( + "RenderPageService: toolbar page-title injected: '{}' at ({},{}) h={} availableW={} " + + "(guidenh.renderpage.title)", + titlePara.getTextContent(), + titleX, + titleY, + titleH, + availableW); + return pc.result(); + } + + /** + * Parse {@code -Dguidenh.renderpage.guiscale} into an int injection value, + * mirroring the navscroll injection pattern. Three-state semantics: + *

            + *
          • absent or empty property → default 4 (the headless render baseline + * is guiscale=4, matching the live client's render_scale=4 capacity + * surface);
          • + *
          • explicit 0 → no injection (layout stays byte-identical to the + * scaleFactor=1 path);
          • + *
          • explicit 1-4 → that value, injected verbatim.
          • + *
          + * Invalid (non-numeric or out-of-range) values are reported once with a + * WARN and disable the injection (return 0). + */ + private static int parseGuiscaleInjection() { + String prop = System.getProperty("guidenh.renderpage.guiscale"); + if (prop == null || prop.isEmpty()) { + // Absent property = unset → baseline default 4 (headless render + // baseline is guiscale=4; see the injection comment at the call + // site). + return 4; + } + try { + int v = Integer.parseInt(prop); + if (v >= 1 && v <= 4) { + return v; + } + if (v == 0) { + // Explicit 0 = disable injection (layout stays byte-identical). + return 0; + } + GuideDebugLog.warnAlways( + "RenderPageService: ignoring invalid -Dguidenh.renderpage.guiscale={} (expected 1-4; 0 = disable)", + prop); + } catch (NumberFormatException e) { + GuideDebugLog.warnAlways("RenderPageService: ignoring invalid -Dguidenh.renderpage.guiscale={}", prop); + } + return 0; + } + + /** + * Composite the two offscreen passes side by side: nav image at x = 0, + * document image shifted right by {@code navWidthPx} (scale-scaled nav + * width). The background fills the remaining band gap if the nav image is + * shorter than the document image. + */ + private static BufferedImage composeChrome(BufferedImage docImage, BufferedImage navImage, int navWidthPx, + int backgroundRgb) { + int w = docImage.getWidth() + navWidthPx; + int h = Math.max(docImage.getHeight(), navImage.getHeight()); + BufferedImage out = new BufferedImage(w, h, BufferedImage.TYPE_INT_RGB); + Graphics2D g = out.createGraphics(); + try { + g.setColor(new Color(backgroundRgb)); + g.fillRect(0, 0, w, h); + g.drawImage(navImage, 0, 0, null); + g.drawImage(docImage, navWidthPx, 0, null); + } finally { + g.dispose(); + } + return out; + } + + // File naming. + + private static String buildBaseName(RenderPageRequest req) { + String name; + if (req.route() != null && ROUTE_HOME_SEARCH.equals(req.route())) { + // Route renders have no page id / md file: derive the stem from the route + // name plus a file-name-safe form of the query (e.g. search_expected_...). + name = "search_" + sanitizeFileName(req.query()); + } else if (req.route() != null && ROUTE_SETTINGS.equals(req.route())) { + // Settings route has no query: the stem is just the route name + // (settings_.png / .json), mirroring the search-route naming pattern. + name = "settings"; + } else if (req.pageId() != null && !req.pageId() + .isEmpty()) { + // Use the path segment after the colon (namespace:path) + String pageId = req.pageId(); + int colon = pageId.indexOf(':'); + if (colon >= 0) { + name = pageId.substring(colon + 1); + } else { + name = pageId; + } + // Replace path separators with underscores + name = name.replace('/', '_') + .replace(':', '_'); + } else { + name = req.mdFile() + .getFileName() + .toString(); + if (name.endsWith(".md")) { + name = name.substring(0, name.length() - 3); + } + } + return name + "_" + + LocalDateTime.now() + .format(FILE_NAME_FORMAT); + } + + /** + * File-name-safe form of the search query: trim and replace every character + * outside {@code [a-zA-Z0-9._-]} with {@code _}. Falls back to {@code empty} + * so a blank query still yields a usable file stem. + */ + private static String sanitizeFileName(@Nullable String value) { + if (value == null || value.trim() + .isEmpty()) { + return "empty"; + } + return value.trim() + .replaceAll("[^a-zA-Z0-9._-]", "_"); + } + + /** + * Resolve a non-colliding file path. Appends {@code _2}, {@code _3} … when + * the candidate already exists, matching the pattern used by + * {@code SceneEditorScreenshotExportService.resolveTargetPath}. + */ + private static Path resolveTargetPath(Path dir, String baseName, String extension) throws IOException { + Path candidate = dir.resolve(baseName + "." + extension); + int collisionIndex = 2; + while (Files.exists(candidate)) { + candidate = dir.resolve(baseName + "_" + collisionIndex + "." + extension); + collisionIndex++; + } + return candidate; + } + + // Bounds JSON. + + private static void writeBoundsJson(LytDocument document, Path target) throws IOException { + var arr = new JsonArray(); + walkBlocksForJson(document, 0, arr); + String json = new GsonBuilder().setPrettyPrinting() + .create() + .toJson(arr); + Files.writeString(target, json, StandardCharsets.UTF_8); + } + + private static void walkBlocksForJson(LytNode node, int depth, JsonArray target) { + if (node instanceof LytBlock block && !LayoutTreeSerializer.shouldSkipInBoundsDump(node)) { + LytRect bounds = block.getBounds(); + if (bounds != null) { + var obj = new JsonObject(); + obj.addProperty("i", target.size()); + obj.addProperty( + "cls", + block.getClass() + .getSimpleName()); + obj.addProperty("x", bounds.x()); + obj.addProperty("y", bounds.y()); + obj.addProperty("w", bounds.width()); + obj.addProperty("h", bounds.height()); + obj.addProperty("depth", depth); + target.add(obj); + } + } + for (var child : node.getChildren()) { + walkBlocksForJson(child, depth + 1, target); + } + } + + // Debug overlay. + + private static void drawDebugOverlay(BufferedImage source, LytDocument document, Path target, int scale) + throws IOException { + int w = source.getWidth(); + int h = source.getHeight(); + BufferedImage overlay = new BufferedImage(w, h, BufferedImage.TYPE_INT_ARGB); + Graphics2D g = overlay.createGraphics(); + try { + g.setRenderingHint(RenderingHints.KEY_TEXT_ANTIALIASING, RenderingHints.VALUE_TEXT_ANTIALIAS_ON); + int[] counter = { 0 }; + drawOverlayBlocks(g, document, 0, counter, scale); + } finally { + g.dispose(); + } + + // Composite the overlay onto a copy of the source image + BufferedImage result = new BufferedImage(w, h, BufferedImage.TYPE_INT_RGB); + Graphics2D rg = result.createGraphics(); + try { + rg.drawImage(source, 0, 0, null); + rg.drawImage(overlay, 0, 0, null); + } finally { + rg.dispose(); + } + ImageIO.write(result, "png", target.toFile()); + } + + /** + * Recursively walk the block tree and draw semi-transparent fills, borders, + * and block-index labels. The colour cycles through six colours based on + * nesting depth. + * + * @param counter single-element array carrying the global block index + */ + private static void drawOverlayBlocks(Graphics2D g, LytNode node, int depth, int[] counter, int scale) { + if (node instanceof LytBlock block) { + LytRect bounds = block.getBounds(); + if (bounds != null && bounds.width() > 0 && bounds.height() > 0) { + int idx = counter[0]++; + int ci = depth % OVERLAY_FILL_COLORS.length; + int bx = bounds.x() * scale; + int by = bounds.y() * scale; + int bw = bounds.width() * scale; + int bh = bounds.height() * scale; + + // Semi-transparent fill + g.setColor(new Color(OVERLAY_FILL_COLORS[ci], true)); + g.fillRect(bx, by, bw, bh); + + // Solid border + g.setColor(new Color(OVERLAY_BORDER_COLORS[ci])); + g.drawRect(bx, by, bw, bh); + + // Block index label near the top-left corner + g.setColor(new Color(OVERLAY_BORDER_COLORS[ci])); + g.drawString(String.valueOf(idx), bx + 2 * scale, by + 12 * scale); + } + } + for (var child : node.getChildren()) { + drawOverlayBlocks(g, child, depth + 1, counter, scale); + } + } + + // Block counting. + + private static int countBlocks(LytNode node) { + int count = 0; + if (node instanceof LytBlock) { + count = 1; + } + for (var child : node.getChildren()) { + count += countBlocks(child); + } + return count; + } +} diff --git a/src/main/java/com/hfstudio/guidenh/guide/internal/host/LytHostWorkItem.java b/src/main/java/com/hfstudio/guidenh/guide/internal/host/LytHostWorkItem.java index 3f09982c..4a4a28b8 100644 --- a/src/main/java/com/hfstudio/guidenh/guide/internal/host/LytHostWorkItem.java +++ b/src/main/java/com/hfstudio/guidenh/guide/internal/host/LytHostWorkItem.java @@ -25,7 +25,7 @@ public boolean shouldRun() { @Override public WorkResult tick(long deadlineNs) { host.step(deadlineNs); - return WorkResult.YIELD; // never leave the queue — shouldRun guards when idle + return WorkResult.YIELD; // never leave the queue; shouldRun guards when idle } @Override diff --git a/src/main/java/com/hfstudio/guidenh/guide/internal/host/ScriptContextImpl.java b/src/main/java/com/hfstudio/guidenh/guide/internal/host/ScriptContextImpl.java index 1ddaf36c..99a540c9 100644 --- a/src/main/java/com/hfstudio/guidenh/guide/internal/host/ScriptContextImpl.java +++ b/src/main/java/com/hfstudio/guidenh/guide/internal/host/ScriptContextImpl.java @@ -59,7 +59,7 @@ public void replace(Object newNode) { // so the block can participate in inline flow layout. At MOUNT time the // dispatch passes the wrapper as "this.node", not the inner placeholder. // - // The wrapper IS the correct replacement target — swapping its inner block + // The wrapper IS the correct replacement target: swapping its inner block // via setBlock() preserves the flow-layout context (alignment, line-breaking, // float registration) that the compiler set up. // @@ -67,6 +67,7 @@ public void replace(Object newNode) { // discussion of why Flow and Block trees are separate and how this bridge works. // if (node instanceof LytFlowInlineBlock wrapper && newNode instanceof LytBlock newBlock) { + inheritUid(wrapper, newBlock); wrapper.setBlock(newBlock); recordResult(newBlock); return; diff --git a/src/main/java/com/hfstudio/guidenh/guide/internal/host/scripts/CsvTableScript.java b/src/main/java/com/hfstudio/guidenh/guide/internal/host/scripts/CsvTableScript.java index 02968de1..2738639f 100644 --- a/src/main/java/com/hfstudio/guidenh/guide/internal/host/scripts/CsvTableScript.java +++ b/src/main/java/com/hfstudio/guidenh/guide/internal/host/scripts/CsvTableScript.java @@ -8,8 +8,11 @@ import com.hfstudio.guidenh.guide.Guide; import com.hfstudio.guidenh.guide.PageCollection; import com.hfstudio.guidenh.guide.compiler.PageCompiler; +import com.hfstudio.guidenh.guide.compiler.tags.BlockTagCompiler; import com.hfstudio.guidenh.guide.compiler.tags.CsvTableCompiler; import com.hfstudio.guidenh.guide.compiler.tags.CsvTableCompiler.CsvTablePlaceholder; +import com.hfstudio.guidenh.guide.document.block.ContentAlign; +import com.hfstudio.guidenh.guide.document.block.ContentWrapMode; import com.hfstudio.guidenh.guide.document.block.LytBlock; import com.hfstudio.guidenh.guide.document.block.LytParagraph; import com.hfstudio.guidenh.guide.extensions.ExtensionCollection; @@ -74,7 +77,13 @@ public void onEvent(Object node, LytEvent event, ScriptContext ctx) { ""); LytBlock table = CsvTableCompiler.buildTable(runtimeCompiler, rows, ph.header, ph.widths); if (table != null) { - ctx.replace(table); + if (ph.wrap != null) { + ContentWrapMode wrapMode = ContentWrapMode.fromString(ph.wrap); + ContentAlign align = ContentAlign.fromString(ph.align); + ctx.replace(BlockTagCompiler.embedBlock(table, wrapMode, align)); + } else { + ctx.replace(table); + } } else { ctx.replace(LytParagraph.error("[CsvTable] Failed to parse CSV: " + ph.src)); } diff --git a/src/main/java/com/hfstudio/guidenh/guide/internal/host/scripts/FloatingImageScript.java b/src/main/java/com/hfstudio/guidenh/guide/internal/host/scripts/FloatingImageScript.java index 957fd22e..05ff9b61 100644 --- a/src/main/java/com/hfstudio/guidenh/guide/internal/host/scripts/FloatingImageScript.java +++ b/src/main/java/com/hfstudio/guidenh/guide/internal/host/scripts/FloatingImageScript.java @@ -83,11 +83,12 @@ public void onEvent(Object node, LytEvent event, ScriptContext ctx) { placeholder.getCropWidth(), placeholder.getCropHeight()); image.setScale(placeholder.getScaleX(), placeholder.getScaleY()); - image.setDisplaySize(placeholder.getDisplayWidth(), placeholder.getDisplayHeight()); image.setMarginTop(placeholder.getMarginTop()); image.setMarginLeft(placeholder.getMarginLeft()); image.setMarginRight(placeholder.getMarginRight()); image.setMarginBottom(placeholder.getMarginBottom()); + image.setExplicitWidth(placeholder.getExplicitWidth()); + image.setExplicitHeight(placeholder.getExplicitHeight()); for (ImageRegionAnnotation ann : placeholder.getAnnotations()) { image.addAnnotation(ann); } diff --git a/src/main/java/com/hfstudio/guidenh/guide/internal/host/scripts/ImageScript.java b/src/main/java/com/hfstudio/guidenh/guide/internal/host/scripts/ImageScript.java index ee85e9a4..858b434d 100644 --- a/src/main/java/com/hfstudio/guidenh/guide/internal/host/scripts/ImageScript.java +++ b/src/main/java/com/hfstudio/guidenh/guide/internal/host/scripts/ImageScript.java @@ -66,6 +66,9 @@ public void onEvent(Object node, LytEvent event, ScriptContext ctx) { image.addAnnotation(ann); } + // Block-level alignment (LytAlignedBlock) is handled at compile time + // by ImageCompiler.compileBlockContext. At script time, just replace the + // placeholder with the loaded image in the appropriate wrapper. if (isWrapped) { LytFlowInlineBlock newWrapper = new LytFlowInlineBlock(); newWrapper.setBlock(image); diff --git a/src/main/java/com/hfstudio/guidenh/guide/internal/host/scripts/ItemGridScript.java b/src/main/java/com/hfstudio/guidenh/guide/internal/host/scripts/ItemGridScript.java index 25a2a688..191b5fb6 100644 --- a/src/main/java/com/hfstudio/guidenh/guide/internal/host/scripts/ItemGridScript.java +++ b/src/main/java/com/hfstudio/guidenh/guide/internal/host/scripts/ItemGridScript.java @@ -2,6 +2,9 @@ import net.minecraft.item.ItemStack; +import org.jetbrains.annotations.Nullable; + +import com.hfstudio.guidenh.guide.compiler.tags.ItemGridCompiler.ItemGridEntry; import com.hfstudio.guidenh.guide.compiler.tags.ItemGridCompiler.ItemGridPlaceholder; import com.hfstudio.guidenh.guide.document.block.LytItemGrid; import com.hfstudio.guidenh.guide.document.block.LytParagraph; @@ -29,8 +32,8 @@ public void onEvent(Object node, LytEvent event, ScriptContext ctx) { if (event.type() == EventType.MOUNT && node instanceof ItemGridPlaceholder ph) { LytItemGrid grid = new LytItemGrid(); int resolved = 0; - for (String itemId : ph.itemIds) { - ItemStack stack = resolveItemId(itemId.trim()); + for (ItemGridEntry entry : ph.entries) { + ItemStack stack = resolveEntry(entry); if (stack != null) { grid.addItem(stack); resolved++; @@ -44,7 +47,20 @@ public void onEvent(Object node, LytEvent event, ScriptContext ctx) { } } - private static ItemStack resolveItemId(String itemId) { - return GuideDisplayItemStacks.resolveItemStack(itemId, "minecraft"); + @Nullable + private static ItemStack resolveEntry(ItemGridEntry entry) { + // Prefer the direct item id; fall back to the ore dictionary name. + if (entry.id() != null && !entry.id() + .isEmpty()) { + ItemStack stack = GuideDisplayItemStacks.resolveItemStack(entry.id(), "minecraft"); + if (stack != null) { + return stack; + } + } + if (entry.ore() != null && !entry.ore() + .isEmpty()) { + return GuideDisplayItemStacks.resolveOreStack(entry.ore()); + } + return null; } } diff --git a/src/main/java/com/hfstudio/guidenh/guide/internal/host/scripts/KeyBindScript.java b/src/main/java/com/hfstudio/guidenh/guide/internal/host/scripts/KeyBindScript.java index 934e8011..d6c6f4cf 100644 --- a/src/main/java/com/hfstudio/guidenh/guide/internal/host/scripts/KeyBindScript.java +++ b/src/main/java/com/hfstudio/guidenh/guide/internal/host/scripts/KeyBindScript.java @@ -1,5 +1,6 @@ package com.hfstudio.guidenh.guide.internal.host.scripts; +import com.hfstudio.guidenh.guide.color.ConstantColor; import com.hfstudio.guidenh.guide.compiler.tags.KeyBindTagCompiler; import com.hfstudio.guidenh.guide.document.flow.LytFlowText; import com.hfstudio.guidenh.guide.internal.host.EventType; @@ -29,7 +30,11 @@ public void onEvent(Object node, LytEvent event, ScriptContext ctx) { var mapping = KeyBindTagCompiler.findMapping(bindId); String display = mapping != null ? KeyBindTagCompiler.describeMapping(mapping) : "[" + bindId + "]"; placeholder.setText(display); - placeholder.setStyle(TextStyle.EMPTY); + placeholder.setStyle( + TextStyle.builder() + .bold(true) + .color(new ConstantColor(0xFFE8EDF5)) + .build()); ctx.replace(placeholder); } } diff --git a/src/main/java/com/hfstudio/guidenh/guide/internal/host/scripts/MermaidScript.java b/src/main/java/com/hfstudio/guidenh/guide/internal/host/scripts/MermaidScript.java index 7640edac..e60f8f91 100644 --- a/src/main/java/com/hfstudio/guidenh/guide/internal/host/scripts/MermaidScript.java +++ b/src/main/java/com/hfstudio/guidenh/guide/internal/host/scripts/MermaidScript.java @@ -17,6 +17,7 @@ import com.hfstudio.guidenh.guide.internal.host.ScriptContext; import com.hfstudio.guidenh.guide.internal.host.ScriptType; import com.hfstudio.guidenh.guide.internal.mermaid.MermaidDiagramType; +import com.hfstudio.guidenh.guide.internal.mermaid.MermaidLayoutPrecomputer; import com.hfstudio.guidenh.guide.internal.mermaid.MermaidSourceExtractor; import com.hfstudio.guidenh.guide.internal.mermaid.flowchart.FlowchartParser; import com.hfstudio.guidenh.guide.internal.mermaid.mindmap.MindmapParser; @@ -108,6 +109,10 @@ private void renderMindmap(ScriptContext ctx, MermaidPlaceholder ph, String sour } } } + // Pre-compute diagram layout before first Rust layout so the canvas + // gets a correct preferredHeight and the VBox receives its real height + // in the initial layout pass (no second pass needed). + precomputeMindmapLayout(block, ctx); ctx.replace(block); } catch (IllegalArgumentException e) { GuideDebugLog.error("[GuideNH] [MermaidScript] Failed to parse Mermaid source: {}", sourceText, e); @@ -133,6 +138,10 @@ private void renderFlowchart(ScriptContext ctx, String sourceText, MermaidPlaceh } } } + // Pre-compute diagram layout before first Rust layout so the canvas + // gets a correct preferredHeight and the VBox receives its real height + // in the initial layout pass (no second pass needed). + precomputeFlowchartLayout(block, ctx); ctx.replace(block); } catch (IllegalArgumentException e) { GuideDebugLog.error("[GuideNH] [MermaidScript] Failed to parse flowchart source: {}", sourceText, e); @@ -153,6 +162,44 @@ private void renderUnknown(ScriptContext ctx, String sourceText, MermaidPlacehol ctx.replace(codeBlock); } + /** + * Pre-compute the flowchart ELK layout using a GuideText-based fallback + * context, cache the result on the canvas, and set preferredHeight so + * Rust's first layout pass allocates the correct canvas height. + */ + private static void precomputeFlowchartLayout(LytMermaidFlowchart block, ScriptContext ctx) { + int pageWidth = ctx.document() + .getAvailableWidth(); + if (pageWidth <= 0) { + pageWidth = 480; // fallback: typical page content width + } + GuideDebugLog.debugAlways("[GuideNH-Mermaid] precomputeFlowchartLayout entered pageWidth={}", pageWidth); + MermaidLayoutPrecomputer.precomputeFlowchartLayout(block, pageWidth); + GuideDebugLog.debugAlways( + "[GuideNH-Mermaid] precomputeFlowchartLayout exit explicitHeight={}", + block.getCanvas() + .getExplicitHeight()); + } + + /** + * Pre-compute the mindmap layout using a GuideText-based fallback context, + * cache the result on the canvas, and set preferredHeight so Rust's first + * layout pass allocates the correct canvas height. + */ + private static void precomputeMindmapLayout(LytMermaidMindmap block, ScriptContext ctx) { + int pageWidth = ctx.document() + .getAvailableWidth(); + if (pageWidth <= 0) { + pageWidth = 480; // fallback: typical page content width + } + GuideDebugLog.debugAlways("[GuideNH-Mermaid] precomputeMindmapLayout entered pageWidth={}", pageWidth); + MermaidLayoutPrecomputer.precomputeMindmapLayout(block, pageWidth); + GuideDebugLog.debugAlways( + "[GuideNH-Mermaid] precomputeMindmapLayout exit explicitHeight={}", + block.getCanvas() + .getExplicitHeight()); + } + private void replaceWithError(ScriptContext ctx, String message) { ctx.replace(LytParagraph.error("[Mermaid] " + message)); } diff --git a/src/main/java/com/hfstudio/guidenh/guide/internal/host/scripts/RecipeScript.java b/src/main/java/com/hfstudio/guidenh/guide/internal/host/scripts/RecipeScript.java index b92a6a0f..80736c57 100644 --- a/src/main/java/com/hfstudio/guidenh/guide/internal/host/scripts/RecipeScript.java +++ b/src/main/java/com/hfstudio/guidenh/guide/internal/host/scripts/RecipeScript.java @@ -15,8 +15,8 @@ import com.hfstudio.guidenh.guide.compiler.tags.RecipeCompiler.HandlerMetadataReader; import com.hfstudio.guidenh.guide.compiler.tags.RecipeCompiler.HandlerRecipeAccess; import com.hfstudio.guidenh.guide.compiler.tags.RecipeCompiler.RecipePlaceholder; -import com.hfstudio.guidenh.guide.document.block.LytBalancedColumns; import com.hfstudio.guidenh.guide.document.block.LytBlock; +import com.hfstudio.guidenh.guide.document.block.LytHBox; import com.hfstudio.guidenh.guide.document.block.LytParagraph; import com.hfstudio.guidenh.guide.document.block.recipes.LytStandardRecipeBox; import com.hfstudio.guidenh.guide.document.flow.LytFlowInlineBlock; @@ -121,6 +121,7 @@ public List readIngredientSlots(Object h, int ri) { return NeiRecipeLookup.readResultSlot(h, ri); } }; + boolean handlerFilterEliminatedAll = false; List handlers = RecipeCompiler.filterHandlers( rawHandlers, ph.handlerName, @@ -153,6 +154,9 @@ public List readIngredientSlots(Object h, int ri) { return; } } else if (hasHandlerFilter) { + // The handler filter matched no candidate at all, so no recipe content is rendered + // further down and the placeholder text is shown instead. + handlerFilterEliminatedAll = true; if (ph.fallbackText != null && !ph.fallbackText.isEmpty()) { String handlerPart = ""; if (ph.handlerName != null || ph.handlerId != null) { @@ -162,54 +166,72 @@ public List readIngredientSlots(Object h, int ri) { } else { GuideDebugLog.debug("Recipe handler filter eliminated all candidates for {}", ph.idStr); } - return; } - // Integration recipe entries - List recipeEntries = usageQuery ? Collections.emptyList() - : GuideNhIntegrationRegistry.global() - .findCraftingRecipeEntries(targetStack); - if (!recipeEntries.isEmpty()) { - List boxes = new ArrayList<>(); - int entryStart = Math.max(ph.recipeIndex, 0); - int entryEnd = ph.recipeIndex >= 0 ? Math.min(recipeEntries.size(), ph.recipeIndex + 1) - : recipeEntries.size(); - for (int i = entryStart; i < entryEnd && boxes.size() < limit; i++) { - var e = recipeEntries.get(i); - if (e.result() == null || e.ingredients() - .isEmpty()) continue; - if (hasRecipeFilter && !RecipeCompiler.entryMatches(e, ph.inputExpr, ph.outputExpr)) continue; - List flat = new ArrayList<>(9); - for (int s = 0; s < 9; s++) flat.add(null); - int idx = 0; - for (RecipeSlot slot : e.ingredients()) { - if (idx >= 9) break; - if (slot.stacks() != null && !slot.stacks() - .isEmpty()) flat.set( - idx, - slot.stacks() - .getFirst()); - idx++; - } - ItemStack resultStack = e.result() - .stacks() != null - && !e.result() - .stacks() - .isEmpty() ? e.result() + // Integration recipe entries, skipped when the handler filter eliminated all candidates + // (user explicitly filtered to a non-existent handler, so no recipe content should render) + if (!handlerFilterEliminatedAll) { + List recipeEntries = usageQuery ? Collections.emptyList() + : GuideNhIntegrationRegistry.global() + .findCraftingRecipeEntries(targetStack); + if (!recipeEntries.isEmpty()) { + List boxes = new ArrayList<>(); + int entryStart = Math.max(ph.recipeIndex, 0); + int entryEnd = ph.recipeIndex >= 0 ? Math.min(recipeEntries.size(), ph.recipeIndex + 1) + : recipeEntries.size(); + for (int i = entryStart; i < entryEnd && boxes.size() < limit; i++) { + var e = recipeEntries.get(i); + if (e.result() == null || e.ingredients() + .isEmpty()) continue; + if (hasRecipeFilter && !RecipeCompiler.entryMatches(e, ph.inputExpr, ph.outputExpr)) continue; + List flat = new ArrayList<>(9); + for (int s = 0; s < 9; s++) flat.add(null); + int idx = 0; + for (RecipeSlot slot : e.ingredients()) { + if (idx >= 9) break; + if (slot.stacks() != null && !slot.stacks() + .isEmpty()) flat.set( + idx, + slot.stacks() + .getFirst()); + idx++; + } + ItemStack resultStack = e.result() + .stacks() != null + && !e.result() .stacks() - .getFirst() : null; - if (resultStack != null) boxes.add(LytStandardRecipeBox.shapeless(flat, resultStack)); - } - if (!boxes.isEmpty()) { - ctx.replace(buildResult(boxes)); - return; + .isEmpty() ? e.result() + .stacks() + .getFirst() : null; + if (resultStack != null) boxes.add(LytStandardRecipeBox.shapeless(flat, resultStack)); + } + if (!boxes.isEmpty()) { + ctx.replace(buildResult(boxes)); + return; + } } } // Vanilla recipe fallback + String fallbackMsg; + if (handlerFilterEliminatedAll) { + String filterInfo = ""; + if (ph.handlerName != null || ph.handlerId != null) { + filterInfo = " with handler " + (ph.handlerName != null ? ph.handlerName : ph.handlerId); + } else if (ph.handlerOrder >= 0) { + filterInfo = " (handler order=" + ph.handlerOrder + ")"; + } + fallbackMsg = "No recipe found for " + ph.idStr + filterInfo; + // All handlers were eliminated by the filter, so show fallbackText directly and + // skip vanilla recipe fallback entirely. + showFallback(ctx, ph, fallbackMsg); + return; + } else { + fallbackMsg = "No recipe found for " + ph.idStr; + } List entries = usageQuery ? Collections.emptyList() : RecipeLookup.findByOutput(item); if (entries.isEmpty()) { - showFallback(ctx, ph, "No recipe found for " + ph.idStr); + showFallback(ctx, ph, fallbackMsg); return; } @@ -227,7 +249,7 @@ public List readIngredientSlots(Object h, int ri) { ctx.replace(buildResult(boxes)); return; } - showFallback(ctx, ph, "No recipe found for " + ph.idStr); + showFallback(ctx, ph, fallbackMsg); } @SuppressWarnings("unchecked") @@ -237,10 +259,12 @@ private static LytBlock buildResult(List boxes) { private static LytBlock buildResultTyped(List boxes) { if (boxes.size() == 1) return boxes.getFirst(); - var columns = new LytBalancedColumns(); - columns.setGap(RecipeCompiler.MULTI_GAP); - for (var b : boxes) columns.append(b); - return columns; + var row = new LytHBox(); + row.setGap(RecipeCompiler.MULTI_GAP); + // Full width so the Rust flex row wraps at the parent's content edge. + row.setFullWidth(true); + for (var b : boxes) row.append(b); + return row; } private void showFallback(ScriptContext ctx, RecipePlaceholder ph, String autoMessage) { diff --git a/src/main/java/com/hfstudio/guidenh/guide/internal/host/scripts/SceneScript.java b/src/main/java/com/hfstudio/guidenh/guide/internal/host/scripts/SceneScript.java index d8e87a49..a8bc79ed 100644 --- a/src/main/java/com/hfstudio/guidenh/guide/internal/host/scripts/SceneScript.java +++ b/src/main/java/com/hfstudio/guidenh/guide/internal/host/scripts/SceneScript.java @@ -139,7 +139,7 @@ public void doInit(ScenePlaceholder ph, ScriptContext ctx) { queueSnbtPreparse(ph, ctx.getPageCollection(), el, tickets); } } - // ImportStructureLib / BlockStats — handled in BUILD phase by their compilers + // ImportStructureLib / BlockStats are handled in BUILD phase by their compilers } ctx.data() @@ -174,7 +174,7 @@ public void doAwaitSnbt(ScenePlaceholder ph, ScriptContext ctx) { return; } - // All tickets complete — proceed to BUILD + // All tickets complete, so proceed to BUILD ctx.data() .put(KEY_STATE, STATE_BUILD); doBuild(ph, ctx); @@ -231,7 +231,7 @@ public void doBuild(ScenePlaceholder ph, ScriptContext ctx) { } } - // Setup element compilers — ALL of them, including ImportStructureLib + // Setup element compilers, ALL of them, including ImportStructureLib Map elementCompilers = new HashMap<>(); if (ph.sceneElementCompilers != null) { for (SceneElementTagCompiler compiler : ph.sceneElementCompilers) { @@ -294,7 +294,7 @@ public void doBuild(ScenePlaceholder ph, ScriptContext ctx) { dispatchSceneSubtrees(scene, ctx); - // Unified build — places both SNBT and StructureLib blocks + // Unified build that places both SNBT and StructureLib blocks scene.build(); // Set metadata on scene from binding results @@ -305,38 +305,56 @@ public void doBuild(ScenePlaceholder ph, ScriptContext ctx) { } } - boolean hasUnsupportedElements = skippedElements.stream() - .anyMatch(element -> !element.contains("(configuration only)")); - boolean hasSceneErrors = !errorSink.errors() - .isEmpty() || hasUnsupportedElements || ph.childrenParseError != null; - if (level.isEmpty()) { - List registeredTagNames = new ArrayList<>(elementCompilers.keySet()); - String diagnostic = describeEmptyScene( - ph.pageDomain + ":" + ph.pagePath, - ast.children() - .size(), - compiledElements, - skippedElements, - errorSink.errors(), - registeredTagNames); - if (nonSceneChildren[0] > 0) { - diagnostic += ", nonSceneChildren=" + nonSceneChildren[0]; + if (!scene.hasMountableSceneContent()) { + // Only a truly empty level produces the block/child diagnostic; scenes that carry + // sounds, particles, weather effects or annotations but no blocks are reported plainly. + String message = "[Scene] Scene has no supported elements"; + if (level.isEmpty()) { + List registeredTagNames = new ArrayList<>(elementCompilers.keySet()); + String diagnostic = describeEmptyScene( + ph.pageDomain + ":" + ph.pagePath, + ast.children() + .size(), + compiledElements, + skippedElements, + errorSink.errors(), + registeredTagNames); + if (nonSceneChildren[0] > 0) { + diagnostic += ", nonSceneChildren=" + nonSceneChildren[0]; + } + GuideDebugLog.warn("[GuideNH] [SceneScript] {}", diagnostic); + message = "[Scene] Scene has no supported elements. Enable GuideNH debug mode for parsed element details."; } - GuideDebugLog.warn("[GuideNH] [SceneScript] {}", diagnostic); - hasSceneErrors = true; + ctx.replace(LytParagraph.error(message)); + return; } + boolean hasUnsupportedElements = skippedElements.stream() + .anyMatch(element -> !element.contains("(configuration only)")); + finalizeSceneGeometry(ph, scene, level, camera); scene.applyDefaultBlockStatsMaxSizeFromScene(); scene.setInitialLevelSnapshot(GuideSceneStructureSnapshot.capture(level)); scene.clearLoadState(); - if (hasSceneErrors) { - String diagnostic = !errorSink.errors() - .isEmpty() ? String.join("; ", errorSink.errors()) - : hasUnsupportedElements ? String.join("; ", skippedElements) - : ph.childrenParseError != null ? ph.childrenParseError : "Scene has no supported elements"; - scene.setLoadFailure("[Scene] " + diagnostic); - } + // Surface build failures instead of silently mounting an empty level: a structure whose + // format was unsupported or that placed zero blocks must show a visible error, and a scene + // that still ends up empty after a build attempt gets the same feedback through the scene's + // build-error channel (LytGuidebookScene#setBuildError). + if (level.isEmpty()) { + String message = scene.getBuildError() != null ? scene.getBuildError() + : "Scene has no structure content (empty level)"; + scene.setBuildError(message); + } else if (!errorSink.errors() + .isEmpty() || hasUnsupportedElements || ph.childrenParseError != null) { + // A scene that mounts but compiled with element errors, skipped element tags or a child + // parse failure must not fail silently: report the collected diagnostics through the same + // build-error channel, which renders them as visible red text. + String diagnostic = !errorSink.errors() + .isEmpty() ? String.join("; ", errorSink.errors()) + : hasUnsupportedElements ? String.join("; ", skippedElements) + : ph.childrenParseError != null ? ph.childrenParseError : "Scene has no supported elements"; + scene.setBuildError("[Scene] " + diagnostic); + } attachSelectionListeners(scene); // Capture the auto-fit camera + orbit pivot AFTER geometry finalization but BEFORE the // ponder baseline (whose updatePonderState restores this pivot). Snapshotting first means @@ -447,13 +465,11 @@ public void applyCameraAndViewport(ScenePlaceholder ph, LytGuidebookScene scene, public void finalizeSceneGeometry(ScenePlaceholder ph, LytGuidebookScene scene, GuidebookLevel level, CameraSettings camera) { - float[] center; + // Rotation center for the explicit-center case is set in applyCameraAndViewport; + // only the auto-center case (level bounds centre) is finalized here. if (!ph.explicitCenter) { - center = level.getCenter(); + float[] center = level.getCenter(); camera.setRotationCenter(center[0], center[1], center[2]); - } else { - center = new float[] { Float.isNaN(ph.centerX) ? 0f : ph.centerX, Float.isNaN(ph.centerY) ? 0f : ph.centerY, - Float.isNaN(ph.centerZ) ? 0f : ph.centerZ }; } boolean explicitOffX = !Float.isNaN(ph.offsetX); @@ -478,8 +494,6 @@ public void finalizeSceneGeometry(ScenePlaceholder ph, LytGuidebookScene scene, camera.setZoom(autoZoom); } } - if (explicitOffX) camera.setOffsetX(ph.offsetX); - if (explicitOffY) camera.setOffsetY(ph.offsetY); } if (!ph.explicitWidth || !ph.explicitHeight) { @@ -503,12 +517,25 @@ public void finalizeSceneGeometry(ScenePlaceholder ph, LytGuidebookScene scene, camera.setOffsetY(savedOffY); } - if (!ph.explicitCenter && !explicitOffX && !explicitOffY) { + // Pan so the structure (level bounds centre) is inside the viewport. This runs even when an + // explicit rotation centre was requested: a rotation centre far from the structure (e.g. + // subnetworks centreY=-15) otherwise leaves the level entirely off-camera and the scene + // silently black. offsetX/offsetY are screen-space pan values (see SceneTagCompiler); the + // camera view matrix applies them in world units, so convert with the projection scale + // s = 0.625 * 16 * zoom = 10 * zoom. + float panScale = 10f * camera.getZoom(); + if (!explicitOffX && !explicitOffY) { camera.setOffsetX(0f); camera.setOffsetY(0f); - var screenCenter = camera.worldToScreen(center[0], center[1], center[2]); - camera.setOffsetX(-screenCenter.x); - camera.setOffsetY(screenCenter.y); + if (!level.isEmpty()) { + float[] levelCenter = level.getCenter(); + var screenCenter = camera.worldToScreen(levelCenter[0], levelCenter[1], levelCenter[2]); + camera.setOffsetX(-screenCenter.x / panScale); + camera.setOffsetY(screenCenter.y / panScale); + } + } else { + if (explicitOffX) camera.setOffsetX(ph.offsetX / panScale); + if (explicitOffY) camera.setOffsetY(ph.offsetY / panScale); } } diff --git a/src/main/java/com/hfstudio/guidenh/guide/internal/host/scripts/SoundLinkScript.java b/src/main/java/com/hfstudio/guidenh/guide/internal/host/scripts/SoundLinkScript.java index be2576a8..d5ef622e 100644 --- a/src/main/java/com/hfstudio/guidenh/guide/internal/host/scripts/SoundLinkScript.java +++ b/src/main/java/com/hfstudio/guidenh/guide/internal/host/scripts/SoundLinkScript.java @@ -1,5 +1,6 @@ package com.hfstudio.guidenh.guide.internal.host.scripts; +import com.hfstudio.guidenh.guide.color.ConstantColor; import com.hfstudio.guidenh.guide.document.flow.LytFlowLink; import com.hfstudio.guidenh.guide.internal.host.EventType; import com.hfstudio.guidenh.guide.internal.host.LytEvent; @@ -26,6 +27,9 @@ public void onEvent(Object node, LytEvent event, ScriptContext ctx) { GuideSoundSpec spec = (GuideSoundSpec) link.getData("soundSpec"); if (spec != null) { link.setClickSoundSpec(spec); + link.modifyStyle( + style -> style.color(new ConstantColor(0xFFFFAA00)) + .underlined(false)); ctx.replace(link); } } diff --git a/src/main/java/com/hfstudio/guidenh/guide/internal/localization/GuideLocalizedPageSourceResolver.java b/src/main/java/com/hfstudio/guidenh/guide/internal/localization/GuideLocalizedPageSourceResolver.java index 2d653fe8..a020346d 100644 --- a/src/main/java/com/hfstudio/guidenh/guide/internal/localization/GuideLocalizedPageSourceResolver.java +++ b/src/main/java/com/hfstudio/guidenh/guide/internal/localization/GuideLocalizedPageSourceResolver.java @@ -3,6 +3,7 @@ import java.nio.charset.StandardCharsets; import java.util.Locale; +import net.minecraft.client.resources.IResourcePack; import net.minecraft.util.ResourceLocation; import org.jetbrains.annotations.Nullable; @@ -38,11 +39,24 @@ public static ParsedGuidePage parse(String sourcePack, String language, String c */ public static ParsedGuidePage parseFrontmatterOnly(String sourcePack, String language, String contentRootFolder, ResourceLocation pageId, byte[] fileBytes) { + return parseFrontmatterOnly(sourcePack, null, language, contentRootFolder, pageId, fileBytes); + } + + /** + * Lightweight variant that also records the exact resource pack the page bytes were read from. The + * provenance is carried through to the parsed page so a compiled page can report the pack that + * actually supplied it instead of only its namespace. + * + * @param sourceResourcePack the pack the bytes came from, or {@code null} when the page did not + * originate from a resource pack selection. + */ + public static ParsedGuidePage parseFrontmatterOnly(String sourcePack, @Nullable IResourcePack sourceResourcePack, + String language, String contentRootFolder, ResourceLocation pageId, byte[] fileBytes) { long t0 = System.nanoTime(); - ResolvedGuidePageSource resolved = resolveFrontmatterOnly(language, contentRootFolder, pageId, fileBytes); - String resolvedSource = resolved.source(); + String resolvedSource = resolve(language, contentRootFolder, pageId, fileBytes, null).source(); long t1 = System.nanoTime(); - ParsedGuidePage result = PageCompiler.parseFrontmatterOnly(sourcePack, language, pageId, resolvedSource); + ParsedGuidePage result = PageCompiler + .parseFrontmatterOnly(sourcePack, sourceResourcePack, language, pageId, resolvedSource); long t2 = System.nanoTime(); long totalUs = (t2 - t0) / 1000; if (totalUs > 5_000) { @@ -56,42 +70,6 @@ public static ParsedGuidePage parseFrontmatterOnly(String sourcePack, String lan return result; } - /** Resolves only the frontmatter during reload, avoiding a full localized body merge. */ - public static ResolvedGuidePageSource resolveFrontmatterOnly(String language, String contentRootFolder, - ResourceLocation pageId, byte[] fileBytes) { - String fallbackSource = new String(fileBytes, StandardCharsets.UTF_8); - String langKey = buildLangKey(contentRootFolder, pageId); - String localizedSource = findLocalizedPageSource(langKey, language); - if (!hasText(localizedSource)) { - String normalizedFallback = PageCompiler.normalizeLineEndings(fallbackSource); - return new ResolvedGuidePageSource( - frontmatterOnly(normalizedFallback), - false, - null, - contentFingerprint(normalizedFallback, null)); - } - - String normalizedFallback = PageCompiler.normalizeLineEndings(fallbackSource); - String normalizedLocalized = PageCompiler.normalizeLineEndings(localizedSource); - String fallbackFrontmatter = PageCompiler.extractFrontmatterText(normalizedFallback); - String localizedFrontmatter = PageCompiler.extractFrontmatterText(normalizedLocalized); - String frontmatterSource; - if (localizedFrontmatter == null) { - frontmatterSource = frontmatterOnly(normalizedFallback); - } else if (fallbackFrontmatter == null) { - frontmatterSource = frontmatterOnly(normalizedLocalized); - } else { - frontmatterSource = GuideLocalizedFrontmatterMerger.merge( - GuideLocalizedFrontmatterMerger.SourceParts.withFrontmatterAndBody(fallbackFrontmatter, ""), - GuideLocalizedFrontmatterMerger.SourceParts.withFrontmatterAndBody(localizedFrontmatter, "")); - } - return new ResolvedGuidePageSource( - frontmatterSource, - true, - langKey, - contentFingerprint(normalizedFallback, normalizedLocalized)); - } - public static ParsedGuidePage parse(String sourcePack, String language, ResourceLocation pageId, ResolvedGuidePageSource resolvedSource) { return PageCompiler.parse(sourcePack, language, pageId, resolvedSource.source()); @@ -157,22 +135,6 @@ private static boolean hasText(@Nullable String text) { return text != null && !text.isEmpty(); } - private static String frontmatterOnly(String source) { - String frontmatter = PageCompiler.extractFrontmatterText(source); - return frontmatter != null ? "---\n" + frontmatter + "\n---\n" : ""; - } - - private static String contentFingerprint(String fallback, @Nullable String localized) { - int fallbackHash = fallback.hashCode(); - int localizedHash = localized != null ? localized.hashCode() : 0; - return Integer.toHexString(fallbackHash) + ':' - + fallback.length() - + ':' - + Integer.toHexString(localizedHash) - + ':' - + (localized != null ? localized.length() : 0); - } - private static String stripMarkdownExtension(String pagePath) { return pagePath.endsWith(".md") ? pagePath.substring(0, pagePath.length() - 3) : pagePath; } @@ -259,11 +221,5 @@ private static String decodeNewlines(String source) { return builder.toString(); } - public record ResolvedGuidePageSource(String source, boolean localized, @Nullable String langKey, - String contentFingerprint) { - - public ResolvedGuidePageSource(String source, boolean localized, @Nullable String langKey) { - this(source, localized, langKey, GuideLocalizedPageSourceResolver.contentFingerprint(source, null)); - } - } + public record ResolvedGuidePageSource(String source, boolean localized, @Nullable String langKey) {} } diff --git a/src/main/java/com/hfstudio/guidenh/guide/internal/markdown/CodeBlockLanguageDetector.java b/src/main/java/com/hfstudio/guidenh/guide/internal/markdown/CodeBlockLanguageDetector.java index 061112fd..76b1aaf0 100644 --- a/src/main/java/com/hfstudio/guidenh/guide/internal/markdown/CodeBlockLanguageDetector.java +++ b/src/main/java/com/hfstudio/guidenh/guide/internal/markdown/CodeBlockLanguageDetector.java @@ -18,6 +18,9 @@ public static CodeBlockLanguage detect(@Nullable String explicitFenceLanguage, S String text = codeText != null ? codeText : ""; String lower = text.toLowerCase(Locale.ROOT); + if (looksLikePython(lower)) { + return require("python"); + } if (looksLikeLua(lower)) { return require("lua"); } @@ -65,6 +68,34 @@ private static CodeBlockLanguage require(String fenceName) { return language != null ? language : PLAIN_TEXT; } + private static boolean looksLikePython(String lower) { + // def and elif are definitive Python indicators not found in Lua + if (lower.contains("def ") || lower.contains("elif ")) { + return true; + } + // from X import Y pattern + if (lower.contains("from ") && lower.contains(" import ")) { + return true; + } + // class definition (Python style with colon) + if (lower.contains("class ") && (lower.contains(":\n") || lower.endsWith(":"))) { + return true; + } + // Python exception handling keywords not in Lua + if (lower.contains("raise ") || lower.contains("except ")) { + return true; + } + // Python-only keywords + if (lower.contains("lambda ") || lower.contains("yield ")) { + return true; + } + // Python f-strings: print(f"...") + if (lower.contains("print(f\"") || lower.contains("print(f'")) { + return true; + } + return false; + } + private static boolean looksLikeLua(String lower) { return lower.contains("local ") || lower.contains("function ") && lower.contains(" end") || lower.contains("print(") diff --git a/src/main/java/com/hfstudio/guidenh/guide/internal/markdown/CodeBlockLanguageRegistry.java b/src/main/java/com/hfstudio/guidenh/guide/internal/markdown/CodeBlockLanguageRegistry.java index a801a1f0..253b8892 100644 --- a/src/main/java/com/hfstudio/guidenh/guide/internal/markdown/CodeBlockLanguageRegistry.java +++ b/src/main/java/com/hfstudio/guidenh/guide/internal/markdown/CodeBlockLanguageRegistry.java @@ -68,6 +68,7 @@ private static Map buildLanguageMap() { register(result, new CodeBlockLanguage("mermaid", "Mermaid")); register(result, new CodeBlockLanguage("javascript", "JavaScript")); register(result, new CodeBlockLanguage("typescript", "TypeScript")); + register(result, new CodeBlockLanguage("python", "Python")); return Map.copyOf(result); } @@ -90,6 +91,7 @@ private static Map buildNormalizedAliases() { registerAlias(result, "mermaid", "mermaid"); registerAlias(result, "javascript", "javascript", "js"); registerAlias(result, "typescript", "typescript", "ts"); + registerAlias(result, "python", "python", "py"); return Map.copyOf(result); } diff --git a/src/main/java/com/hfstudio/guidenh/guide/internal/markdown/FileTreeCompiler.java b/src/main/java/com/hfstudio/guidenh/guide/internal/markdown/FileTreeCompiler.java index 82ef58ea..88a9de52 100644 --- a/src/main/java/com/hfstudio/guidenh/guide/internal/markdown/FileTreeCompiler.java +++ b/src/main/java/com/hfstudio/guidenh/guide/internal/markdown/FileTreeCompiler.java @@ -41,6 +41,7 @@ public static LytFileTree compile(PageCompiler compiler, String source) { LytBlock iconBlock = entry.icon() != null ? buildIconBlock(compiler, entry.icon()) : null; tree.appendRow(entry.slots(), iconBlock, payload); } + tree.finalizeRowGaps(); return tree; } diff --git a/src/main/java/com/hfstudio/guidenh/guide/internal/markdown/FootnotePreprocessor.java b/src/main/java/com/hfstudio/guidenh/guide/internal/markdown/FootnotePreprocessor.java index 0e6577ad..bfbbc15e 100644 --- a/src/main/java/com/hfstudio/guidenh/guide/internal/markdown/FootnotePreprocessor.java +++ b/src/main/java/com/hfstudio/guidenh/guide/internal/markdown/FootnotePreprocessor.java @@ -60,7 +60,8 @@ public static String preprocess(String markdown) { } break; } - definitions.put(id, definition.toString()); + // Keep first definition; ignore subsequent definitions with the same id + definitions.putIfAbsent(id, definition.toString()); } String transformedBody = replaceReferences(body.toString(), definitions); @@ -76,7 +77,7 @@ public static String preprocess(String markdown) { if (!result.isEmpty()) { result.append('\n'); } - result.append("\n\n"); + result.append("\n\n"); result.append("## Footnotes\n\n"); int index = 1; for (var entry : definitions.entrySet()) { @@ -92,29 +93,48 @@ public static String preprocess(String markdown) { } private static String replaceReferences(String body, Map definitions) { - Matcher matcher = REFERENCE.matcher(body); - StringBuilder buffer = new StringBuilder(body.length()); + if (body.isEmpty()) { + return body; + } + + List lines = GuideStringLines.splitLines(body); + StringBuilder result = new StringBuilder(body.length()); int nextNumber = 1; Map numbers = new LinkedHashMap<>(); - while (matcher.find()) { - String id = matcher.group(1) - .trim(); - Integer number = numbers.get(id); - if (number == null) { - number = nextNumber++; - numbers.put(id, number); - } - if (!definitions.containsKey(id)) { - matcher.appendReplacement(buffer, Matcher.quoteReplacement(matcher.group(0))); + for (String line : lines) { + String trimmed = line.trim(); + // Skip Expected: and INVARIANTS lines; they are test/spec documentation, not page content + if (trimmed.startsWith("Expected:") || trimmed.startsWith("INVARIANTS")) { + appendLine(result, line); continue; } - String replacement = "" + definitions.get(id) + ""; - matcher.appendReplacement(buffer, Matcher.quoteReplacement(replacement)); + Matcher matcher = REFERENCE.matcher(line); + StringBuilder sb = new StringBuilder(line.length()); + while (matcher.find()) { + String id = matcher.group(1) + .trim(); + + // Undefined references: leave as-is, consume no number + if (!definitions.containsKey(id)) { + matcher.appendReplacement(sb, Matcher.quoteReplacement(matcher.group(0))); + continue; + } + + Integer number = numbers.get(id); + if (number == null) { + number = nextNumber++; + numbers.put(id, number); + } + + String replacement = "[" + number + "]"; + matcher.appendReplacement(sb, Matcher.quoteReplacement(replacement)); + } + matcher.appendTail(sb); + appendLine(result, sb.toString()); } - matcher.appendTail(buffer); - return buffer.toString(); + return result.toString(); } private static String trimDefinitionIndent(String line) { diff --git a/src/main/java/com/hfstudio/guidenh/guide/internal/markdown/MarkdownLatexShorthand.java b/src/main/java/com/hfstudio/guidenh/guide/internal/markdown/MarkdownLatexShorthand.java index bb9c2e4c..11a3ff67 100644 --- a/src/main/java/com/hfstudio/guidenh/guide/internal/markdown/MarkdownLatexShorthand.java +++ b/src/main/java/com/hfstudio/guidenh/guide/internal/markdown/MarkdownLatexShorthand.java @@ -18,8 +18,8 @@ import lombok.Getter; /** - * Utility for detecting and splitting {@code $$formula$$} shorthand LaTeX expressions - * inside Markdown text nodes. + * Utility for detecting and splitting {@code $$formula$$} / {@code $formula$} shorthand LaTeX + * expressions inside Markdown text nodes. * *

          * A {@code $$formula$$} shorthand always uses default rendering parameters (white colour, @@ -28,12 +28,27 @@ *

          * Display-mode detection: if a paragraph's only text content is exactly {@code $$formula$$} * (after trimming whitespace), the formula is rendered as a centred display block. Otherwise - * each {@code $$formula$$} fragment is rendered as an inline block inside the surrounding text. + * each {@code $$formula$$} or {@code $formula$} fragment is rendered as an inline block inside + * the surrounding text. + * + *

          + * Single-dollar inline formulas follow Pandoc-style rules: + * the opening {@code $} must be followed by a non-whitespace character, + * the closing {@code $} must be preceded by a non-whitespace character, + * and the closing {@code $} must not be followed by a digit (to avoid currency false positives). + * Use {@code \$} to escape a dollar sign that should be treated as literal text. */ public class MarkdownLatexShorthand { private static final String PLACEHOLDER_PREFIX = "\uE000GUIDENH_LATEX_"; private static final String PLACEHOLDER_SUFFIX = "_\uE001"; + private static final String ESCAPE_PLACEHOLDER_PREFIX = "\uE000GUIDENH_LATEXESC_"; + + /** + * Sentinel used to temporarily replace {@code $$} after double-dollar masking, + * preventing the single-dollar pattern from matching placeholder wraps. + */ + private static final String DOLLAR_SENTINEL = "\uE004\uE005"; /** * Matches {@code $$...$$} where the content contains no literal {@code $} characters. @@ -41,6 +56,32 @@ public class MarkdownLatexShorthand { */ private static final Pattern DOLLAR_PATTERN = Pattern.compile("\\$\\$([^$]+?)\\$\\$", Pattern.DOTALL); + /** + * Matches {@code \$} escape sequences: a backslash followed by a dollar. + */ + private static final Pattern ESCAPED_DOLLAR_PATTERN = Pattern.compile("\\\\[$]"); + + /** + * Single-dollar inline formula pattern (Pandoc rules): + *

            + *
          • Opening {@code $} must be followed by a non-whitespace, non-{@code $} character. + *
          • Closing {@code $} must be preceded by a non-whitespace, non-{@code $} character. + *
          • Closing {@code $} must not be followed by a digit (avoid currency false positives). + *
          • Content must not contain {@code $} or newlines. + *
          + */ + private static final String SINGLE_DOLLAR_REGEX = "\\$([^\\s$](?:[^$\\n]*[^\\s$])?)\\$(?!\\d)"; + private static final Pattern SINGLE_DOLLAR_PATTERN = Pattern.compile(SINGLE_DOLLAR_REGEX); + + /** + * Combined pattern for {@link #split}: {@code $$...$$} branch (priority), then single {@code $...$} branch. + *
            + *
          • Match present → use {@link #formulaFromMatch(Matcher)} to extract the formula content
          • + *
          + */ + private static final Pattern COMBINED_PATTERN = Pattern + .compile("(\\$\\$([^$]+?)\\$\\$)|(\\$([^\\s$](?:[^$\\n]*[^\\s$])?)\\$(?!\\d))", Pattern.DOTALL); + private MarkdownLatexShorthand() {} public static MaskResult mask(String source) { @@ -50,18 +91,64 @@ public static MaskResult mask(String source) { if (!mayContain(source)) { return new MaskResult(source, Map.of()); } - Matcher matcher = DOLLAR_PATTERN.matcher(source); - StringBuilder masked = new StringBuilder(source.length()); Map formulas = new HashMap<>(); int index = 0; - while (matcher.find()) { - String placeholder = PLACEHOLDER_PREFIX + index + PLACEHOLDER_SUFFIX; - formulas.put(placeholder, matcher.group(1)); - matcher.appendReplacement(masked, Matcher.quoteReplacement("$$" + placeholder + "$$")); - index++; - } - matcher.appendTail(masked); - return new MaskResult(masked.toString(), formulas); + + // Step (a): mask $$...$$ and leave the surrounding text unchanged + { + Matcher matcher = DOLLAR_PATTERN.matcher(source); + StringBuilder sb = new StringBuilder(source.length()); + while (matcher.find()) { + String placeholder = PLACEHOLDER_PREFIX + index + PLACEHOLDER_SUFFIX; + formulas.put(placeholder, matcher.group(1)); + matcher.appendReplacement(sb, Matcher.quoteReplacement("$$" + placeholder + "$$")); + index++; + } + matcher.appendTail(sb); + source = sb.toString(); + } + + // Temporarily protect remaining $$ (placeholder wraps and unmatched pairs) + // so the single-$ pattern in step (c) does not match them. + boolean hasProtected = source.contains("$$"); + if (hasProtected) { + source = source.replace("$$", DOLLAR_SENTINEL); + } + + // Step (b): mask \$ escapes → bare placeholder, restore yields literal $ + { + Matcher matcher = ESCAPED_DOLLAR_PATTERN.matcher(source); + StringBuilder sb = new StringBuilder(source.length()); + while (matcher.find()) { + String placeholder = ESCAPE_PLACEHOLDER_PREFIX + index + PLACEHOLDER_SUFFIX; + formulas.put(placeholder, "$"); + matcher.appendReplacement(sb, Matcher.quoteReplacement(placeholder)); + index++; + } + matcher.appendTail(sb); + source = sb.toString(); + } + + // Step (c): mask single $...$ ($$ are protected so they won't match) + { + Matcher matcher = SINGLE_DOLLAR_PATTERN.matcher(source); + StringBuilder sb = new StringBuilder(source.length()); + while (matcher.find()) { + String placeholder = PLACEHOLDER_PREFIX + index + PLACEHOLDER_SUFFIX; + formulas.put(placeholder, matcher.group(1)); + matcher.appendReplacement(sb, Matcher.quoteReplacement("$" + placeholder + "$")); + index++; + } + matcher.appendTail(sb); + source = sb.toString(); + } + + // Restore protected $$ + if (hasProtected) { + source = source.replace(DOLLAR_SENTINEL, "$$"); + } + + return new MaskResult(source, formulas); } public static void restore(MdAstNode root, MaskResult maskResult) { @@ -72,10 +159,10 @@ public static void restore(MdAstNode root, MaskResult maskResult) { } /** - * Quick pre-check: returns {@code false} if {@code text} cannot contain any {@code $$} pattern. + * Quick pre-check: returns {@code false} if {@code text} cannot contain any {@code $} pattern. */ public static boolean mayContain(String text) { - return text != null && text.contains("$$"); + return text != null && text.contains("$"); } /** @@ -100,6 +187,19 @@ public static String extractSoleDisplayFormula(String text) { return formula.isEmpty() ? null : formula; } + /** + * Extracts the formula content from a {@link #COMBINED_PATTERN} match. + *
            + *
          • Group 2: formula from {@code $$...$$} branch
          • + *
          • Group 4: formula from single {@code $...$} branch
          • + *
          + */ + private static String formulaFromMatch(Matcher m) { + String d = m.group(2); + if (d != null) return d; + return m.group(4); + } + /** * Splits {@code text} into alternating plain-text and LaTeX-formula {@link Segment}s. * Plain-text segments may be empty strings only when the text starts or ends with a formula. @@ -112,13 +212,13 @@ public static List split(String text) { return List.of(); } List result = new ArrayList<>(); - Matcher m = DOLLAR_PATTERN.matcher(text); + Matcher m = COMBINED_PATTERN.matcher(text); int last = 0; while (m.find()) { if (m.start() > last) { result.add(Segment.text(text.substring(last, m.start()))); } - result.add(Segment.formula(m.group(1))); + result.add(Segment.formula(formulaFromMatch(m))); last = m.end(); } if (last < text.length()) { diff --git a/src/main/java/com/hfstudio/guidenh/guide/internal/markdown/MarkdownListSemantics.java b/src/main/java/com/hfstudio/guidenh/guide/internal/markdown/MarkdownListSemantics.java index 7b11c36b..a38ae924 100644 --- a/src/main/java/com/hfstudio/guidenh/guide/internal/markdown/MarkdownListSemantics.java +++ b/src/main/java/com/hfstudio/guidenh/guide/internal/markdown/MarkdownListSemantics.java @@ -16,27 +16,132 @@ public class MarkdownListSemantics { private MarkdownListSemantics() {} + /** + * Detects whether the given {@code + * +
        • } children contain a GFM task-list + * marker ({@code [x]} / {@code [ ]}) in the first paragraph. + * + *

          + * This method is PURE DETECTION; it does NOT mutate the AST tree. + * The returned {@link TaskMarker} carries the prefix length so callers + * can strip the prefix for display without permanently altering the AST. + * This is critical because the same {@code ParsedGuidePage} (and its AST) + * may be compiled multiple times (e.g. by {@code CompileWorker} then by + * {@code RenderPageService}); mutation would cause the second compile to + * miss the marker. + * + * @param children the {@code

        • } element's children + * @return a {@link TaskMarker} describing the detected marker, or {@code null} + */ public static @Nullable TaskMarker extractTaskMarker(List children) { - if (children.size() != 1) { + // Find the first

          child (nested

        • may have [

          ,

            ]) + MdxJsxFlowElement p = null; + for (var child : children) { + if (child instanceof MdxJsxFlowElement el && "p".equals(el.name())) { + p = el; + break; + } + } + if (p == null) { return null; } - MdAstAnyContent firstChild = children.getFirst(); - // Post-conversion:

            element wrapping the task text - if (firstChild instanceof MdxJsxFlowElement p && "p".equals(p.name())) { - if (p.children() - .isEmpty()) { - return null; + + var pChildren = p.children(); + if (pChildren.isEmpty()) { + return null; + } + + // Use recursive toText() to capture full text across inline formatting + // (e.g. "[x] Bold task", with "Bold" carrying emphasis, has text nodes + // only for "[x] " and " task"; the emphasised word sits in a non-text + // MdxJsxTextElement). + String fullText = p.toText(); + + Matcher matcher = TASK_PATTERN.matcher(fullText); + if (!matcher.matches()) { + return null; + } + + boolean checked = !" ".equals(matcher.group(1)); + // remainingText is computed from the regex; the AST text nodes are NOT modified. + String remainingText = matcher.group(2); + int prefixLen = fullText.length() - remainingText.length(); + + // Find the first text node (for callers to locate the text to strip). + MdAstText firstTextNode = null; + for (Object child : pChildren) { + if (child instanceof MdAstText text) { + firstTextNode = text; + break; } - if (p.children() - .getFirst() instanceof MdAstText text) { - Matcher matcher = TASK_PATTERN.matcher(text.value); - if (matcher.matches()) { - return new TaskMarker(!" ".equals(matcher.group(1)), matcher.group(2), text); + } + + if (firstTextNode == null) { + return null; + } + + return new TaskMarker(checked, remainingText, firstTextNode, prefixLen); + } + + /** + * Strips the task-marker prefix from the first {@code + * +

            + * } child's text + * nodes in-place. Callers MUST save original values beforehand and restore + * them after compilation to keep the AST immutable. + * + * @param p the {@code

            } element whose text children to modify + * @param prefixLen number of characters to strip from the start + * @return a list of original text values in order (for later restoration) + */ + public static List stripPrefixInPlace(MdxJsxFlowElement p, int prefixLen) { + var saved = new java.util.ArrayList(); + int remainingToStrip = prefixLen; + for (Object child : p.children()) { + if (child instanceof MdAstText text) { + saved.add(text.value); + if (remainingToStrip > 0) { + int stripFromThis = Math.min(remainingToStrip, text.value.length()); + text.setValue(text.value.substring(stripFromThis)); + remainingToStrip -= stripFromThis; } } } + return saved; + } + + /** + * Restores text nodes to their original values after a temporary + * {@link #stripPrefixInPlace} call. + */ + public static void restoreTextValues(MdxJsxFlowElement p, List savedValues) { + int idx = 0; + for (Object child : p.children()) { + if (child instanceof MdAstText text && idx < savedValues.size()) { + text.setValue(savedValues.get(idx++)); + } + } + } + + /** + * Finds the first {@code + * +

            + * } child in an {@code + * +

          • }'s children. + */ + @Nullable + public static MdxJsxFlowElement findFirstP(List children) { + for (var child : children) { + if (child instanceof MdxJsxFlowElement el && "p".equals(el.name())) { + return el; + } + } return null; } - public record TaskMarker(boolean checked, String remainingText, MdAstText textNode) {} + public record TaskMarker(boolean checked, String remainingText, MdAstText textNode, int prefixLen) {} } diff --git a/src/main/java/com/hfstudio/guidenh/guide/internal/markdown/MarkdownRuntimeBlocks.java b/src/main/java/com/hfstudio/guidenh/guide/internal/markdown/MarkdownRuntimeBlocks.java index 72bd3bb1..f9544485 100644 --- a/src/main/java/com/hfstudio/guidenh/guide/internal/markdown/MarkdownRuntimeBlocks.java +++ b/src/main/java/com/hfstudio/guidenh/guide/internal/markdown/MarkdownRuntimeBlocks.java @@ -196,7 +196,8 @@ private static String trimLeadingDirectiveText(String text, int startIndex) { if (text == null || text.isEmpty() || startIndex < 0 || startIndex >= text.length()) { return ""; } - return text.substring(startIndex); + return text.substring(startIndex) + .trim(); } private static List splitTokens(String expression) { diff --git a/src/main/java/com/hfstudio/guidenh/guide/internal/markdown/MdAstToMdxConverter.java b/src/main/java/com/hfstudio/guidenh/guide/internal/markdown/MdAstToMdxConverter.java index ec23ce1b..2941ef7e 100644 --- a/src/main/java/com/hfstudio/guidenh/guide/internal/markdown/MdAstToMdxConverter.java +++ b/src/main/java/com/hfstudio/guidenh/guide/internal/markdown/MdAstToMdxConverter.java @@ -6,6 +6,7 @@ import java.util.List; import java.util.Map; import java.util.Set; +import java.util.regex.Pattern; import org.jetbrains.annotations.Nullable; @@ -47,6 +48,25 @@ public final class MdAstToMdxConverter { + /** + * Matches a kramdown-style attribute line ({@code {: ... }}) that the GFM + * table parser may swallow as an extra table row. + */ + private static final Pattern TABLE_ATTRIBUTE_LINE = Pattern.compile("^\\{:\\s*(.+?)\\s*}$"); + + /** + * Matches an inline attribute block {@code {key=value key="value" key='value'}} + * attached to a Markdown image or link, e.g. {@code {align=center}}. + */ + private static final Pattern INLINE_ATTR_BLOCK = Pattern.compile("^\\{(.+)\\}$"); + + /** + * Matches a single attribute key=value pair inside {@code {...}}. + * Supports unquoted values, double-quoted, and single-quoted values. + */ + private static final Pattern INLINE_ATTR_PAIR = Pattern + .compile("(\\w+)=\"([^\"]*)\"|(\\w+)='([^']*)'|(\\w+)=(\\w+)"); + private MdAstToMdxConverter() {} /** @@ -119,7 +139,7 @@ private static List castAnyChildren(List children) { return (List) children; } - // Phrasing (inline) children conversion — also handles block nodes that + // Phrasing (inline) children conversion, which also handles block nodes that // may appear inside phrasing containers (e.g. MdAstParagraph inside
        • ). @SuppressWarnings({ "unchecked", "rawtypes" }) @@ -161,6 +181,7 @@ private static void convertPhrasingChildren(List children, Map()); @@ -176,6 +197,7 @@ private static void convertPhrasingChildren(List children, Map()); @@ -222,6 +244,18 @@ private static void convertFlowChildren(List children, Map so it goes through + // ImageCompiler.compileBlockContext and produces a proper + // LytAlignedBlock (instead of an inline LytFlowInlineBlock). + MdxJsxTextElement imgEl = (MdxJsxTextElement) p.children() + .get(0); + MdxJsxFlowElement flowImg = new MdxJsxFlowElement(); + flowImg.setName("img"); + flowImg.attributes() + .addAll(imgEl.attributes()); + replacement = flowImg; } else { replacement = createFlow("p", p.children()); } @@ -260,7 +294,16 @@ private static void convertFlowChildren(List children, Map aligns) { return sb.toString(); } + /** + * Checks if the child at {@code index + 1} is an {@link MdAstText} containing + * an inline attribute block ({@code {key=value ...}}). If so, parses the + * attributes and adds them to the given {@link MdxJsxTextElement}, then + * removes the consumed text node from the children list. + *

          + * This bridges the gap between Markdown image syntax {@code ![alt](src){align=center}} + * and the JSX attribute representation expected by tag compilers. + */ + @SuppressWarnings({ "unchecked", "rawtypes" }) + private static void consumeTrailingAttributes(List children, int index, MdxJsxTextElement el) { + int nextIdx = index + 1; + if (nextIdx >= children.size()) return; + Object next = children.get(nextIdx); + if (!(next instanceof MdAstText text)) return; + String trimmed = text.value.trim(); + java.util.regex.Matcher blockMatcher = INLINE_ATTR_BLOCK.matcher(trimmed); + if (!blockMatcher.matches()) return; + + String inner = blockMatcher.group(1) + .trim(); + java.util.regex.Matcher pairMatcher = INLINE_ATTR_PAIR.matcher(inner); + boolean found = false; + while (pairMatcher.find()) { + found = true; + // Group 1/2: double-quoted value; 3/4: single-quoted; 5/6: unquoted + String name = pairMatcher.group(1) != null ? pairMatcher.group(1) + : pairMatcher.group(3) != null ? pairMatcher.group(3) : pairMatcher.group(5); + String value = pairMatcher.group(2) != null ? pairMatcher.group(2) + : pairMatcher.group(4) != null ? pairMatcher.group(4) : pairMatcher.group(6); + if (name != null && value != null) { + el.addAttribute(name, value); + } + } + if (found) { + children.remove(nextIdx); + } + } + + /** + * Checks if a paragraph has been converted to contain only a single + * {@code } element with an {@code align} attribute. Such paragraphs + * come from standalone Markdown image syntax with kramdown-style alignment, + * e.g. {@code ![alt](src){align=center}} on its own line. + *

          + * These should be promoted to block-level {@code } so that + * {@link com.hfstudio.guidenh.guide.compiler.tags.ImageCompiler} can + * produce a block-level {@code LytAlignedBlock} at compile time, avoiding + * the inline {@code LytFlowInlineBlock} wrapper that cannot position + * block-level alignment nodes. + */ + private static boolean isSoloAlignedImageParagraph(MdAstParagraph p) { + if (p.children() + .size() != 1) return false; + Object sole = p.children() + .get(0); + if (!(sole instanceof MdxJsxTextElement imgEl)) return false; + if (!"img".equals(imgEl.name())) return false; + return imgEl.getAttribute("align") != null; + } + /** * Extracts the expression from a kramdown-style attribute paragraph * ({@code {: ...}}), or returns null if not a kramdown meta line. @@ -399,4 +503,39 @@ private static String extractKramdownMeta(MdAstParagraph p) { if (v.startsWith("{:") && v.endsWith("}")) return v; return null; } + + /** + * Concatenates the text content of all cells in a GfmTableRow, separated by + * spaces. Returns null if the resulting text is empty (after trim). + *

          + * Iterates as {@link Object} and uses instanceof to handle both + * {@link GfmTableCell} (original) and {@link MdxJsxFlowElement} (post-conversion + * {@code + * +

          }) child types, avoiding ClassCastException when the row's + * children list contains mixed types. + */ + @Nullable + private static String getRowText(GfmTableRow row) { + StringBuilder sb = new StringBuilder(); + for (Object cell : row.children()) { + String cellText = null; + if (cell instanceof GfmTableCell tableCell) { + cellText = tableCell.toText(); + } else if (cell instanceof MdxJsxFlowElement flowElem && "td".equals(flowElem.name())) { + cellText = flowElem.toText(); + } else if (cell instanceof MdAstNode node) { + cellText = node.toText(); + } + if (cellText != null && !cellText.isEmpty()) { + if (!sb.isEmpty()) { + sb.append(' '); + } + sb.append(cellText); + } + } + String text = sb.toString() + .trim(); + return text.isEmpty() ? null : text; + } } diff --git a/src/main/java/com/hfstudio/guidenh/guide/internal/mermaid/MermaidLayoutPrecomputer.java b/src/main/java/com/hfstudio/guidenh/guide/internal/mermaid/MermaidLayoutPrecomputer.java new file mode 100644 index 00000000..2e18357d --- /dev/null +++ b/src/main/java/com/hfstudio/guidenh/guide/internal/mermaid/MermaidLayoutPrecomputer.java @@ -0,0 +1,80 @@ +package com.hfstudio.guidenh.guide.internal.mermaid; + +import com.hfstudio.guidenh.guide.document.block.LytMermaidFlowchart; +import com.hfstudio.guidenh.guide.document.block.LytMermaidMindmap; +import com.hfstudio.guidenh.guide.layout.FontMetrics; +import com.hfstudio.guidenh.guide.layout.LayoutContext; +import com.hfstudio.guidenh.guide.render.GuideText; +import com.hfstudio.guidenh.guide.style.ResolvedTextStyle; + +/** + * Shared precomputation of Mermaid diagram layout before the Rust layout engine + * processes the block. Called both from {@code PreCompiler} (compile-time fenced + * blocks) and from {@code MermaidScript} (runtime script-dispatch path). + *

          + * Each method creates a GuideText-backed {@link LayoutContext}, invokes the + * canvas's {@code precomputeLayout} to run the ELK / mindmap layout, and sets + * {@code preferredHeight} on the canvas so the Rust layout engine allocates the + * correct canvas height on its first pass (avoiding the height-0-then-correct + * pattern that causes parent VBox to freeze at a collapsed height). + */ +public final class MermaidLayoutPrecomputer { + + private MermaidLayoutPrecomputer() { + // utility class + } + + /** + * Pre-compute ELK layout for a flowchart block and set preferredHeight on its + * canvas so Rust's first layout pass allocates the correct height. + * + * @param block the flowchart block whose canvas gets pre-computed + * @param pageWidth available page content width (used as canvas extents); + * caller should supply a positive value (480 is the standard + * fallback when no runtime width is available) + */ + public static void precomputeFlowchartLayout(LytMermaidFlowchart block, int pageWidth) { + int safePageWidth = pageWidth > 0 ? pageWidth : 480; + LayoutContext layoutCtx = new LayoutContext(new FontMetrics() { + + @Override + public float getAdvance(int codePoint, ResolvedTextStyle s) { + return GuideText.measureWidth(new String(Character.toChars(codePoint)), s); + } + + @Override + public int getLineHeight(ResolvedTextStyle s) { + return GuideText.lineHeight(s); + } + }); + block.getCanvas() + .precomputeLayout(layoutCtx, safePageWidth); + } + + /** + * Pre-compute mindmap layout and set preferredHeight on the canvas so Rust's + * first layout pass allocates the correct height. + * + * @param block the mindmap block whose canvas gets pre-computed + * @param pageWidth available page content width (used as canvas extents); + * caller should supply a positive value (480 is the standard + * fallback when no runtime width is available) + */ + public static void precomputeMindmapLayout(LytMermaidMindmap block, int pageWidth) { + int safePageWidth = pageWidth > 0 ? pageWidth : 480; + LayoutContext layoutCtx = new LayoutContext(new FontMetrics() { + + @Override + public float getAdvance(int codePoint, ResolvedTextStyle s) { + return GuideText.measureWidth(new String(Character.toChars(codePoint)), s); + } + + @Override + public int getLineHeight(ResolvedTextStyle s) { + return GuideText.lineHeight(s); + } + }); + block.getCanvas() + .precomputeLayout(layoutCtx, safePageWidth); + } +} diff --git a/src/main/java/com/hfstudio/guidenh/guide/internal/mermaid/MermaidParser.java b/src/main/java/com/hfstudio/guidenh/guide/internal/mermaid/MermaidParser.java index 09238201..486eb698 100644 --- a/src/main/java/com/hfstudio/guidenh/guide/internal/mermaid/MermaidParser.java +++ b/src/main/java/com/hfstudio/guidenh/guide/internal/mermaid/MermaidParser.java @@ -92,7 +92,8 @@ public enum NodeShapePattern { TRAPEZOID_C("/[", "]/", MermaidNodeShape.TRAPEZOID), TRAPEZOID_D("\\", "\\", MermaidNodeShape.TRAPEZOID), SQUARE("[", "]", MermaidNodeShape.SQUARE), - DOUBLE_CIRCLE("((", "))", MermaidNodeShape.DOUBLE_CIRCLE), + DOUBLE_CIRCLE("(((", ")))", MermaidNodeShape.DOUBLE_CIRCLE), + CIRCLE("((", "))", MermaidNodeShape.CIRCLE), ROUNDED("(", ")", MermaidNodeShape.ROUNDED), BANG("))", "((", MermaidNodeShape.BANG), CLOUD(")", "(", MermaidNodeShape.CLOUD); @@ -111,22 +112,12 @@ public enum NodeShapePattern { public static NodeShapeResult match(String rest) { if (rest == null || rest.isEmpty()) return null; for (NodeShapePattern pattern : values()) { - // BANG/CLOUD are declared after all valid flowchart shapes, so they - // can never match first in this startsWith loop — no flowchart node + // BANG/CLOUD are declared after all valid shapes, so they + // can never match first in this startsWith loop; no node // shape syntax starts with ) or )). if (rest.startsWith(pattern.open) && rest.endsWith(pattern.close)) { String inner = rest.substring(pattern.open.length(), rest.length() - pattern.close.length()) .trim(); - if (pattern == DOUBLE_CIRCLE) { - if (inner.startsWith("(") && inner.endsWith(")")) { - return new NodeShapeResult( - null, - inner.substring(1, inner.length() - 1) - .trim(), - MermaidNodeShape.DOUBLE_CIRCLE); - } - return new NodeShapeResult(null, inner, MermaidNodeShape.CIRCLE); - } return new NodeShapeResult(null, inner, pattern.shape); } } @@ -145,16 +136,6 @@ public static ShapeMatch tryParseShape(String text) { .trim(); String label = text.substring(openIndex + pattern.open.length(), length) .trim(); - if (pattern == DOUBLE_CIRCLE) { - if (label.startsWith("(") && label.endsWith(")")) { - return new ShapeMatch( - prefix, - label.substring(1, label.length() - 1) - .trim(), - MermaidNodeShape.DOUBLE_CIRCLE); - } - return new ShapeMatch(prefix, label, MermaidNodeShape.CIRCLE); - } return new ShapeMatch(prefix, label, pattern.shape); } return null; diff --git a/src/main/java/com/hfstudio/guidenh/guide/internal/mermaid/NodeShapeDefinition.java b/src/main/java/com/hfstudio/guidenh/guide/internal/mermaid/NodeShapeDefinition.java index b8bde09d..a13bbafc 100644 --- a/src/main/java/com/hfstudio/guidenh/guide/internal/mermaid/NodeShapeDefinition.java +++ b/src/main/java/com/hfstudio/guidenh/guide/internal/mermaid/NodeShapeDefinition.java @@ -13,7 +13,7 @@ public enum NodeShapeDefinition { ELLIPSE("(-", "-)", MermaidNodeShape.ELLIPSE), - // [ variants — all before SQUARE (which is a single '[') + // [ variants, all before SQUARE (which is a single '[') STADIUM("([", "])", MermaidNodeShape.STADIUM), SUBROUTINE("[[", "]]", MermaidNodeShape.SUBPROCESS), CYLINDER("[(", ")]", MermaidNodeShape.CYLINDER), @@ -23,7 +23,7 @@ public enum NodeShapeDefinition { LEAN_LEFT("[\\", "\\]", MermaidNodeShape.ASYMMETRIC), SQUARE('[', ']', MermaidNodeShape.SQUARE), - // ( variants — all before ROUND (which is a single '(') + // ( variants, all before ROUND (which is a single '(') DOUBLE_CIRCLE("(((", ")))", MermaidNodeShape.DOUBLE_CIRCLE), CIRCLE("((", "))", MermaidNodeShape.CIRCLE), ROUND('(', ')', MermaidNodeShape.ROUNDED), @@ -34,7 +34,7 @@ public enum NodeShapeDefinition { ASYMMETRIC('>', ']', MermaidNodeShape.ASYMMETRIC), - // ) variants — BANG before CLOUD (both start with )) + // ) variants: BANG before CLOUD (both start with )) BANG("))", "((", MermaidNodeShape.BANG), CLOUD(')', '(', MermaidNodeShape.CLOUD); @@ -80,7 +80,7 @@ public static MatchResult match(String text) { if (text.startsWith(def.open) && text.endsWith(def.close)) { String inner = text.substring(def.open.length(), text.length() - def.close.length()) .trim(); - // double-circle with ((())) — inner wrapped in extra parens + // double-circle with ((())): inner wrapped in extra parens if (def == DOUBLE_CIRCLE && inner.startsWith("(") && inner.endsWith(")")) { return new MatchResult( DOUBLE_CIRCLE, @@ -91,7 +91,7 @@ public static MatchResult match(String text) { } } // If a trailing ] is present, the text might end with "[/...\]" where - // the close is literally backslash+bracket — the above already handles + // the close is literally backslash+bracket; the above already handles // this via the "\\]" close string. return null; } diff --git a/src/main/java/com/hfstudio/guidenh/guide/internal/mermaid/flowchart/ElkLayoutStrategy.java b/src/main/java/com/hfstudio/guidenh/guide/internal/mermaid/flowchart/ElkLayoutStrategy.java index d94568dd..d0f14e02 100644 --- a/src/main/java/com/hfstudio/guidenh/guide/internal/mermaid/flowchart/ElkLayoutStrategy.java +++ b/src/main/java/com/hfstudio/guidenh/guide/internal/mermaid/flowchart/ElkLayoutStrategy.java @@ -107,7 +107,7 @@ public FlowchartLayoutResult layout(FlowchartDocument document, elkNodeMap.put(node.getId(), elkNode); } - // Create edges — for cross-hierarchy edges we build a chain of ports + // Create edges: for cross-hierarchy edges we build a chain of ports // through every compound ancestor so ELK can route each segment within // a single layout run. The chain is later stitched back into one path. List splitChains = new ArrayList<>(); diff --git a/src/main/java/com/hfstudio/guidenh/guide/internal/mermaid/flowchart/FlowchartDocument.java b/src/main/java/com/hfstudio/guidenh/guide/internal/mermaid/flowchart/FlowchartDocument.java index 21a2c5aa..f148d7a5 100644 --- a/src/main/java/com/hfstudio/guidenh/guide/internal/mermaid/flowchart/FlowchartDocument.java +++ b/src/main/java/com/hfstudio/guidenh/guide/internal/mermaid/flowchart/FlowchartDocument.java @@ -38,7 +38,15 @@ public FlowchartDocument(FlowchartDirection direction, Map src = nodes != null ? nodes : Map.of(); - this.nodes = Map.copyOf(new LinkedHashMap<>(src)); + // NB: NOT Map.copyOf. JDK 9+ ImmutableCollections.MapN salts its hash + // table with a per-JVM random value (SALT32L = const * System.nanoTime() + // >> 16), so Map.copyOf's iteration order varies across JVM runs for + // the same content. Layout code iterates this map to order ELK node + // creation (and node min-size computation), and ELK preserves that + // order for equal-solution ties (isolated nodes, symmetric layers): + // a per-run order flip moved mermaid nodes between renders. An + // unmodifiable LinkedHashMap keeps the deterministic parse order. + this.nodes = Collections.unmodifiableMap(new LinkedHashMap<>(src)); this.edges = List.copyOf(new ArrayList<>(edges != null ? edges : List.of())); this.subgraphs = List.copyOf(new ArrayList<>(subgraphs != null ? subgraphs : List.of())); this.layoutMode = layoutMode != null ? layoutMode : FlowchartLayoutMode.BUILTIN; diff --git a/src/main/java/com/hfstudio/guidenh/guide/internal/mermaid/flowchart/FlowchartParser.java b/src/main/java/com/hfstudio/guidenh/guide/internal/mermaid/flowchart/FlowchartParser.java index 1b6b2795..cadaa45e 100644 --- a/src/main/java/com/hfstudio/guidenh/guide/internal/mermaid/flowchart/FlowchartParser.java +++ b/src/main/java/com/hfstudio/guidenh/guide/internal/mermaid/flowchart/FlowchartParser.java @@ -478,7 +478,7 @@ NodeSpec parseNodeSpec(String text) { label = stripIconFromLabel(label); if (label.isEmpty()) label = id; LOGGER.warn( - "Node '{}' uses icon '{}' — TrueType font icon rendering is not implemented; rendering as text badge", + "Node '{}' uses icon '{}': TrueType font icon rendering is not implemented; rendering as text badge", id, icon); } @@ -500,7 +500,7 @@ NodeSpec parseNodeSpec(String text) { label = inner; markdownLabel = true; LOGGER.warn( - "Node '{}' has markdownLabel=true — markdown text rendering is not implemented; falling back to plain text", + "Node '{}' has markdownLabel=true: markdown text rendering is not implemented; falling back to plain text", id); } } @@ -546,7 +546,7 @@ private NodeSpec parseExtendedNode(String id, String rest) { icon = value.isEmpty() ? null : value; if (icon != null) { LOGGER.warn( - "Node '{}' uses icon '{}' — TrueType font icon rendering is not implemented; rendering as text badge", + "Node '{}' uses icon '{}': TrueType font icon rendering is not implemented; rendering as text badge", id, icon); } diff --git a/src/main/java/com/hfstudio/guidenh/guide/internal/mermaid/flowchart/LinkDefinition.java b/src/main/java/com/hfstudio/guidenh/guide/internal/mermaid/flowchart/LinkDefinition.java index 770cfdb9..fe70e4f1 100644 --- a/src/main/java/com/hfstudio/guidenh/guide/internal/mermaid/flowchart/LinkDefinition.java +++ b/src/main/java/com/hfstudio/guidenh/guide/internal/mermaid/flowchart/LinkDefinition.java @@ -51,6 +51,12 @@ public enum LinkDefinition { 1), DASHED_CIRCLE_REV("o-.", MermaidEdgeStyle.DASHED, false, true, MermaidArrowHead.NONE, MermaidArrowHead.CIRCLE, '.', 1), + DASHED_CROSS_FWD("-.x", MermaidEdgeStyle.DASHED, true, false, MermaidArrowHead.CROSS, MermaidArrowHead.NONE, '.', + 1), + DASHED_CROSS_REV("x-.", MermaidEdgeStyle.DASHED, false, true, MermaidArrowHead.NONE, MermaidArrowHead.CROSS, '.', + 1), + DASHED_CROSS_BOTH("x-.x", MermaidEdgeStyle.DASHED, true, true, MermaidArrowHead.CROSS, MermaidArrowHead.CROSS, '.', + 1), // ── Dotted (tilde) ───────────────────────────────────── DOTTED_ARROW("~~>", MermaidEdgeStyle.DOTTED, true, false, MermaidArrowHead.TRIANGLE, MermaidArrowHead.NONE, '~', 2), @@ -58,6 +64,16 @@ public enum LinkDefinition { DOTTED_REV("<~~~", MermaidEdgeStyle.DOTTED, false, true, MermaidArrowHead.NONE, MermaidArrowHead.TRIANGLE, '~', 2), DOTTED_BOTH("<~~>", MermaidEdgeStyle.DOTTED, true, true, MermaidArrowHead.TRIANGLE, MermaidArrowHead.TRIANGLE, '~', 2), + DOTTED_CIRCLE_FWD("~~o", MermaidEdgeStyle.DOTTED, true, false, MermaidArrowHead.CIRCLE, MermaidArrowHead.NONE, '~', + 2), + DOTTED_CIRCLE_REV("o~~", MermaidEdgeStyle.DOTTED, false, true, MermaidArrowHead.NONE, MermaidArrowHead.CIRCLE, '~', + 2), + DOTTED_CROSS_FWD("~~x", MermaidEdgeStyle.DOTTED, true, false, MermaidArrowHead.CROSS, MermaidArrowHead.NONE, '~', + 2), + DOTTED_CROSS_REV("x~~", MermaidEdgeStyle.DOTTED, false, true, MermaidArrowHead.NONE, MermaidArrowHead.CROSS, '~', + 2), + DOTTED_CROSS_BOTH("x~~x", MermaidEdgeStyle.DOTTED, true, true, MermaidArrowHead.CROSS, MermaidArrowHead.CROSS, '~', + 2), // ── Invisible ────────────────────────────────────────── INVISIBLE_LINK("~~~", MermaidEdgeStyle.INVISIBLE, false, false, MermaidArrowHead.NONE, MermaidArrowHead.NONE, '~', diff --git a/src/main/java/com/hfstudio/guidenh/guide/internal/recipe/LytNeiRecipeBox.java b/src/main/java/com/hfstudio/guidenh/guide/internal/recipe/LytNeiRecipeBox.java index fba806a7..bd3f1b51 100644 --- a/src/main/java/com/hfstudio/guidenh/guide/internal/recipe/LytNeiRecipeBox.java +++ b/src/main/java/com/hfstudio/guidenh/guide/internal/recipe/LytNeiRecipeBox.java @@ -82,11 +82,17 @@ public class LytNeiRecipeBox extends LytBlock implements InteractiveElement { private final @Nullable Object iconImage; private final int iconImageW; private final int iconImageH; + @Getter private final int bodyWidth; + @Getter private final int bodyHeight; + @Getter private final int bodyTopInset; + @Getter private final int bodyYShift; + @Getter private final int titleHeight; + @Getter private final boolean recipeJumpEnabled; private final @Nullable GuideTooltip actionButtonTooltip; /** @@ -161,11 +167,25 @@ private int iconSize() { return (iconStack != null || iconImage != null) ? ICON_SIZE : 0; } + /** + * Public accessor for serialization: icon display size (0 or {@value #ICON_SIZE}). + */ + public int getIconSizeResult() { + return iconSize(); + } + private int titleTextWidth() { if (handlerName.isEmpty()) return 0; return Minecraft.getMinecraft().fontRenderer.getStringWidth(handlerName); } + /** + * Public accessor for serialization: title text pixel width via Minecraft font metrics. + */ + public int getTitleTextWidth() { + return titleTextWidth(); + } + @Override public void render(RenderContext context) { int x = bounds.x(); diff --git a/src/main/java/com/hfstudio/guidenh/guide/internal/screen/GuideIconButton.java b/src/main/java/com/hfstudio/guidenh/guide/internal/screen/GuideIconButton.java index 129787f6..76375a68 100644 --- a/src/main/java/com/hfstudio/guidenh/guide/internal/screen/GuideIconButton.java +++ b/src/main/java/com/hfstudio/guidenh/guide/internal/screen/GuideIconButton.java @@ -176,6 +176,7 @@ public enum Role { CLOSE(GuidebookText.Close, 2, 0), SCENE_EDITOR_CLOSE(GuidebookText.SceneEditorClose, 2, 0), SEARCH(GuidebookText.Search, 3, 0), + SETTINGS(GuidebookText.Settings, 13, 1), HOMEPAGE(GuidebookText.HomePage, 1, 5), BOOKMARK(GuidebookText.Bookmark, 8, 0), BOOKMARKED(GuidebookText.Bookmarked, 9, 0), diff --git a/src/main/java/com/hfstudio/guidenh/guide/internal/screen/GuideNavBar.java b/src/main/java/com/hfstudio/guidenh/guide/internal/screen/GuideNavBar.java index efbb71e5..d1f3f8c5 100644 --- a/src/main/java/com/hfstudio/guidenh/guide/internal/screen/GuideNavBar.java +++ b/src/main/java/com/hfstudio/guidenh/guide/internal/screen/GuideNavBar.java @@ -24,14 +24,24 @@ import com.hfstudio.guidenh.guide.GuidePageIcon; import com.hfstudio.guidenh.guide.PageCollection; import com.hfstudio.guidenh.guide.color.ColorUtils; +import com.hfstudio.guidenh.guide.color.ConstantColor; +import com.hfstudio.guidenh.guide.document.LytRect; +import com.hfstudio.guidenh.guide.document.block.LytDocument; import com.hfstudio.guidenh.guide.internal.GuideBookmarkState; import com.hfstudio.guidenh.guide.internal.GuidebookText; import com.hfstudio.guidenh.guide.internal.util.DisplayScale; import com.hfstudio.guidenh.guide.internal.util.SmoothFloatState; import com.hfstudio.guidenh.guide.navigation.NavigationNode; import com.hfstudio.guidenh.guide.navigation.NavigationTree; -import com.hfstudio.guidenh.guide.render.GuideFontCompat; import com.hfstudio.guidenh.guide.render.GuidePageTexture; +import com.hfstudio.guidenh.guide.render.GuideRenderPrimitive; +import com.hfstudio.guidenh.guide.render.GuideText; +import com.hfstudio.guidenh.guide.render.PrimitiveCollector; +import com.hfstudio.guidenh.guide.render.VanillaRenderContext; +import com.hfstudio.guidenh.guide.scene.support.GuideDebugLog; +import com.hfstudio.guidenh.guide.style.ResolvedTextStyle; +import com.hfstudio.guidenh.guide.style.TextAlignment; +import com.hfstudio.guidenh.guide.style.WhiteSpaceMode; import lombok.Getter; @@ -160,6 +170,47 @@ public boolean shouldCreateNewPage() { private static final int SCROLLBAR_GUTTER = SCROLLBAR_WIDTH + 3; private static final int MIN_SCROLLBAR_THUMB_HEIGHT = 16; + // GuideText (Rust) text styles. + /** + * Row text scale: 0.70 → lineHeight = round(17×0.70) = 12 == ROW_H, so the + * line box exactly fills the row. Measured against msyh.ttc (see assumption + * log): GuideText.ascent()=12.0 (scale 1) → row baseline = lineTop + 8.4; + * CJK ink spans y=[2.0, 9.0] within the 12px row: no poke-through, so no + * downgrade to 0.65 was needed. Title text scale: 0.80 → lineHeight = + * round(17×0.80) = 14 < TITLE_H 16; title baseline = lineTop + 9.6. No drop + * shadow on either (mirrors legacy drawString(..., false)). + */ + private static final float ROW_FONT_SCALE = 0.70f; + private static final float TITLE_FONT_SCALE = 0.80f; + private static final int TITLE_TEXT_COLOR = ColorUtils.ARGB_FFE8E8E8.getColor(); + private static final ResolvedTextStyle ROW_TEXT_STYLE = navBarTextStyle(ROW_FONT_SCALE, 0xFFBBBBBB); + private static final ResolvedTextStyle TITLE_TEXT_STYLE = navBarTextStyle(TITLE_FONT_SCALE, TITLE_TEXT_COLOR); + + private static ResolvedTextStyle navBarTextStyle(float fontScale, int argb) { + return new ResolvedTextStyle( + fontScale, + false, + false, + false, + false, + false, + false, + false, + null, + new ConstantColor(argb), + WhiteSpaceMode.NORMAL, + TextAlignment.LEFT, + false, + null, + false, + 0.0f); + } + + /** Row style carrying the dynamic row tint (current/hover/normal/failed). */ + private static ResolvedTextStyle rowTextStyle(int color) { + return navBarTextStyle(ROW_FONT_SCALE, color); + } + public interface GuideExpansionListener { void onExpansionChanged(List changes, boolean allCollapsed); @@ -202,6 +253,12 @@ public record ExpansionChange(@Nullable ResourceLocation guideId, ResourceLocati @Nullable private Row hoveredScrollingRow; private long hoveredScrollingStartedAtMillis; + /** + * Lazily-created legacy RenderContext holder for the text PrimitiveCollector + * (only constructed once the Rust font system is available in-game). + */ + @Nullable + private VanillaRenderContext textRenderContext; public void setBounds(int x, int y, int height) { if (this.x != x || this.y != y || this.height != height) { @@ -436,12 +493,21 @@ public void render(Minecraft mc, @Nullable ResourceLocation currentGuideId, return; } - renderTitle(mc, w, mouseX, mouseY, showNewPageButton); + // Text pipeline: all bar text (title + rows) is collected as + // GuideText glyph primitives and executed in one pass at the end, + // after every legacy rect/icon draw (execute() restores GL state and + // closes the scissor test, so it must be last). When the Rust font + // system is not ready (init exception, handle=0) the collector stays + // null and the legacy FontRenderer fallback keeps the bar legible. + PrimitiveCollector textCollector = GuideText.isAvailable() ? newTextCollector(mc, w) : null; + + renderTitle(mc, textCollector, w, mouseX, mouseY, showNewPageButton); int bodyY = getBodyY(); int bodyHeight = getBodyHeight(); if (bodyHeight <= 0) { resetTitleScroll(); + executeNavText(textCollector); return; } @@ -449,7 +515,13 @@ public void render(Minecraft mc, @Nullable ResourceLocation currentGuideId, GL11.glEnable(GL11.GL_SCISSOR_TEST); setBodyScissor(mc, scaleFactor); - FontRenderer fontRenderer = mc.fontRenderer; + // Clip row text to the body via a screen-space scissor primitive: the + // legacy GL scissor above clips only the legacy rects/icons, while + // the engine owns text clipping inside the single execute pass. + if (textCollector != null) { + textCollector.pushScreenScissor(x, getBodyY(), currentWidth(), getBodyHeight()); + } + StickyStack stickyRows = computeStickyStack(bodyY); int firstVisibleRow = getFirstVisibleRowIndex(); int stickyPointer = 0; @@ -467,9 +539,16 @@ public void render(Minecraft mc, @Nullable ResourceLocation currentGuideId, : Integer.MAX_VALUE; continue; } + if (overlapsStickyRow(rowY, stickyRows)) { + // Screen-level avoidance: this scrolling row's text band + // intersects a sticky row's fixed screen band, so drawing it + // would double-print the same pixels (user report ② + // ghosted/overlapping rows). The sticky row owns that band. + continue; + } titleScrollActive |= renderRow( mc, - fontRenderer, + textCollector, row, rowY, mouseX, @@ -488,7 +567,7 @@ public void render(Minecraft mc, @Nullable ResourceLocation currentGuideId, for (int stackIndex = 0; stackIndex < stickyRows.size(); stackIndex++) { titleScrollActive |= renderRow( mc, - fontRenderer, + textCollector, stickyRows.rowAt(stackIndex), stickyRows.rowYAt(stackIndex), mouseX, @@ -507,6 +586,12 @@ public void render(Minecraft mc, @Nullable ResourceLocation currentGuideId, if (!titleScrollActive) { resetTitleScroll(); } + if (textCollector != null) { + textCollector.popScreenScissor(); + } + executeNavText(textCollector); + // The body scissor stops at the row edge, so it would clip the + // scrollbar drawn in the gutter to the right of it. GL11.glDisable(GL11.GL_SCISSOR_TEST); renderScrollbar(mouseX, mouseY); } finally { @@ -517,10 +602,10 @@ public void render(Minecraft mc, @Nullable ResourceLocation currentGuideId, } } - private boolean renderRow(Minecraft mc, FontRenderer fr, Row row, int rowY, int mouseX, int mouseY, int rowRight, - int textRightBase, @Nullable ResourceLocation currentGuideId, @Nullable ResourceLocation currentPageId, - @Nullable PageCollection pageCollection, GuideBookmarkState bookmarkState, int bookmarkActionLeft, - int bookmarkIconX, int scaleFactor, boolean sticky) { + private boolean renderRow(Minecraft mc, @Nullable PrimitiveCollector textCollector, Row row, int rowY, int mouseX, + int mouseY, int rowRight, int textRightBase, @Nullable ResourceLocation currentGuideId, + @Nullable ResourceLocation currentPageId, @Nullable PageCollection pageCollection, + GuideBookmarkState bookmarkState, int bookmarkActionLeft, int bookmarkIconX, int scaleFactor, boolean sticky) { GuideNavProjection.DisplayRow displayRow = row.displayRow(); int indent = row.indent(); int rowX = x + 2 + indent; @@ -564,7 +649,7 @@ private boolean renderRow(Minecraft mc, FontRenderer fr, Row row, int rowY, int ResourceLocation pageId = row.pageId(); boolean failed = pageId != null && pageCollection != null && pageCollection.isPageFailed(pageId); int color = getRowTextColor(current, hovered, failed); - titleScrollActive = renderRowTitle(mc, fr, row, textX, rowY, maxTw, color, hovered, scaleFactor); + titleScrollActive = renderRowTitle(mc, textCollector, row, textX, rowY, maxTw, color, hovered, scaleFactor); } boolean bookmarkHovered = hovered @@ -599,13 +684,35 @@ private StickyStack computeStickyStack(int bodyY) { int defaultRowY = bodyY + index * ROW_H; int subtreeBoundaryY = getRowY(row.subtreeEndRowIndexExclusive()); int rowBottomLimit = Math.min(nextRowY, subtreeBoundaryY); - int stickyRowY = Math.min(defaultRowY, rowBottomLimit - ROW_H); + // Screen-level lower bound at bodyY: a sticky row must never float + // up into the title band, so clamp it to the body + // top instead of letting it cross into the title area. + int stickyRowY = Math.max(bodyY, Math.min(defaultRowY, rowBottomLimit - ROW_H)); stickyStack.setRowYAt(index, stickyRowY); nextRowY = stickyRowY; } return stickyStack; } + /** + * Screen-level avoidance predicate: true when the given scrolling row's + * screen band {@code [rowY, rowY + ROW_H)} intersects any sticky row's + * fixed screen band {@code [rowYAt, rowYAt + ROW_H)}. The two y sources + * (getRowY vs computeStickyStack) are independent; without this guard a + * scrolling row passing through a sticky row's fixed screen band draws on + * the same pixels as the sticky row, which is the user report ② + * ghosted/overlapped rows root cause. + */ + private static boolean overlapsStickyRow(int rowY, StickyStack stickyStack) { + for (int index = 0; index < stickyStack.size(); index++) { + int stickyY = stickyStack.rowYAt(index); + if (rowY < stickyY + ROW_H && rowY + ROW_H > stickyY) { + return true; + } + } + return false; + } + private void collectStickyAncestors(Row anchorRow) { collectStickyAncestorChain(anchorRow.parentRowIndex()); } @@ -626,8 +733,8 @@ private void resetTitleScroll() { hoveredScrollingStartedAtMillis = 0L; } - private void renderTitle(Minecraft mc, int width, int mouseX, int mouseY, boolean showNewPageButton) { - FontRenderer fr = mc.fontRenderer; + private void renderTitle(Minecraft mc, @Nullable PrimitiveCollector textCollector, int width, int mouseX, + int mouseY, boolean showNewPageButton) { Gui.drawRect(x, y, x + width - 1, y + TITLE_H, ColorUtils.ARGB_D0202020.getColor()); Gui.drawRect(x, y + TITLE_H - 1, x + width - 1, y + TITLE_H, ColorUtils.ARGB_FF2A2A2A.getColor()); int pinX = getPinButtonX(); @@ -662,26 +769,78 @@ private void renderTitle(Minecraft mc, int width, int mouseX, int mouseY, boolea int titleW = Math.max(0, titleRight - titleX); if (titleW > 0) { String title = GuidebookText.NavigationTitle.text(); - String renderedTitle = fr.getStringWidth(title) > titleW - ? fr.trimStringToWidth(title, Math.max(0, titleW - 4)) + "…" - : title; - fr.drawString( - renderedTitle, - titleX, - y + (TITLE_H - fr.FONT_HEIGHT) / 2 + 1, - ColorUtils.ARGB_FFE8E8E8.getColor(), - false); + if (textCollector == null) { + // Fallback: the Rust font system is not ready, so keep the title legible + // through the legacy FontRenderer. + FontRenderer fr = mc.fontRenderer; + String renderedTitle = fr.getStringWidth(title) > titleW + ? fr.trimStringToWidth(title, Math.max(0, titleW - 4)) + "…" + : title; + fr.drawString(renderedTitle, titleX, y + (TITLE_H - fr.FONT_HEIGHT) / 2 + 1, TITLE_TEXT_COLOR, false); + } else { + String renderedTitle = GuideText.measureWidth(title, TITLE_TEXT_STYLE) > titleW + ? GuideText.clipToWidth(title, titleW, TITLE_TEXT_STYLE, GuideText.ClipSuffix.UNICODE_ELLIPSIS) + : title; + GuideText.emitText(textCollector, renderedTitle, titleX, titleLineTop(), TITLE_TEXT_STYLE); + } } } - private boolean renderRowTitle(Minecraft mc, FontRenderer fr, Row row, int textX, int rowY, int maxTw, int color, + private boolean renderRowTitle(Minecraft mc, @Nullable PrimitiveCollector textCollector, Row row, int textX, + int rowY, int maxTw, int color, boolean hovered, int scaleFactor) { + if (textCollector == null) { + return renderRowTitleFallback(mc, row, textX, rowY, maxTw, color, hovered, scaleFactor); + } + if (!hovered || row.getTitleWidth() <= maxTw) { + GuideText.emitText(textCollector, row.getTitle(maxTw), textX, rowLineTop(rowY), rowTextStyle(color)); + return false; + } + + int cycleWidth = row.getScrollCycleWidth(); + if (cycleWidth <= 0) { + return false; + } + long now = System.currentTimeMillis(); + if (hoveredScrollingRow != row) { + hoveredScrollingRow = row; + hoveredScrollingStartedAtMillis = now; + } + long elapsed = Math.max(0L, now - hoveredScrollingStartedAtMillis); + int offset = (int) ((elapsed / TITLE_SCROLL_INTERVAL_MILLIS) % cycleWidth); + int clipLeft = Math.max(textX, x); + int clipRight = Math.min(textX + maxTw, x + currentWidth()); + int clipTop = Math.max(rowY, getBodyY()); + int clipBottom = Math.min(rowY + ROW_H, getBodyBottom()); + int clipWidth = clipRight - clipLeft; + int clipHeight = clipBottom - clipTop; + if (clipWidth <= 0 || clipHeight <= 0) { + return false; + } + // Scroll clip as a screen-space scissor primitive (PrimitiveCollector + // supports PushScreenScissor): the whole bar stays a single execute pass + // with smooth scrolling and no per-row GL scissor churn. + textCollector.pushScreenScissor(clipLeft, clipTop, clipWidth, clipHeight); + GuideText + .emitText(textCollector, row.getScrollingTitle(), textX - offset, rowLineTop(rowY), rowTextStyle(color)); + textCollector.popScreenScissor(); + return true; + } + + /** Legacy FontRenderer path, used only while GuideText is unavailable. */ + private boolean renderRowTitleFallback(Minecraft mc, Row row, int textX, int rowY, int maxTw, int color, boolean hovered, int scaleFactor) { - if (!hovered || row.getTitleWidth(fr) <= maxTw) { - fr.drawString(row.getTitle(fr, maxTw), textX, rowY + 2, color, false); + FontRenderer fr = mc.fontRenderer; + String title = row.displayRow() + .title(); + if (!hovered || fr.getStringWidth(title) <= maxTw) { + if (fr.getStringWidth(title) > maxTw) { + title = fr.trimStringToWidth(title, Math.max(0, maxTw - 4)) + "…"; + } + fr.drawString(title, textX, rowY + 2, color, false); return false; } - int cycleWidth = row.getScrollCycleWidth(fr); + int cycleWidth = fr.getStringWidth(title + TITLE_SCROLL_GAP); if (cycleWidth <= 0) { return false; } @@ -1013,6 +1172,18 @@ private void clampScrollToRows() { } } + /** + * Headless render-injection hook: snap the scroll state to an arbitrary + * offset (mirrors {@link #restoreState}, without touching expansion state). + * Lets the headless chrome pass render a single frame at any scroll + * position; used by RenderPageService to reproduce and regression-test the + * sticky/scroll overlap (system property {@code guidenh.renderpage.navscroll}). + */ + public void setScrollY(int scrollY) { + this.scrollY = scrollY; + visualScrollY.snapTo(scrollY); + } + private int getMaxScrollY() { int contentHeight = rows.size() * ROW_H + CONTENT_PADDING * 2; return Math.max(0, contentHeight - getBodyHeight()); @@ -1163,6 +1334,263 @@ private static void setScissor(Minecraft mc, int x, int y, int w, int h, int sca GL11.glScissor(x * scaleFactor, mc.displayHeight - (y + h) * scaleFactor, w * scaleFactor, h * scaleFactor); } + // GuideText primitive-pipeline helpers. + + /** + * Fresh per-frame text collector. The legacy RenderContext holder is created + * once and reused (its scissor stack is never touched by the nav bar; text + * clipping goes through screen-space scissor primitives instead). + */ + private PrimitiveCollector newTextCollector(Minecraft mc, int width) { + VanillaRenderContext ctx = textRenderContext; + if (ctx == null) { + ctx = new VanillaRenderContext(LytRect.empty(), mc.displayHeight); + textRenderContext = ctx; + } + return new PrimitiveCollector(new LytRect(x, y, width, height), ctx); + } + + /** + * Execute the whole bar's collected text in one pass. Must run after every + * legacy rect/icon draw: {@code GuideRenderEngine.execute} restores GL state + * in its {@code finally} (including closing the GL scissor test), so text is + * rendered last. The stale legacy body scissor is cleared first; inside + * execute, clipping is owned by the PushScreenScissor primitives, which need + * the engine's {@code guiScale} to match the current display scale, hence the + * explicit beginFrame. + */ + private static void executeNavText(@Nullable PrimitiveCollector textCollector) { + if (textCollector == null) { + return; + } + List prims = textCollector.result(); + if (prims.isEmpty()) { + return; + } + GL11.glDisable(GL11.GL_SCISSOR_TEST); + var engine = LytDocument.getRenderEngine(); + engine.beginFrame(LytRect.empty(), DisplayScale.scaleFactor()); + engine.execute(prims); + } + + // Headless chrome-pass collection. + + /** + * Headless chrome-pass collection: mirror of {@link #render} that emits the + * whole bar (band background, title bar, row geometry and GuideText glyph + * runs) as render primitives into {@code collector} instead of drawing to + * the GL framebuffer. Text goes through the exact same GuideText pipeline + * (GuideText.emitText via {@link #renderRowTitle}); geometry that the live + * path draws with Gui.drawRect / drawVGradient / drawArrow is emitted as + * FillRect / GradientFill primitives. Texture-blit chrome (pin/new-page + * buttons, page mini-icons, bookmark icons) is intentionally not emitted + * (declared assumption in RenderPageService); row text x still advances + * past the icon slot to match the live bar. The body clip uses a + * document-space {@link PrimitiveCollector#pushScissor} (the headless + * variant of the live PushScreenScissor) so the clip scales with the + * offscreen render density exactly like the glyph runs. + */ + public void collectPrimitives(@Nullable ResourceLocation currentGuideId, @Nullable ResourceLocation currentPageId, + @Nullable PageCollection pageCollection, GuideBookmarkState bookmarkState, boolean showNewPageButton, + PrimitiveCollector collector) { + updateVisualScroll(); + int w = currentWidth(); + int rowRight = x + w - 1; + int textRightBase = x + w - 2; + int bookmarkActionLeft = getBookmarkActionLeft(w); + int bookmarkIconX = bookmarkActionLeft + ACTION_PADDING_RIGHT; + collector.emit( + new GuideRenderPrimitive.GradientFill( + x, + y, + w, + height, + ColorUtils.ARGB_E0151515.getColor(), + ColorUtils.ARGB_E0101010.getColor())); + collector.emit( + new GuideRenderPrimitive.FillRect( + rowRight, + y, + (x + w) - rowRight, + height, + ColorUtils.ARGB_FF2A2A2A.getColor())); + + if (!isOpen()) { + emitArrowPrimitive(collector, x + w / 2 - 2, y + height / 2 - 3, true, ColorUtils.ARGB_FF888888.getColor()); + return; + } + + collector.emit(new GuideRenderPrimitive.FillRect(x, y, w - 1, TITLE_H, ColorUtils.ARGB_D0202020.getColor())); + collector + .emit(new GuideRenderPrimitive.FillRect(x, y + TITLE_H - 1, w - 1, 1, ColorUtils.ARGB_FF2A2A2A.getColor())); + + int titleX = x + TITLE_TEXT_LEFT_PADDING; + int titleRight = (showNewPageButton ? getNewPageButtonX() : getPinButtonX()) - TITLE_BUTTON_GAP; + int titleW = Math.max(0, titleRight - titleX); + if (titleW > 0) { + String title = GuidebookText.NavigationTitle.text(); + String renderedTitle = GuideText.measureWidth(title, TITLE_TEXT_STYLE) > titleW + ? GuideText.clipToWidth(title, titleW, TITLE_TEXT_STYLE, GuideText.ClipSuffix.UNICODE_ELLIPSIS) + : title; + GuideText.emitText(collector, renderedTitle, titleX, titleLineTop(), TITLE_TEXT_STYLE); + } + + int bodyY = getBodyY(); + int bodyHeight = getBodyHeight(); + if (bodyHeight <= 0) { + return; + } + + collector.pushScissor(x, bodyY, w, bodyHeight); + + StickyStack stickyRows = computeStickyStack(bodyY); + int firstVisibleRow = getFirstVisibleRowIndex(); + int stickyPointer = 0; + int nextStickyRowIndex = !stickyRows.isEmpty() ? stickyRows.rowIndexAt(0) : Integer.MAX_VALUE; + for (int rowIndex = firstVisibleRow; rowIndex < rows.size(); rowIndex++) { + Row row = rows.get(rowIndex); + int rowY = getRowY(rowIndex); + if (rowY >= getBodyBottom()) { + break; + } + if (rowIndex == nextStickyRowIndex) { + stickyPointer++; + nextStickyRowIndex = stickyPointer < stickyRows.size() ? stickyRows.rowIndexAt(stickyPointer) + : Integer.MAX_VALUE; + continue; + } + if (overlapsStickyRow(rowY, stickyRows)) { + // Screen-level avoidance (mirror of the live path): skip this + // scrolling row when its band intersects a sticky row's fixed + // screen band, which avoids double-printed ghosted text. + continue; + } + collectRow( + collector, + row, + rowY, + rowRight, + textRightBase, + currentGuideId, + currentPageId, + pageCollection, + bookmarkState, + bookmarkActionLeft, + bookmarkIconX, + false); + } + for (int stackIndex = 0; stackIndex < stickyRows.size(); stackIndex++) { + collectRow( + collector, + stickyRows.rowAt(stackIndex), + stickyRows.rowYAt(stackIndex), + rowRight, + textRightBase, + currentGuideId, + currentPageId, + pageCollection, + bookmarkState, + bookmarkActionLeft, + bookmarkIconX, + true); + } + collector.popScissor(); + } + + private void collectRow(PrimitiveCollector collector, Row row, int rowY, int rowRight, int textRightBase, + @Nullable ResourceLocation currentGuideId, @Nullable ResourceLocation currentPageId, + @Nullable PageCollection pageCollection, GuideBookmarkState bookmarkState, int bookmarkActionLeft, + int bookmarkIconX, boolean sticky) { + GuideNavProjection.DisplayRow displayRow = row.displayRow(); + int indent = row.indent(); + int rowX = x + 2 + indent; + boolean current = isCurrentRow(row, currentGuideId, currentPageId); + boolean bookmarkable = row.bookmarkable(); + + GuideDebugLog.infoAlways( + "[chrome] nav row y={} indent={} title={} kind={}", + rowY, + indent, + displayRow.title(), + displayRow.kind()); + + if (sticky) { + collector.emit( + new GuideRenderPrimitive.FillRect(x, rowY, rowRight - x, ROW_H, ColorUtils.ARGB_F0181818.getColor())); + collector.emit( + new GuideRenderPrimitive.FillRect( + x, + rowY + ROW_H - 1, + rowRight - x, + 1, + ColorUtils.ARGB_802A2A2A.getColor())); + } + if (current) { + collector.emit( + new GuideRenderPrimitive.FillRect(x, rowY, rowRight - x, ROW_H, ColorUtils.ARGB_40FFFFFF.getColor())); + } + + if (row.hasChildren()) { + emitArrowPrimitive(collector, rowX, rowY + 2, isCollapsed(row), ColorUtils.ARGB_FFCCCCCC.getColor()); + } + + int textX = rowX + EXPAND_INDENT; + GuidePageIcon icon = displayRow.icon(); + if (icon != null) { + // Headless: texture/item mini-icon blits are skipped (GUI texture and + // item rendering do not round-trip through the offscreen primitive + // pipeline); the text x still advances past the icon slot to match + // the live bar geometry. + textX += ICON_SIZE + 2; + } + + int textRight = textRightBase; + if (bookmarkable) { + textRight -= ACTION_SLOT_W; + } + int maxTw = textRight - textX; + if (maxTw > 0) { + ResourceLocation pageId = row.pageId(); + boolean failed = pageId != null && pageCollection != null && pageCollection.isPageFailed(pageId); + int color = getRowTextColor(current, false, failed); + renderRowTitle( + Minecraft.getMinecraft(), + collector, + row, + textX, + rowY, + maxTw, + color, + false, + DisplayScale.scaleFactor()); + } + } + + /** Emit the stepped expander/closed arrow as 1px quads (mirror of {@link #drawArrow}). */ + private static void emitArrowPrimitive(PrimitiveCollector collector, int x, int y, boolean pointRight, int color) { + if (pointRight) { + collector.emit(new GuideRenderPrimitive.FillRect(x, y, 1, 7, color)); + collector.emit(new GuideRenderPrimitive.FillRect(x + 1, y + 1, 1, 5, color)); + collector.emit(new GuideRenderPrimitive.FillRect(x + 2, y + 2, 1, 3, color)); + collector.emit(new GuideRenderPrimitive.FillRect(x + 3, y + 3, 1, 1, color)); + } else { + collector.emit(new GuideRenderPrimitive.FillRect(x, y, 7, 1, color)); + collector.emit(new GuideRenderPrimitive.FillRect(x + 1, y + 1, 5, 1, color)); + collector.emit(new GuideRenderPrimitive.FillRect(x + 2, y + 2, 3, 1, color)); + collector.emit(new GuideRenderPrimitive.FillRect(x + 3, y + 3, 1, 1, color)); + } + } + + /** Line top that centers the row text's line box vertically in a ROW_H box. */ + private static int rowLineTop(int rowY) { + return rowY + (ROW_H - GuideText.lineHeight(ROW_TEXT_STYLE)) / 2; + } + + /** Line top that centers the title text's line box vertically in the TITLE_H bar. */ + private int titleLineTop() { + return y + (TITLE_H - GuideText.lineHeight(TITLE_TEXT_STYLE)) / 2; + } + public static int getRowTextColor(boolean current, boolean hovered, boolean failed) { if (failed) { return current ? ColorUtils.ARGB_FFFF9999.getColor() @@ -1302,9 +1730,6 @@ public static class Row { private int cachedMaxTw = -1; private int cachedTitleWidth = -1; private int cachedScrollCycleWidth = -1; - // The width these were measured at, so a font whose metrics change (a custom font from another mod) - // re-measures instead of keeping a width the new font no longer matches. - private int cachedWidthAtLineHeight = -1; public Row(GuideNavProjection.ProjectedRow projectedRow) { this.projectedRow = projectedRow; @@ -1372,28 +1797,29 @@ public GuideNavProjection.RowKind kind() { return displayRow.kind(); } - public String getTitle(FontRenderer fr, int maxTw) { + public String getTitle(int maxTw) { if (maxTw == cachedMaxTw && cachedTitle != null) { return cachedTitle; } String title = displayRow.title(); - cachedTitle = getTitleWidth(fr) > maxTw ? fr.trimStringToWidth(title, Math.max(0, maxTw - 4)) + "…" : title; + cachedTitle = getTitleWidth() > maxTw + ? GuideText.clipToWidth(title, maxTw, ROW_TEXT_STYLE, GuideText.ClipSuffix.UNICODE_ELLIPSIS) + : title; cachedMaxTw = maxTw; return cachedTitle; } - public int getTitleWidth(FontRenderer fr) { - int lineHeight = GuideFontCompat.getLineHeight(fr); - if (cachedTitleWidth < 0 || cachedWidthAtLineHeight != lineHeight) { - cachedWidthAtLineHeight = lineHeight; - cachedTitleWidth = fr.getStringWidth(displayRow.title()); - cachedScrollCycleWidth = fr.getStringWidth(displayRow.title() + TITLE_SCROLL_GAP); + public int getTitleWidth() { + if (cachedTitleWidth < 0) { + cachedTitleWidth = GuideText.measureWidth(displayRow.title(), ROW_TEXT_STYLE); } return cachedTitleWidth; } - public int getScrollCycleWidth(FontRenderer fr) { - getTitleWidth(fr); + public int getScrollCycleWidth() { + if (cachedScrollCycleWidth < 0) { + cachedScrollCycleWidth = GuideText.measureWidth(displayRow.title() + TITLE_SCROLL_GAP, ROW_TEXT_STYLE); + } return cachedScrollCycleWidth; } diff --git a/src/main/java/com/hfstudio/guidenh/guide/internal/search/GuideSearchSnippetFormatter.java b/src/main/java/com/hfstudio/guidenh/guide/internal/search/GuideSearchSnippetFormatter.java index e7d6a3a8..fedaf716 100644 --- a/src/main/java/com/hfstudio/guidenh/guide/internal/search/GuideSearchSnippetFormatter.java +++ b/src/main/java/com/hfstudio/guidenh/guide/internal/search/GuideSearchSnippetFormatter.java @@ -12,6 +12,7 @@ import com.hfstudio.guidenh.guide.document.flow.LytFlowContent; import com.hfstudio.guidenh.guide.document.flow.LytFlowSpan; import com.hfstudio.guidenh.guide.document.flow.LytFlowText; +import com.hfstudio.guidenh.guide.render.GuideText; public class GuideSearchSnippetFormatter { @@ -139,12 +140,20 @@ public static LytFlowContent clipToVisibleCharsWithEllipsis(LytFlowContent conte return new LytFlowSpan(); } if (maxVisibleChars <= ELLIPSIS.length()) { - return LytFlowText.of(ELLIPSIS.substring(0, maxVisibleChars)); + return LytFlowText.of(GuideText.clipToChars(ELLIPSIS, maxVisibleChars, GuideText.ClipSuffix.NONE)); } var plainText = toPlainText(content); int clippedChars = Math.max(0, maxVisibleChars - ELLIPSIS.length()); - while (clippedChars > 0 && Character.isWhitespace(plainText.charAt(clippedChars - 1))) { + // Codepoint-aware trailing-whitespace trim: clippedChars is a codepoint + // budget, so index the plain text by codepoint offset, never charAt + // (which could land inside a surrogate pair). + while (clippedChars > 0) { + int offset = Character.offsetByCodePoints(plainText, 0, clippedChars); + int lastCodepoint = plainText.codePointBefore(offset); + if (!Character.isWhitespace(lastCodepoint)) { + break; + } clippedChars--; } @@ -252,7 +261,7 @@ public static int countVisibleChars(LytFlowContent content) { @Override public void text(String value) { - count[0] += value.length(); + count[0] += value.codePointCount(0, value.length()); } }); return count[0]; @@ -278,7 +287,7 @@ public static LytFlowContent clipNode(LytFlowContent content, int remainingChars if (content instanceof LytFlowText textNode) { var clipped = new LytFlowText(); var text = textNode.getText(); - clipped.setText(text.substring(0, Math.min(text.length(), remainingChars))); + clipped.setText(GuideText.clipToChars(text, remainingChars, GuideText.ClipSuffix.NONE)); clipped.setStyle(textNode.getStyle()); clipped.setHoverStyle(textNode.getHoverStyle()); return clipped; diff --git a/src/main/java/com/hfstudio/guidenh/guide/internal/settings/GuideSettingsDocumentBuilder.java b/src/main/java/com/hfstudio/guidenh/guide/internal/settings/GuideSettingsDocumentBuilder.java new file mode 100644 index 00000000..dce30ea2 --- /dev/null +++ b/src/main/java/com/hfstudio/guidenh/guide/internal/settings/GuideSettingsDocumentBuilder.java @@ -0,0 +1,324 @@ +package com.hfstudio.guidenh.guide.internal.settings; + +import java.util.function.Consumer; + +import com.hfstudio.guidenh.config.ModConfig; +import com.hfstudio.guidenh.guide.color.ConstantColor; +import com.hfstudio.guidenh.guide.document.LytSize; +import com.hfstudio.guidenh.guide.document.block.AlignItems; +import com.hfstudio.guidenh.guide.document.block.LytButton; +import com.hfstudio.guidenh.guide.document.block.LytDocument; +import com.hfstudio.guidenh.guide.document.block.LytHBox; +import com.hfstudio.guidenh.guide.document.block.LytParagraph; +import com.hfstudio.guidenh.guide.document.block.LytVBox; +import com.hfstudio.guidenh.guide.internal.GuideScreen; +import com.hfstudio.guidenh.guide.render.GuideText; +import com.hfstudio.guidenh.guide.render.PrimitiveCollector; +import com.hfstudio.guidenh.guide.style.BorderStyle; +import com.hfstudio.guidenh.guide.ui.GuideUiHost; + +/** + * Builds the reader settings page document. + *

          + * Pure static construction: {@link #buildDocument()} only assembles the Lyt + * tree and reads {@link ModConfig.reader}, with no GL, font loading or Minecraft + * singleton access, so the same code path is headless-testable. The ± click + * callbacks (which do touch the screen to refresh the echo) only fire at + * render-time interaction, never during build. + *

          + * Rows in declared order: font size, body font family, code font + * family; all three share {@link #buildStepperRow(String, String, String, + * String, Consumer, Consumer)} so they are structurally identical by + * construction. + */ +public class GuideSettingsDocumentBuilder { + + public static final ConstantColor DIVIDER_COLOR = new ConstantColor(0xFF3A3A3A); + public static final ConstantColor CATEGORY_SELECTED_COLOR = new ConstantColor(0xFF00D2FC); + + /** Ascending presets, first entry 0 = follow system (see GuideText.resolveBaseFontSize). */ + public static final int[] FONT_SIZE_PRESETS = { 0, 9, 10, 11, 12, 13, 14, 16, 18, 20 }; + + /** + * Body font-family presets. The array order IS the + * stepping order; the raw config string is displayed untranslated. + */ + public static final String[] BODY_FONT_FAMILY_PRESETS = { "system", "serif", "monospace" }; + + /** + * Code font-family presets. The array order IS the + * stepping order; the raw config string is displayed untranslated. + */ + public static final String[] CODE_FONT_FAMILY_PRESETS = { "mono", "Consolas", "Courier New" }; + + private static final int STEP_BUTTON_WIDTH = 18; + private static final int STEP_BUTTON_HEIGHT = 16; + + private GuideSettingsDocumentBuilder() {} + + /** Builds the settings page tree: category sidebar on the left, setting rows on the right. */ + public static LytDocument buildDocument() { + var document = new LytDocument(); + var root = new LytHBox(); + root.setFullWidth(true); + root.setWrap(false); + root.setGap(16); + root.setAlignItems(AlignItems.START); + root.append(buildCategorySidebar()); + root.append(buildSettingsRows()); + document.append(root); + return document; + } + + private static LytVBox buildCategorySidebar() { + var sidebar = new LytVBox(); + sidebar.setGap(4); + sidebar.setPaddingRight(8); + sidebar.setFullWidth(false); + + // Single category today; the selected-state visual is bold + accent + // color (paragraphs have no border/background rendering). + var readerCategory = new LytParagraph(); + readerCategory.appendText("阅读 / Reader"); + readerCategory.modifyStyle( + style -> style.bold(true) + .color(CATEGORY_SELECTED_COLOR)); + sidebar.append(readerCategory); + return sidebar; + } + + private static LytVBox buildSettingsRows() { + var rows = new LytVBox(); + rows.setFullWidth(true); + rows.setFlexGrow(1f); + rows.setGap(0); + rows.append(buildFontSizeRow()); + rows.append(buildBodyFontFamilyRow()); + rows.append(buildCodeFontFamilyRow()); + return rows; + } + + /** + * Shared stepper-row skeleton: label (flex-grow) on the left, + * then − / value / +, bottom divider. Every settings row is built through + * this one method, so the three rows are isomorphic by construction. + */ + private static LytHBox buildStepperRow(String labelText, String valueText, String minusTooltip, String plusTooltip, + Consumer onMinus, Consumer onPlus) { + // Row skeleton mirrors GuideSearchResultDocumentBuilder.ResultRowBlock: + // full-width, no wrap, stable height via padding + bottom divider. + var row = new LytHBox(); + row.setFullWidth(true); + row.setWrap(false); + row.setGap(8); + row.setAlignItems(AlignItems.CENTER); + row.setPaddingBottom(4); + row.setBorderBottom(new BorderStyle(DIVIDER_COLOR, 1)); + + var label = new LytParagraph(); + label.setFlexGrow(1f); + label.appendText(labelText); + + var value = new LytParagraph(); + value.appendText(valueText); + + var minus = new TextButton("−"); + minus.setOnClick(onMinus); + minus.setTooltipText(minusTooltip); + + var plus = new TextButton("+"); + plus.setOnClick(onPlus); + plus.setTooltipText(plusTooltip); + + row.append(label); + row.append(minus); + row.append(value); + row.append(plus); + return row; + } + + private static LytHBox buildFontSizeRow() { + return buildStepperRow( + "字号 / Font size", + displayFontSize(ModConfig.reader.readerFontSize), + "减小字号 / Decrease font size", + "增大字号 / Increase font size", + screen -> onFontSizeStep(screen, false), + screen -> onFontSizeStep(screen, true)); + } + + private static LytHBox buildBodyFontFamilyRow() { + return buildStepperRow( + "正文字体 / Font family", + ModConfig.reader.readerFontFamily, + "上一个字体 / Previous font family", + "下一个字体 / Next font family", + screen -> onBodyFontFamilyStep(screen, false), + screen -> onBodyFontFamilyStep(screen, true)); + } + + private static LytHBox buildCodeFontFamilyRow() { + return buildStepperRow( + "代码字体 / Code font", + ModConfig.reader.readerCodeFontFamily, + "上一个字体 / Previous font family", + "下一个字体 / Next font family", + screen -> onCodeFontFamilyStep(screen, false), + screen -> onCodeFontFamilyStep(screen, true)); + } + + /** Applies one preset step to the persisted reader font size (write + save). */ + static void applyFontSizeStep(boolean increase) { + int current = ModConfig.reader.readerFontSize; + int next = increase ? FontSizePresets.next(current) : FontSizePresets.prev(current); + if (next == current) { + return; + } + ModConfig.reader.readerFontSize = next; + ModConfig.save(); + } + + /** Applies one preset step to the persisted reader body font family (write + save). */ + static void applyBodyFontFamilyStep(boolean increase) { + String current = ModConfig.reader.readerFontFamily; + String next = increase ? StringPresets.next(BODY_FONT_FAMILY_PRESETS, current) + : StringPresets.prev(BODY_FONT_FAMILY_PRESETS, current); + if (next.equals(current)) { + return; + } + ModConfig.reader.readerFontFamily = next; + ModConfig.save(); + } + + /** Applies one preset step to the persisted reader code font family (write + save). */ + static void applyCodeFontFamilyStep(boolean increase) { + String current = ModConfig.reader.readerCodeFontFamily; + String next = increase ? StringPresets.next(CODE_FONT_FAMILY_PRESETS, current) + : StringPresets.prev(CODE_FONT_FAMILY_PRESETS, current); + if (next.equals(current)) { + return; + } + ModConfig.reader.readerCodeFontFamily = next; + ModConfig.save(); + } + + /** + * Click wiring: persist the step, then refresh the settings document so the + * value echo updates. Relayout itself is automatic: the frame-by-frame + * invalidation in {@code GuideScreen.ensureLayout}, which compares + * {@code resolveBaseFontSize} every frame, handles it, so no extra + * invalidation mechanism is added here. + */ + private static void onFontSizeStep(GuideUiHost screen, boolean increase) { + applyFontSizeStep(increase); + if (screen instanceof GuideScreen guideScreen) { + guideScreen.rebuildSettingsDocument(); + } + } + + private static void onBodyFontFamilyStep(GuideUiHost screen, boolean increase) { + applyBodyFontFamilyStep(increase); + if (screen instanceof GuideScreen guideScreen) { + guideScreen.rebuildSettingsDocument(); + } + } + + private static void onCodeFontFamilyStep(GuideUiHost screen, boolean increase) { + applyCodeFontFamilyStep(increase); + if (screen instanceof GuideScreen guideScreen) { + guideScreen.rebuildSettingsDocument(); + } + } + + public static String displayFontSize(int size) { + return size == 0 ? "跟随系统" : String.valueOf(size); + } + + /** Pure stepping helper (endpoints clamp) so the preset walk is unit-testable. */ + public static final class FontSizePresets { + + private FontSizePresets() {} + + public static int next(int current) { + for (int preset : FONT_SIZE_PRESETS) { + if (preset > current) { + return preset; + } + } + return FONT_SIZE_PRESETS[FONT_SIZE_PRESETS.length - 1]; + } + + public static int prev(int current) { + for (int i = FONT_SIZE_PRESETS.length - 1; i >= 0; i--) { + if (FONT_SIZE_PRESETS[i] < current) { + return FONT_SIZE_PRESETS[i]; + } + } + return FONT_SIZE_PRESETS[0]; + } + } + + /** + * Pure string-preset stepping helper shared by both font-family rows. + * The {@code presets} array order IS the stepping order; endpoints + * clamp exactly like {@link FontSizePresets}: the font-size row clamps, so + * the family rows clamp too. A current value absent from the array (e.g. a + * hand-edited config value) is treated as above-range for {@link #next} + * (top clamp) and below-range for {@link #prev} (bottom clamp); the string + * analog of FontSizePresets' out-of-range behavior, since string presets + * carry no intrinsic total order. + */ + public static final class StringPresets { + + private StringPresets() {} + + public static String next(String[] presets, String current) { + for (int i = 0; i < presets.length; i++) { + if (presets[i].equals(current)) { + return i + 1 < presets.length ? presets[i + 1] : presets[i]; + } + } + return presets[presets.length - 1]; + } + + public static String prev(String[] presets, String current) { + for (int i = 0; i < presets.length; i++) { + if (presets[i].equals(current)) { + return i > 0 ? presets[i - 1] : presets[i]; + } + } + return presets[0]; + } + } + + /** + * Text-rendering stepper button. Sprite-less LytButton (construction is + * headless-safe); the label is drawn at render time through the standard + * GuideText pipeline, mirroring LytParagraph's fallback emission. + */ + public static final class TextButton extends LytButton { + + private final String label; + + public TextButton(String label) { + super(null, new LytSize(STEP_BUTTON_WIDTH, STEP_BUTTON_HEIGHT)); + this.label = label; + modifyStyle(style -> style.bold(true)); + } + + public String getLabel() { + return label; + } + + @Override + public void computePrimitives(PrimitiveCollector c) { + var bounds = getBounds(); + if (bounds == null || bounds.width() <= 0 || bounds.height() <= 0 || label.isEmpty()) { + return; + } + int lineHeight = GuideText.baseLineHeight(); + int textY = bounds.y() + Math.max(0, (bounds.height() - lineHeight) / 2); + GuideText.emitText(c, label, bounds.x() + 3, textY, resolveStyle()); + } + } +} diff --git a/src/main/java/com/hfstudio/guidenh/guide/internal/settings/GuideSettingsPage.java b/src/main/java/com/hfstudio/guidenh/guide/internal/settings/GuideSettingsPage.java new file mode 100644 index 00000000..00bd3069 --- /dev/null +++ b/src/main/java/com/hfstudio/guidenh/guide/internal/settings/GuideSettingsPage.java @@ -0,0 +1,28 @@ +package com.hfstudio.guidenh.guide.internal.settings; + +import net.minecraft.util.ResourceLocation; + +import org.jetbrains.annotations.Nullable; + +import com.hfstudio.guidenh.guide.PageAnchor; +import com.hfstudio.guidenh.guide.internal.GuideME; + +/** + * Routing helpers for the virtual "settings" page (Kind.SETTINGS). The page + * document itself is built by {@link GuideSettingsDocumentBuilder}; this class + * this class owns the page id / anchor only. + */ +public class GuideSettingsPage { + + public static final ResourceLocation PAGE_ID = GuideME.makeId("settings"); + + private GuideSettingsPage() {} + + public static boolean isSettingsAnchor(@Nullable PageAnchor anchor) { + return anchor != null && PAGE_ID.equals(anchor.pageId()); + } + + public static PageAnchor anchor() { + return PageAnchor.page(PAGE_ID); + } +} diff --git a/src/main/java/com/hfstudio/guidenh/guide/internal/util/DisplayScale.java b/src/main/java/com/hfstudio/guidenh/guide/internal/util/DisplayScale.java index 59cf642f..ae3a9e5b 100644 --- a/src/main/java/com/hfstudio/guidenh/guide/internal/util/DisplayScale.java +++ b/src/main/java/com/hfstudio/guidenh/guide/internal/util/DisplayScale.java @@ -12,10 +12,42 @@ public class DisplayScale { public static int cachedScaledWidth = 0; public static int cachedScaledHeight = 0; + /** + * Headless injection-window scale override source (0 = disabled). + * + *

          + * When {@code > 0}, {@link #scaleFactor()} returns this value directly, + * short-circuiting before any {@link ScaledResolution} computation. This + * bypasses the MC 1.7.10 ScaledResolution geometric cap + * ({@code sf = min(guiScale, floor(dw/320), floor(dh/240))}), which would + * otherwise clamp the effective scaleFactor to 2 on the 854×480 headless + * window even with guiScale=4 injected. + * + *

          + * Scope is strictly equal to {@code RenderPageService}'s injection + * window: the value is set right after the guiscale injection and MUST be + * cleared (set to 0) when that window ends. + */ + private static int injectedScaleFactor = 0; + + /** + * Set the headless injection-window scale override (0 = disabled). + * See {@link #injectedScaleFactor} for the scope/cleanup contract. + */ + public static void setInjectedScaleFactor(int sf) { + injectedScaleFactor = sf; + } + private DisplayScale() {} public static void refreshIfNeeded() { - Minecraft mc = Minecraft.getMinecraft(); + Minecraft mc; + try { + mc = Minecraft.getMinecraft(); + } catch (Throwable t) { + return; // headless (unit tests): Minecraft class unavailable, keep cached defaults + } + if (mc == null) return; // headless (unit tests): keep cached defaults int dw = mc.displayWidth; int dh = mc.displayHeight; int gs = mc.gameSettings != null ? mc.gameSettings.guiScale : 0; @@ -30,6 +62,7 @@ public static void refreshIfNeeded() { } public static int scaleFactor() { + if (injectedScaleFactor > 0) return injectedScaleFactor; refreshIfNeeded(); return cachedScaleFactor; } diff --git a/src/main/java/com/hfstudio/guidenh/guide/internal/welcome/GuideWelcomeScreen.java b/src/main/java/com/hfstudio/guidenh/guide/internal/welcome/GuideWelcomeScreen.java index 54635902..e681dd13 100644 --- a/src/main/java/com/hfstudio/guidenh/guide/internal/welcome/GuideWelcomeScreen.java +++ b/src/main/java/com/hfstudio/guidenh/guide/internal/welcome/GuideWelcomeScreen.java @@ -452,22 +452,17 @@ private void renderDocument(int mouseX, int mouseY) { int viewportTop = Math.max(0, Math.round(scrollY)); renderContext.setViewport(new LytRect(0, viewportTop, docW, docH)); + renderContext.setScreenViewport(new LytRect(docX, docY, docW, docH)); renderContext.setScreenHeight(height); renderContext.setDocumentOrigin(docX, docY); renderContext.setPreciseScrollOffsetY(scrollY); renderContext.setZoom(1.0f); - renderContext.pushScissor(new LytRect(docX, docY, docW, docH)); - GL11.glPushMatrix(); - GL11.glTranslatef(docX, docY, 0f); - GL11.glTranslatef(0f, -scrollY, 0f); + // No GL matrix or context scissor here: the primitive pipeline's render + // engine owns the document->screen transform and the viewport clip. try { document.render(renderContext); } catch (Throwable t) { GuideDebugLog.error("[GuideNH] Error rendering welcome popup", t); - } finally { - GL11.glPopMatrix(); - renderContext.restoreExternalRenderState(); - renderContext.popScissor(); } } diff --git a/src/main/java/com/hfstudio/guidenh/guide/latex/GuideLatexRenderer.java b/src/main/java/com/hfstudio/guidenh/guide/latex/GuideLatexRenderer.java index c1a3b235..cd523307 100644 --- a/src/main/java/com/hfstudio/guidenh/guide/latex/GuideLatexRenderer.java +++ b/src/main/java/com/hfstudio/guidenh/guide/latex/GuideLatexRenderer.java @@ -1,13 +1,28 @@ package com.hfstudio.guidenh.guide.latex; +import java.awt.BasicStroke; import java.awt.Color; +import java.awt.FontMetrics; +import java.awt.Graphics; import java.awt.Graphics2D; +import java.awt.GraphicsConfiguration; import java.awt.Insets; +import java.awt.Paint; +import java.awt.Polygon; +import java.awt.Rectangle; import java.awt.RenderingHints; +import java.awt.Shape; +import java.awt.Stroke; +import java.awt.font.GlyphVector; +import java.awt.geom.AffineTransform; import java.awt.image.BufferedImage; +import java.awt.image.BufferedImageOp; +import java.awt.image.RenderedImage; +import java.awt.image.renderable.RenderableImage; import java.nio.ByteBuffer; -import java.util.Map; +import java.text.AttributedCharacterIterator; import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.atomic.AtomicInteger; import net.minecraft.client.renderer.Tessellator; @@ -20,65 +35,182 @@ import com.hfstudio.guidenh.guide.color.ColorUtils; import com.hfstudio.guidenh.guide.scene.support.GuideDebugLog; +/** + * Typeset-at-target-size LaTeX renderer. + * + *

          + * Formulas are typeset directly at their final pixel font size + * ({@code fontSize} = body base font size × user scale) via + * {@code TeXIconBuilder.setSize(fontSize)} - no 100px oversized texture, no + * scaling, no reference-string calibration. The returned icon pixel + * dimensions ARE the display dimensions. The only retained mechanism is the + * {@code TeXIcon#getBaseLine()} ratio cache, which feeds the inline baseline + * anchoring chain (see {@code LytLatexBlock}). + * + *

          + * Device-resolution rasterization. The OpenGL texture is rasterized + * at {@code fontSize × rasterScale} device pixels (rasterScale = the real + * output scale: in-game {@code DisplayScale.scaleFactor()}, headless the + * offscreen {@code PushTransform(0,0,scale)} wrap), while the blit quad keeps + * the document {@code formulaDisplayW/H} dimensions. Because the blit quad is + * scaled by the same factor to device pixels, texture pixels map 1:1 onto + * device pixels (no GL_LINEAR upscale blur) - the same contract as the text + * path, which rasterizes glyphs at {@code font_size × render_scale} + * (parley_text.rs:834). + * + *

          + * The texture is created lazily at blit time: blocks emit a stable + * negative blit token (never a GL texture id) via + * {@link #registerLatexBlit}; {@code GuideRenderEngine.drawBlitTexture} + * resolves the real texture via {@link #resolveLatexTexture} with the exact + * device scale of the current transform stack, which is the only point where + * the headless output scale is known (the offscreen {@code PushTransform} + * wrap is applied after primitive collection). + */ public class GuideLatexRenderer { public static final GuideLatexRenderer INSTANCE = new GuideLatexRenderer(); private static final int DEFAULT_FILL_COLOR_ARGB = ColorUtils.WHITE.getColor(); - /** Calibration formula used to determine a reference character height at a given sourceScale. */ - private static final String CALIBRATION_FORMULA = "x"; - private static final int MAX_REF_HEIGHT_ENTRIES = 16; - - /** Maps sourceScale float (rounded to string) -> calibrated reference height in pixels. */ - private final ConcurrentHashMap refHeightCache = new ConcurrentHashMap<>(); - - protected GuideLatexRenderer() {} + /** + * Supersampling factor applied to formula texture rasterization. + * + *

          + * Mechanism. jlatexmath's built-in CM math fonts (jlm_cmmi10.ttf + * etc.) carry no TrueType hinting tables (no cvt/fpgm/prep), so glyph stems + * (physical width 2.5-3.1 device px at the output scales in use) cannot be + * snapped to the pixel grid when rasterized 1:1 at device resolution: core + * pixels stay under-weighted and the AA fringe dominates, making strokes + * read visually thinner than body text (msyh, which IS hinted). + * Supersampling attacks this in the pure pixel domain: the formula is + * typeset and rasterized internally at {@code fontSize × rasterScale × S} + * and then bilinearly downsampled back to {@code fontSize × rasterScale}. + * The multi-sample energy of a sub-pixel stem is redistributed onto the + * target grid, restoring core-pixel weight without changing any geometry. + * + *

          + * Relationship to {@link #LATEX_EMBOLDEN_EM} (probe-decided: + * stack, not replace). An offline probe harness (real + * jlatexmath-1.0.7.jar + bundled CM fonts, full-bright = alpha>200 over + * alpha>30 ink) measured the in-game default scale (s=2, fontSize 11): + * outline embolden ALONE at S=1 only reaches 0.31-0.47 full-bright + * (unreachable for the ≥0.65 target), while S=3 alone is marginal + * (0.603 for 'x'). S=3 + em=0.010 reaches ≥0.70 at s=2 and ≥0.82 at s=4 + * across the corpus - hence the two mechanisms are STACKED: supersampling + * restores sub-pixel stem energy, the outline stroke adds physical stem + * width. See {@link #LATEX_EMBOLDEN_EM} for the outline mechanism. + * + *

          + * Resolution contract boundary. This constant ONLY raises the internal + * rasterization resolution. The final texture dimensions stay exactly the + * device-resolution {@code fontSize × rasterScale} icon size (see + * {@link #getOrCreateTexture}: the target size icon is built at + * {@code fontSize × rasterScale}), and the blit keeps its 1:1 device-pixel + * mapping with GL_LINEAR - nothing in the layout/typesetting chain or the + * texture upload/quad path changes. Geometry never enters Java: this is a + * pure rasterization (pixel) layer change. + */ + private static final float LATEX_SUPERSAMPLE_S = 3f; /** - * Returns the calibrated reference height (in pixels) for {@code "x"} rendered at - * {@code sourceScale}. Subsequent calls with the same scale are instant (cached). - * Safe to call from any thread; does not touch OpenGL. + * Outline-domain emboldening stroke width, in em units of the internal + * rasterization em ({@code em = fontSize × rasterScale × S}). * - * @param sourceScale jlatexmath render size parameter - * @return pixel height of a lower-case "x" glyph at the given scale + *

          + * Mechanism (MathJax SVG "blacker" - Java2D equivalent). + * MathJax SVG output strokes every glyph path with the same colour as its + * fill: {@code path[data-c], use[data-c] { stroke-width: blacker }} with + * {@code stroke: currentColor} inherited from the root group + * (MathJax-src {@code ts/output/svg/Wrappers/TextNode.ts} addStyles; + * {@code ts/output/svg/SVG.ts} OPTIONS {@code blacker: 3}); i.e. each + * glyph outline is drawn fill(currentColor) + stroke(currentColor, + * stroke-width=3/1000em). KaTeX does the same at font-build time + * (METAFONT blacker 15 + ttfautohint). Outline-first-then-rasterize is the + * standard answer for unhinted thin stems; pixel-domain compensation + * (supersampling) is not a substitute (see {@link #LATEX_SUPERSAMPLE_S}). + * In Java2D this is done by intercepting {@code Graphics2D.drawChars} (the + * only text path jlatexmath uses - {@code CharBox.draw} → + * {@code g2.drawChars}) in {@link #renderToImage} and re-rendering each + * glyph as its {@code GlyphVector} outline: fill + same-colour + * {@link java.awt.BasicStroke} (see {@link EmboldenGraphics2D}). + * + *

          + * Em conversion (constant device-pixel stroke, NOT per-glyph + * em-ratio). The internal rasterization em is + * {@code fontSize × rasterScale × S} device px of the supersampled image + * (typeset via {@code setSize(fontSize × rasterScale × S)}), so the stroke + * width in that image is {@code LATEX_EMBOLDEN_EM × fontSize × rasterScale + * × S} px; after the S× bilinear downsample the device stroke is + * {@code LATEX_EMBOLDEN_EM × fontSize × rasterScale} px. That is a CONSTANT + * number of device pixels for every glyph - script/script-script glyphs do + * NOT get an em-proportional thinner stroke, because the script size factor + * is already inside CharBox's transform scale and + * {@code strokeUserWidth()} divides it out again (see + * {@code EmboldenGraphics2D}). This is the deliberate difference from + * MathJax's em-relative {@code blacker} (3/1000em per glyph at that glyph's + * own em), kept for three reasons: script glyphs have the thinnest stems + * and need the full stroke for readability; METAFONT blacker is likewise + * not an em-ratio adjustment per size; and the whole probe calibration + * (next paragraph) was measured on this constant-device-pixel behaviour. + * + *

          + * Value (probe-calibrated from the MathJax default). MathJax's + * default blacker is 3/1000em; at our em scales (44px at s=4, 22px at + * s=2) that is a 0.13/0.07 px stroke - sub-pixel, insufficient (probe: + * S=3+0.003 → 'x' 0.616 full-bright at s=2, below the 0.65 target). + * The same probe harness (real jlatexmath jar + CM fonts) swept + * 0.003-0.030em; 0.010 (=10/1000em, ~0.44/0.22 px device stroke) is the + * smallest value reaching ≥0.70 full-bright at s=2 and ≥0.82 at s=4 for + * the corpus, hence the default. All these numbers (including the + * counter-closure measurements below) were measured under the + * constant-device-pixel stroke described above - script glyphs included - + * so switching to MathJax's per-glyph em-ratio mechanism would invalidate + * them and require re-running the entire calibration, with script stems + * ending up thinner than the values below were chosen to keep readable. + * + *

          + * Counter-closure risk (probe-measured at s=4). blacker too + * large closes glyph counters (MathJax community reference: >20/1000em + * starts closing details). Probe measurement of interior white + * (alpha≤30 enclosed) at s=4 S=3 fontSize 11: at 0.010 the counters + * shrink by ≤15% (e: 464→394, a: 1176→1093, g: 1142→1035 px) and NO hole + * is destroyed for e/a/g/√x; {@code \int} and {@code \sum} have no + * enclosed counter at this resolution; the {@code \frac{a}{b}} interior + * (2327→2131 px, -8%) and its rule stay open. Closure appears only far + * above the default (0.030 shrinks e's counter to 304 px but still open), + * so 0.010 sits well inside the safe range. + * + *

          + * Contract. This is a pure rasterization-layer change inside + * {@link #renderToImage}: the final texture dimensions, the 1:1 blit + * geometry and the GL_LINEAR upload are untouched. 0 disables emboldening + * (plain {@code drawChars} path) for A/B comparison. */ - public int calibrateRefHeight(float sourceScale) { - String key = GuideLatexTextureCache.buildScaleKey(sourceScale); - Integer height = refHeightCache.computeIfAbsent(key, k -> { - try { - TeXFormula formula = new TeXFormula(CALIBRATION_FORMULA); - TeXIcon icon = formula.new TeXIconBuilder().setStyle(TeXConstants.STYLE_DISPLAY) - .setSize(sourceScale) - .setFGColor(new Color(DEFAULT_FILL_COLOR_ARGB, true)) - .build(); - icon.setInsets(new Insets(2, 2, 2, 2)); - int h = icon.getIconHeight(); - return Math.max(1, h); - } catch (ParseException e) { - GuideDebugLog.warn("[GuideNH/LaTeX] Failed to calibrate reference height for scale {}", sourceScale, e); - return 16; - } - }); - trimRefHeightCacheIfNeeded(); - return height; - } + private static final float LATEX_EMBOLDEN_EM = 0.010f; - private void trimRefHeightCacheIfNeeded() { - if (refHeightCache.size() <= MAX_REF_HEIGHT_ENTRIES) { - return; - } - for (Map.Entry entry : refHeightCache.entrySet()) { - if (refHeightCache.size() <= MAX_REF_HEIGHT_ENTRIES) { - return; - } - refHeightCache.remove(entry.getKey(), entry.getValue()); - } - } + /** + * Maps (fontSize:style:formula) size-key -> the TeXIcon baseline ratio + * from {@link TeXIcon#getBaseLine()}: the distance from the icon's top edge + * to its math baseline, expressed as a fraction of the icon's total height + * (insets included), in {@code [0,1]}. Kept in exact float form so the + * inline anchor computation never round-trips through an intermediate + * ceil of the icon depth (see {@code LytLatexBlock}). + */ + private final ConcurrentHashMap baselineRatioCache = new ConcurrentHashMap<>(); + + protected GuideLatexRenderer() {} /** - * Returns the pixel dimensions {@code [widthPx, heightPx, depthPx]} of {@code formula} rendered at - * {@code sourceScale}, or {@code null} if the formula is invalid/failed. + * Returns the pixel dimensions {@code [widthPx, heightPx, depthPx]} of {@code formula} typeset at + * {@code fontSize} with the given jlatexmath {@code style}, or {@code null} if the formula is + * invalid/failed. + * + *

          + * {@code widthPx}/{@code heightPx} are the full TeXIcon pixel dimensions (the true 2px/side + * icon insets included) and ARE the display dimensions - no scaling happens downstream. This is + * the typeset-at-target-size contract: {@code setSize(fontSize)} typesets the formula at exactly + * {@code fontSize} pixels, so the icon width/height match the blit box 1:1. * *

          * {@code depthPx} is the typographic depth in jlatexmath pixels, the number of pixels the formula @@ -90,10 +222,12 @@ private void trimRefHeightCacheIfNeeded() { * * @param formula LaTeX source string * @param fillColorArgb ARGB colour (only used for cache key uniformity; does not affect size) - * @param sourceScale jlatexmath render size parameter + * @param fontSize target typeset font size in pixels (body base font size × user scale) + * @param style jlatexmath style constant ({@link TeXConstants#STYLE_DISPLAY} or + * {@link TeXConstants#STYLE_TEXT}) * @return [widthPx, heightPx, depthPx] or null on parse failure */ - public int[] measureSize(String formula, int fillColorArgb, float sourceScale) { + public int[] measureSize(String formula, int fillColorArgb, float fontSize, int style) { if (formula == null || formula.isEmpty()) { return null; } @@ -101,7 +235,7 @@ public int[] measureSize(String formula, int fillColorArgb, float sourceScale) { return null; } - String sizeKey = GuideLatexTextureCache.buildSizeCacheKey(formula, sourceScale); + String sizeKey = GuideLatexTextureCache.buildSizeCacheKey(formula, fontSize, style); int[] cached = GuideLatexTextureCache.INSTANCE.getSize(sizeKey); if (cached != null) { return cached; @@ -109,14 +243,17 @@ public int[] measureSize(String formula, int fillColorArgb, float sourceScale) { try { TeXFormula texFormula = new TeXFormula(formula); - TeXIcon icon = texFormula.new TeXIconBuilder().setStyle(TeXConstants.STYLE_DISPLAY) - .setSize(sourceScale) + TeXIcon icon = texFormula.new TeXIconBuilder().setStyle(style) + .setSize(fontSize) .setFGColor(new Color(fillColorArgb, true)) .build(); - icon.setInsets(new Insets(2, 2, 2, 2)); + // Two-arg form (trueValues): real 2px/side insets - the single-arg + // setInsets(Insets) silently adds (int)(0.18f*size) per side instead. + icon.setInsets(new Insets(2, 2, 2, 2), true); int w = icon.getIconWidth(); int h = icon.getIconHeight(); int d = getIconDepthPx(icon); + baselineRatioCache.put(sizeKey, icon.getBaseLine()); GuideLatexTextureCache.INSTANCE.putSize(sizeKey, w, h, d); return new int[] { w, h, d }; } catch (ParseException e) { @@ -131,23 +268,197 @@ public int[] measureSize(String formula, int fillColorArgb, float sourceScale) { } /** - * Returns (and caches) the OpenGL texture for {@code formula}. + * Returns the TeXIcon math-baseline ratio for {@code formula} typeset at {@code fontSize} + * with the given jlatexmath {@code style}, or {@code 0f} if the formula is invalid/failed. + * + *

          + * The ratio is exactly {@link TeXIcon#getBaseLine()}: the distance from the icon's top edge + * to its math baseline divided by the icon's total height (the true 2px/side insets + * included - the icons are built with the two-arg {@code setInsets(insets, true)}, NOT the + * single-arg variant, which silently adds {@code (int)(0.18f*size)} per side), a + * value in {@code [0,1]}. It is deliberately kept in this exact float form - NOT rounded via + * {@code ceil(getTrueIconDepth())} - so consumers can compute the display depth as + * {@code displayH * (1 - ratio)} with a single rounding at the end, instead of rounding the + * source depth twice. + * + *

          + * Safe to call from any thread; does NOT upload any OpenGL texture. + * + * @param formula LaTeX source string + * @param fillColorArgb ARGB colour (only used for cache key uniformity; does not affect size) + * @param fontSize target typeset font size in pixels (body base font size × user scale) + * @param style jlatexmath style constant ({@link TeXConstants#STYLE_DISPLAY} or + * {@link TeXConstants#STYLE_TEXT}) + * @return baseline ratio in [0,1], or 0f on parse failure + */ + public float measureBaselineRatio(String formula, int fillColorArgb, float fontSize, int style) { + if (formula == null || formula.isEmpty()) { + return 0f; + } + if (GuideLatexTextureCache.INSTANCE.hasFailed(formula)) { + return 0f; + } + + String sizeKey = GuideLatexTextureCache.buildSizeCacheKey(formula, fontSize, style); + Float cached = baselineRatioCache.get(sizeKey); + if (cached != null) { + return cached; + } + + try { + TeXFormula texFormula = new TeXFormula(formula); + TeXIcon icon = texFormula.new TeXIconBuilder().setStyle(style) + .setSize(fontSize) + .setFGColor(new Color(fillColorArgb, true)) + .build(); + // Two-arg form (trueValues): real 2px/side insets - the single-arg + // setInsets(Insets) silently adds (int)(0.18f*size) per side instead. + icon.setInsets(new Insets(2, 2, 2, 2), true); + float ratio = icon.getBaseLine(); + baselineRatioCache.put(sizeKey, ratio); + trimBaselineRatioCacheIfNeeded(); + return ratio; + } catch (ParseException e) { + GuideDebugLog + .warnAlways("[GuideNH/LaTeX] Parse error measuring baseline '{}': {}", formula, e.getMessage()); + return 0f; + } catch (Exception e) { + GuideDebugLog + .warnAlways("[GuideNH/LaTeX] Unexpected error measuring baseline '{}': {}", formula, e.getMessage(), e); + return 0f; + } + } + + private void trimBaselineRatioCacheIfNeeded() { + if (baselineRatioCache.size() <= MAX_BASELINE_RATIO_ENTRIES) { + return; + } + int removeCount = baselineRatioCache.size() - MAX_BASELINE_RATIO_ENTRIES; + for (String key : baselineRatioCache.keySet()) { + if (removeCount <= 0) { + return; + } + if (baselineRatioCache.remove(key) != null) { + removeCount--; + } + } + } + + private static final int MAX_BASELINE_RATIO_ENTRIES = 512; + + // Deferred LaTeX blit tokens. + + /** + * Render parameters for a formula registered for deferred rasterization. + */ + private record LatexBlitParams(String formula, int fillColorArgb, float fontSize, int style) {} + + /** Dedupe: (formula,fillColorArgb,fontSize,style) key -> stable negative blit token. */ + private final ConcurrentHashMap latexBlitTokenByKey = new ConcurrentHashMap<>(); + /** Negative blit token -> render parameters. */ + private final ConcurrentHashMap latexBlitParamsByToken = new ConcurrentHashMap<>(); + /** Negative token allocator. GL texture ids are always >= 1, so negative never collides. */ + private final AtomicInteger latexBlitTokenSeq = new AtomicInteger(-1); + + /** + * Registers {@code formula} for deferred rasterization at the blit-side + * device scale and returns a stable negative blit token (never a + * GL texture id). The token is emitted into the {@code BlitTexture} + * primitive; {@code GuideRenderEngine} resolves the real texture at draw + * time via {@link #resolveLatexTexture}, where the exact device output + * scale of the transform stack is known. + * + *

          + * The mapping is deduplicated by (formula, color, fontSize, style), so + * calling this every frame for the same formula returns the same token and + * the registry stays bounded by the distinct-formula count. + */ + public int registerLatexBlit(String formula, int fillColorArgb, float fontSize, int style) { + String key = buildBlitKey(formula, fillColorArgb, fontSize, style); + Integer existing = latexBlitTokenByKey.get(key); + if (existing != null) { + return existing; + } + int token = latexBlitTokenSeq.getAndDecrement(); + Integer raced = latexBlitTokenByKey.putIfAbsent(key, token); + if (raced != null) { + return raced; + } + latexBlitParamsByToken.put(token, new LatexBlitParams(formula, fillColorArgb, fontSize, style)); + return token; + } + + /** Returns {@code true} when {@code texId} is a deferred latex blit token, not a GL texture id. */ + public boolean isLatexBlitToken(int texId) { + return texId < 0; + } + + /** + * Resolves (creating if needed) the real OpenGL texture for a latex blit + * token at the given device raster scale. Returns + * {@code [textureId, widthPx, heightPx]} or {@code null} on failure. * Must be called from the Minecraft render thread. + */ + public int[] resolveLatexTexture(int token, float rasterScale) { + LatexBlitParams p = latexBlitParamsByToken.get(token); + if (p == null) { + return null; + } + return getOrCreateTexture(p.formula(), p.fillColorArgb(), p.fontSize(), p.style(), rasterScale); + } + + private static String buildBlitKey(String formula, int fillColorArgb, float fontSize, int style) { + return GuideLatexTextureCache.buildScaleKey(fontSize) + ':' + style + ':' + fillColorArgb + ':' + formula; + } + + /** + * Returns (and caches) the OpenGL texture for {@code formula}, typeset at + * {@code fontSize} × {@code rasterScale} pixels. Must be called from the + * Minecraft render thread. + * + *

          + * Raster scale contract. The texture is rasterized at + * {@code fontSize × rasterScale} - the real device output scale (in-game + * {@code DisplayScale.scaleFactor()}; headless the offscreen + * {@code PushTransform(0,0,scale)} wrap) - while the blit quad keeps the + * document {@code formulaDisplayW/H} dimensions. Since the quad is scaled + * by that same factor to device pixels, texture pixels map 1:1 onto device + * pixels and GL_LINEAR never upscales (no thin-stroke brightness collapse). + * The cache key includes {@code rasterScale}, so switching GUI / output + * scale never reuses a texture rasterized for another device resolution. + * + *

          + * Supersampled rasterization. The texture is internally + * typeset/rasterized at {@code fontSize × rasterScale × + * LATEX_SUPERSAMPLE_S} and then bilinearly downsampled to the exact + * device-resolution {@code fontSize × rasterScale} icon size (see + * {@link #LATEX_SUPERSAMPLE_S} for the mechanism and the resolution contract + * boundary). The final texture dimensions, the 1:1 blit quad geometry and + * the GL_LINEAR upload are unchanged - only the internal rasterization + * resolution rises, redistributing sub-pixel stem energy onto the target + * grid so unhinted CM-math-font stems (2.5-3.1 px) read as full-weight. * * @param formula LaTeX source string * @param fillColorArgb ARGB colour for the glyph pixels - * @param sourceScale jlatexmath render quality (e.g. 100.0f) + * @param fontSize target typeset font size in pixels (body base font size × user scale) + * @param style jlatexmath style constant ({@link TeXConstants#STYLE_DISPLAY} or + * {@link TeXConstants#STYLE_TEXT}) + * @param rasterScale device output scale: texture pixels = fontSize × rasterScale * @return [textureId, widthPx, heightPx] or null on failure */ - public int[] getOrCreateTexture(String formula, int fillColorArgb, float sourceScale) { + public int[] getOrCreateTexture(String formula, int fillColorArgb, float fontSize, int style, float rasterScale) { if (formula == null || formula.isEmpty()) { return null; } if (GuideLatexTextureCache.INSTANCE.hasFailed(formula)) { return null; } + if (!(rasterScale > 0f)) { + rasterScale = 1f; + } - String texKey = GuideLatexTextureCache.buildTextureCacheKey(formula, fillColorArgb, sourceScale); + String texKey = GuideLatexTextureCache + .buildTextureCacheKey(formula, fillColorArgb, fontSize, rasterScale, style); int[] cached = GuideLatexTextureCache.INSTANCE.getTexture(texKey); if (cached != null) { return cached; @@ -155,24 +466,54 @@ public int[] getOrCreateTexture(String formula, int fillColorArgb, float sourceS try { TeXFormula texFormula = new TeXFormula(formula); - TeXIcon icon = texFormula.new TeXIconBuilder().setStyle(TeXConstants.STYLE_DISPLAY) - .setSize(sourceScale) + + // Target-size icon: typeset at fontSize × rasterScale exactly as the + // Device-resolution contract - its pixel dimensions ARE the + // final texture size (1:1 blit, GL_LINEAR upload untouched). Built + // only to read the exact target width/height. + TeXIcon targetIcon = texFormula.new TeXIconBuilder().setStyle(style) + .setSize(fontSize * rasterScale) .setFGColor(new Color(fillColorArgb, true)) .build(); - icon.setInsets(new Insets(2, 2, 2, 2)); + // Two-arg form (trueValues): real 2px/side insets - the single-arg + // setInsets(Insets) silently adds (int)(0.18f*size) per side instead. + // The insets scale with rasterScale so the texture is an exact + // rasterScale × scale of the fontSize document icon (whose insets + // are 2px, see measureSize) - the glyph then maps 1:1 into the + // document-sized blit quad at the device scale. + int targetInset = Math.max(1, Math.round(2f * rasterScale)); + targetIcon.setInsets(new Insets(targetInset, targetInset, targetInset, targetInset), true); + int targetW = targetIcon.getIconWidth(); + int targetH = targetIcon.getIconHeight(); + + // Supersampled icon: typeset at fontSize × rasterScale × S with + // insets scaled ×S (TeXIcon paints its box at (x + insets)/size, so + // insets are pixel constants that must grow with the size). The + // supersampled content is a pure S× linear scale of the target + // content; rendering it at S× and downsampling redistributes the + // multi-sample coverage of sub-pixel stems back onto the target + // grid (see LATEX_SUPERSAMPLE_S). + float ssSize = fontSize * rasterScale * LATEX_SUPERSAMPLE_S; + int ssInset = Math.max(1, Math.round(2f * rasterScale * LATEX_SUPERSAMPLE_S)); + TeXIcon icon = texFormula.new TeXIconBuilder().setStyle(style) + .setSize(ssSize) + .setFGColor(new Color(fillColorArgb, true)) + .build(); + icon.setInsets(new Insets(ssInset, ssInset, ssInset, ssInset), true); icon.setForeground(new Color(fillColorArgb, true)); - BufferedImage image = renderToImage(icon); + BufferedImage ssImage = renderToImage(icon, LATEX_EMBOLDEN_EM * ssSize); + BufferedImage image = downsampleToDevice(ssImage, targetW, targetH, fillColorArgb); int w = image.getWidth(); int h = image.getHeight(); int textureId = uploadToGL(image, w, h); GuideLatexTextureCache.INSTANCE.putTexture(texKey, textureId, w, h); - String sizeKey = GuideLatexTextureCache.buildSizeCacheKey(formula, sourceScale); - int d = getIconDepthPx(icon); - GuideLatexTextureCache.INSTANCE.putSize(sizeKey, w, h, d); - + // NOTE: deliberately NOT writing the size cache here. The size + // cache stores the fontSize document dimensions from measureSize; + // this texture is rasterized at fontSize × rasterScale, so its + // pixel dimensions differ and must never feed layout geometry. return new int[] { textureId, w, h }; } catch (ParseException e) { GuideDebugLog.warn("[GuideNH/LaTeX] Parse error rendering '{}': {}", formula, e.getMessage()); @@ -194,8 +535,8 @@ public int[] getOrCreateTexture(String formula, int fillColorArgb, float sourceS * * @param x screen X (document-relative) * @param y screen Y (document-relative) - * @param displayW rendered display width in GUI units - * @param displayH rendered display height in GUI units + * @param displayW rendered display width in GUI units (equals the texture width) + * @param displayH rendered display height in GUI units (equals the texture height) * @param textureId OpenGL texture ID obtained from {@link #getOrCreateTexture} */ public void renderLatex(int x, int y, int displayW, int displayH, int textureId) { @@ -223,7 +564,334 @@ private static int getIconDepthPx(TeXIcon icon) { return Math.max(0, (int) Math.ceil(icon.getTrueIconDepth())); } - private BufferedImage renderToImage(TeXIcon icon) { + /** + * Downsample threshold for the coverage-preserving hole fill. + * + *

          + * A target pixel whose two point-sample phases both land at or below this + * alpha is eligible for the true-footprint coverage fill in + * {@link #downsampleToDevice}: its {@code S×S} source cell coverage is + * consulted and replaces the alpha when it carries substantially more ink. + * 50 is the visibility boundary the sparse-stroke defect is measured at + * ("baseline full-bright → final alpha ≤ 50" = degraded); pixels at or + * below it read as gaps, and only those are touched, so crisp stroke cores + * (alpha ≫ 50) are never re-weighted. + */ + private static final int DOWNSAMPLE_FILL_ALPHA = 50; + + /** + * Minimum footprint coverage (0-255) for the hole fill in + * {@link #downsampleToDevice} to re-value a pixel. The fill only rescues a + * gap pixel when its true {@code S×S} cell is at least this much inked on + * average; faint edge overlaps (coverage 50-70, visually negligible) are + * left as-is so the fill never adds noise-width strokes. + */ + private static final int DOWNSAMPLE_FILL_COVERAGE = 70; + + /** + * Bilinearly downsamples the {@code S×} supersampled ARGB image to the + * exact device-resolution {@code targetW × targetH} icon size. + * + *

          + * Dual-phase sampling. The supersampled icon is typeset at + * exactly {@code S×} the target icon with {@code S×} the insets, so its + * glyph content sits on an exact {@code S×} lattice of the target content: + * content belonging to target column {@code dx} occupies source column + * {@code S·dx}. The legacy center-of-pixel mapping + * {@code sx = (dx + 0.5) · sw/targetW − 0.5} carries a −0.5 phase offset: + * at an exact 3:1 ratio it samples source columns {@code 3·dx + 1} and + * therefore skips every source column of the form {@code 3·dx} entirely - a + * thin stroke (2.5-3.1 device px ≈ 7.5-9.3 source px wide) whose core sits + * at {@code 3·dx} can fall completely between two sample points and vanish + * (measured: the {@code \frac{a+b}{c-d}} "+" stem at column 21 collapses to + * alpha 0). The fix samples BOTH lattices and takes the max-coverage blend: + * the legacy grid (crisp single-texel cores at an exact 3:1 ratio, the + * legacy full-bright behaviour) plus a content-aligned grid + * ({@code sx = S·dx + 0.5}, which lands ON the {@code S·dx} content columns + * the legacy grid cannot see). A target pixel is never darker than the + * strongest phase that finds ink there. The two grids are the same bilinear + * kernel, only their phase offsets differ; neither is a formula- or + * column-specific special case, and the ratio mismatch (2.88-3.43, from + * jlatexmath's {@code (int)(boxW·size + 0.99)} truncation) only decides + * which phase happens to land closer to each stroke's core. + * + *

          + * Coverage-preserving hole fill. Where BOTH phases still + * land in a gap (e.g. content shifted off its nominal {@code S×} lattice by + * per-glyph rounding, or a stroke thinner than the sample pitch), the pixel + * is not background - its true {@code S×S} footprint carries ink that the + * point samples cannot see. Such pixels (alpha ≤ {@link + * #DOWNSAMPLE_FILL_ALPHA}) are re-valued with the footprint's area coverage + * (box-filter ratio of inked source area over cell area), so no stroke + * column is ever fully zeroed and the "baseline full-bright → final ≤ 50" + * degradation is eliminated. This is the coverage semantics of option ② in + * that diagnosis: alpha IS coverage, and a pixel whose samples straddle + * a stroke boundary (or miss it entirely) is valued by the ink actually + * covering its cell, not by an average that can land at zero. + * + *

          + * Why bilinear point sampling and not full area averaging. The + * supersampled icon is not an exact integer {@code S}× scale of + * the target icon: jlatexmath's pixel dimensions go through + * {@code (int)(boxW * size + 0.99)} truncation (see {@code TeXIcon}), + * so the measured size ratio lands between 2.97 and 3.00 (e.g. 196×393 + * over 66×131 = 2.970). Full area averaging was measured and rejected: it + * dilutes core pixels (a cell of 8 full + 1 edge sample averages below the + * full-bright threshold), regressing the very full-bright metric this fix + * exists to restore. The hole fill above applies the area ratio ONLY to + * pixels that are otherwise zero - cores keep their crisp point-sample + * value. + * + *

          + * Alpha-only semantics (with local-colour pass-through). A + * jlatexmath formula texture is a coverage mask: glyph pixels carry a + * colour in RGB and the antialiased coverage in alpha (background is + * transparent black), and the blit blends with GL_SRC_ALPHA / + * ONE_MINUS_SRC_ALPHA. Downsampling is therefore performed on the + * alpha channel only: the multi-sample coverage energy of the + * supersampled grid is redistributed onto the target grid via bilinear + * interpolation. For the RGB channels, an ink sample within + * {@link #INK_COLOR_TOLERANCE} of the global fill colour is re-stamped + * with that constant fill colour - keeping the straight-alpha contract the + * GL_SRC_ALPHA blend expects (interpolating straight-alpha RGBA directly + * would average edge pixels against transparent black and darken the RGB + * at the border, the classic premultiplied-vs-straight fringe). But when a + * sampled ink pixel clearly differs from the fill colour (a local + * \texttt{textcolor} subtree ({@code \color} is not a registered macro in jlatexmath-1.0.7), + * {@code ColorAtom}/{@code ColorBox} + * {@code setColor}), the RGB is alpha-weighted interpolated from the ink + * samples instead, so locally coloured glyphs survive the downsample - the + * same per-glyph colours the old direct-render-and-upload pipeline produced. + * The footprint fill applies the same rule to its ink samples. + * + * @param ssImage the {@code fontSize × rasterScale × S} supersampled render + * @param targetW exact device-resolution width (the icon width at {@code fontSize × rasterScale}) + * @param targetH exact device-resolution height (the icon height at {@code fontSize × rasterScale}) + * @param fillColorArgb ARGB fill colour (re-stamped onto ink pixels whose RGB is within tolerance of it) + * @return the downsampled {@code targetW × targetH} ARGB image + */ + private static BufferedImage downsampleToDevice(BufferedImage ssImage, int targetW, int targetH, + int fillColorArgb) { + int sw = ssImage.getWidth(); + int sh = ssImage.getHeight(); + if (sw == targetW && sh == targetH) { + return ssImage; + } + int[] src = new int[sw * sh]; + ssImage.getRGB(0, 0, sw, sh, src, 0, sw); + + int fillRgb = fillColorArgb & 0x00FFFFFF; + int[] dst = new int[targetW * targetH]; + for (int dy = 0; dy < targetH; dy++) { + // Phase A - legacy box-ratio sample grid (crisp single-texel cores). + float syA = clampSamplePos((dy + 0.5f) * sh / targetH - 0.5f, sh); + int y0A = (int) Math.floor(syA); + int y1A = Math.min(y0A + 1, sh - 1); + float fyA = syA - y0A; + // Phase B - content-aligned grid at the exact S× lattice. + float syB = clampSamplePos(LATEX_SUPERSAMPLE_S * dy + 0.5f, sh); + int y0B = (int) Math.floor(syB); + int y1B = Math.min(y0B + 1, sh - 1); + float fyB = syB - y0B; + // True S×S footprint of this target pixel in the content frame. + float yLo = LATEX_SUPERSAMPLE_S * dy; + float yHi = yLo + LATEX_SUPERSAMPLE_S; + for (int dx = 0; dx < targetW; dx++) { + float sxA = clampSamplePos((dx + 0.5f) * sw / targetW - 0.5f, sw); + int x0A = (int) Math.floor(sxA); + int x1A = Math.min(x0A + 1, sw - 1); + float fxA = sxA - x0A; + + int p00A = src[y0A * sw + x0A]; + int p10A = src[y0A * sw + x1A]; + int p01A = src[y1A * sw + x0A]; + int p11A = src[y1A * sw + x1A]; + float a00A = ((p00A >>> 24) & 0xFF) / 255f; + float a10A = ((p10A >>> 24) & 0xFF) / 255f; + float a01A = ((p01A >>> 24) & 0xFF) / 255f; + float a11A = ((p11A >>> 24) & 0xFF) / 255f; + float w00A = (1f - fxA) * (1f - fyA); + float w10A = fxA * (1f - fyA); + float w01A = (1f - fxA) * fyA; + float w11A = fxA * fyA; + float aA = a00A * w00A + a10A * w10A + a01A * w01A + a11A * w11A; + + float sxB = clampSamplePos(LATEX_SUPERSAMPLE_S * dx + 0.5f, sw); + int x0B = (int) Math.floor(sxB); + int x1B = Math.min(x0B + 1, sw - 1); + float fxB = sxB - x0B; + + int p00B = src[y0B * sw + x0B]; + int p10B = src[y0B * sw + x1B]; + int p01B = src[y1B * sw + x0B]; + int p11B = src[y1B * sw + x1B]; + float a00B = ((p00B >>> 24) & 0xFF) / 255f; + float a10B = ((p10B >>> 24) & 0xFF) / 255f; + float a01B = ((p01B >>> 24) & 0xFF) / 255f; + float a11B = ((p11B >>> 24) & 0xFF) / 255f; + float w00B = (1f - fxB) * (1f - fyB); + float w10B = fxB * (1f - fyB); + float w01B = (1f - fxB) * fyB; + float w11B = fxB * fyB; + float aB = a00B * w00B + a10B * w10B + a01B * w01B + a11B * w11B; + + // Max-coverage blend: never darker than the strongest phase. + int ai; + int rgb; + if (aB > aA) { + ai = Math.round(aB * 255f); + rgb = downsampledRgb(p00B, p10B, p01B, p11B, w00B, w10B, w01B, w11B, fillRgb); + } else { + ai = Math.round(aA * 255f); + rgb = downsampledRgb(p00A, p10A, p01A, p11A, w00A, w10A, w01A, w11A, fillRgb); + } + if (ai < 0) { + ai = 0; + } else if (ai > 255) { + ai = 255; + } + + if (ai <= DOWNSAMPLE_FILL_ALPHA) { + // Coverage-preserving hole fill: the true S×S footprint. + float xLo = LATEX_SUPERSAMPLE_S * dx; + float xHi = xLo + LATEX_SUPERSAMPLE_S; + int bx0 = Math.max(0, (int) Math.floor(xLo)); + int bx1 = Math.min(sw - 1, (int) Math.ceil(xHi) - 1); + int by0 = Math.max(0, (int) Math.floor(yLo)); + int by1 = Math.min(sh - 1, (int) Math.ceil(yHi) - 1); + float aSum = 0f; + float wSum = 0f; + float rAcc = 0f; + float gAcc = 0f; + float bAcc = 0f; + float wCol = 0f; + boolean colored = false; + for (int syy = by0; syy <= by1; syy++) { + float wy = Math.max(0f, Math.min(yHi, syy + 1) - Math.max(yLo, syy)); + for (int sxx = bx0; sxx <= bx1; sxx++) { + float wx = Math.max(0f, Math.min(xHi, sxx + 1) - Math.max(xLo, sxx)); + float w = wx * wy; + int p = src[syy * sw + sxx]; + int pa = (p >>> 24) & 0xFF; + aSum += w * pa; + wSum += w; + if (pa > 0) { + int pr = (p >> 16) & 0xFF; + int pg = (p >> 8) & 0xFF; + int pb = p & 0xFF; + if (Math.abs(pr - ((fillRgb >> 16) & 0xFF)) > INK_COLOR_TOLERANCE + || Math.abs(pg - ((fillRgb >> 8) & 0xFF)) > INK_COLOR_TOLERANCE + || Math.abs(pb - (fillRgb & 0xFF)) > INK_COLOR_TOLERANCE) { + colored = true; + } + float ww = w * pa; + rAcc += ww * pr; + gAcc += ww * pg; + bAcc += ww * pb; + wCol += ww; + } + } + } + if (wSum > 0f) { + int bai = Math.round(aSum / wSum); + if (bai > ai && bai >= DOWNSAMPLE_FILL_COVERAGE) { + ai = bai; + if (!colored || wCol <= 0f) { + rgb = fillRgb; + } else { + rgb = (Math.round(rAcc / wCol) << 16) | (Math.round(gAcc / wCol) << 8) + | Math.round(bAcc / wCol); + } + } + } + } + dst[dy * targetW + dx] = (ai << 24) | rgb; + } + } + + BufferedImage out = new BufferedImage(targetW, targetH, BufferedImage.TYPE_INT_ARGB); + out.setRGB(0, 0, targetW, targetH, dst, 0, targetW); + return out; + } + + /** + * Clamps a fractional source sample position into {@code [0, size - 1]} so a + * phase offset at the image border can never produce a negative or + * out-of-range texel index or a negative interpolation weight. + */ + private static float clampSamplePos(float pos, int size) { + if (pos < 0f) { + return 0f; + } + if (pos > size - 1f) { + return size - 1f; + } + return pos; + } + + /** + * Per-channel colour tolerance (0-255 scale) for {@link #downsampleToDevice}: + * an ink sample whose RGB is within this many units of the global fill + * colour in every channel is treated as uncoloured and re-stamped with the + * fill colour; a sample differing by more is a local + * \texttt{textcolor} colour that must survive downsampling. + * Java2D paints glyph RGBs exactly (no channel blending happens inside the + * fill/stroke), so uncoloured ink pixels match the fill colour bit-for-bit + * and the tolerance only keeps the fast re-stamp path strict while a local + * colour almost identical to the fill colour (visually indistinguishable + * anyway) is not misclassified. + */ + private static final int INK_COLOR_TOLERANCE = 8; + + /** + * Decides the RGB of one downsampled pixel from its four bilinear samples. + * Transparent background samples ({@code alpha == 0}) carry a meaningless + * RGB and are excluded so they cannot darken the mix (the straight-alpha + * fringe the re-stamp path exists to avoid). When every ink sample is + * within {@link #INK_COLOR_TOLERANCE} of {@code fillRgb} the constant fill + * colour is re-stamped (historical behaviour); when at least one ink sample + * clearly differs (local \texttt{textcolor} subtree) the RGB is alpha-weighted + * bilinear interpolation of the ink samples, preserving locally coloured + * glyphs through the downsample. + */ + private static int downsampledRgb(int p00, int p10, int p01, int p11, float w00, float w10, float w01, float w11, + int fillRgb) { + int fillR = (fillRgb >> 16) & 0xFF; + int fillG = (fillRgb >> 8) & 0xFF; + int fillB = fillRgb & 0xFF; + int[] ps = { p00, p10, p01, p11 }; + float[] ws = { w00, w10, w01, w11 }; + float rAcc = 0f; + float gAcc = 0f; + float bAcc = 0f; + float wAcc = 0f; + boolean colored = false; + for (int i = 0; i < 4; i++) { + int p = ps[i]; + int pa = (p >>> 24) & 0xFF; + if (pa == 0) { + continue; + } + int pr = (p >> 16) & 0xFF; + int pg = (p >> 8) & 0xFF; + int pb = p & 0xFF; + if (Math.abs(pr - fillR) > INK_COLOR_TOLERANCE || Math.abs(pg - fillG) > INK_COLOR_TOLERANCE + || Math.abs(pb - fillB) > INK_COLOR_TOLERANCE) { + colored = true; + } + float w = ws[i] * pa; + rAcc += w * pr; + gAcc += w * pg; + bAcc += w * pb; + wAcc += w; + } + if (!colored || wAcc <= 0f) { + return fillRgb; + } + return (Math.round(rAcc / wAcc) << 16) | (Math.round(gAcc / wAcc) << 8) | Math.round(bAcc / wAcc); + } + + private BufferedImage renderToImage(TeXIcon icon, float strokePx) { int w = icon.getIconWidth(); int h = icon.getIconHeight(); @@ -242,13 +910,487 @@ private BufferedImage renderToImage(TeXIcon icon) { g.setColor(new Color(ColorUtils.TRANSPARENT.getColor(), true)); g.fillRect(0, 0, w, h); - icon.paintIcon(null, g, 0, 0); + // Outline-domain emboldening (MathJax "blacker" equivalent): wrap the + // Graphics2D so every glyph jlatexmath draws via drawChars (CharBox.draw) + // is rendered as its GlyphVector outline - fill + same-colour stroke - + // instead of plain Java2D text (which cannot be stroked). strokePx is the + // desired stroke width in pixels of THIS supersampled image + // (= LATEX_EMBOLDEN_EM × fontSize × rasterScale × S, see the constant). + // strokePx <= 0 keeps the plain drawChars path (disabled emboldening). + Graphics2D paintTarget = (strokePx > 0f) ? new EmboldenGraphics2D(g, strokePx) : g; + icon.paintIcon(null, paintTarget, 0, 0); } finally { g.dispose(); } return image; } + /** + * Delegating {@link Graphics2D} that converts text drawing into glyph-outline + * fill + same-colour stroke - the Java2D equivalent of the MathJax SVG + * {@code blacker} mechanism (see {@link #LATEX_EMBOLDEN_EM}). + * + *

          + * Java2D cannot stroke text rendered through {@code drawChars} (the only + * text path jlatexmath uses - {@code CharBox.draw}), so this wrapper + * intercepts {@link #drawChars} / {@code drawString} / {@link #drawGlyphVector} + * and re-renders each glyph as {@code GlyphVector.getOutline(x, y)}: the + * outline is filled with the current colour (byte-identical to {@code drawChars} + * at stroke width 0 - probe-verified, diffPx=0) and then stroked with a + * {@link BasicStroke} of the requested width. All other Graphics2D operations + * (fraction bars are {@code fill(Rectangle2D)}, boxes are shape draws) are + * delegated untouched, so only glyph strokes are emboldened - matching MathJax, + * whose blacker CSS targets {@code path[data-c]} glyph paths only. + * + *

          + * Stroke width in user space (constant device-pixel stroke). + * {@code strokePx} is in device pixels of the supersampled image, so at draw + * time the BasicStroke is requested in user-space units as + * {@code strokePx / |current transform scale|} ({@code strokeUserWidth()}) + * and the CTM maps it back to a device width of exactly {@code strokePx}. + * The current transform is CharBox's {@code scale(size/100)} (TeXIcon's + * global {@code scale(size)} already applied outside it), and that per-glyph + * scale carries the script size factor + * ({@code DefaultTeXFont.getSizeFactor()}: script 0.7, script-script 0.5), + * so dividing it out cancels it: the stroked device width is the SAME + * {@code strokePx} for normal, script and script-script glyphs. Script + * glyphs therefore do NOT get an em-proportional (thinner) stroke - on the + * contrary they end up ~1/0.7 heavier relative to their own reduced em. + * + *

          + * This deliberately differs from MathJax, whose SVG {@code blacker} + * (3/1000em) is em-relative per glyph: MathJax glyph paths are stroked in + * their own em coordinate space, so script glyphs receive a proportionally + * thinner stroke. We keep the constant device-pixel width because (a) script + * glyphs have the thinnest stems and need the full stroke for readability + * (readability over strict metric equivalence - adjudicated decision G); + * (b) METAFONT's blacker is likewise not an em-ratio adjustment per size + * (it is a fixed offset applied at font-build time); and (c) the entire + * probe calibration (s=2 corpus ≥0.70 full-bright, glyph counters not + * closed) was measured under this constant-device-pixel behaviour - moving + * to per-glyph em-ratio strokes would require re-calibrating everything and + * would make script glyphs thinner at exactly the size where unhinted thin + * stems are hardest to keep readable. + */ + private static final class EmboldenGraphics2D extends Graphics2D { + + private final Graphics2D delegate; + private final float strokePx; + + private EmboldenGraphics2D(Graphics2D delegate, float strokePx) { + this.delegate = delegate; + this.strokePx = strokePx; + } + + private float strokeUserWidth() { + AffineTransform t = delegate.getTransform(); + double scale = Math.hypot(t.getScaleX(), t.getShearY()); + if (scale <= 0d) { + scale = 1d; + } + return strokePx / (float) scale; + } + + private void drawGlyphsAsOutlines(String text, float x, float y) { + if (text.isEmpty()) { + return; + } + java.awt.Font font = delegate.getFont(); + GlyphVector gv = font.createGlyphVector(delegate.getFontRenderContext(), text); + Shape outline = gv.getOutline(x, y); + delegate.fill(outline); + float width = strokeUserWidth(); + if (width > 0f) { + Stroke old = delegate.getStroke(); + delegate.setStroke(new BasicStroke(width, BasicStroke.CAP_ROUND, BasicStroke.JOIN_ROUND)); + delegate.draw(outline); + delegate.setStroke(old); + } + } + + @Override + public void drawChars(char[] data, int offset, int length, int x, int y) { + drawGlyphsAsOutlines(new String(data, offset, length), x, y); + } + + @Override + public void drawString(String str, int x, int y) { + drawGlyphsAsOutlines(str, x, y); + } + + @Override + public void drawString(String str, float x, float y) { + drawGlyphsAsOutlines(str, x, y); + } + + @Override + public void drawString(AttributedCharacterIterator iterator, int x, int y) { + delegate.drawString(iterator, x, y); + } + + @Override + public void drawString(AttributedCharacterIterator iterator, float x, float y) { + delegate.drawString(iterator, x, y); + } + + @Override + public void drawGlyphVector(GlyphVector g, float x, float y) { + Shape outline = g.getOutline(x, y); + delegate.fill(outline); + float width = strokeUserWidth(); + if (width > 0f) { + Stroke old = delegate.getStroke(); + delegate.setStroke(new BasicStroke(width, BasicStroke.CAP_ROUND, BasicStroke.JOIN_ROUND)); + delegate.draw(outline); + delegate.setStroke(old); + } + } + + // Every drawing operation other than text is pure delegation. + + @Override + public void draw(Shape s) { + delegate.draw(s); + } + + @Override + public boolean drawImage(java.awt.Image img, AffineTransform xform, java.awt.image.ImageObserver obs) { + return delegate.drawImage(img, xform, obs); + } + + @Override + public void drawImage(BufferedImage img, BufferedImageOp op, int x, int y) { + delegate.drawImage(img, op, x, y); + } + + @Override + public void drawRenderedImage(RenderedImage img, AffineTransform xform) { + delegate.drawRenderedImage(img, xform); + } + + @Override + public void drawRenderableImage(RenderableImage img, AffineTransform xform) { + delegate.drawRenderableImage(img, xform); + } + + @Override + public void fill(Shape s) { + delegate.fill(s); + } + + @Override + public boolean hit(Rectangle rect, Shape s, boolean onStroke) { + return delegate.hit(rect, s, onStroke); + } + + @Override + public GraphicsConfiguration getDeviceConfiguration() { + return delegate.getDeviceConfiguration(); + } + + @Override + public void setComposite(java.awt.Composite comp) { + delegate.setComposite(comp); + } + + @Override + public void setPaint(Paint paint) { + delegate.setPaint(paint); + } + + @Override + public void setStroke(Stroke s) { + delegate.setStroke(s); + } + + @Override + public void setRenderingHint(RenderingHints.Key hintKey, Object hintValue) { + delegate.setRenderingHint(hintKey, hintValue); + } + + @Override + public Object getRenderingHint(RenderingHints.Key hintKey) { + return delegate.getRenderingHint(hintKey); + } + + @Override + public void setRenderingHints(java.util.Map hints) { + delegate.setRenderingHints(hints); + } + + @Override + public void addRenderingHints(java.util.Map hints) { + delegate.addRenderingHints(hints); + } + + @Override + public RenderingHints getRenderingHints() { + return delegate.getRenderingHints(); + } + + @Override + public void translate(int x, int y) { + delegate.translate(x, y); + } + + @Override + public void translate(double tx, double ty) { + delegate.translate(tx, ty); + } + + @Override + public void rotate(double theta) { + delegate.rotate(theta); + } + + @Override + public void rotate(double theta, double x, double y) { + delegate.rotate(theta, x, y); + } + + @Override + public void scale(double sx, double sy) { + delegate.scale(sx, sy); + } + + @Override + public void shear(double shx, double shy) { + delegate.shear(shx, shy); + } + + @Override + public void transform(AffineTransform Tx) { + delegate.transform(Tx); + } + + @Override + public void setTransform(AffineTransform Tx) { + delegate.setTransform(Tx); + } + + @Override + public AffineTransform getTransform() { + return delegate.getTransform(); + } + + @Override + public Paint getPaint() { + return delegate.getPaint(); + } + + @Override + public java.awt.Composite getComposite() { + return delegate.getComposite(); + } + + @Override + public void setBackground(Color color) { + delegate.setBackground(color); + } + + @Override + public Color getBackground() { + return delegate.getBackground(); + } + + @Override + public Stroke getStroke() { + return delegate.getStroke(); + } + + @Override + public void clip(Shape s) { + delegate.clip(s); + } + + @Override + public java.awt.font.FontRenderContext getFontRenderContext() { + return delegate.getFontRenderContext(); + } + + // Straight Graphics delegation. + + @Override + public Graphics create() { + return delegate.create(); + } + + @Override + public Color getColor() { + return delegate.getColor(); + } + + @Override + public void setColor(Color c) { + delegate.setColor(c); + } + + @Override + public void setPaintMode() { + delegate.setPaintMode(); + } + + @Override + public void setXORMode(Color c1) { + delegate.setXORMode(c1); + } + + @Override + public java.awt.Font getFont() { + return delegate.getFont(); + } + + @Override + public void setFont(java.awt.Font font) { + delegate.setFont(font); + } + + @Override + public FontMetrics getFontMetrics(java.awt.Font f) { + return delegate.getFontMetrics(f); + } + + @Override + public Rectangle getClipBounds() { + return delegate.getClipBounds(); + } + + @Override + public void clipRect(int x, int y, int width, int height) { + delegate.clipRect(x, y, width, height); + } + + @Override + public void setClip(int x, int y, int width, int height) { + delegate.setClip(x, y, width, height); + } + + @Override + public Shape getClip() { + return delegate.getClip(); + } + + @Override + public void setClip(Shape clip) { + delegate.setClip(clip); + } + + @Override + public void copyArea(int x, int y, int width, int height, int dx, int dy) { + delegate.copyArea(x, y, width, height, dx, dy); + } + + @Override + public void drawLine(int x1, int y1, int x2, int y2) { + delegate.drawLine(x1, y1, x2, y2); + } + + @Override + public void fillRect(int x, int y, int width, int height) { + delegate.fillRect(x, y, width, height); + } + + @Override + public void clearRect(int x, int y, int width, int height) { + delegate.clearRect(x, y, width, height); + } + + @Override + public void drawRoundRect(int x, int y, int width, int height, int arcWidth, int arcHeight) { + delegate.drawRoundRect(x, y, width, height, arcWidth, arcHeight); + } + + @Override + public void fillRoundRect(int x, int y, int width, int height, int arcWidth, int arcHeight) { + delegate.fillRoundRect(x, y, width, height, arcWidth, arcHeight); + } + + @Override + public void drawOval(int x, int y, int width, int height) { + delegate.drawOval(x, y, width, height); + } + + @Override + public void fillOval(int x, int y, int width, int height) { + delegate.fillOval(x, y, width, height); + } + + @Override + public void drawArc(int x, int y, int width, int height, int startAngle, int arcAngle) { + delegate.drawArc(x, y, width, height, startAngle, arcAngle); + } + + @Override + public void fillArc(int x, int y, int width, int height, int startAngle, int arcAngle) { + delegate.fillArc(x, y, width, height, startAngle, arcAngle); + } + + @Override + public void drawPolyline(int[] xPoints, int[] yPoints, int nPoints) { + delegate.drawPolyline(xPoints, yPoints, nPoints); + } + + @Override + public void drawPolygon(int[] xPoints, int[] yPoints, int nPoints) { + delegate.drawPolygon(xPoints, yPoints, nPoints); + } + + @Override + public void drawPolygon(Polygon p) { + delegate.drawPolygon(p); + } + + @Override + public void fillPolygon(int[] xPoints, int[] yPoints, int nPoints) { + delegate.fillPolygon(xPoints, yPoints, nPoints); + } + + @Override + public void fillPolygon(Polygon p) { + delegate.fillPolygon(p); + } + + @Override + public boolean drawImage(java.awt.Image img, int x, int y, java.awt.image.ImageObserver observer) { + return delegate.drawImage(img, x, y, observer); + } + + @Override + public boolean drawImage(java.awt.Image img, int x, int y, int width, int height, + java.awt.image.ImageObserver observer) { + return delegate.drawImage(img, x, y, width, height, observer); + } + + @Override + public boolean drawImage(java.awt.Image img, int x, int y, Color bgcolor, + java.awt.image.ImageObserver observer) { + return delegate.drawImage(img, x, y, bgcolor, observer); + } + + @Override + public boolean drawImage(java.awt.Image img, int x, int y, int width, int height, Color bgcolor, + java.awt.image.ImageObserver observer) { + return delegate.drawImage(img, x, y, width, height, bgcolor, observer); + } + + @Override + public boolean drawImage(java.awt.Image img, int dx1, int dy1, int dx2, int dy2, int sx1, int sy1, int sx2, + int sy2, java.awt.image.ImageObserver observer) { + return delegate.drawImage(img, dx1, dy1, dx2, dy2, sx1, sy1, sx2, sy2, observer); + } + + @Override + public boolean drawImage(java.awt.Image img, int dx1, int dy1, int dx2, int dy2, int sx1, int sy1, int sx2, + int sy2, Color bgcolor, java.awt.image.ImageObserver observer) { + return delegate.drawImage(img, dx1, dy1, dx2, dy2, sx1, sy1, sx2, sy2, bgcolor, observer); + } + + @Override + public void dispose() { + delegate.dispose(); + } + } + private static int uploadToGL(BufferedImage image, int w, int h) { int[] pixels = new int[w * h]; image.getRGB(0, 0, w, h, pixels, 0, w); diff --git a/src/main/java/com/hfstudio/guidenh/guide/latex/GuideLatexTextureCache.java b/src/main/java/com/hfstudio/guidenh/guide/latex/GuideLatexTextureCache.java index c40ae083..b2c0e006 100644 --- a/src/main/java/com/hfstudio/guidenh/guide/latex/GuideLatexTextureCache.java +++ b/src/main/java/com/hfstudio/guidenh/guide/latex/GuideLatexTextureCache.java @@ -123,15 +123,34 @@ public void clearAll() { } /** - * Builds the cache key used for both texture and size lookups. + * Builds the cache key used for texture lookups. + * + *

          + * The key includes {@code rasterScale} (the device output scale the + * texture is rasterized at), mirroring the text path's {@code renderScale} + * in its {@code ShapeKey} (GuideText.java:52): a GUI-scale / output-scale + * change must never reuse a texture rasterized for a different device + * resolution (blurry upscale), so different rasterScales get distinct + * entries. * * @param formula LaTeX source string * @param fillColorArgb ARGB fill colour - * @param sourceScale jlatexmath render scale (e.g. 100.0f) + * @param sourceScale jlatexmath render scale (the typeset font size) + * @param rasterScale device output scale the texture is rasterized at + * (texture pixels = sourceScale × rasterScale) + * @param style jlatexmath style constant (STYLE_DISPLAY or STYLE_TEXT) * @return cache key string */ - public static String buildTextureCacheKey(String formula, int fillColorArgb, float sourceScale) { - return toHexColor(fillColorArgb) + ':' + buildScaleKey(sourceScale) + ':' + formula; + public static String buildTextureCacheKey(String formula, int fillColorArgb, float sourceScale, float rasterScale, + int style) { + return toHexColor(fillColorArgb) + ':' + + buildScaleKey(sourceScale) + + ':' + + buildScaleKey(rasterScale) + + ':' + + style + + ':' + + formula; } /** @@ -139,10 +158,11 @@ public static String buildTextureCacheKey(String formula, int fillColorArgb, flo * * @param formula LaTeX source string * @param sourceScale jlatexmath render scale + * @param style jlatexmath style constant (STYLE_DISPLAY or STYLE_TEXT) * @return size cache key string */ - public static String buildSizeCacheKey(String formula, float sourceScale) { - return buildScaleKey(sourceScale) + ':' + formula; + public static String buildSizeCacheKey(String formula, float sourceScale, int style) { + return buildScaleKey(sourceScale) + ':' + style + ':' + formula; } public static String buildScaleKey(float sourceScale) { diff --git a/src/main/java/com/hfstudio/guidenh/guide/layout/FontProvider.java b/src/main/java/com/hfstudio/guidenh/guide/layout/FontProvider.java new file mode 100644 index 00000000..da1644fb --- /dev/null +++ b/src/main/java/com/hfstudio/guidenh/guide/layout/FontProvider.java @@ -0,0 +1,45 @@ +package com.hfstudio.guidenh.guide.layout; + +/** + * Provides TTF/OTC font data for the Rust layout engine's font system. + *

          + * Implementations resolve font files from system paths, config files, or + * bundled resources. The default implementation is {@link SystemFontProvider}. + *

          + *

          + * This is a global configuration concern, not a CSS theme concern: font data + * is loaded once at {@link LayoutBridge#init(byte[], String)} time and shared + * across all documents. + *

          + */ +public interface FontProvider { + + /** + * Read the font file bytes for the given locale. + * + * @param locale BCP 47 locale tag (e.g. {@code "zh_CN"}, {@code "en_US"}) + * @return font file bytes, or an empty array if no suitable font could be + * located + */ + byte[] getFontData(String locale); + + /** + * Read fallback symbol-font file bytes for the given locale (e.g. a + * symbols-only font covering glyphs the primary CJK font lacks). + * + * @param locale BCP 47 locale tag (e.g. {@code "zh_CN"}, {@code "en_US"}) + * @return font file bytes, or an empty array if no fallback font could be + * located (callers must skip when empty) + */ + default byte[] getFallbackFontData(String locale) { + return new byte[0]; + } + + /** + * Return the resolved font file path (for diagnostics / logging). + * + * @return the absolute path to the font file, or {@code "none"} if no font + * was found + */ + String getFontPath(); +} diff --git a/src/main/java/com/hfstudio/guidenh/guide/layout/LayoutBridge.java b/src/main/java/com/hfstudio/guidenh/guide/layout/LayoutBridge.java new file mode 100644 index 00000000..73a1440a --- /dev/null +++ b/src/main/java/com/hfstudio/guidenh/guide/layout/LayoutBridge.java @@ -0,0 +1,112 @@ +package com.hfstudio.guidenh.guide.layout; + +import java.io.IOException; +import java.io.InputStream; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.StandardCopyOption; + +/** + * JNI bridge to the Rust guide_layout_engine native library. + *

          + * All methods are static. FontSystem handle is the only persistent state. + * The native library is extracted from the classpath on first load. + */ +public final class LayoutBridge { + + private static boolean loaded; + + static { + loadNative(); + } + + private static void loadNative() { + if (loaded) return; + + // Allow override via system property for development/testing + String overridePath = System.getProperty("guide.native.lib.path"); + if (overridePath != null && !overridePath.isEmpty()) { + System.load(overridePath); + loaded = true; + return; + } + + // Extract native lib from classpath resources + String os = System.getProperty("os.name") + .toLowerCase(); + String libName; + if (os.contains("win")) { + libName = "guide_layout_engine.dll"; + } else if (os.contains("mac")) { + libName = "guide_layout_engine.dylib"; + } else { + libName = "libguide_layout_engine.so"; + } + + String resourcePath = "/natives/" + libName; + try (InputStream is = LayoutBridge.class.getResourceAsStream(resourcePath)) { + if (is == null) { + throw new UnsatisfiedLinkError("Native library not found in classpath: " + resourcePath); + } + Path tmpFile = Files.createTempFile("guide_layout_engine_", libName); + tmpFile.toFile() + .deleteOnExit(); + Files.copy(is, tmpFile, StandardCopyOption.REPLACE_EXISTING); + System.load( + tmpFile.toAbsolutePath() + .toString()); + loaded = true; + } catch (IOException e) { + throw new UnsatisfiedLinkError("Failed to extract native library: " + e.getMessage()); + } + } + + private static long globalFontHandle; + + /** Set the global font handle (called once after init()). */ + public static void setFontHandle(long handle) { + globalFontHandle = handle; + } + + /** Get the global font handle. Returns 0 if not initialized. */ + public static long getFontHandle() { + return globalFontHandle; + } + + /** Load TTF font data and create a FontSystem. Returns opaque handle (long). */ + public static native long init(byte[] fontTtfData, String locale); + + /** + * Register fallback symbol-font data (e.g. seguisym.ttf) into the existing + * FontSystem and append it to the Han fallback key. Best-effort: a no-op on + * empty data; callers should pass {@code new byte[0]} to skip. + * + * @param handle FontSystem handle from init() + * @param fallbackData font file bytes, or empty array to skip + */ + public static native void loadFallbackFont(long handle, byte[] fallbackData); + + /** + * Measure the entire layout tree. + * + * @param handle FontSystem handle from init() + * @param input FlatBuffer-encoded LayoutInput bytes + * @return FlatBuffer-encoded LayoutResult bytes, or empty byte[] on failure + */ + public static native byte[] measureLayout(long handle, byte[] input); + + /** + * Shape + rasterize one styled text (unified text pipeline entry). + * + * @param handle FontSystem handle from init() + * @param input FlatBuffer-encoded ShapeTextInput bytes + * @return FlatBuffer-encoded ShapeTextResult bytes (atlas-keyed quads + + * metrics), or empty byte[] on failure + */ + public static native byte[] shapeText(long handle, byte[] input); + + /** Destroy the FontSystem and free native memory. */ + public static native void destroy(long handle); + + private LayoutBridge() {} +} diff --git a/src/main/java/com/hfstudio/guidenh/guide/layout/LayoutContext.java b/src/main/java/com/hfstudio/guidenh/guide/layout/LayoutContext.java index d443970d..317f150a 100644 --- a/src/main/java/com/hfstudio/guidenh/guide/layout/LayoutContext.java +++ b/src/main/java/com/hfstudio/guidenh/guide/layout/LayoutContext.java @@ -32,6 +32,13 @@ public LayoutContext withVisualScale(float visualScale) { return this; } + /** + * Clears the per-layout transient state so a retained context can lay out an unrelated block + * tree. Callers that cache a context across frames must invoke this before every {@code layout} + * call, otherwise float lanes and the visual scale of the previous tree leak into the next one. + * + * @return this context, for chaining + */ public LayoutContext resetTransientState() { visualScale = 1.0f; leftFloats.clear(); @@ -51,16 +58,8 @@ public void addRightFloat(LytRect bounds) { cachedRightFloatLeftEdge = Integer.MAX_VALUE; } - public OptionalInt getLeftFloatRightEdge() { - if (leftFloats.isEmpty()) { - return OptionalInt.empty(); - } - return OptionalInt.of(getLeftFloatRightEdgeOr(0)); - } - /** * Returns the right edge of the furthest-right left float, or {@code fallback} if there are none. - * Prefer this over {@link #getLeftFloatRightEdge()} in hot paths to avoid {@link OptionalInt} allocation. */ public int getLeftFloatRightEdgeOr(int fallback) { if (leftFloats.isEmpty()) { @@ -77,16 +76,8 @@ public int getLeftFloatRightEdgeOr(int fallback) { return cachedLeftFloatRightEdge; } - public OptionalInt getRightFloatLeftEdge() { - if (rightFloats.isEmpty()) { - return OptionalInt.empty(); - } - return OptionalInt.of(getRightFloatLeftEdgeOr(0)); - } - /** * Returns the left edge of the furthest-left right float, or {@code fallback} if there are none. - * Prefer this over {@link #getRightFloatLeftEdge()} in hot paths to avoid {@link OptionalInt} allocation. */ public int getRightFloatLeftEdgeOr(int fallback) { if (rightFloats.isEmpty()) { diff --git a/src/main/java/com/hfstudio/guidenh/guide/layout/LayoutNodeSerializer.java b/src/main/java/com/hfstudio/guidenh/guide/layout/LayoutNodeSerializer.java new file mode 100644 index 00000000..2f9c100c --- /dev/null +++ b/src/main/java/com/hfstudio/guidenh/guide/layout/LayoutNodeSerializer.java @@ -0,0 +1,1026 @@ +package com.hfstudio.guidenh.guide.layout; + +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.HashSet; +import java.util.List; +import java.util.Set; + +import com.google.flatbuffers.FlatBufferBuilder; +import com.hfstudio.guidenh.guide.document.block.LytBlock; +import com.hfstudio.guidenh.guide.document.block.LytHeading; +import com.hfstudio.guidenh.guide.document.block.LytImage; +import com.hfstudio.guidenh.guide.document.block.LytImageBlock; +import com.hfstudio.guidenh.guide.document.block.LytLatexBlock; +import com.hfstudio.guidenh.guide.document.block.LytLatexDisplayBlock; +import com.hfstudio.guidenh.guide.document.block.LytListItem; +import com.hfstudio.guidenh.guide.document.block.LytParagraph; +import com.hfstudio.guidenh.guide.document.block.LytSlot; +import com.hfstudio.guidenh.guide.document.block.LytStructureView; +import com.hfstudio.guidenh.guide.document.block.LytThematicBreak; +import com.hfstudio.guidenh.guide.document.block.chart.LytBarChart; +import com.hfstudio.guidenh.guide.document.block.chart.LytChartBase; +import com.hfstudio.guidenh.guide.document.block.chart.LytColumnChart; +import com.hfstudio.guidenh.guide.document.block.chart.LytLineChart; +import com.hfstudio.guidenh.guide.document.block.chart.LytPieChart; +import com.hfstudio.guidenh.guide.document.block.chart.LytScatterChart; +import com.hfstudio.guidenh.guide.document.block.functiongraph.FunctionPlot; +import com.hfstudio.guidenh.guide.document.block.functiongraph.LytFunctionGraph; +import com.hfstudio.guidenh.guide.document.block.table.LytTable; +import com.hfstudio.guidenh.guide.document.flow.LytFlowBreak; +import com.hfstudio.guidenh.guide.document.flow.LytFlowContent; +import com.hfstudio.guidenh.guide.document.flow.LytFlowInlineBlock; +import com.hfstudio.guidenh.guide.document.flow.LytFlowSpan; +import com.hfstudio.guidenh.guide.document.flow.LytFlowText; +import com.hfstudio.guidenh.guide.internal.recipe.LytNeiRecipeBox; +import com.hfstudio.guidenh.guide.layout.flatbuffers.ChartData; +import com.hfstudio.guidenh.guide.layout.flatbuffers.FlatNode; +import com.hfstudio.guidenh.guide.layout.flatbuffers.FunctionGraphData; +import com.hfstudio.guidenh.guide.layout.flatbuffers.GuidebookSceneData; +import com.hfstudio.guidenh.guide.layout.flatbuffers.ImageData; +import com.hfstudio.guidenh.guide.layout.flatbuffers.LatexDisplayData; +import com.hfstudio.guidenh.guide.layout.flatbuffers.ListMarkerData; +import com.hfstudio.guidenh.guide.layout.flatbuffers.MediaWikiGeneratedListData; +import com.hfstudio.guidenh.guide.layout.flatbuffers.MediaWikiSpecialGeneratedData; +import com.hfstudio.guidenh.guide.layout.flatbuffers.PieChartData; +import com.hfstudio.guidenh.guide.layout.flatbuffers.RecipeBoxData; +import com.hfstudio.guidenh.guide.layout.flatbuffers.SlotData; +import com.hfstudio.guidenh.guide.layout.flatbuffers.StructureViewData; +import com.hfstudio.guidenh.guide.layout.flatbuffers.TextData; +import com.hfstudio.guidenh.guide.layout.flatbuffers.TextSpan; +import com.hfstudio.guidenh.guide.layout.flatbuffers.TextStyle; +import com.hfstudio.guidenh.guide.layout.flatbuffers.ThematicBreakData; +import com.hfstudio.guidenh.guide.mediawiki.MediaWikiGeneratedListBlock; +import com.hfstudio.guidenh.guide.mediawiki.MediaWikiSpecialGeneratedBlock; +import com.hfstudio.guidenh.guide.render.GuideText; +import com.hfstudio.guidenh.guide.scene.LytGuidebookScene; +import com.hfstudio.guidenh.guide.scene.support.GuideDebugLog; +import com.hfstudio.guidenh.guide.style.ResolvedTextStyle; + +/** + * Determines node_type and builds FlatNode with the appropriate sub-data table. + */ +public final class LayoutNodeSerializer { + + private LayoutNodeSerializer() {} + + /** + * One inline block paired with its paragraph's U+FFFC placeholder: flat index + * plus the vertical alignment request consumed by the Rust inline post-pass + * (see InlineBlockRef in the schema: 0=bottom 2px below baseline, 1=baseline + * ascent, 2=center on line plus offset, retired in favor of anchoring icon + * atoms to the bottom edge of their ink box) and the atom ink box (floats in + * the 16-unit icon space, matching IconMetrics; all zero means undeclared, + * which keeps the legacy behavior). + */ + public record InlineRef(int flatIndex, int align, float param, int inkLeft, int inkRight, int inkBottom) { + + /** No ink box (legacy constructor; all ink values zero means undeclared). */ + public InlineRef(int flatIndex, int align, float param) { + this(flatIndex, align, param, 0, 0, 0); + } + } + + /** Extract the full text of a paragraph (with U+FFFC placeholders for inline blocks). */ + static String paragraphText(LytParagraph par) { + StringBuilder sb = new StringBuilder(); + for (LytFlowContent fc : par.getContent()) { + extractFlowText(fc, sb); + } + return sb.toString(); + } + + public static int build(FlatBufferBuilder fbb, LytBlock block, int styleOff, List childIndices, + List inlineRefs) { + byte nodeType = resolveNodeType(block); + int textOff = nodeType == 1 ? buildTextData(fbb, block, inlineRefs) : 0; + int imageOff = nodeType == 2 ? buildImageData(fbb, block) : 0; + int slotOff = nodeType == 3 ? buildSlotData(fbb, block) : 0; + int breakOff = nodeType == 4 ? buildThematicBreakData(fbb) : 0; + int latexOff = nodeType == 8 ? buildLatexData(fbb, block) : 0; + int recipeBoxOff = nodeType == 20 ? buildRecipeBoxData(fbb, block) : 0; + int pieChartOff = nodeType == 21 ? buildPieChartData(fbb, block) : 0; + int chartDataOff = (nodeType == 22 || nodeType == 23 || nodeType == 24 || nodeType == 25) + ? buildChartData(fbb, block) + : 0; + int structureViewDataOff = nodeType == 26 ? buildStructureViewData(fbb, block) : 0; + int guidebookSceneDataOff = nodeType == 27 ? buildGuidebookSceneData(fbb, block) : 0; + int functionGraphDataOff = nodeType == 28 ? buildFunctionGraphData(fbb, block) : 0; + int mediawikiGeneratedListDataOff = nodeType == 29 ? buildMediaWikiGeneratedListData(fbb, block) : 0; + int mediawikiSpecialGeneratedDataOff = nodeType == 30 ? buildMediaWikiSpecialGeneratedData(fbb, block) : 0; + byte customLayout = 0; + // List-item shared gutter marker declaration: Java declares it, Rust shapes + // and positions it, and Java has no dedicated rendering path. Non-list items + // and subclasses that own their marker (task checkboxes and similar) write + // nothing here (0 = no declaration). + int listMarkerOff = buildListMarkerData(fbb, block); + + int childrenVec = buildChildrenVector(fbb, childIndices); + return FlatNode.createFlatNode( + fbb, + styleOff, + nodeType, + textOff, + imageOff, + slotOff, + breakOff, + 0, + latexOff, + customLayout, + childrenVec, + recipeBoxOff, + pieChartOff, + chartDataOff, + structureViewDataOff, + guidebookSceneDataOff, + functionGraphDataOff, + mediawikiGeneratedListDataOff, + mediawikiSpecialGeneratedDataOff, + listMarkerOff); + } + + static byte resolveNodeType(LytBlock block) { + if (block instanceof MediaWikiSpecialGeneratedBlock) return 30; + if (block instanceof MediaWikiGeneratedListBlock) return 29; + if (block instanceof LytFunctionGraph) return 28; + if (block instanceof LytGuidebookScene) return 27; + if (block instanceof LytStructureView) return 26; + if (block instanceof LytPieChart) return 21; + if (block instanceof LytBarChart) return 22; + if (block instanceof LytColumnChart) return 23; + if (block instanceof LytLineChart) return 24; + if (block instanceof LytScatterChart) return 25; + if (block instanceof LytNeiRecipeBox) return 20; + if (block instanceof LytThematicBreak) return 4; + if (block instanceof LytImage || block instanceof LytImageBlock) return 2; + if (block instanceof LytSlot) return 3; + if (block instanceof LytLatexBlock || block instanceof LytLatexDisplayBlock) return 8; + if (block instanceof LytTable) return 7; + if (block instanceof LytParagraph par && isOpaqueText(par)) return 0; // opaque leaf: no glyph run + if (block instanceof LytParagraph) return 1; // Text: contains LytFlowText children + return 0; // Container + } + + /** + * Paragraphs whose text cannot be represented by the glyph-run path are + * lowered to opaque Container leaves: Rust reserves their box from the + * Java-computed bounds, and rendering falls back to the legacy path + * (HostDraw) automatically since no glyph run is produced. + *

          + * All paragraphs use Rust text shaping for layout; float-aligned inline + * blocks are serialized with align=3/4 and positioned inline by the Rust + * post-pass (paragraph-level float support deferred to a later step). + */ + private static boolean isOpaqueText(LytParagraph par) { + return false; + } + + /** + * List-item shared gutter marker declaration: Java declares it, Rust shapes the + * marker string with the same shaping pipeline used for body text, right-aligns + * it against the list item's own left edge (the document text line of that + * nesting level), and emits the glyphs as ordinary glyph quads into the page + * output stream. Java has no dedicated rendering path for it; the old DrawText + * emit, measureWidth positioning, and bullet-square drawing were removed from + * LytListItem. + * + *

          + * Only {@link LytListItem} instances that use the shared marker write this + * field (ordered "N." including the start semantics, unordered "•", see + * {@link LytListItem#getSharedMarkerText()}). Subclasses with their own gutter + * marker (task checkboxes and similar, hasOwnMarker=true) return null and + * declare nothing, because a custom marker uses its own drawing path and the + * shared slot must not declare it twice. The style is body regular + * (BASE_FONT_SIZE) in the {@link LytListItem#COL_MARKER} gray. + * + *

          + * Ordered list numbering timing fix: ordered numbers are computed on the spot + * while serializing. {@link LytListItem#getSharedMarkerText()} scans the sibling + * items of the parent {@link LytList} to derive the number (including the start + * semantics). serialize (this class) runs before + * {@code afterExternalLayout()} (LytDocument.createLayout), so any number cached + * during layout is unavailable on the first serialization pass. This is O(N) and + * runs once per layout pass, which is acceptable. + */ + private static int buildListMarkerData(FlatBufferBuilder fbb, LytBlock block) { + if (!(block instanceof LytListItem item)) { + return 0; + } + String markerText = item.getSharedMarkerText(); + if (markerText == null) { + return 0; + } + int textOff = fbb.createString(markerText); + long markerArgb = LytListItem.COL_MARKER.resolve() & 0xFFFFFFFFL; + // Real font_family: the marker declares the body font family. The resolved + // style font wins; a null font falls back to + // GuideText.resolveBaseFontFamily(), whose default "system" maps to null, so + // nothing is written and the wire stays byte-identical to the state before + // this field was activated. The pipeline only needs to carry it. + var markerStyle = item.resolveStyle(); + String markerFamily = markerStyle != null && markerStyle.font() != null ? markerStyle.font() + : GuideText.resolveBaseFontFamily(); + int markerFamilyOff = markerFamily != null ? fbb.createString(markerFamily) : 0; + int styleOff = TextStyle.createTextStyle( + fbb, + GuideText.BASE_FONT_SIZE, + false, // bold: the body font is regular + false, // italic + 1f, // fontScale + markerArgb, + markerFamilyOff, + false, // underlined + false, // strikethrough + 0L, // highlightArgb + false, // inlineCode + 0.0f, // baselineShift + false, // wavyUnderline + false); // dottedUnderline + return ListMarkerData.createListMarkerData(fbb, textOff, styleOff); + } + + private static int buildTextData(FlatBufferBuilder fbb, LytBlock block, List inlineRefs) { + String text = ""; + // Base em size mirrors GuideText.resolveBaseFontSize() (= GuideText.BASE_FONT_SIZE + // = 11 when readerFontSize = 0); line height is size × 1.55 + // (round(17.05) = GuideText.BASE_LINE_HEIGHT = 17 at scale 1) across every Rust + // path (parley_text.rs shape_paragraph, layout.rs emitText, measure.rs + // empty-paragraph fallback). fontScale (headings etc.) scales both + // proportionally: Rust computes scaled = fontSize × fontScale. + float fontSize = GuideText.resolveBaseFontSize(); + boolean bold = false; + boolean italic = false; + float fontScale = 1f; + long baseColor = 0xFFFFFFFFL; + // Real font_family of the paragraph base run: the resolved style font wins, + // a null font falls back to GuideText.resolveBaseFontFamily() (its default + // "system" maps to null, so nothing is written and the wire stays + // byte-identical to the state before this field was activated). Span-level + // families are handled separately by buildFbTextStyle. + String baseFamily = null; + byte wsByte = 0; + List spanParts = new ArrayList<>(); + // In-paragraph hard breaks (
          ): raw byte offsets in the break-free + // text at which the Rust pusher splits the paragraph into independently + // shaped pieces (a hard break, not a whitespace char). + List breaks = new ArrayList<>(); + // In-paragraph clear breaks with their raw (original-text) UTF-8 byte + // offset; collected in document order alongside the text extraction. + List clears = new ArrayList<>(); + + if (block instanceof LytParagraph par) { + // Collect all text recursively: text can be nested inside LytFlowSpan wrappers + // (e.g. bold/italic spans). The simple instanceof LytFlowText filter misses spans. + StringBuilder sb = new StringBuilder(); + for (LytFlowContent fc : par.getContent()) { + extractFlowText(fc, sb); + } + text = sb.toString(); + + // DIAG: log extracted text (first 80 chars) to confirm recursive extraction works + if (text.length() > 80) { + GuideDebugLog + .warnAlways("Layout: para text(len={}) preview=\"{}\"", text.length(), text.substring(0, 80)); + } else if (!text.isEmpty()) { + GuideDebugLog.warnAlways("Layout: para text(len={}) preview=\"{}\"", text.length(), text); + } + + // Resolve resolved style from the paragraph (uses its own style + parent styles) + var resolved = par.resolveStyle(); + if (resolved != null) { + // The ResolvedTextStyle doesn't carry fontSize directly; + // default to paragraph's font data or whatever is available. + // The Rust side uses font_size * font_scale * 1.45 for the + // empty-paragraph line-height fallback (see measure.rs). + bold = resolved.bold(); + italic = resolved.italic(); + if (resolved.fontScale() != 1f) { + fontScale = resolved.fontScale(); + } + // Base run tint (single-style runs fall back to this color). + baseColor = GuideText.resolveColor(resolved) & 0xFFFFFFFFL; + // Base run family: the style font wins, and a null font falls back to + // the reader config (resolveBaseFontFamily; its default "system" maps + // to null, so the wire stays neutral). + baseFamily = resolved.font() != null ? resolved.font() : GuideText.resolveBaseFontFamily(); + var resolvedWs = resolved.whiteSpace(); + wsByte = resolvedWs == com.hfstudio.guidenh.guide.style.WhiteSpaceMode.PRE_WRAP ? (byte) 1 + : resolvedWs == com.hfstudio.guidenh.guide.style.WhiteSpaceMode.PRE ? (byte) 2 : (byte) 0; + } + + // Rich spans: per-leaf resolved styles in document order (concatenated + // span texts equal the extracted text, U+FFFC placeholders included). + for (LytFlowContent fc : par.getContent()) { + collectSpanParts(fc, resolved, spanParts); + } + // Single-style override: if all content resolves to one distinct style + // that differs from the paragraph's own resolved style, the base run + // style must come from that single span style rather than the paragraph + // style, so that the single span's appearance is preserved without + // needing rich spans (needsRichSpans requires >=2 distinct styles). + if (spanParts.size() == 1 && resolved != null && !spanParts.get(0).style.equals(resolved)) { + var partStyle = spanParts.get(0).style; + bold = partStyle.bold(); + italic = partStyle.italic(); + if (partStyle.fontScale() != 1f) { + fontScale = partStyle.fontScale(); + } + baseColor = GuideText.resolveColor(partStyle) & 0xFFFFFFFFL; + // The single-style override takes the same reader config fallback. + baseFamily = partStyle.font() != null ? partStyle.font() : GuideText.resolveBaseFontFamily(); + var partWs = partStyle.whiteSpace(); + wsByte = partWs == com.hfstudio.guidenh.guide.style.WhiteSpaceMode.PRE_WRAP ? (byte) 1 + : partWs == com.hfstudio.guidenh.guide.style.WhiteSpaceMode.PRE ? (byte) 2 : (byte) 0; + } + // Clear breaks: record each
          at its raw byte offset so the + // Rust pusher can drop the following lines below the cleared floats. + int[] clearOff = new int[] { 0 }; + for (LytFlowContent fc : par.getContent()) { + walkClears(fc, clearOff, clears); + } + // Hard breaks: record every
          (including clear ones) so the Rust + // pusher splits the paragraph into independently shaped pieces. + int[] breakOff = new int[] { 0 }; + for (LytFlowContent fc : par.getContent()) { + walkBreaks(fc, breakOff, breaks); + } + } + + int strOff = fbb.createString(text); + // Wire base family = baseFamily (the resolved or part style font wins, + // otherwise resolveBaseFontFamily; its default "system" maps to null, so + // familyOff stays 0 and the wire is byte-neutral). + int baseFamilyOff = baseFamily != null ? fbb.createString(baseFamily) : 0; + int styleOff = TextStyle.createTextStyle( + fbb, + fontSize, + bold, + italic, + fontScale, + baseColor, + baseFamilyOff, + false, + false, + 0L, + false, + 0.0f, + false, + false); + int inlineBlocksVec = 0; + if (!inlineRefs.isEmpty()) { + int[] refs = new int[inlineRefs.size()]; + for (int i = 0; i < refs.length; i++) { + var r = inlineRefs.get(i); + // Pass the atom ink box through unchanged: the IconMetrics + // inkLeft/inkRight/inkBottom values (floats in the 16-unit icon + // space) are written to the wire, and the Rust inline post-pass and + // measurement layer consume them for advance/kerning/baseline. All + // zero means undeclared (legacy data, or an icon whose metrics cannot + // be measured falls back to the cell width). The units match + // IconMetrics (16-unit icon space); Rust converts them to px as + // declared cell height * ink/16. + refs[i] = com.hfstudio.guidenh.guide.layout.flatbuffers.InlineBlockRef.createInlineBlockRef( + fbb, + r.flatIndex(), + (byte) r.align(), + r.param(), + r.inkLeft(), + r.inkRight(), + r.inkBottom()); + } + inlineBlocksVec = TextData.createInlineBlocksVector(fbb, refs); + } + int spansVec = 0; + if (needsRichSpans(spanParts)) { + int[] spans = new int[spanParts.size()]; + for (int i = 0; i < spans.length; i++) { + SpanPart part = spanParts.get(i); + int spanTextOff = fbb.createString(part.text); + int spanStyleOff = buildFbTextStyle(fbb, fontSize, part.style); + spans[i] = TextSpan.createTextSpan(fbb, spanTextOff, spanStyleOff); + } + spansVec = TextData.createSpansVector(fbb, spans); + } + int clearsVec = 0; + if (!clears.isEmpty()) { + int[] cs = new int[clears.size()]; + for (int i = 0; i < cs.length; i++) { + int[] c = clears.get(i); + cs[i] = com.hfstudio.guidenh.guide.layout.flatbuffers.ClearBreak + .createClearBreak(fbb, c[0], (byte) c[1]); + } + clearsVec = TextData.createClearsVector(fbb, cs); + } + int breaksVec = 0; + if (!breaks.isEmpty()) { + long[] bs = new long[breaks.size()]; + for (int i = 0; i < bs.length; i++) { + bs[i] = breaks.get(i) & 0xFFFFFFFFL; + } + breaksVec = TextData.createBreaksVector(fbb, bs); + } + boolean separator = (block instanceof LytHeading heading) + && (heading.getDepth() == 1 || heading.getDepth() == 2); + // Read the per-paragraph text alignment from the resolved style + byte alignmentByte = 0; // default: Start(Left) + if (block instanceof LytParagraph par) { + var resolved = par.resolveStyle(); + if (resolved != null && resolved.alignment() != null) { + alignmentByte = switch (resolved.alignment()) { + case CENTER -> 1; + case RIGHT -> 2; + default -> 0; // LEFT + }; + } + } + // bands / float_clips are DEPRECATED slots (marked in the fbs schema, never + // consumed by Rust) and are no longer written. An omitted slot falls back to + // its default (a null vector), so the wire carries no bytes for them. + TextData.startTextData(fbb); + TextData.addBreaks(fbb, breaksVec); + TextData.addClears(fbb, clearsVec); + TextData.addSpans(fbb, spansVec); + TextData.addInlineBlocks(fbb, inlineBlocksVec); + TextData.addStyle(fbb, styleOff); + TextData.addText(fbb, strOff); + TextData.addAlignment(fbb, alignmentByte); + TextData.addSeparator(fbb, separator); + TextData.addWhiteSpace(fbb, wsByte); + return TextData.endTextData(fbb); + } + + /** One text run with its resolved style, in document order. */ + private static final class SpanPart { + + String text; + final ResolvedTextStyle style; + + SpanPart(String text, ResolvedTextStyle style) { + this.text = text; + this.style = style; + } + } + + /** + * Split a flow content tree into styled span parts (document order), merging + * adjacent parts that share the same resolved style. Inline blocks become + * U+FFFC placeholder parts in the paragraph's own style. Mirrors + * {@link #extractFlowText} so concatenated part texts equal the full text. + */ + private static void collectSpanParts(LytFlowContent fc, ResolvedTextStyle paragraphStyle, List out) { + if (fc instanceof LytFlowText ft) { + appendSpanPart(out, ft.getText(), fc.resolveStyle()); + } else if (fc instanceof LytFlowInlineBlock) { + appendSpanPart(out, "", paragraphStyle); + } else if (fc instanceof LytFlowSpan fs) { + for (LytFlowContent child : fs.getChildren()) { + collectSpanParts(child, paragraphStyle, out); + } + } + // Other flow types (LytFlowBreak, etc.) contribute no text + } + + private static void appendSpanPart(List out, String text, ResolvedTextStyle style) { + if (text.isEmpty()) { + return; + } + if (!out.isEmpty() && out.get(out.size() - 1).style.equals(style)) { + out.get(out.size() - 1).text += text; + } else { + out.add(new SpanPart(text, style)); + } + } + + /** + * Whether the paragraph needs rich spans (TextData.spans): multiple distinct + * resolved styles, or any decoration the single-style run cannot express. + * Single-style paragraphs keep the legacy text+style fields (spans empty). + */ + private static boolean needsRichSpans(List parts) { + Set distinct = new HashSet<>(); + for (SpanPart part : parts) { + distinct.add(part.style); + ResolvedTextStyle s = part.style; + if (s.underlined() || s.strikethrough() + || s.wavyUnderline() + || s.dottedUnderline() + || s.backgroundColor() != null + || s.inlineCode()) { + return true; + } + } + return distinct.size() >= 2; + } + + /** Build a schema TextStyle for one span: color/decorations resolved now. */ + private static int buildFbTextStyle(FlatBufferBuilder fbb, float fontSize, ResolvedTextStyle style) { + long argb = GuideText.resolveColor(style) & 0xFFFFFFFFL; + boolean inlineCode = style.inlineCode(); + long highlight = 0L; + if (inlineCode) { + highlight = 0x1A6FB6FFL; + } else if (style.backgroundColor() != null) { + highlight = style.backgroundColor() + .resolve() & 0xFFFFFFFFL; + } + // An inline_code span declares a monospace family through + // GuideText.resolveCodeFontFamily() (the config default "mono" maps to + // "monospace"; Rust resolve_family maps it to GenericFamily::Monospace, and + // each fontique backend resolves the platform monospace default), which is + // byte-identical to the legacy literal. A non-code span keeps its real + // font_family (style.font() wins, otherwise resolveBaseFontFamily(), whose + // default null writes null, keeping non-code pages byte-identical). This + // shares one predicate with GuideText.fontFamilyOf, so the cache key, the + // wire family, and the family actually used for shaping always agree. + // FlatBuffers strings must be created before the table that references them. + String spanFamily = style.inlineCode() ? GuideText.resolveCodeFontFamily() + : (style.font() != null ? style.font() : GuideText.resolveBaseFontFamily()); + int familyOff = spanFamily != null ? fbb.createString(spanFamily) : 0; + return TextStyle.createTextStyle( + fbb, + fontSize, + style.bold(), + style.italic(), + style.fontScale(), + argb, + familyOff, + style.underlined(), + style.strikethrough(), + highlight, + inlineCode, + style.baselineShift(), + style.wavyUnderline(), + style.dottedUnderline()); + } + + /** + * Recursively extract all text from a flow content tree. + * Handles {@link LytFlowText} (direct text) and {@link LytFlowSpan} + * (wrapper with nested children). + */ + /** + * Walk flow content in document order, tracking the running raw (original + * text, U+FFFC included) UTF-8 byte offset and recording every clear break + * at its offset. Mirrors {@link #extractFlowText}'s traversal so the offset + * matches the byte position in TextData.text: text contributes its UTF-8 + * length, an inline block contributes the 3 bytes of its U+FFFC placeholder, + * a break contributes nothing (it is not in the text). + */ + private static void walkClears(LytFlowContent fc, int[] offset, List out) { + if (fc instanceof LytFlowText ft) { + offset[0] += ft.getText() + .getBytes(StandardCharsets.UTF_8).length; + } else if (fc instanceof LytFlowInlineBlock) { + offset[0] += 3; // U+FFFC placeholder = 3 UTF-8 bytes + } else if (fc instanceof LytFlowBreak fb) { + boolean left = fb.isClearLeft(); + boolean right = fb.isClearRight(); + if (left || right) { + byte side = (byte) (left && right ? 3 : left ? 1 : 2); + out.add(new int[] { offset[0], side }); + } + } else if (fc instanceof LytFlowSpan fs) { + for (LytFlowContent child : fs.getChildren()) { + walkClears(child, offset, out); + } + } + } + + /** + * Record every in-paragraph {@code
          + * } (hard break) at its raw byte offset + * in the break-free text. Mirrors {@link #walkClears}' traversal and offset + * accounting (a break contributes no bytes to the text), but records ALL + * breaks, not only the clearing ones. The Rust pusher splits the paragraph + * at these offsets and shapes each piece independently. + */ + private static void walkBreaks(LytFlowContent fc, int[] offset, List out) { + if (fc instanceof LytFlowText ft) { + offset[0] += ft.getText() + .getBytes(StandardCharsets.UTF_8).length; + } else if (fc instanceof LytFlowInlineBlock) { + offset[0] += 3; // U+FFFC placeholder = 3 UTF-8 bytes + } else if (fc instanceof LytFlowBreak fb) { + // A clearing break is handled by the clear-floor path (single-shape + // trailing clear); recording it here as a hard split too would put it + // exactly on a segment boundary and lose it. Only non-clearing
          + // are hard splits. + if (!fb.isClearLeft() && !fb.isClearRight()) { + out.add(offset[0]); // the break itself is not in the text + } + } else if (fc instanceof LytFlowSpan fs) { + for (LytFlowContent child : fs.getChildren()) { + walkBreaks(child, offset, out); + } + } + } + + private static void extractFlowText(LytFlowContent fc, StringBuilder out) { + if (fc instanceof LytFlowText ft) { + out.append(ft.getText()); + } else if (fc instanceof LytFlowInlineBlock) { + // Inline block placeholders: one OBJECT REPLACEMENT CHARACTER per + // inline block, in order. The Rust side replaces the placeholder's + // advance with the block's real width (kerning) and anchors the + // block at the placeholder's pen position. + out.append(''); + } else if (fc instanceof LytFlowSpan fs) { + for (LytFlowContent child : fs.getChildren()) { + extractFlowText(child, out); + } + } + // Other flow types (LytFlowBreak, etc.) contribute no text + } + + private static int buildImageData(FlatBufferBuilder fbb, LytBlock block) { + float naturalW = 0; + float naturalH = 0; + float scaleX = 1f; + float scaleY = 1f; + float explicitW = -1f; + float explicitH = -1f; + int cropX = 0; + int cropY = 0; + int cropW = -1; + int cropH = -1; + + if (block instanceof LytImage img) { + var tex = img.getTexture(); + if (tex != null && !tex.isMissing()) { + var size = tex.getSize(); + naturalW = size.width(); + naturalH = size.height(); + } + // Pass crop / scale / explicit fields so the Rust ImageData path + // receives the same crop information as the Java Blit path. + cropX = img.getCropX(); + cropY = img.getCropY(); + cropW = img.getCropWidth(); + cropH = img.getCropHeight(); + scaleX = (float) img.getScaleX(); + scaleY = (float) img.getScaleY(); + explicitW = img.getExplicitWidth(); + explicitH = img.getExplicitHeight(); + } + if (block instanceof LytImageBlock imb) { + // LytImageBlock extends LytParagraph (not LytImage) so the + // LytImage branch above is never entered. Read explicit + // dimensions, scale, and crop so Rust measure_image can + // size the image correctly. Without this, ImageData retains + // all defaults (naturalW=0, naturalH=0, explicitW=-1f) and + // measure_image returns 1x1, so the block-level image collapses and the + // parent container (e.g. LytListItem as column flex) does not stretch to + // include the visual image height. + explicitW = imb.getExplicitWidth(); + explicitH = imb.getExplicitHeight(); + scaleX = (float) imb.getScaleX(); + scaleY = (float) imb.getScaleY(); + cropX = imb.getCropX(); + cropY = imb.getCropY(); + cropW = imb.getCropWidth(); + cropH = imb.getCropHeight(); + } + + return ImageData + .createImageData(fbb, naturalW, naturalH, cropX, cropY, cropW, cropH, scaleX, scaleY, explicitW, explicitH); + } + + private static int buildSlotData(FlatBufferBuilder fbb, LytBlock block) { + return SlotData.createSlotData(fbb, 18f); + } + + private static int buildThematicBreakData(FlatBufferBuilder fbb) { + return ThematicBreakData.createThematicBreakData(fbb, 6f); + } + + private static int buildLatexData(FlatBufferBuilder fbb, LytBlock block) { + String formula = ""; + int fillColorArgb = 0xFFFFFFFF; + float userScale = 1f; + int offsetX = 0; + int offsetY = 0; + float rawW = 0; + float rawH = 0; + + if (block instanceof LytLatexBlock lb) { + formula = lb.getFormula(); + fillColorArgb = lb.getFillColorArgb(); + userScale = lb.getUserScale(); + offsetX = lb.getOffsetX(); + offsetY = lb.getOffsetY(); + rawW = lb.getFormulaDisplayW(); + rawH = lb.getFormulaDisplayH(); + } else if (block instanceof LytLatexDisplayBlock ldb) { + rawW = ldb.getFormulaDisplayW(); + rawH = ldb.getFormulaDisplayH(); + } + + int formulaOff = fbb.createString(formula); + // source_scale / ref_h are DEPRECATED slots (marked in the fbs schema, never + // consumed by Rust) and are no longer written. An omitted slot falls back to + // its default (100.0 / 0.0), so the wire carries no bytes for them. + LatexDisplayData.startLatexDisplayData(fbb); + LatexDisplayData.addRawH(fbb, rawH); + LatexDisplayData.addRawW(fbb, rawW); + LatexDisplayData.addOffsetY(fbb, offsetY); + LatexDisplayData.addOffsetX(fbb, offsetX); + LatexDisplayData.addUserScale(fbb, userScale); + LatexDisplayData.addFillColorArgb(fbb, fillColorArgb); + LatexDisplayData.addFormula(fbb, formulaOff); + return LatexDisplayData.endLatexDisplayData(fbb); + } + + private static int buildRecipeBoxData(FlatBufferBuilder fbb, LytBlock block) { + float bodyWidth = 0; + float bodyHeight = 0; + float bodyTopInset = 0; + float bodyYShift = 0; + float titleTextWidth = 0; + float iconSize = 0; + boolean recipeJumpEnabled = false; + float titleHeight = 0; + + if (block instanceof LytNeiRecipeBox box) { + bodyWidth = box.getBodyWidth(); + bodyHeight = box.getBodyHeight(); + bodyTopInset = box.getBodyTopInset(); + bodyYShift = box.getBodyYShift(); + titleTextWidth = box.getTitleTextWidth(); + iconSize = box.getIconSizeResult(); + recipeJumpEnabled = box.isRecipeJumpEnabled(); + titleHeight = box.getTitleHeight(); + } + + return RecipeBoxData.createRecipeBoxData( + fbb, + bodyWidth, + bodyHeight, + bodyTopInset, + bodyYShift, + titleTextWidth, + iconSize, + recipeJumpEnabled, + titleHeight); + } + + private static int buildPieChartData(FlatBufferBuilder fbb, LytBlock block) { + float preferredWidth = 0; + float totalHeight = 0; + float titleChrome = 0; + byte legendPosition = 0; + float legendRowHeight = 10f; + float[] legendLabelWidths = new float[0]; + + if (block instanceof LytPieChart chart) { + // preferredWidth: mirrors LytChartBase.preferredWidth() + int ew = chart.getExplicitWidth(); + preferredWidth = (ew > 0 ? ew : LytChartBase.DEFAULT_WIDTH) + chart.getExtraPlotWidth(); + // totalHeight: mirrors LytChartBase.computeLayout: explicitHeight or DEFAULT_HEIGHT + int eh = chart.getExplicitHeight(); + totalHeight = eh > 0 ? eh : LytChartBase.DEFAULT_HEIGHT; + // New fields for Rust-side chrome computation (width-independent): + titleChrome = chart.getTitleChromeForRust(); + legendPosition = chart.getLegendPositionForRust(); + legendRowHeight = chart.getLegendRowHeightForRust(); + legendLabelWidths = chart.getLegendLabelWidthsForRust(); + } + + int widthsVec = PieChartData.createLegendLabelWidthsVector(fbb, legendLabelWidths); + // chrome_height is a DEPRECATED slot (marked in the fbs schema, never + // consumed by Rust) and is no longer written. An omitted slot falls back to + // its default 0.0, so the wire carries no bytes for it. + PieChartData.startPieChartData(fbb); + PieChartData.addLegendLabelWidths(fbb, widthsVec); + PieChartData.addLegendRowHeight(fbb, legendRowHeight); + PieChartData.addTitleChrome(fbb, titleChrome); + PieChartData.addTotalHeight(fbb, totalHeight); + PieChartData.addPreferredWidth(fbb, preferredWidth); + PieChartData.addLegendPosition(fbb, legendPosition); + return PieChartData.endPieChartData(fbb); + } + + private static int buildChartData(FlatBufferBuilder fbb, LytBlock block) { + float preferredWidth = 0; + float totalHeight = 0; + float titleChrome = 0; + byte legendPosition = 0; + float legendRowHeight = 10f; + float[] legendLabelWidths = new float[0]; + + if (block instanceof LytChartBase chart) { + // preferredWidth: mirrors LytChartBase.preferredWidth() + int ew = chart.getExplicitWidth(); + preferredWidth = (ew > 0 ? ew : LytChartBase.DEFAULT_WIDTH) + chart.getExtraPlotWidth(); + // totalHeight: mirrors LytChartBase.computeLayout: explicitHeight or DEFAULT_HEIGHT + int eh = chart.getExplicitHeight(); + totalHeight = eh > 0 ? eh : LytChartBase.DEFAULT_HEIGHT; + // New fields for Rust-side chrome computation (width-independent): + titleChrome = chart.getTitleChromeForRust(); + legendPosition = chart.getLegendPositionForRust(); + legendRowHeight = chart.getLegendRowHeightForRust(); + legendLabelWidths = chart.getLegendLabelWidthsForRust(); + } + + int widthsVec = ChartData.createLegendLabelWidthsVector(fbb, legendLabelWidths); + // chrome_height is a DEPRECATED slot (marked in the fbs schema, never + // consumed by Rust) and is no longer written. An omitted slot falls back to + // its default 0.0, so the wire carries no bytes for it. + ChartData.startChartData(fbb); + ChartData.addLegendLabelWidths(fbb, widthsVec); + ChartData.addLegendRowHeight(fbb, legendRowHeight); + ChartData.addTitleChrome(fbb, titleChrome); + ChartData.addTotalHeight(fbb, totalHeight); + ChartData.addPreferredWidth(fbb, preferredWidth); + ChartData.addLegendPosition(fbb, legendPosition); + return ChartData.endChartData(fbb); + } + + private static int buildStructureViewData(FlatBufferBuilder fbb, LytBlock block) { + float viewWidth = LytStructureView.DEFAULT_WIDTH; + float viewHeight = LytStructureView.DEFAULT_HEIGHT; + + if (block instanceof LytStructureView sv) { + viewWidth = sv.getViewWidth(); + viewHeight = sv.getViewHeight(); + } + + return StructureViewData.createStructureViewData(fbb, viewWidth, viewHeight); + } + + private static int buildGuidebookSceneData(FlatBufferBuilder fbb, LytBlock block) { + float sceneWidth = LytGuidebookScene.DEFAULT_WIDTH; + float sceneHeight = LytGuidebookScene.DEFAULT_HEIGHT; + float buttonColumnReserve = 0; + float buttonsTotalHeight = 0; + float leftDock = 0; + float rightDock = 0; + float topDock = 0; + float bottomDock = 0; + float bottomControlAreaHeight = 0; + boolean reserveBottomControl = false; + + if (block instanceof LytGuidebookScene scene) { + sceneWidth = scene.getSceneWidth(); + sceneHeight = scene.getSceneHeight(); + buttonColumnReserve = scene.getSceneButtonColumnReserveForExport(); + buttonsTotalHeight = scene.getButtonsTotalHeightForExport(); + leftDock = scene.getLeftDockForExport(); + rightDock = scene.getRightDockForExport(); + topDock = scene.getTopDockForExport(); + bottomDock = scene.getBottomDockForExport(); + bottomControlAreaHeight = scene.getBottomControlAreaHeight(); + reserveBottomControl = scene.isReserveBottomControlArea(); + } + + return GuidebookSceneData.createGuidebookSceneData( + fbb, + sceneWidth, + sceneHeight, + buttonColumnReserve, + buttonsTotalHeight, + leftDock, + rightDock, + topDock, + bottomDock, + bottomControlAreaHeight, + reserveBottomControl); + } + + private static int buildFunctionGraphData(FlatBufferBuilder fbb, LytBlock block) { + float baseWidth = LytFunctionGraph.DEFAULT_WIDTH; + float baseHeight = LytFunctionGraph.DEFAULT_HEIGHT; + float titleChrome = 0; + float legendRowHeight = 0; + float[] labelItemWidths = new float[0]; + + if (block instanceof LytFunctionGraph graph) { + int ew = graph.getExplicitWidth(); + int eh = graph.getExplicitHeight(); + baseWidth = ew > 0 ? ew : LytFunctionGraph.DEFAULT_WIDTH; + baseHeight = eh > 0 ? eh : LytFunctionGraph.DEFAULT_HEIGHT; + + // titleChrome = lineHeight(TITLE_STYLE) + TITLE_GAP, or 0 if no title + String title = graph.getTitle(); + if (title != null && !title.isEmpty()) { + int titleLineHeight = GuideText.lineHeight(LytFunctionGraph.getTitleStyle()); + titleChrome = titleLineHeight + LytFunctionGraph.getTitleGapConstant(); + } + + // Legend item widths and row height + List plots = graph.getPlots(); + int swatchSize = LytFunctionGraph.getLegendSwatchSize(); + int swatchTextGap = LytFunctionGraph.getLegendSwatchTextGap(); + legendRowHeight = Math.max(swatchSize, GuideText.lineHeight(LytFunctionGraph.getLegendLabelStyle())); + labelItemWidths = new float[plots.size()]; + boolean hasLabel = false; + for (int i = 0; i < plots.size(); i++) { + FunctionPlot plot = plots.get(i); + String label = plot.getLabel(); + if (label != null && !label.isEmpty()) { + int labelW = GuideText.measureWidth(label, LytFunctionGraph.getLegendLabelStyle()); + labelItemWidths[i] = swatchSize + swatchTextGap + labelW; + hasLabel = true; + } + } + if (!hasLabel) { + legendRowHeight = 0; // suppress the legend entirely: there are no labels + } + } + + int labelWidthsVec = FunctionGraphData.createLabelItemWidthsVector(fbb, labelItemWidths); + return FunctionGraphData + .createFunctionGraphData(fbb, baseWidth, baseHeight, titleChrome, legendRowHeight, labelWidthsVec); + } + + private static int buildMediaWikiGeneratedListData(FlatBufferBuilder fbb, LytBlock block) { + float maxContentHeight = 0; + + if (block instanceof MediaWikiGeneratedListBlock mw) { + maxContentHeight = mw.getMaxPrecomputedContentHeight(); + } + + return MediaWikiGeneratedListData.createMediaWikiGeneratedListData(fbb, maxContentHeight); + } + + private static int buildMediaWikiSpecialGeneratedData(FlatBufferBuilder fbb, LytBlock block) { + if (block instanceof MediaWikiSpecialGeneratedBlock mw) { + // Collect font facts from the block (uses Minecraft font metrics, + // width-independent). The serializer does NOT have a LayoutContext, + // so fonts facts are collected via computeLayout context which is + // already complete by the time serialization runs. We pass null + // context here: font facts were already captured during layout. + var facts = mw.getCollectedFontFacts(); + if (facts != null) { + var impl = facts.impl(); + int groupTitleWidthsOff = impl.groupTitleWidths().length > 0 + ? MediaWikiSpecialGeneratedData.createGroupTitleWidthsVector(fbb, impl.groupTitleWidths()) + : 0; + int groupEntryCountsOff = impl.groupEntryCounts().length > 0 + ? MediaWikiSpecialGeneratedData.createGroupEntryCountsVector(fbb, impl.groupEntryCounts()) + : 0; + int groupEstimatedHeightsOff = impl.groupEstimatedHeights().length > 0 + ? MediaWikiSpecialGeneratedData.createGroupEstimatedHeightsVector(fbb, impl.groupEstimatedHeights()) + : 0; + int entryTitleWidthsOff = impl.entryTitleWidths().length > 0 + ? MediaWikiSpecialGeneratedData.createEntryTitleWidthsVector(fbb, impl.entryTitleWidths()) + : 0; + int entryHasIconOff = impl.entryHasIcon().length > 0 + ? MediaWikiSpecialGeneratedData.createEntryHasIconVector(fbb, impl.entryHasIcon()) + : 0; + int entryEstimatedHeightsOff = impl.entryEstimatedHeights().length > 0 + ? MediaWikiSpecialGeneratedData.createEntryEstimatedHeightsVector(fbb, impl.entryEstimatedHeights()) + : 0; + int entrySubtitleLineCountsOff = impl.entrySubtitleLineCounts().length > 0 + ? MediaWikiSpecialGeneratedData + .createEntrySubtitleLineCountsVector(fbb, impl.entrySubtitleLineCounts()) + : 0; + int subtitleLineWordCountsOff = impl.subtitleLineWordCounts().length > 0 ? MediaWikiSpecialGeneratedData + .createSubtitleLineWordCountsVector(fbb, impl.subtitleLineWordCounts()) : 0; + int subtitleWordWidthsOff = impl.subtitleWordWidths().length > 0 + ? MediaWikiSpecialGeneratedData.createSubtitleWordWidthsVector(fbb, impl.subtitleWordWidths()) + : 0; + + // max_content_height is a DEPRECATED slot (marked in the fbs schema, + // never consumed by Rust) and is no longer written. An omitted slot + // falls back to its default 0.0, so the wire carries no bytes for it. + MediaWikiSpecialGeneratedData.startMediaWikiSpecialGeneratedData(fbb); + MediaWikiSpecialGeneratedData.addSubtitleLineHeight(fbb, impl.subtitleLineHeight()); + MediaWikiSpecialGeneratedData.addLinkLineHeight(fbb, impl.linkLineHeight()); + MediaWikiSpecialGeneratedData.addSubtitleSpaceWidth(fbb, impl.subtitleSpaceWidth()); + MediaWikiSpecialGeneratedData.addSubtitleWordWidths(fbb, subtitleWordWidthsOff); + MediaWikiSpecialGeneratedData.addSubtitleLineWordCounts(fbb, subtitleLineWordCountsOff); + MediaWikiSpecialGeneratedData.addEntrySubtitleLineCounts(fbb, entrySubtitleLineCountsOff); + MediaWikiSpecialGeneratedData.addEntryEstimatedHeights(fbb, entryEstimatedHeightsOff); + MediaWikiSpecialGeneratedData.addEntryHasIcon(fbb, entryHasIconOff); + MediaWikiSpecialGeneratedData.addEntryTitleWidths(fbb, entryTitleWidthsOff); + MediaWikiSpecialGeneratedData.addTotalEntryCount(fbb, impl.totalEntryCount()); + MediaWikiSpecialGeneratedData.addGroupEstimatedHeights(fbb, groupEstimatedHeightsOff); + MediaWikiSpecialGeneratedData.addGroupEntryCounts(fbb, groupEntryCountsOff); + MediaWikiSpecialGeneratedData.addGroupTitleWidths(fbb, groupTitleWidthsOff); + MediaWikiSpecialGeneratedData.addGroupCount(fbb, impl.groupCount()); + MediaWikiSpecialGeneratedData.addColumnCount(fbb, impl.columnCount()); + MediaWikiSpecialGeneratedData.addHasMore(fbb, impl.hasMore()); + return MediaWikiSpecialGeneratedData.endMediaWikiSpecialGeneratedData(fbb); + } + } + + // Fallback: no block data or no font facts (should not happen in normal flow). + // All slot values equal their fbs defaults → empty table, wire-identical + // to the previous create() call (max_content_height DEPRECATED, unwritten). + MediaWikiSpecialGeneratedData.startMediaWikiSpecialGeneratedData(fbb); + MediaWikiSpecialGeneratedData.addSubtitleLineHeight(fbb, 10.0f); + MediaWikiSpecialGeneratedData.addLinkLineHeight(fbb, 10.0f); + MediaWikiSpecialGeneratedData.addSubtitleSpaceWidth(fbb, 4.0f); + MediaWikiSpecialGeneratedData.addColumnCount(fbb, 1); + MediaWikiSpecialGeneratedData.addHasMore(fbb, false); + return MediaWikiSpecialGeneratedData.endMediaWikiSpecialGeneratedData(fbb); + } + + private static int buildChildrenVector(FlatBufferBuilder fbb, List indices) { + fbb.startVector(4, indices.size(), 4); + for (int i = indices.size() - 1; i >= 0; i--) { + fbb.addInt(indices.get(i)); + } + return fbb.endVector(); + } +} diff --git a/src/main/java/com/hfstudio/guidenh/guide/layout/LayoutStyleExtractor.java b/src/main/java/com/hfstudio/guidenh/guide/layout/LayoutStyleExtractor.java new file mode 100644 index 00000000..7e665d68 --- /dev/null +++ b/src/main/java/com/hfstudio/guidenh/guide/layout/LayoutStyleExtractor.java @@ -0,0 +1,383 @@ +package com.hfstudio.guidenh.guide.layout; + +import com.google.flatbuffers.FlatBufferBuilder; +import com.hfstudio.guidenh.guide.document.block.LytAxisBox; +import com.hfstudio.guidenh.guide.document.block.LytBlock; +import com.hfstudio.guidenh.guide.document.block.LytBox; +import com.hfstudio.guidenh.guide.document.block.LytCodeBlock; +import com.hfstudio.guidenh.guide.document.block.LytCodeBlockToolbar; +import com.hfstudio.guidenh.guide.document.block.LytDocumentFloat; +import com.hfstudio.guidenh.guide.document.block.LytFloatAwareBlock; +import com.hfstudio.guidenh.guide.document.block.LytHBox; +import com.hfstudio.guidenh.guide.document.block.LytItemGrid; +import com.hfstudio.guidenh.guide.document.block.LytLatexDisplayBlock; +import com.hfstudio.guidenh.guide.document.block.LytSizeBox; +import com.hfstudio.guidenh.guide.document.block.LytSlot; +import com.hfstudio.guidenh.guide.document.block.LytSlotGrid; +import com.hfstudio.guidenh.guide.document.block.LytViewportBox; +import com.hfstudio.guidenh.guide.document.block.recipes.LytStandardRecipeBox; +import com.hfstudio.guidenh.guide.layout.flatbuffers.Style; + +/** + * High-level FlatBuffer builder for Taffy layout properties. + *

          + * Handles ALL LytBlock → FlatBuffer Style field mappings. + * Do not call Style.createStyle() directly; use {@link #build} instead. + */ +public final class LayoutStyleExtractor { + + /** Style modifiers bitmask, passed to build() to override defaults. */ + public static final class Flags { + + public static final int NONE = 0; + + /** + * Historical: SIZE_FROM_JAVA_BOUNDS = 1 << 0 was used by the Java layout + * pre-pass to reserve Java-computed bounds for opaque containers. The + * pre-pass has been removed; opaque containers now declare size through + * explicit dimensions or Rust-side measure functions. The constant is + * kept as a comment-only symbol; any reference outside this comment should + * be treated as a bug. + */ + // public static final int SIZE_FROM_JAVA_BOUNDS = 1 << 0; + + private Flags() {} + } + + private LayoutStyleExtractor() {} + + /** Out-of-flow marker for inline blocks: visual box size in px (position comes from Rust's inline post-pass). */ + public record FloatAbs(int width, int height) {} + + /** + * Per-node style adjustments computed by {@code LayoutTreeSerializer} while + * lowering the tree. {@code floatSide} (0 none, 1 left, 2 right) is set on a + * floated block's inner so the Rust pusher treats it as a real CSS float + * (the float gap rides the inner's margin); {@code abs} lowers inline blocks + * to position:absolute. + */ + public record NodeAdjustments(int marginT, int marginR, int marginB, int marginL, FloatAbs abs, int floatSide, + int columnWidth, byte alignItems) { + + public static final NodeAdjustments ZERO = new NodeAdjustments(0, 0, 0, 0, null, 0, 0, (byte) 0); + } + + /** + * Build with flags and per-node adjustments from the serializer's lowering pass. + */ + public static int build(FlatBufferBuilder fbb, LytBlock block, int flags, NodeAdjustments adj) { + byte display = getDisplay(block); + byte flexDir = getFlexDirection(block); + byte flexWrap = getFlexWrap(block); + // align_items override from eliminated LytAlignedBlock: when a wrapper + // inherits ContentAlign.CENTER/RIGHT, its align_items is set to Center/End + // so children keep natural width and are positioned accordingly. + byte alignItems = adj.alignItems() != 0 ? adj.alignItems() : getAlignItems(block); + byte alignSelf = getAlignSelf(block); + byte justify = 0; // default: Start + + int sizeWOff = dimAuto(fbb); // default: auto + int sizeHOff = dimAuto(fbb); + int minWOff = 0; + int minHOff = 0; + int maxWOff = 0; + int maxHOff = 0; + + // Apply explicit size constraints from the block + int explicitW = block.getExplicitWidth(); + int explicitH = block.getExplicitHeight(); + + // Per-class size rules that the Java lowering applies before Rust measures. + // Fixed grids: explicit width so the row-wrap forms exactly N columns. + if (block instanceof LytSlotGrid sg && explicitW <= 0) { + explicitW = sg.getWidth() * LytSlot.OUTER_SIZE; + } + // Item grids: fixed 3-column semantic, declared as an explicit width so + // the row-wrap wraps to min(3, slotCount) columns (6 -> 2 rows, 9 -> 3x3, + // 1-3 -> single row, 4+ -> compact 3+1). Without this, size_w=auto + // shrink-wraps to a single row regardless of slot count. + // Taffy 0.12 sizes are BorderBox by default (Style::DEFAULT box_sizing = + // BorderBox): size_w covers padding, so horizontal padding must be added + // to leave cols*OUTER_SIZE of CONTENT width for the wrapped slot row. + // LytItemGrid pads 5 per side (LytItemGrid ctor), so a bare + // cols*OUTER_SIZE (=54 for 3 cols) left only 44px of content and wrapped + // to two 18px slots per line. + // (LytSlotGrid has zero padding, so its width×OUTER_SIZE needs no pad.) + if (block instanceof LytItemGrid ig && explicitW <= 0) { + explicitW = Math.min(3, ig.getSlotCount()) * LytSlot.OUTER_SIZE + readLytBoxPadding(ig, "paddingLeft") + + readLytBoxPadding(ig, "paddingRight"); + } + // Size boxes with a preferred width reserve exactly that width. + if (block instanceof LytSizeBox sb && sb.getPreferredWidth() > 0 && explicitW <= 0) { + explicitW = sb.getPreferredWidth(); + } + // Code blocks with a preferred body width (from width= fence meta) reserve that width + // so the Rust layout engine constrains the code block instead of measuring naturally. + if (block instanceof LytCodeBlock cb && cb.getPreferredBodyWidth() > 0 && explicitW <= 0) { + explicitW = cb.getPreferredBodyWidth(); + } + // Scroll containers clip their content through an inner LytViewportBox + // (which declares the viewport height itself), so no container-level + // size/overflow rule is needed here. + + // The SIZE_FROM_JAVA_BOUNDS consumption branch was removed together with + // the Java layout pre-pass. Opaque containers declare size through explicit + // dimensions or Rust-side measure functions. The flow-bounds fallback no + // longer applies; getFlowBounds() is retained for other callers. + + // Table cells keep their Java-resolved column width so cell content + // wraps at the column width (column model not yet on taffy Grid). + if (adj.columnWidth() > 0 && explicitW <= 0) { + explicitW = adj.columnWidth(); + } + + // Inline-block absolute sizing: inline blocks become position:absolute + // with their visual box as inset + size (out of flow). Document-level + // floats are NOT lowered here: the inner carries a real `float` (floatSide) + // and the Rust pusher lays it out, with the float gap expressed as the + // inner's margin. + if (adj.abs() != null) { + explicitW = adj.abs() + .width(); + explicitH = adj.abs() + .height(); + } + + if (explicitW > 0) { + sizeWOff = dimPx(fbb, explicitW); + } + + // Full-width and centering: force 100% width for root-level stretching. + // In a taffy root node (document-sequence block), width: Auto shrink-wraps + // to content, so the node never stretches to the available width even when + // align_self=Stretch or the inner child uses align_self=Center. Setting an + // explicit 100% width resolves against the available space in taffy, so the + // container fills the page width and the inner align_self (Stretch/Center) + // works correctly within this full-width context. + // The blanket `|| block instanceof LytFloatAwareBlock` was removed: the + // FloatAwareBlock constructor copies inner.isFullWidth(), so + // block.isFullWidth() already covers the fullWidth case. The blanket + // instanceof check forced 100% width on ALL float-aware blocks (including + // natural-width tables), preventing content-based shrink. + // A narrow re-addition: a FloatAwareBlock wrapping a LytLatexDisplayBlock + // must be full-width, otherwise the wrapper shrink-wraps to the formula + // width (w=101) and the inner's align_self=Center has no cross-axis margin + // left (x=5 instead of the centered x around 400). Only the display-LaTeX + // wrapper is forced to 100%; natural-width tables stay at natural width. + boolean needFullWidth = block.isFullWidth() || adj.alignItems() != 0 + || (block instanceof LytFloatAwareBlock fb && fb.getInner() instanceof LytLatexDisplayBlock); + if (needFullWidth && explicitW <= 0) { + sizeWOff = dimPercent(fbb, 100); + } + + if (explicitH > 0) { + sizeHOff = dimPx(fbb, explicitH); + } + + float marginL = block.getMarginLeft() + adj.marginL(); + float marginR = block.getMarginRight() + adj.marginR(); + float marginT = block.getMarginTop() + adj.marginT(); + float marginB = block.getMarginBottom() + adj.marginB(); + if (adj.abs() != null) { + // Absolutely-positioned (floated) blocks: margins are meaningless. + marginL = 0; + marginR = 0; + marginT = 0; + marginB = 0; + } + // Float gap rides the inner's margin on the text-facing side + bottom, + // so the pusher's registered (margin-box) rectangle keeps FLOAT_GAP + // clear of the float content while the drawn box stays the content box. + if (adj.floatSide() == 2) { + marginL += LytDocumentFloat.FLOAT_GAP; + marginB += LytDocumentFloat.FLOAT_GAP; + } else if (adj.floatSide() == 1) { + marginR += LytDocumentFloat.FLOAT_GAP; + marginB += LytDocumentFloat.FLOAT_GAP; + } + float padL = 0; + float padR = 0; + float padT = 0; + float padB = 0; + // Read actual padding from LytBox subclasses (VBox, HBox, etc.) + if (block instanceof LytBox lb) { + padL = readLytBoxPadding(lb, "paddingLeft"); + padR = readLytBoxPadding(lb, "paddingRight"); + padT = readLytBoxPadding(lb, "paddingTop"); + padB = readLytBoxPadding(lb, "paddingBottom"); + } + float borderL = block.getBorderLeft() + .width(); + float borderR = block.getBorderRight() + .width(); + float borderT = block.getBorderTop() + .width(); + float borderB = block.getBorderBottom() + .width(); + + int gapWOff = 0; + int gapHOff = 0; + if (block instanceof LytAxisBox ax) { + float g = ax.getGap(); + if (g > 0) { + if (flexDir == 1) { // column + gapHOff = dimPx(fbb, g); + } else { + gapWOff = dimPx(fbb, g); + } + } + } + + byte overflow = getOverflow(block); + + float flexGrow = block.getFlexGrow(); + // Content inside a scroll viewport keeps its natural height: Taffy must + // not shrink it to fit the (shorter) viewport. + float flexShrink = block.getParent() instanceof LytViewportBox ? 0f : 1f; + int flexBasisOff = 0; + + byte float_ = (byte) adj.floatSide(); // 0 none, 1 left, 2 right: a real CSS float for the pusher + byte clear = 0; + + byte position = 0; + int insetTOff = 0; + int insetROff = 0; + int insetBOff = 0; + int insetLOff = 0; + if (adj.abs() != null) { + position = 1; // Absolute: Rust's inline post-pass assigns the real position + } + + return Style.createStyle( + fbb, + display, + flexDir, + flexWrap, + alignItems, + alignSelf, + justify, + gapWOff, + gapHOff, + sizeWOff, + sizeHOff, + minWOff, + minHOff, + maxWOff, + maxHOff, + 0f, // aspectRatio + marginL, + marginR, + marginT, + marginB, + false, + false, + false, + false, // marginAuto + padL, + padR, + padT, + padB, + borderL, + borderR, + borderT, + borderB, + overflow, + flexGrow, + flexShrink, + flexBasisOff, + float_, + clear, + position, + insetTOff, + insetROff, + insetBOff, + insetLOff); + } + + /** Auto (null in FlatBuffer = unset). */ + public static int dimAuto(FlatBufferBuilder fbb) { + return 0; // FlatBuffer convention: 0 = null for table fields + } + + /** Fixed pixel value. */ + public static int dimPx(FlatBufferBuilder fbb, float px) { + return com.hfstudio.guidenh.guide.layout.flatbuffers.Dimension.createDimension(fbb, px, (byte) 1); + } + + /** Percentage value (resolves against available space in taffy). */ + public static int dimPercent(FlatBufferBuilder fbb, float pct) { + return com.hfstudio.guidenh.guide.layout.flatbuffers.Dimension.createDimension(fbb, pct, (byte) 2); + } + + /** + * Read an int padding field from LytBox via reflection. + * {@code LytBox.paddingLeft/Top/Right/Bottom} are {@code protected}, not + * accessible from this package, so we use reflection as the least-invasive + * bridge. Revisit if a public getter is added to LytBox in the future. + */ + static int readLytBoxPadding(LytBox box, String fieldName) { + try { + var field = LytBox.class.getDeclaredField(fieldName); + field.setAccessible(true); + return field.getInt(box); + } catch (Exception e) { + return 0; + } + } + + private static byte getDisplay(LytBlock block) { + return 0; // Flex (default for all blocks) + } + + private static byte getFlexDirection(LytBlock block) { + if (block instanceof LytHBox) return 0; // Row + // Compiler lowering: grids and horizontal composites become row containers. + if (block instanceof LytSlotGrid || block instanceof LytItemGrid) return 0; + if (block instanceof LytStandardRecipeBox) return 0; + if (block instanceof LytCodeBlockToolbar) return 0; + // Table rows are real Row containers (cells keep pinned column widths). + if (block instanceof com.hfstudio.guidenh.guide.document.block.table.LytTableRow) return 0; + return 1; // Column (VBox, default) + } + + private static byte getFlexWrap(LytBlock block) { + if (block instanceof LytHBox hb && hb.isWrap()) return 1; // Wrap + // Grids lower to wrapping rows: N per line, then wrap. + if (block instanceof LytSlotGrid || block instanceof LytItemGrid) return 1; + return 0; // NoWrap + } + + private static byte getAlignItems(LytBlock block) { + if (block instanceof LytCodeBlockToolbar) return 1; // Center (label + icon buttons) + if (block instanceof LytAxisBox ax) { + return switch (ax.getAlignItems()) { + case CENTER -> 1; + case END -> 2; + case BASELINE -> 4; + // START -> Stretch: preserve existing behavior for the many blocks + // that never call setAlignItems() and keep the LytAxisBox default. + default -> 3; + }; + } + // CSS default is stretch: block-level children fill the cross axis. + return 3; // Stretch + } + + private static byte getAlignSelf(LytBlock block) { + // Block-level (display) LaTeX formulas must be horizontally centered. + if (block instanceof LytLatexDisplayBlock) return 2; // Center + // LytFloatAwareBlock wrapping LytLatexDisplayBlock: propagate center alignment + // so the wrapper does not swallow the centering (the wrapper is the flat node, + // not the inner display block). + if (block instanceof LytFloatAwareBlock fb && fb.getInner() instanceof LytLatexDisplayBlock) return 2; // Center + if (block.isFullWidth()) return 4; // Stretch + // Item grids wrap by available width, so stretch to let the wrap engage. + if (block instanceof LytItemGrid) return 4; + return 0; // Auto + } + + private static byte getOverflow(LytBlock block) { + if (block instanceof LytSizeBox) return 2; // Scroll + return 0; // Visible + } +} diff --git a/src/main/java/com/hfstudio/guidenh/guide/layout/LayoutTreeSerializer.java b/src/main/java/com/hfstudio/guidenh/guide/layout/LayoutTreeSerializer.java new file mode 100644 index 00000000..b1785862 --- /dev/null +++ b/src/main/java/com/hfstudio/guidenh/guide/layout/LayoutTreeSerializer.java @@ -0,0 +1,736 @@ +package com.hfstudio.guidenh.guide.layout; + +import java.util.ArrayList; +import java.util.IdentityHashMap; +import java.util.List; +import java.util.Map; + +import javax.annotation.Nullable; + +import com.google.flatbuffers.FlatBufferBuilder; +import com.hfstudio.guidenh.guide.document.LytRect; +import com.hfstudio.guidenh.guide.document.block.ContentAlign; +import com.hfstudio.guidenh.guide.document.block.IconMetrics; +import com.hfstudio.guidenh.guide.document.block.LytAlignedBlock; +import com.hfstudio.guidenh.guide.document.block.LytBlock; +import com.hfstudio.guidenh.guide.document.block.LytBox; +import com.hfstudio.guidenh.guide.document.block.LytDocumentFloat; +import com.hfstudio.guidenh.guide.document.block.LytGuiSprite; +import com.hfstudio.guidenh.guide.document.block.LytImage; +import com.hfstudio.guidenh.guide.document.block.LytImageBlock; +import com.hfstudio.guidenh.guide.document.block.LytItemImage; +import com.hfstudio.guidenh.guide.document.block.LytLatexBlock; +import com.hfstudio.guidenh.guide.document.block.LytLatexDisplayBlock; +import com.hfstudio.guidenh.guide.document.block.LytListItem; +import com.hfstudio.guidenh.guide.document.block.LytNode; +import com.hfstudio.guidenh.guide.document.block.LytParagraph; +import com.hfstudio.guidenh.guide.document.block.LytSlot; +import com.hfstudio.guidenh.guide.document.block.LytThematicBreak; +import com.hfstudio.guidenh.guide.document.block.table.LytTable; +import com.hfstudio.guidenh.guide.document.block.table.LytTableCell; +import com.hfstudio.guidenh.guide.document.block.table.LytTableRow; +import com.hfstudio.guidenh.guide.document.flow.InlineBlockAlignment; +import com.hfstudio.guidenh.guide.document.flow.LytFlowContent; +import com.hfstudio.guidenh.guide.document.flow.LytFlowInlineBlock; +import com.hfstudio.guidenh.guide.document.flow.LytFlowSpan; +import com.hfstudio.guidenh.guide.document.flow.LytFlowText; +import com.hfstudio.guidenh.guide.layout.flatbuffers.LayoutInput; +import com.hfstudio.guidenh.guide.render.GuideText; +import com.hfstudio.guidenh.guide.scene.support.GuideDebugLog; +import com.hfstudio.guidenh.guide.style.ResolvedTextStyle; + +/** + * Serializes a Lyt document tree into a FlatBuffer LayoutInput byte array. + *

          + * Handles: + * - Tree traversal and flat_index assignment + * - Node elimination (spans, anchors, aligned blocks, floats) + * - Paragraph merging (multiple LytFlowContent → one text FlatNode) + * - Delegates Style build to {@link LayoutStyleExtractor} + * - Delegates FlatNode build to {@link LayoutNodeSerializer} + *

          + * This replaces RFLayoutTreeBuilder. + */ +public class LayoutTreeSerializer { + + /** Theme token: text justification (0=off, 1=auto, which stretches spaces on Latin lines). */ + private static final com.hfstudio.guidenh.guide.style.token.TokenKey TEXT_JUSTIFY = com.hfstudio.guidenh.guide.style.token.TokenKey + .define( + "--lyt-text-justify", + com.hfstudio.guidenh.guide.style.token.TokenType.INT, + new com.hfstudio.guidenh.guide.style.token.IntValue(1)); + + /** Padding subtracted from available width for table column layout (matches Rust layout.rs:17). */ + private static final int CONTENT_PAD = 14; + + /** + * Minimum available width (px) for justified text. Parley's Justify has no + * inter-word spacing cap: below ~60 chars per line it stretches the leftover + * space into wide word caves ("rivers"), so narrow columns fall back to left + * alignment (ragged right) instead. Wide columns keep the + * {@link #TEXT_JUSTIFY} theme token's decision, and per-paragraph + * center/right alignment is unaffected, because it outranks justify in Rust's + * resolve_alignment (parley_text.rs). + */ + private static final float JUSTIFY_MIN_WIDTH = 550f; + + private final List flatNodes = new ArrayList<>(); + private final Map nodeToIndex = new IdentityHashMap<>(); + /** Margins accumulated from eliminated ancestors, applied during style extraction. */ + private final Map marginOffsets = new IdentityHashMap<>(); + /** + * Absolute-position lowering for paragraph-inline blocks only: inset + size + * in px relative to the Rust parent's content box. (Document-level floats no + * longer lower to absolute; the inner carries a real `float` instead.) + */ + private final Map absoluteFloats = new IdentityHashMap<>(); + /** + * Float side (1 left, 2 right) for a floated block's inner, set when the + * {@link LytDocumentFloat} wrapper is eliminated so the inner is emitted as a + * real CSS float for the Rust pusher. + */ + private final Map floatIntents = new IdentityHashMap<>(); + /** Table-cell column widths (px), kept until the column model moves to taffy Grid. */ + private final Map columnWidths = new IdentityHashMap<>(); + /** + * Float rects registered during serialize, in document coordinates. NOT fed + * to Rust (the pusher computes its own float geometry); retained solely as + * the test-only oracle for the harness glyph-vs-float overlap invariant. + * Filled from the Java-laid-out inner bounds at wrapper-elimination time. + */ + private final List floatRects = new ArrayList<>(); + /** + * Alignment intents inherited from eliminated {@link LytAlignedBlock} wrappers. + * Keys are flat nodes (nearest non-eliminated ancestors of a non-LEFT AlignedBlock); + * values are Taffy align_items bytes: 1=Center, 2=End. + * Applied in {@link LayoutStyleExtractor#build} to override the default Stretch + * so the wrapper centres/right-aligns its children with natural width. + */ + private final Map alignIntents = new IdentityHashMap<>(); + /** Available width (px) from the last serialize(), needed by flattenTree for table column layout. */ + private float serializeAvailWidth; + /** Visual scale from the last serialize(), needed by flattenTree for inline-block dimension computation. */ + private float serializeVisualScale; + + record FloatRect(LytRect rect, boolean right) {} + + public byte[] serialize(LytNode root, float availWidth, float visualScale, float renderScale) { + flatNodes.clear(); + nodeToIndex.clear(); + marginOffsets.clear(); + absoluteFloats.clear(); + floatIntents.clear(); + columnWidths.clear(); + floatRects.clear(); + alignIntents.clear(); + this.serializeAvailWidth = availWidth; + this.serializeVisualScale = visualScale; + + flattenTree(root); + + FlatBufferBuilder fbb = new FlatBufferBuilder(4096); + int[] nodeOffsets = new int[flatNodes.size()]; + + // DEBUG: log flat node count and types + long paraCount = flatNodes.stream() + .filter(b -> b instanceof LytParagraph) + .count(); + long imgCount = flatNodes.stream() + .filter(b -> b instanceof LytImage || b instanceof LytImageBlock) + .count(); + GuideDebugLog + .warnAlways("Layout: serializing {} flat nodes ({} para, {} img)", flatNodes.size(), paraCount, imgCount); + + for (int i = 0; i < flatNodes.size(); i++) { + LytBlock block = flatNodes.get(i); + MarginAccum mo = marginOffsets.getOrDefault(block, MarginAccum.ZERO); + List childIndices = getChildIndices(block); + int flags = LayoutStyleExtractor.Flags.NONE; + byte nodeType = LayoutNodeSerializer.resolveNodeType(block); + // SIZE_FROM_JAVA_BOUNDS was previously set for opaque leaf containers + // that had no Rust measure function. The Java layout pre-pass has been + // removed, so opaque containers now declare their size through explicit + // dimensions or Rust-side measure functions. See the Flag constants in + // LayoutStyleExtractor (kept as a historical comment). + byte alignIntent = alignIntents.getOrDefault(block, (byte) 0); + var adj = new LayoutStyleExtractor.NodeAdjustments( + (int) mo.top(), + (int) mo.right(), + (int) mo.bottom(), + (int) mo.left(), + absoluteFloats.get(block), + floatIntents.getOrDefault(block, 0), + columnWidths.getOrDefault(block, 0), + alignIntent); + int styleOff = LayoutStyleExtractor.build(fbb, block, flags, adj); + List inlineRefs = new ArrayList<>(); + if (block instanceof LytParagraph par) { + for (LytFlowContent fc : par.getContent()) { + collectInlineRefs(fc, inlineRefs); + } + } + nodeOffsets[i] = LayoutNodeSerializer.build(fbb, block, styleOff, childIndices, inlineRefs); + } + + int nodesVec = fbb.createVectorOfTables(nodeOffsets); + // Narrow-column typography: justify is only enabled when the available + // width is wide enough for ~60 chars/line; below that the stretched + // inter-word spaces form rivers/word caves, so the paragraph falls back + // to left alignment. Per-paragraph alignment (center/right) is untouched: + // Rust's resolve_alignment already ranks it above justify. + byte justify = 0; + if (availWidth >= JUSTIFY_MIN_WIDTH) { + justify = (byte) com.hfstudio.guidenh.guide.style.token.GuideThemeManager.instance() + .active() + .int_(TEXT_JUSTIFY) + .value(); + } + // LayoutInput.shear_k is DEPRECATED (GLYPH_SHEAR_K is render-only now) and + // Java no longer writes it: the 0.25 default reproduces the historical + // bytes, because addFloat omits a slot whose value equals the default, so + // the wire is byte-identical before and after the removal. The shear + // constant now lives in Rust (its own copy) and in the renderer + // GuideRenderEngine. + LayoutInput.startLayoutInput(fbb); + LayoutInput.addAvailableWidth(fbb, availWidth); + LayoutInput.addVisualScale(fbb, visualScale); + LayoutInput.addRenderScale(fbb, renderScale); + LayoutInput.addJustify(fbb, justify); + LayoutInput.addNodes(fbb, nodesVec); + int inputOff = LayoutInput.endLayoutInput(fbb); + fbb.finish(inputOff); + return fbb.sizedByteArray(); + } + + @Nullable + public LytNode getNodeByFlatIndex(int index) { + return index >= 0 && index < flatNodes.size() ? flatNodes.get(index) : null; + } + + public int getFlatIndex(LytNode node) { + return nodeToIndex.getOrDefault(node, -1); + } + + /** + * Test-only view of the float rects registered during the last serialize() + * call (document coordinates). Backs LayoutPipelineHarness's + * glyph-vs-float overlap invariant. + */ + List getFloatRects() { + return floatRects; + } + + /** + * Recursively walk flow content to collect inline-block refs. Float-aligned + * inline blocks (FLOAT_LEFT / FLOAT_RIGHT) carry their alignment from the + * {@link LytFlowInlineBlock} wrapper; inline blocks use type-based alignment. + */ + private void collectInlineRefs(LytFlowContent fc, List out) { + if (fc instanceof LytFlowInlineBlock ib && ib.getBlock() != null) { + int ibIdx = getFlatIndex(ib.getBlock()); + if (ibIdx >= 0) { + out.add(inlineRefOf(ibIdx, ib.getBlock(), ib.getAlignment())); + } + } else if (fc instanceof LytFlowSpan fs) { + for (LytFlowContent child : fs.getChildren()) { + collectInlineRefs(child, out); + } + } + } + + /** + * Vertical alignment request for one inline block (see InlineBlockRef in the + * schema). Float aligns (3/4) override type-based alignment (0/1/2). + *

          + * An icon atom declares its ink box truth (IconMetrics, in the 16-unit icon + * space). Only an inline icon without a label declares it: the block width of + * the label path includes the label text, so its advance must keep the cell + * width. When the ink box cannot be measured (IconMetrics null) all three + * values are 0, which means undeclared and makes Rust fall back to the cell + * width. + */ + private static LayoutNodeSerializer.InlineRef inlineRefOf(int flatIndex, LytBlock ib, + InlineBlockAlignment flowAlign) { + if (flowAlign == InlineBlockAlignment.FLOAT_LEFT) { + return new LayoutNodeSerializer.InlineRef(flatIndex, 3, 0f); + } + if (flowAlign == InlineBlockAlignment.FLOAT_RIGHT) { + return new LayoutNodeSerializer.InlineRef(flatIndex, 4, 0f); + } + if (ib instanceof LytLatexBlock latex) { + return new LayoutNodeSerializer.InlineRef(flatIndex, 1, latex.getBaselineAscent()); + } + if (ib instanceof com.hfstudio.guidenh.guide.document.block.LytItemImage img && img.isInline() + && img.isShowingIcon()) { + if (img.hasLabel()) { + // Decision for labelled inline icons: such an icon must not declare + // the icon ink box, because its advance is the icon + gap + label + // text width (the width declared by the label block), not the icon + // ink box. The retired align=2 centering hack (a leftover align=2 + // falls back to the default tier, which puts the block bottom 2 px + // below the baseline) lifted the label text baseline about one ascent + // above the body baseline for the labelled form, which real content + // hit in several inline label icons under + // getting-started/p2p_tunnels (AE2). Use align=1 baseline ascent + // instead (meaning: the block top sits `param` px above the text + // baseline), with param = height from the label text baseline to the + // block top, so the label text baseline stays on the body baseline + // and the icon's vertical position within the line matches the + // retired centering tier (block height is about the line height). + return new LayoutNodeSerializer.InlineRef(flatIndex, 1, img.getLabelBaselineOffset()); + } + // No label here: declare the icon ink box (IconMetrics, 16-unit icon space). + { + IconMetrics m = IconMetrics.forStack(img.getStack()); + if (m != null) { + return new LayoutNodeSerializer.InlineRef( + flatIndex, + 2, + img.getInlineVerticalOffset(), + m.inkLeft, + m.inkRight, + m.inkBottom); + } + } + // No label and the ink box is unmeasurable (IconMetrics null, the rare + // path where the atlas is not ready): keep align=2, which falls back to + // the default tier (the retired behavior) and puts the block bottom 2 px + // below the baseline on the Rust side. + return new LayoutNodeSerializer.InlineRef(flatIndex, 2, img.getInlineVerticalOffset()); + } + return new LayoutNodeSerializer.InlineRef(flatIndex, 0, 0f); + } + + /** + * Declared px size for a {@link LytParagraph} lowered into inline flow + * (script error-fallback paragraphs). Width = {@link GuideText#measureWidth} + * of the longest line: multi-line error text carries no PRE, so the box must + * cover the widest line for the Rust InlineBox pen to advance past the whole + * block. Height = line count * {@link GuideText#lineHeight} so the declared + * box covers every line. + *

          + * Returns {@code {0, 0}} (the previous zero-width behaviour) when the + * paragraph has no measurable text, so a broken message degrades to the old + * inline placement instead of misdeclaring a width. + */ + private static int[] inlineParagraphSize(LytParagraph err) { + String text = collectParagraphText(err); + if (text.isEmpty()) { + GuideDebugLog.debug("Layout: inline paragraph has no text content - zero-width inline block fallback"); + return new int[] { 0, 0 }; + } + ResolvedTextStyle style = err.resolveStyle(); + int width = 0; + int lines = 0; + for (String line : text.split("\n", -1)) { + lines++; + width = Math.max(width, GuideText.measureWidth(line, style)); + } + if (width <= 0) { + GuideDebugLog + .debug("Layout: inline paragraph text measurement failed (w=0) - zero-width inline block fallback"); + return new int[] { 0, 0 }; + } + return new int[] { width, Math.max(1, GuideText.lineHeight(style) * lines) }; + } + + /** + * Concatenates the plain text of a paragraph's flow content (mirrors + * {@code LytParagraph.collectPlainText}). + */ + private static String collectParagraphText(LytParagraph par) { + StringBuilder sb = new StringBuilder(); + for (LytFlowContent fc : par.getContent()) { + collectPlainText(fc, sb); + } + return sb.toString(); + } + + private static void collectPlainText(LytFlowContent fc, StringBuilder out) { + if (fc instanceof LytFlowText ft) { + out.append(ft.getText()); + } else if (fc instanceof LytFlowInlineBlock) { + out.append(' '); // placeholder space for inline blocks + } else if (fc instanceof LytFlowSpan fs) { + for (LytFlowContent child : fs.getChildren()) { + collectPlainText(child, out); + } + } + } + + /** + * Accumulated margins from eliminated intermediate nodes that should be + * pushed onto the nearest non-eliminated descendant block's own margins. + */ + private record MarginAccum(float top, float right, float bottom, float left) { + + static MarginAccum ZERO = new MarginAccum(0, 0, 0, 0); + + MarginAccum add(LytBlock block) { + return new MarginAccum( + top + block.getMarginTop(), + right + block.getMarginRight(), + bottom + block.getMarginBottom(), + left + block.getMarginLeft()); + } + } + + private void flattenTree(LytNode node) { + flattenTree(node, MarginAccum.ZERO, 0, null, 0f); + } + + /** + * @param inherited margins accumulated from eliminated ancestors + * @param pendingFloat a float wrapper's anchor node was just emitted + * up-stack; the first non-eliminated block descendant + * becomes its absolutely-positioned child + * @param nearestAncestor the most recent non-eliminated {@link LytBlock} + * ancestor in the current tree path, or {@code null} + * at the root. When an {@link LytAlignedBlock} with + * non-{@link ContentAlign#LEFT} is eliminated, its + * alignment intent is recorded on this ancestor so + * its style can be adjusted (align_items → Center/End) + * during serialization. + * @param listPadAccum horizontal padding (px) accumulated from every + * ancestor {@link LytListItem}'s paddingLeft. Blocks + * nested inside list items lay out in the item's + * content box AFTER this padding; table column widths + * are constrained by it so a table does not "escape" + * the item indentation by laying out at the full + * document width (which would force the shrink-wrapped + * list wider than the page content box). + */ + private void flattenTree(LytNode node, MarginAccum inherited, int pendingFloatSide, + @javax.annotation.Nullable LytBlock nearestAncestor, float listPadAccum) { + if (shouldEliminate(node)) { + // Add this node's margins to the inherited accumulator + MarginAccum total = inherited; + if (node instanceof LytBlock elided) { + total = total.add(elided); + } + // A float wrapper is eliminated here: register its rect for the + // harness overlap oracle (from the Java-laid-out inner bounds) and + // forward the float side to the inner, which becomes a real float. + int childSide = pendingFloatSide; + if (node instanceof LytDocumentFloat df) { + LytRect inner = df.getBounds(); + int gap = LytDocumentFloat.FLOAT_GAP; + LytRect frect = df.isFloatRight() + ? new LytRect(inner.x() - gap, inner.y(), inner.width() + gap, inner.height() + gap) + : new LytRect(inner.x(), inner.y(), inner.width() + gap, inner.height() + gap); + floatRects.add(new FloatRect(frect, df.isFloatRight())); + childSide = df.isFloatRight() ? 2 : 1; + } + // Capture alignment from eliminated AlignedBlock on the nearest + // non-eliminated ancestor (the wrapper that owns the flex container + // for its children). The ancestor's align_items will override the + // default Stretch so children keep natural width and are positioned. + if (node instanceof LytAlignedBlock aligned && nearestAncestor != null) { + ContentAlign a = aligned.getAlign(); + if (a == ContentAlign.CENTER) { + alignIntents.put(nearestAncestor, (byte) 1); // Center + } else if (a == ContentAlign.RIGHT) { + alignIntents.put(nearestAncestor, (byte) 2); // End + } + } + for (LytNode child : node.getChildren()) { + flattenTree(child, total, childSide, nearestAncestor, listPadAccum); + } + return; + } + + // Assign index and register + int idx = flatNodes.size(); + // Only LytBlock subclasses can be flat nodes; skip non-block content + if (node instanceof LytBlock block) { + if (inherited != MarginAccum.ZERO) { + marginOffsets.put(block, inherited); + } + flatNodes.add(block); + nodeToIndex.put(node, idx); + if (pendingFloatSide != 0) { + // The float wrapper was eliminated up-stack; this inner becomes + // a real CSS float for the Rust pusher (the float gap is added + // to its margin during style extraction). + floatIntents.put(block, pendingFloatSide); + pendingFloatSide = 0; + } + if (block instanceof LytTable table && !table.getColumns() + .isEmpty()) { + // Column widths must be resolved before serialization so cells + // carry their column width constraint. The Java pre-pass is + // removed, so layoutColumns is called here with the available + // width from the serialize() parameter. (x=0 is fine: column.x is + // a serialization-time declaration only and is never overwritten by + // Rust; the table's vertical separators are derived from + // Rust-written cell bounds, not from column.x.) + // List-item containment: a table nested inside LytListItems lays + // out in the item's CONTENT box, which starts after the item's + // paddingLeft. listPadAccum carries the total padding of all + // ancestor list items, so the pinned column widths sum to the item + // content width instead of the full document width; a full-width + // table would otherwise force the shrink-wrapped list wider than the + // page content box (the table "escapes" the item indentation). + // When ALL columns have declared preferred widths, + // use the sum of declared widths as the table's natural width + // instead of the full available width. This allows tables to + // shrink to content width when not explicitly set to fullWidth. + int availW = Math.max(1, Math.round(serializeAvailWidth - 2.0f * CONTENT_PAD - listPadAccum)); + boolean allDeclared = table.getColumns() + .stream() + .allMatch(c -> c.getPreferredWidth() > 0); + if (allDeclared && !table.isFullWidth()) { + int sumPreferred = table.getColumns() + .stream() + .mapToInt(c -> c.getPreferredWidth()) + .sum(); + int borders = (table.getColumns() + .size() + 1) * LytTable.CELL_BORDER; + int naturalW = sumPreferred + borders; + table.layoutColumns(0, Math.min(naturalW, availW)); + } else { + table.layoutColumns(0, availW); + } + // Table cells keep the Java-computed COLUMN widths (the column + // model resolves preferred/flexible widths) so cell content + // wraps at the column width; heights are Rust-measured so + // wrapped content grows its row instead of overflowing a + // pinned box. + for (LytTableRow row : table.getChildren()) { + int ci = 0; + for (LytTableCell cell : row.getChildren()) { + var column = table.getColumns() + .get(ci++); + columnWidths.put(cell, column.getWidth()); + } + } + } + if (block instanceof LytParagraph par) { + // Inline blocks embedded in the paragraph's flow content become + // absolutely positioned leaves; the Rust inline post-pass + // anchors each at its U+FFFC placeholder's pen position. The + // serialized size is the block's VISUAL box (the legacy line- + // expansion insets of e.g. LytLatexBlock must not leak into the + // anchor math, since the post-pass aligns the visual box itself). + for (LytBlock ib : par.getInlineBlocks()) { + int vw; + int vh; + if (ib instanceof LytLatexBlock latex && latex.getFormulaDisplayW() > 0) { + vw = latex.getFormulaDisplayW(); + vh = latex.getFormulaDisplayH(); + } else if (ib instanceof LytLatexDisplayBlock ldb && ldb.getFormulaDisplayW() > 0) { + vw = ldb.getFormulaDisplayW(); + vh = ldb.getFormulaDisplayH(); + } else if (ib instanceof LytSlot slot) { + int sz = slot.isLargeSlot() ? LytSlot.OUTER_SIZE_LARGE : LytSlot.OUTER_SIZE; + vw = vh = sz; + } else if (ib instanceof LytThematicBreak) { + LytRect b = ib.getBounds(); + vw = b != null ? b.width() : 0; + vh = 6; + } else if (ib instanceof LytGuiSprite gs) { + vw = Math.max(1, gs.getExplicitWidth()); + vh = Math.max(1, gs.getExplicitHeight()); + } else if (ib instanceof LytImage image) { + // LytImage after script materialization: compute displayed + // dimensions from texture, crop, scale, and DEFAULT_LAYOUT_SCALE + // (mirrors LytImage.computeLayout). The Java layout pre-pass is + // removed, so ib.getBounds() is LytRect.empty(); relying on the + // explicit-dimensions guard alone would leave size_h=0, making + // Rust inline_block_height() return 0 and inline_line_growth() + // stay 0, so the paragraph never grows to include the image and + // the parent flex container (LytListItem) omits the image height. + // This mirrors Rust measure_image: two explicit dimensions win; + // a single explicit dimension infers the missing axis from the + // natural aspect ratio (inferred = explicit * natural_other / + // natural_given, no per-axis scale on the inferred axis); + // otherwise the legacy natural * DEFAULT_LAYOUT_SCALE * scale + // fallback applies. + var tex = image.getTexture(); + boolean hasNatural = tex != null && !tex.isMissing(); + int natW = hasNatural ? (image.getCropWidth() > 0 ? image.getCropWidth() + : tex.getSize() + .width()) + : 0; + int natH = hasNatural ? (image.getCropHeight() > 0 ? image.getCropHeight() + : tex.getSize() + .height()) + : 0; + int ew = image.getExplicitWidth(); + int eh = image.getExplicitHeight(); + if (ew > 0 && eh > 0) { + vw = ew; + vh = eh; + } else if (ew > 0 && natW > 0 && natH > 0) { + vw = ew; + vh = Math.max(1, (int) Math.round(ew * (natH / (double) natW))); + } else if (eh > 0 && natW > 0 && natH > 0) { + vw = Math.max(1, (int) Math.round(eh * (natW / (double) natH))); + vh = eh; + } else { + if (ew > 0) { + vw = ew; + } else if (hasNatural) { + vw = Math + .max(1, (int) Math.round(natW * LytImage.DEFAULT_LAYOUT_SCALE * image.getScaleX())); + } else { + LytRect b = ib.getBounds(); + vw = b != null ? b.width() : 0; + } + if (eh > 0) { + vh = eh; + } else if (hasNatural) { + vh = Math + .max(1, (int) Math.round(natH * LytImage.DEFAULT_LAYOUT_SCALE * image.getScaleY())); + } else { + LytRect b = ib.getBounds(); + vh = b != null ? b.height() : 0; + } + } + // Apply visual scale (mirrors LytImage.computeLayout) + float vs = serializeVisualScale; + if (vs < 0.999f) { + vw = Math.max(1, Math.round(vw * vs)); + vh = Math.max(1, Math.round(vh * vs)); + } + } else if (ib instanceof LytImageBlock imb && imb.getExplicitWidth() > 0 + && imb.getExplicitHeight() > 0) { + vw = imb.getExplicitWidth(); + vh = imb.getExplicitHeight(); + } else if (ib instanceof LytItemImage itemImg) { + int[] size = itemImg.measureSerializedInlineSize(); + vw = size[0]; + vh = size[1]; + } else if (ib instanceof LytParagraph errPar) { + // Script error-fallback paragraphs (ItemImage / + // ItemLink / FloatingImage / Image failures) previously + // fell through to the else branch, which reads + // getBounds() = LytRect.empty() → FloatAbs(0,0) → Rust + // inline_block_width=0 → Parley InlineBox zero-width → + // the paragraph pen never advances and the following + // body text overlaps the red error text. Declare the px + // box from the measured error text instead (Rust text + // shaping shares the same font pipeline, so the declared + // width matches the shaped advance the pen must skip). + int[] errSize = inlineParagraphSize(errPar); + vw = errSize[0]; + vh = errSize[1]; + } else { + LytRect b = ib.getBounds(); + vw = b != null ? b.width() : 0; + vh = b != null ? b.height() : 0; + } + absoluteFloats.put(ib, new LayoutStyleExtractor.FloatAbs(vw, vh)); + } + } + } + + LytBlock ancestorForChildren = (node instanceof LytBlock b) ? b : nearestAncestor; + // Descend with the accumulated list-item padding: a LytListItem's + // paddingLeft is applied to its own children's content box, so it is + // added for the subtree below it. + float childListPad = listPadAccum; + if (node instanceof LytListItem item) { + childListPad += readListItemPaddingLeft(item); + } + for (LytNode child : node.getChildren()) { + flattenTree(child, MarginAccum.ZERO, pendingFloatSide, ancestorForChildren, childListPad); + } + } + + /** + * Reads a {@link LytListItem}'s {@code paddingLeft} via reflection (the + * field is protected on {@link LytBox}, mirroring + * {@link LayoutStyleExtractor#readLytBoxPadding}). Falls back to + * {@link LytListItem#LEVEL_MARGIN} if the field is unreadable. + */ + private static float readListItemPaddingLeft(LytListItem item) { + try { + var field = LytBox.class.getDeclaredField("paddingLeft"); + field.setAccessible(true); + int pad = field.getInt(item); + return pad > 0 ? pad : LytListItem.LEVEL_MARGIN; + } catch (Exception e) { + return LytListItem.LEVEL_MARGIN; + } + } + + private static boolean shouldEliminate(LytNode node) { + // Flow classes don't extend LytNode, so use a class name check + String name = node.getClass() + .getName(); + if (name.contains("LytFlowSpan") || name.contains("LytFlowAnchor") + || name.contains("LytFlowBreak") + || name.contains("LytFlowInlineBlock")) { + return true; + } + // Blocks that are layout wrappers are eliminated in the tree. + // LytDocumentFloat is eliminated too: its inner is re-emitted as a real CSS + // float (the wrapper's only remaining job, registering the harness oracle + // rect, happens in the elimination branch of flattenTree). + if (node instanceof LytAlignedBlock) { + return true; + } + if (node instanceof LytDocumentFloat) { + return true; + } + return false; + } + + private List getChildIndices(LytBlock block) { + List indices = new ArrayList<>(); + if (block instanceof LytParagraph) { + // Text paragraphs stay measured text leaves in Taffy: their inline + // blocks are hoisted to the parent's vector and paired through + // TextData.inline_blocks instead. + return indices; + } + collectBlockChildren(block, indices); + return indices; + } + + /** + * Collect flat-node indices for all {@link LytBlock} descendants of + * {@code node}, skipping eliminated intermediate nodes. + */ + private void collectBlockChildren(LytNode node, List out) { + for (LytNode child : node.getChildren()) { + if (child instanceof LytParagraph par) { + Integer idx = nodeToIndex.get(child); + if (idx != null) { + out.add(idx); + } + // Inline blocks hoist to this (grand)parent's child list right + // after their paragraph, as absolute out-of-flow nodes. + for (LytBlock ib : par.getInlineBlocks()) { + Integer ibIdx = nodeToIndex.get(ib); + if (ibIdx != null) { + out.add(ibIdx); + } + } + continue; + } + if (shouldEliminate(child)) { + // Skip eliminated wrapper, descend into its children + collectBlockChildren(child, out); + } else { + Integer idx = nodeToIndex.get(child); + if (idx != null) { + out.add(idx); + } + } + } + } + + /** + * Returns true when a node should be excluded from the bounds dump JSON: + * the node is eliminated and its bounds are zero-size (width <= 0 or height <= 0). + */ + public static boolean shouldSkipInBoundsDump(LytNode node) { + return shouldEliminate(node) && node.getBounds() != null + && (node.getBounds() + .width() <= 0 + || node.getBounds() + .height() <= 0); + } +} diff --git a/src/main/java/com/hfstudio/guidenh/guide/layout/Layouts.java b/src/main/java/com/hfstudio/guidenh/guide/layout/Layouts.java index 2c775298..c3da9b46 100644 --- a/src/main/java/com/hfstudio/guidenh/guide/layout/Layouts.java +++ b/src/main/java/com/hfstudio/guidenh/guide/layout/Layouts.java @@ -81,45 +81,6 @@ public static LytRect verticalLayout(LayoutContext context, List child return new LytRect(x, y, contentWidth + paddingRight, contentHeight + paddingBottom); } - @Deprecated - public static LytRect horizontalLayout(LayoutContext context, List children, int x, int y, - int availableWidth, int paddingLeft, int paddingTop, int paddingRight, int paddingBottom, int gap, - AlignItems alignItems) { - return horizontalLayout( - context, - children, - x, - y, - availableWidth, - false, - paddingLeft, - paddingTop, - paddingRight, - paddingBottom, - gap, - alignItems); - } - - @Deprecated - public static LytRect horizontalLayout(LayoutContext context, List children, int x, int y, - int availableWidth, boolean fullWidth, int paddingLeft, int paddingTop, int paddingRight, int paddingBottom, - int gap, AlignItems alignItems) { - return horizontalLayout( - context, - children, - x, - y, - availableWidth, - fullWidth, - paddingLeft, - paddingTop, - paddingRight, - paddingBottom, - gap, - alignItems, - true); - } - /** * Lays out all children along the horizontal axis, and returns the bounding box of the content area. */ diff --git a/src/main/java/com/hfstudio/guidenh/guide/layout/RustFontMetrics.java b/src/main/java/com/hfstudio/guidenh/guide/layout/RustFontMetrics.java new file mode 100644 index 00000000..59a3f3f4 --- /dev/null +++ b/src/main/java/com/hfstudio/guidenh/guide/layout/RustFontMetrics.java @@ -0,0 +1,71 @@ +package com.hfstudio.guidenh.guide.layout; + +import javax.annotation.Nullable; + +import com.hfstudio.guidenh.guide.render.GuideText; +import com.hfstudio.guidenh.guide.style.ResolvedTextStyle; + +/** + * {@link FontMetrics} backed by the unified text pipeline ({@link GuideText}, + * cosmic-text over the system TTF), so layout measurement is identical + * to what the glyph-run renderer draws. + *

          + * Falls back when the Rust font system is not initialized: to + * {@link MinecraftFontMetrics} in-game, or to constant estimates in pure-JVM + * tests where the Minecraft class itself is unavailable. + */ +public class RustFontMetrics implements FontMetrics { + + private volatile MinecraftFontMetrics fallback; + + @Nullable + private MinecraftFontMetrics fallback() { + MinecraftFontMetrics f = fallback; + if (f == null) { + try { + if (net.minecraft.client.Minecraft.getMinecraft() == null) { + return null; + } + f = new MinecraftFontMetrics(); + } catch (Throwable t) { + return null; // headless: Minecraft class unavailable + } + fallback = f; + } + return f; + } + + @Override + public float getAdvance(int codePoint, ResolvedTextStyle style) { + if (!GuideText.isAvailable()) { + var f = fallback(); + if (f != null) { + return f.getAdvance(codePoint, style); + } + // Headless constant estimate (matches the harness mock convention). + return codePoint < 128 ? 6f : 12f; + } + return GuideText.advanceOf(codePoint, style); + } + + @Override + public int getLineHeight(ResolvedTextStyle style) { + if (!GuideText.isAvailable()) { + var f = fallback(); + return f != null ? f.getLineHeight(style) : GuideText.BASE_LINE_HEIGHT; + } + return GuideText.lineHeight(style); + } + + @Override + public int getStringWidth(String text, ResolvedTextStyle style) { + if (!GuideText.isAvailable()) { + var f = fallback(); + if (f != null) { + return f.getStringWidth(text, style); + } + return FontMetrics.super.getStringWidth(text, style); + } + return GuideText.measureWidth(text, style); + } +} diff --git a/src/main/java/com/hfstudio/guidenh/guide/layout/SystemFontProvider.java b/src/main/java/com/hfstudio/guidenh/guide/layout/SystemFontProvider.java new file mode 100644 index 00000000..c3ee8034 --- /dev/null +++ b/src/main/java/com/hfstudio/guidenh/guide/layout/SystemFontProvider.java @@ -0,0 +1,192 @@ +package com.hfstudio.guidenh.guide.layout; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; + +import com.hfstudio.guidenh.guide.scene.support.GuideDebugLog; + +/** + * System-font-based {@link FontProvider} that resolves the default CJK- + * supporting font from well-known OS-specific paths. + *

          + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + *
          Resolved paths by OS
          OSFontPath
          WindowsMicrosoft YaHei{@code C:\Windows\Fonts\msyh.ttc}
          LinuxNoto Sans CJK SC{@code /usr/share/fonts/opentype/noto/NotoSansCJK-Regular.ttc}
          macOSPingFang SC{@code /System/Library/Fonts/PingFang.ttc}
          + *

          + *

          + * If none of the well-known paths exist the provider falls back to an empty + * byte array so the existing no-font fallback path continues to work. + *

          + */ +public final class SystemFontProvider implements FontProvider { + + private String resolvedPath = "none"; + + @Override + public byte[] getFontData(String locale) { + Path fontPath = resolveFontPath(); + if (fontPath == null) { + GuideDebugLog + .warnAlways("SystemFontProvider: no font file found for locale={}, using empty fallback", locale); + return new byte[0]; + } + try { + byte[] data = Files.readAllBytes(fontPath); + resolvedPath = fontPath.toAbsolutePath() + .toString(); + GuideDebugLog.warnAlways("SystemFontProvider: loaded {} bytes from {}", data.length, resolvedPath); + return data; + } catch (IOException e) { + GuideDebugLog.warnAlways("SystemFontProvider: failed to read {}: {}", fontPath, e.getMessage()); + return new byte[0]; + } + } + + @Override + public String getFontPath() { + return resolvedPath; + } + + /** + * Read fallback symbol-font bytes: Segoe UI Symbol (seguisym.ttf) on + * Windows, which covers the callout icons ⓘ ✦ ➤ ⚠ ☢ (U+24D8/2726/27A4/26A0/2622) + * that msyh.ttc lacks. Other OSes get best-effort DejaVu / Apple Symbols + * candidates. Returns an empty array when nothing is found (callers skip). + */ + @Override + public byte[] getFallbackFontData(String locale) { + Path fontPath = resolveFallbackSymbolFontPath(); + if (fontPath == null) { + GuideDebugLog + .warnAlways("SystemFontProvider: no fallback symbol font found for locale={}, skipping", locale); + return new byte[0]; + } + try { + byte[] data = Files.readAllBytes(fontPath); + GuideDebugLog + .warnAlways("SystemFontProvider: loaded fallback symbol font {} bytes from {}", data.length, fontPath); + return data; + } catch (IOException e) { + GuideDebugLog + .warnAlways("SystemFontProvider: failed to read fallback symbol font {}: {}", fontPath, e.getMessage()); + return new byte[0]; + } + } + + // OS platform detection for the well-known font paths. + + private static Path resolveFontPath() { + String os = System.getProperty("os.name") + .toLowerCase(); + if (os.contains("win")) { + return resolveWindowsFont(); + } else if (os.contains("mac")) { + return resolveMacFont(); + } else { + return resolveLinuxFont(); + } + } + + private static Path resolveFallbackSymbolFontPath() { + String os = System.getProperty("os.name") + .toLowerCase(); + if (os.contains("win")) { + // Segoe UI Symbol: Windows always ships it; covers ⓘ ✦ ➤ ⚠ ☢ + String windir = System.getenv("WINDIR"); + if (windir == null) { + windir = "C:\\Windows"; + } + Path seguisym = Paths.get(windir, "Fonts", "seguisym.ttf"); + if (Files.exists(seguisym)) return seguisym; + // Fallback: Segoe UI Emoji (also carries the pictographs) + Path segoeemoji = Paths.get(windir, "Fonts", "seguiemj.ttf"); + if (Files.exists(segoeemoji)) return segoeemoji; + return null; + } else if (os.contains("mac")) { + // Apple Symbols: covers common dingbats/symbols + Path appleSymbols = Paths.get("/System/Library/Fonts/Apple Symbols.ttf"); + if (Files.exists(appleSymbols)) return appleSymbols; + return null; + } + // Linux best-effort: DejaVu Sans covers ⓘ ✦ ➤ ⚠ ☢ + Path[] candidates = { Paths.get("/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf"), + Paths.get("/usr/share/fonts/dejavu/DejaVuSans.ttf"), + Paths.get("/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf") }; + for (Path p : candidates) { + if (Files.exists(p)) return p; + } + return null; + } + + private static Path resolveWindowsFont() { + String windir = System.getenv("WINDIR"); + if (windir == null) { + windir = "C:\\Windows"; + } + Path base = Paths.get(windir, "Fonts"); + + // Microsoft YaHei (msyh.ttc): good CJK coverage + Path msyh = base.resolve("msyh.ttc"); + if (Files.exists(msyh)) return msyh; + // Fallback: SimSun + Path simsun = base.resolve("simsun.ttc"); + if (Files.exists(simsun)) return simsun; + // Fallback: any .ttf / .ttc in the Fonts dir (last resort, checks a few) + Path segoeui = base.resolve("segoeui.ttf"); + if (Files.exists(segoeui)) return segoeui; + // Fallback: Arial + Path arial = base.resolve("arial.ttf"); + if (Files.exists(arial)) return arial; + + return null; + } + + private static Path resolveLinuxFont() { + // Noto Sans CJK: common locations + Path[] candidates = { Paths.get("/usr/share/fonts/opentype/noto/NotoSansCJK-Regular.ttc"), + Paths.get("/usr/share/fonts/truetype/noto/NotoSansCJK-Regular.ttc"), + Paths.get("/usr/share/fonts/noto/NotoSansCJK-Regular.ttc"), + Paths.get("/usr/share/fonts/opentype/noto/NotoSansSC-Regular.otf"), + Paths.get("/usr/share/fonts/truetype/droid/DroidSansFallbackFull.ttf"), + // Debian/Ubuntu package fonts-noto-cjk + Paths.get("/usr/share/fonts/opentype/noto/NotoSansCJKsc-Regular.otf"), }; + for (Path p : candidates) { + if (Files.exists(p)) return p; + } + return null; + } + + private static Path resolveMacFont() { + Path pingfang = Paths.get("/System/Library/Fonts/PingFang.ttc"); + if (Files.exists(pingfang)) return pingfang; + // macOS 10.11+ also has PingFang SC + Path pingfangSc = Paths.get("/System/Library/Fonts/PingFang.ttc"); + if (Files.exists(pingfangSc)) return pingfangSc; + // Fallback: Hiragino Sans + Path hiragino = Paths.get("/System/Library/Fonts/Hiragino Sans GB.ttc"); + if (Files.exists(hiragino)) return hiragino; + return null; + } +} diff --git a/src/main/java/com/hfstudio/guidenh/guide/layout/flatbuffers/ChartData.java b/src/main/java/com/hfstudio/guidenh/guide/layout/flatbuffers/ChartData.java new file mode 100644 index 00000000..d632b622 --- /dev/null +++ b/src/main/java/com/hfstudio/guidenh/guide/layout/flatbuffers/ChartData.java @@ -0,0 +1,194 @@ +// automatically generated by the FlatBuffers compiler, do not modify + +package com.hfstudio.guidenh.guide.layout.flatbuffers; + +import java.nio.ByteBuffer; +import java.nio.ByteOrder; + +import com.google.flatbuffers.BaseVector; +import com.google.flatbuffers.Constants; +import com.google.flatbuffers.FlatBufferBuilder; +import com.google.flatbuffers.FloatVector; +import com.google.flatbuffers.Table; + +/** + * Generic Cartesian chart sizing data (Bar/Column/Line/Scatter). + * Same fields as PieChartData, kept as a separate table so node_type 21 + * (PieChart) retains backward compatibility. node_type 22 (BarChart) and + * future Cartesian charts use this table. + */ +@SuppressWarnings("unused") +public final class ChartData extends Table { + + public static void ValidateVersion() { + Constants.FLATBUFFERS_23_5_26(); + } + + public static ChartData getRootAsChartData(ByteBuffer _bb) { + return getRootAsChartData(_bb, new ChartData()); + } + + public static ChartData getRootAsChartData(ByteBuffer _bb, ChartData obj) { + _bb.order(ByteOrder.LITTLE_ENDIAN); + return (obj.__assign(_bb.getInt(_bb.position()) + _bb.position(), _bb)); + } + + public void __init(int _i, ByteBuffer _bb) { + __reset(_i, _bb); + } + + public ChartData __assign(int _i, ByteBuffer _bb) { + __init(_i, _bb); + return this; + } + + public float preferredWidth() { + int o = __offset(4); + return o != 0 ? bb.getFloat(o + bb_pos) : 0.0f; + } + + public float totalHeight() { + int o = __offset(6); + return o != 0 ? bb.getFloat(o + bb_pos) : 0.0f; + } + + /** + * DEPRECATED: Rust now computes chrome internally from the final width w. + * Kept for backward compatibility with old serialized data. + */ + public float chromeHeight() { + int o = __offset(8); + return o != 0 ? bb.getFloat(o + bb_pos) : 0.0f; + } + + /** + * Title lineHeight(TITLE_STYLE) + TITLE_GAP; 0 if no title (width-independent). + */ + public float titleChrome() { + int o = __offset(10); + return o != 0 ? bb.getFloat(o + bb_pos) : 0.0f; + } + + /** + * Legend position: 0=NONE 1=TOP 2=BOTTOM 3=LEFT 4=RIGHT. + */ + public byte legendPosition() { + int o = __offset(12); + return o != 0 ? bb.get(o + bb_pos) : 0; + } + + /** + * Row height for legend wrapping: max(LEGEND_SWATCH_SIZE, lineHeight(LEGEND_LABEL_STYLE)). + */ + public float legendRowHeight() { + int o = __offset(14); + return o != 0 ? bb.getFloat(o + bb_pos) : 10.0f; + } + + /** + * Per-entry legend label widths: LEGEND_SWATCH_SIZE + SWATCH_TEXT_GAP + measureWidth(label, style). + */ + public float legendLabelWidths(int j) { + int o = __offset(16); + return o != 0 ? bb.getFloat(__vector(o) + j * 4) : 0; + } + + public int legendLabelWidthsLength() { + int o = __offset(16); + return o != 0 ? __vector_len(o) : 0; + } + + public FloatVector legendLabelWidthsVector() { + return legendLabelWidthsVector(new FloatVector()); + } + + public FloatVector legendLabelWidthsVector(FloatVector obj) { + int o = __offset(16); + return o != 0 ? obj.__assign(__vector(o), bb) : null; + } + + public ByteBuffer legendLabelWidthsAsByteBuffer() { + return __vector_as_bytebuffer(16, 4); + } + + public ByteBuffer legendLabelWidthsInByteBuffer(ByteBuffer _bb) { + return __vector_in_bytebuffer(_bb, 16, 4); + } + + public static int createChartData(FlatBufferBuilder builder, float preferredWidth, float totalHeight, + float chromeHeight, float titleChrome, byte legendPosition, float legendRowHeight, + int legendLabelWidthsOffset) { + builder.startTable(7); + ChartData.addLegendLabelWidths(builder, legendLabelWidthsOffset); + ChartData.addLegendRowHeight(builder, legendRowHeight); + ChartData.addTitleChrome(builder, titleChrome); + ChartData.addChromeHeight(builder, chromeHeight); + ChartData.addTotalHeight(builder, totalHeight); + ChartData.addPreferredWidth(builder, preferredWidth); + ChartData.addLegendPosition(builder, legendPosition); + return ChartData.endChartData(builder); + } + + public static void startChartData(FlatBufferBuilder builder) { + builder.startTable(7); + } + + public static void addPreferredWidth(FlatBufferBuilder builder, float preferredWidth) { + builder.addFloat(0, preferredWidth, 0.0f); + } + + public static void addTotalHeight(FlatBufferBuilder builder, float totalHeight) { + builder.addFloat(1, totalHeight, 0.0f); + } + + public static void addChromeHeight(FlatBufferBuilder builder, float chromeHeight) { + builder.addFloat(2, chromeHeight, 0.0f); + } + + public static void addTitleChrome(FlatBufferBuilder builder, float titleChrome) { + builder.addFloat(3, titleChrome, 0.0f); + } + + public static void addLegendPosition(FlatBufferBuilder builder, byte legendPosition) { + builder.addByte(4, legendPosition, 0); + } + + public static void addLegendRowHeight(FlatBufferBuilder builder, float legendRowHeight) { + builder.addFloat(5, legendRowHeight, 10.0f); + } + + public static void addLegendLabelWidths(FlatBufferBuilder builder, int legendLabelWidthsOffset) { + builder.addOffset(6, legendLabelWidthsOffset, 0); + } + + public static int createLegendLabelWidthsVector(FlatBufferBuilder builder, float[] data) { + builder.startVector(4, data.length, 4); + for (int i = data.length - 1; i >= 0; i--) builder.addFloat(data[i]); + return builder.endVector(); + } + + public static void startLegendLabelWidthsVector(FlatBufferBuilder builder, int numElems) { + builder.startVector(4, numElems, 4); + } + + public static int endChartData(FlatBufferBuilder builder) { + int o = builder.endTable(); + return o; + } + + public static final class Vector extends BaseVector { + + public Vector __assign(int _vector, int _element_size, ByteBuffer _bb) { + __reset(_vector, _element_size, _bb); + return this; + } + + public ChartData get(int j) { + return get(new ChartData(), j); + } + + public ChartData get(ChartData obj, int j) { + return obj.__assign(__indirect(__element(j), bb), bb); + } + } +} diff --git a/src/main/java/com/hfstudio/guidenh/guide/layout/flatbuffers/ClearBreak.java b/src/main/java/com/hfstudio/guidenh/guide/layout/flatbuffers/ClearBreak.java new file mode 100644 index 00000000..0bccf698 --- /dev/null +++ b/src/main/java/com/hfstudio/guidenh/guide/layout/flatbuffers/ClearBreak.java @@ -0,0 +1,95 @@ +// automatically generated by the FlatBuffers compiler, do not modify + +package com.hfstudio.guidenh.guide.layout.flatbuffers; + +import java.nio.ByteBuffer; +import java.nio.ByteOrder; + +import com.google.flatbuffers.BaseVector; +import com.google.flatbuffers.Constants; +import com.google.flatbuffers.FlatBufferBuilder; +import com.google.flatbuffers.Table; + +/** + * One in-paragraph clear break (`
          + * `). `raw_offset` is the + * UTF-8 byte offset into TextData.text (the original text, U+FFFC included) + * at which the break occurs; the Rust pusher maps it to its cleaned text and, + * after the line covering that offset is laid out, drops subsequent lines to + * the cleared floats' bottom edge. side: 1=left 2=right 3=both. + */ +@SuppressWarnings("unused") +public final class ClearBreak extends Table { + + public static void ValidateVersion() { + Constants.FLATBUFFERS_23_5_26(); + } + + public static ClearBreak getRootAsClearBreak(ByteBuffer _bb) { + return getRootAsClearBreak(_bb, new ClearBreak()); + } + + public static ClearBreak getRootAsClearBreak(ByteBuffer _bb, ClearBreak obj) { + _bb.order(ByteOrder.LITTLE_ENDIAN); + return (obj.__assign(_bb.getInt(_bb.position()) + _bb.position(), _bb)); + } + + public void __init(int _i, ByteBuffer _bb) { + __reset(_i, _bb); + } + + public ClearBreak __assign(int _i, ByteBuffer _bb) { + __init(_i, _bb); + return this; + } + + public long rawOffset() { + int o = __offset(4); + return o != 0 ? (long) bb.getInt(o + bb_pos) & 0xFFFFFFFFL : 0L; + } + + public byte side() { + int o = __offset(6); + return o != 0 ? bb.get(o + bb_pos) : 0; + } + + public static int createClearBreak(FlatBufferBuilder builder, long rawOffset, byte side) { + builder.startTable(2); + ClearBreak.addRawOffset(builder, rawOffset); + ClearBreak.addSide(builder, side); + return ClearBreak.endClearBreak(builder); + } + + public static void startClearBreak(FlatBufferBuilder builder) { + builder.startTable(2); + } + + public static void addRawOffset(FlatBufferBuilder builder, long rawOffset) { + builder.addInt(0, (int) rawOffset, (int) 0L); + } + + public static void addSide(FlatBufferBuilder builder, byte side) { + builder.addByte(1, side, 0); + } + + public static int endClearBreak(FlatBufferBuilder builder) { + int o = builder.endTable(); + return o; + } + + public static final class Vector extends BaseVector { + + public Vector __assign(int _vector, int _element_size, ByteBuffer _bb) { + __reset(_vector, _element_size, _bb); + return this; + } + + public ClearBreak get(int j) { + return get(new ClearBreak(), j); + } + + public ClearBreak get(ClearBreak obj, int j) { + return obj.__assign(__indirect(__element(j), bb), bb); + } + } +} diff --git a/src/main/java/com/hfstudio/guidenh/guide/layout/flatbuffers/CustomData.java b/src/main/java/com/hfstudio/guidenh/guide/layout/flatbuffers/CustomData.java new file mode 100644 index 00000000..fcaf9f7b --- /dev/null +++ b/src/main/java/com/hfstudio/guidenh/guide/layout/flatbuffers/CustomData.java @@ -0,0 +1,122 @@ +// automatically generated by the FlatBuffers compiler, do not modify + +package com.hfstudio.guidenh.guide.layout.flatbuffers; + +import java.nio.ByteBuffer; +import java.nio.ByteOrder; + +import com.google.flatbuffers.BaseVector; +import com.google.flatbuffers.ByteVector; +import com.google.flatbuffers.Constants; +import com.google.flatbuffers.FlatBufferBuilder; +import com.google.flatbuffers.Table; + +@SuppressWarnings("unused") +public final class CustomData extends Table { + + public static void ValidateVersion() { + Constants.FLATBUFFERS_23_5_26(); + } + + public static CustomData getRootAsCustomData(ByteBuffer _bb) { + return getRootAsCustomData(_bb, new CustomData()); + } + + public static CustomData getRootAsCustomData(ByteBuffer _bb, CustomData obj) { + _bb.order(ByteOrder.LITTLE_ENDIAN); + return (obj.__assign(_bb.getInt(_bb.position()) + _bb.position(), _bb)); + } + + public void __init(int _i, ByteBuffer _bb) { + __reset(_i, _bb); + } + + public CustomData __assign(int _i, ByteBuffer _bb) { + __init(_i, _bb); + return this; + } + + public long typeId() { + int o = __offset(4); + return o != 0 ? (long) bb.getInt(o + bb_pos) & 0xFFFFFFFFL : 0L; + } + + public int payload(int j) { + int o = __offset(6); + return o != 0 ? bb.get(__vector(o) + j * 1) & 0xFF : 0; + } + + public int payloadLength() { + int o = __offset(6); + return o != 0 ? __vector_len(o) : 0; + } + + public ByteVector payloadVector() { + return payloadVector(new ByteVector()); + } + + public ByteVector payloadVector(ByteVector obj) { + int o = __offset(6); + return o != 0 ? obj.__assign(__vector(o), bb) : null; + } + + public ByteBuffer payloadAsByteBuffer() { + return __vector_as_bytebuffer(6, 1); + } + + public ByteBuffer payloadInByteBuffer(ByteBuffer _bb) { + return __vector_in_bytebuffer(_bb, 6, 1); + } + + public static int createCustomData(FlatBufferBuilder builder, long typeId, int payloadOffset) { + builder.startTable(2); + CustomData.addPayload(builder, payloadOffset); + CustomData.addTypeId(builder, typeId); + return CustomData.endCustomData(builder); + } + + public static void startCustomData(FlatBufferBuilder builder) { + builder.startTable(2); + } + + public static void addTypeId(FlatBufferBuilder builder, long typeId) { + builder.addInt(0, (int) typeId, (int) 0L); + } + + public static void addPayload(FlatBufferBuilder builder, int payloadOffset) { + builder.addOffset(1, payloadOffset, 0); + } + + public static int createPayloadVector(FlatBufferBuilder builder, byte[] data) { + return builder.createByteVector(data); + } + + public static int createPayloadVector(FlatBufferBuilder builder, ByteBuffer data) { + return builder.createByteVector(data); + } + + public static void startPayloadVector(FlatBufferBuilder builder, int numElems) { + builder.startVector(1, numElems, 1); + } + + public static int endCustomData(FlatBufferBuilder builder) { + int o = builder.endTable(); + return o; + } + + public static final class Vector extends BaseVector { + + public Vector __assign(int _vector, int _element_size, ByteBuffer _bb) { + __reset(_vector, _element_size, _bb); + return this; + } + + public CustomData get(int j) { + return get(new CustomData(), j); + } + + public CustomData get(CustomData obj, int j) { + return obj.__assign(__indirect(__element(j), bb), bb); + } + } +} diff --git a/src/main/java/com/hfstudio/guidenh/guide/layout/flatbuffers/DecorationRect.java b/src/main/java/com/hfstudio/guidenh/guide/layout/flatbuffers/DecorationRect.java new file mode 100644 index 00000000..9d7e60c3 --- /dev/null +++ b/src/main/java/com/hfstudio/guidenh/guide/layout/flatbuffers/DecorationRect.java @@ -0,0 +1,143 @@ +// automatically generated by the FlatBuffers compiler, do not modify + +package com.hfstudio.guidenh.guide.layout.flatbuffers; + +import java.nio.ByteBuffer; +import java.nio.ByteOrder; + +import com.google.flatbuffers.BaseVector; +import com.google.flatbuffers.Constants; +import com.google.flatbuffers.FlatBufferBuilder; +import com.google.flatbuffers.Table; + +/** + * A decoration rectangle in absolute document coordinates (top-left origin), + * derived from a span's glyph extents: kind 0 = background (highlight / + * inline-code), 1 = underline, 2 = strikethrough. + */ +@SuppressWarnings("unused") +public final class DecorationRect extends Table { + + public static void ValidateVersion() { + Constants.FLATBUFFERS_23_5_26(); + } + + public static DecorationRect getRootAsDecorationRect(ByteBuffer _bb) { + return getRootAsDecorationRect(_bb, new DecorationRect()); + } + + public static DecorationRect getRootAsDecorationRect(ByteBuffer _bb, DecorationRect obj) { + _bb.order(ByteOrder.LITTLE_ENDIAN); + return (obj.__assign(_bb.getInt(_bb.position()) + _bb.position(), _bb)); + } + + public void __init(int _i, ByteBuffer _bb) { + __reset(_i, _bb); + } + + public DecorationRect __assign(int _i, ByteBuffer _bb) { + __init(_i, _bb); + return this; + } + + public long node() { + int o = __offset(4); + return o != 0 ? (long) bb.getInt(o + bb_pos) & 0xFFFFFFFFL : 0L; + } + + public float x() { + int o = __offset(6); + return o != 0 ? bb.getFloat(o + bb_pos) : 0.0f; + } + + public float y() { + int o = __offset(8); + return o != 0 ? bb.getFloat(o + bb_pos) : 0.0f; + } + + public float w() { + int o = __offset(10); + return o != 0 ? bb.getFloat(o + bb_pos) : 0.0f; + } + + public float h() { + int o = __offset(12); + return o != 0 ? bb.getFloat(o + bb_pos) : 0.0f; + } + + public long argb() { + int o = __offset(14); + return o != 0 ? (long) bb.getInt(o + bb_pos) & 0xFFFFFFFFL : 0L; + } + + public byte kind() { + int o = __offset(16); + return o != 0 ? bb.get(o + bb_pos) : 0; + } + + public static int createDecorationRect(FlatBufferBuilder builder, long node, float x, float y, float w, float h, + long argb, byte kind) { + builder.startTable(7); + DecorationRect.addArgb(builder, argb); + DecorationRect.addH(builder, h); + DecorationRect.addW(builder, w); + DecorationRect.addY(builder, y); + DecorationRect.addX(builder, x); + DecorationRect.addNode(builder, node); + DecorationRect.addKind(builder, kind); + return DecorationRect.endDecorationRect(builder); + } + + public static void startDecorationRect(FlatBufferBuilder builder) { + builder.startTable(7); + } + + public static void addNode(FlatBufferBuilder builder, long node) { + builder.addInt(0, (int) node, (int) 0L); + } + + public static void addX(FlatBufferBuilder builder, float x) { + builder.addFloat(1, x, 0.0f); + } + + public static void addY(FlatBufferBuilder builder, float y) { + builder.addFloat(2, y, 0.0f); + } + + public static void addW(FlatBufferBuilder builder, float w) { + builder.addFloat(3, w, 0.0f); + } + + public static void addH(FlatBufferBuilder builder, float h) { + builder.addFloat(4, h, 0.0f); + } + + public static void addArgb(FlatBufferBuilder builder, long argb) { + builder.addInt(5, (int) argb, (int) 0L); + } + + public static void addKind(FlatBufferBuilder builder, byte kind) { + builder.addByte(6, kind, 0); + } + + public static int endDecorationRect(FlatBufferBuilder builder) { + int o = builder.endTable(); + return o; + } + + public static final class Vector extends BaseVector { + + public Vector __assign(int _vector, int _element_size, ByteBuffer _bb) { + __reset(_vector, _element_size, _bb); + return this; + } + + public DecorationRect get(int j) { + return get(new DecorationRect(), j); + } + + public DecorationRect get(DecorationRect obj, int j) { + return obj.__assign(__indirect(__element(j), bb), bb); + } + } +} diff --git a/src/main/java/com/hfstudio/guidenh/guide/layout/flatbuffers/Dimension.java b/src/main/java/com/hfstudio/guidenh/guide/layout/flatbuffers/Dimension.java new file mode 100644 index 00000000..1b7388bb --- /dev/null +++ b/src/main/java/com/hfstudio/guidenh/guide/layout/flatbuffers/Dimension.java @@ -0,0 +1,90 @@ +// automatically generated by the FlatBuffers compiler, do not modify + +package com.hfstudio.guidenh.guide.layout.flatbuffers; + +import java.nio.ByteBuffer; +import java.nio.ByteOrder; + +import com.google.flatbuffers.BaseVector; +import com.google.flatbuffers.Constants; +import com.google.flatbuffers.FlatBufferBuilder; +import com.google.flatbuffers.Table; + +/** + * A size value: auto, points, or percent. + */ +@SuppressWarnings("unused") +public final class Dimension extends Table { + + public static void ValidateVersion() { + Constants.FLATBUFFERS_23_5_26(); + } + + public static Dimension getRootAsDimension(ByteBuffer _bb) { + return getRootAsDimension(_bb, new Dimension()); + } + + public static Dimension getRootAsDimension(ByteBuffer _bb, Dimension obj) { + _bb.order(ByteOrder.LITTLE_ENDIAN); + return (obj.__assign(_bb.getInt(_bb.position()) + _bb.position(), _bb)); + } + + public void __init(int _i, ByteBuffer _bb) { + __reset(_i, _bb); + } + + public Dimension __assign(int _i, ByteBuffer _bb) { + __init(_i, _bb); + return this; + } + + public float value() { + int o = __offset(4); + return o != 0 ? bb.getFloat(o + bb_pos) : 0.0f; + } + + public byte unit() { + int o = __offset(6); + return o != 0 ? bb.get(o + bb_pos) : 0; + } + + public static int createDimension(FlatBufferBuilder builder, float value, byte unit) { + builder.startTable(2); + Dimension.addValue(builder, value); + Dimension.addUnit(builder, unit); + return Dimension.endDimension(builder); + } + + public static void startDimension(FlatBufferBuilder builder) { + builder.startTable(2); + } + + public static void addValue(FlatBufferBuilder builder, float value) { + builder.addFloat(0, value, 0.0f); + } + + public static void addUnit(FlatBufferBuilder builder, byte unit) { + builder.addByte(1, unit, 0); + } + + public static int endDimension(FlatBufferBuilder builder) { + int o = builder.endTable(); + return o; + } + + public static final class Vector extends BaseVector { + + public Vector __assign(int _vector, int _element_size, ByteBuffer _bb) { + __reset(_vector, _element_size, _bb); + return this; + } + + public Dimension get(int j) { + return get(new Dimension(), j); + } + + public Dimension get(Dimension obj, int j) { + return obj.__assign(__indirect(__element(j), bb), bb); + } + } +} diff --git a/src/main/java/com/hfstudio/guidenh/guide/layout/flatbuffers/FlatLayout.java b/src/main/java/com/hfstudio/guidenh/guide/layout/flatbuffers/FlatLayout.java new file mode 100644 index 00000000..99cacf71 --- /dev/null +++ b/src/main/java/com/hfstudio/guidenh/guide/layout/flatbuffers/FlatLayout.java @@ -0,0 +1,117 @@ +// automatically generated by the FlatBuffers compiler, do not modify + +package com.hfstudio.guidenh.guide.layout.flatbuffers; + +import java.nio.ByteBuffer; +import java.nio.ByteOrder; + +import com.google.flatbuffers.BaseVector; +import com.google.flatbuffers.Constants; +import com.google.flatbuffers.FlatBufferBuilder; +import com.google.flatbuffers.Table; + +@SuppressWarnings("unused") +public final class FlatLayout extends Table { + + public static void ValidateVersion() { + Constants.FLATBUFFERS_23_5_26(); + } + + public static FlatLayout getRootAsFlatLayout(ByteBuffer _bb) { + return getRootAsFlatLayout(_bb, new FlatLayout()); + } + + public static FlatLayout getRootAsFlatLayout(ByteBuffer _bb, FlatLayout obj) { + _bb.order(ByteOrder.LITTLE_ENDIAN); + return (obj.__assign(_bb.getInt(_bb.position()) + _bb.position(), _bb)); + } + + public void __init(int _i, ByteBuffer _bb) { + __reset(_i, _bb); + } + + public FlatLayout __assign(int _i, ByteBuffer _bb) { + __init(_i, _bb); + return this; + } + + public float x() { + int o = __offset(4); + return o != 0 ? bb.getFloat(o + bb_pos) : 0.0f; + } + + public float y() { + int o = __offset(6); + return o != 0 ? bb.getFloat(o + bb_pos) : 0.0f; + } + + public float w() { + int o = __offset(8); + return o != 0 ? bb.getFloat(o + bb_pos) : 0.0f; + } + + public float h() { + int o = __offset(10); + return o != 0 ? bb.getFloat(o + bb_pos) : 0.0f; + } + + public long order() { + int o = __offset(12); + return o != 0 ? (long) bb.getInt(o + bb_pos) & 0xFFFFFFFFL : 0L; + } + + public static int createFlatLayout(FlatBufferBuilder builder, float x, float y, float w, float h, long order) { + builder.startTable(5); + FlatLayout.addOrder(builder, order); + FlatLayout.addH(builder, h); + FlatLayout.addW(builder, w); + FlatLayout.addY(builder, y); + FlatLayout.addX(builder, x); + return FlatLayout.endFlatLayout(builder); + } + + public static void startFlatLayout(FlatBufferBuilder builder) { + builder.startTable(5); + } + + public static void addX(FlatBufferBuilder builder, float x) { + builder.addFloat(0, x, 0.0f); + } + + public static void addY(FlatBufferBuilder builder, float y) { + builder.addFloat(1, y, 0.0f); + } + + public static void addW(FlatBufferBuilder builder, float w) { + builder.addFloat(2, w, 0.0f); + } + + public static void addH(FlatBufferBuilder builder, float h) { + builder.addFloat(3, h, 0.0f); + } + + public static void addOrder(FlatBufferBuilder builder, long order) { + builder.addInt(4, (int) order, (int) 0L); + } + + public static int endFlatLayout(FlatBufferBuilder builder) { + int o = builder.endTable(); + return o; + } + + public static final class Vector extends BaseVector { + + public Vector __assign(int _vector, int _element_size, ByteBuffer _bb) { + __reset(_vector, _element_size, _bb); + return this; + } + + public FlatLayout get(int j) { + return get(new FlatLayout(), j); + } + + public FlatLayout get(FlatLayout obj, int j) { + return obj.__assign(__indirect(__element(j), bb), bb); + } + } +} diff --git a/src/main/java/com/hfstudio/guidenh/guide/layout/flatbuffers/FlatNode.java b/src/main/java/com/hfstudio/guidenh/guide/layout/flatbuffers/FlatNode.java new file mode 100644 index 00000000..8cd2d42c --- /dev/null +++ b/src/main/java/com/hfstudio/guidenh/guide/layout/flatbuffers/FlatNode.java @@ -0,0 +1,383 @@ +// automatically generated by the FlatBuffers compiler, do not modify + +package com.hfstudio.guidenh.guide.layout.flatbuffers; + +import java.nio.ByteBuffer; +import java.nio.ByteOrder; + +import com.google.flatbuffers.BaseVector; +import com.google.flatbuffers.Constants; +import com.google.flatbuffers.FlatBufferBuilder; +import com.google.flatbuffers.IntVector; +import com.google.flatbuffers.Table; + +@SuppressWarnings("unused") +public final class FlatNode extends Table { + + public static void ValidateVersion() { + Constants.FLATBUFFERS_23_5_26(); + } + + public static FlatNode getRootAsFlatNode(ByteBuffer _bb) { + return getRootAsFlatNode(_bb, new FlatNode()); + } + + public static FlatNode getRootAsFlatNode(ByteBuffer _bb, FlatNode obj) { + _bb.order(ByteOrder.LITTLE_ENDIAN); + return (obj.__assign(_bb.getInt(_bb.position()) + _bb.position(), _bb)); + } + + public void __init(int _i, ByteBuffer _bb) { + __reset(_i, _bb); + } + + public FlatNode __assign(int _i, ByteBuffer _bb) { + __init(_i, _bb); + return this; + } + + public com.hfstudio.guidenh.guide.layout.flatbuffers.Style style() { + return style(new com.hfstudio.guidenh.guide.layout.flatbuffers.Style()); + } + + public com.hfstudio.guidenh.guide.layout.flatbuffers.Style style( + com.hfstudio.guidenh.guide.layout.flatbuffers.Style obj) { + int o = __offset(4); + return o != 0 ? obj.__assign(__indirect(o + bb_pos), bb) : null; + } + + public byte nodeType() { + int o = __offset(6); + return o != 0 ? bb.get(o + bb_pos) : 0; + } + + public com.hfstudio.guidenh.guide.layout.flatbuffers.TextData text() { + return text(new com.hfstudio.guidenh.guide.layout.flatbuffers.TextData()); + } + + public com.hfstudio.guidenh.guide.layout.flatbuffers.TextData text( + com.hfstudio.guidenh.guide.layout.flatbuffers.TextData obj) { + int o = __offset(8); + return o != 0 ? obj.__assign(__indirect(o + bb_pos), bb) : null; + } + + public com.hfstudio.guidenh.guide.layout.flatbuffers.ImageData image() { + return image(new com.hfstudio.guidenh.guide.layout.flatbuffers.ImageData()); + } + + public com.hfstudio.guidenh.guide.layout.flatbuffers.ImageData image( + com.hfstudio.guidenh.guide.layout.flatbuffers.ImageData obj) { + int o = __offset(10); + return o != 0 ? obj.__assign(__indirect(o + bb_pos), bb) : null; + } + + public com.hfstudio.guidenh.guide.layout.flatbuffers.SlotData slot() { + return slot(new com.hfstudio.guidenh.guide.layout.flatbuffers.SlotData()); + } + + public com.hfstudio.guidenh.guide.layout.flatbuffers.SlotData slot( + com.hfstudio.guidenh.guide.layout.flatbuffers.SlotData obj) { + int o = __offset(12); + return o != 0 ? obj.__assign(__indirect(o + bb_pos), bb) : null; + } + + public com.hfstudio.guidenh.guide.layout.flatbuffers.ThematicBreakData break_() { + return break_(new com.hfstudio.guidenh.guide.layout.flatbuffers.ThematicBreakData()); + } + + public com.hfstudio.guidenh.guide.layout.flatbuffers.ThematicBreakData break_( + com.hfstudio.guidenh.guide.layout.flatbuffers.ThematicBreakData obj) { + int o = __offset(14); + return o != 0 ? obj.__assign(__indirect(o + bb_pos), bb) : null; + } + + public com.hfstudio.guidenh.guide.layout.flatbuffers.CustomData custom() { + return custom(new com.hfstudio.guidenh.guide.layout.flatbuffers.CustomData()); + } + + public com.hfstudio.guidenh.guide.layout.flatbuffers.CustomData custom( + com.hfstudio.guidenh.guide.layout.flatbuffers.CustomData obj) { + int o = __offset(16); + return o != 0 ? obj.__assign(__indirect(o + bb_pos), bb) : null; + } + + public com.hfstudio.guidenh.guide.layout.flatbuffers.LatexDisplayData latex() { + return latex(new com.hfstudio.guidenh.guide.layout.flatbuffers.LatexDisplayData()); + } + + public com.hfstudio.guidenh.guide.layout.flatbuffers.LatexDisplayData latex( + com.hfstudio.guidenh.guide.layout.flatbuffers.LatexDisplayData obj) { + int o = __offset(18); + return o != 0 ? obj.__assign(__indirect(o + bb_pos), bb) : null; + } + + public byte customLayout() { + int o = __offset(20); + return o != 0 ? bb.get(o + bb_pos) : 0; + } + + public long children(int j) { + int o = __offset(22); + return o != 0 ? (long) bb.getInt(__vector(o) + j * 4) & 0xFFFFFFFFL : 0; + } + + public int childrenLength() { + int o = __offset(22); + return o != 0 ? __vector_len(o) : 0; + } + + public IntVector childrenVector() { + return childrenVector(new IntVector()); + } + + public IntVector childrenVector(IntVector obj) { + int o = __offset(22); + return o != 0 ? obj.__assign(__vector(o), bb) : null; + } + + public ByteBuffer childrenAsByteBuffer() { + return __vector_as_bytebuffer(22, 4); + } + + public ByteBuffer childrenInByteBuffer(ByteBuffer _bb) { + return __vector_in_bytebuffer(_bb, 22, 4); + } + + public com.hfstudio.guidenh.guide.layout.flatbuffers.RecipeBoxData recipeBox() { + return recipeBox(new com.hfstudio.guidenh.guide.layout.flatbuffers.RecipeBoxData()); + } + + public com.hfstudio.guidenh.guide.layout.flatbuffers.RecipeBoxData recipeBox( + com.hfstudio.guidenh.guide.layout.flatbuffers.RecipeBoxData obj) { + int o = __offset(24); + return o != 0 ? obj.__assign(__indirect(o + bb_pos), bb) : null; + } + + public com.hfstudio.guidenh.guide.layout.flatbuffers.PieChartData pieChart() { + return pieChart(new com.hfstudio.guidenh.guide.layout.flatbuffers.PieChartData()); + } + + public com.hfstudio.guidenh.guide.layout.flatbuffers.PieChartData pieChart( + com.hfstudio.guidenh.guide.layout.flatbuffers.PieChartData obj) { + int o = __offset(26); + return o != 0 ? obj.__assign(__indirect(o + bb_pos), bb) : null; + } + + public com.hfstudio.guidenh.guide.layout.flatbuffers.ChartData chartData() { + return chartData(new com.hfstudio.guidenh.guide.layout.flatbuffers.ChartData()); + } + + public com.hfstudio.guidenh.guide.layout.flatbuffers.ChartData chartData( + com.hfstudio.guidenh.guide.layout.flatbuffers.ChartData obj) { + int o = __offset(28); + return o != 0 ? obj.__assign(__indirect(o + bb_pos), bb) : null; + } + + public com.hfstudio.guidenh.guide.layout.flatbuffers.StructureViewData structureViewData() { + return structureViewData(new com.hfstudio.guidenh.guide.layout.flatbuffers.StructureViewData()); + } + + public com.hfstudio.guidenh.guide.layout.flatbuffers.StructureViewData structureViewData( + com.hfstudio.guidenh.guide.layout.flatbuffers.StructureViewData obj) { + int o = __offset(30); + return o != 0 ? obj.__assign(__indirect(o + bb_pos), bb) : null; + } + + public com.hfstudio.guidenh.guide.layout.flatbuffers.GuidebookSceneData guidebookSceneData() { + return guidebookSceneData(new com.hfstudio.guidenh.guide.layout.flatbuffers.GuidebookSceneData()); + } + + public com.hfstudio.guidenh.guide.layout.flatbuffers.GuidebookSceneData guidebookSceneData( + com.hfstudio.guidenh.guide.layout.flatbuffers.GuidebookSceneData obj) { + int o = __offset(32); + return o != 0 ? obj.__assign(__indirect(o + bb_pos), bb) : null; + } + + public com.hfstudio.guidenh.guide.layout.flatbuffers.FunctionGraphData functionGraphData() { + return functionGraphData(new com.hfstudio.guidenh.guide.layout.flatbuffers.FunctionGraphData()); + } + + public com.hfstudio.guidenh.guide.layout.flatbuffers.FunctionGraphData functionGraphData( + com.hfstudio.guidenh.guide.layout.flatbuffers.FunctionGraphData obj) { + int o = __offset(34); + return o != 0 ? obj.__assign(__indirect(o + bb_pos), bb) : null; + } + + public com.hfstudio.guidenh.guide.layout.flatbuffers.MediaWikiGeneratedListData mediawikiGeneratedListData() { + return mediawikiGeneratedListData( + new com.hfstudio.guidenh.guide.layout.flatbuffers.MediaWikiGeneratedListData()); + } + + public com.hfstudio.guidenh.guide.layout.flatbuffers.MediaWikiGeneratedListData mediawikiGeneratedListData( + com.hfstudio.guidenh.guide.layout.flatbuffers.MediaWikiGeneratedListData obj) { + int o = __offset(36); + return o != 0 ? obj.__assign(__indirect(o + bb_pos), bb) : null; + } + + public com.hfstudio.guidenh.guide.layout.flatbuffers.MediaWikiSpecialGeneratedData mediawikiSpecialGeneratedData() { + return mediawikiSpecialGeneratedData( + new com.hfstudio.guidenh.guide.layout.flatbuffers.MediaWikiSpecialGeneratedData()); + } + + public com.hfstudio.guidenh.guide.layout.flatbuffers.MediaWikiSpecialGeneratedData mediawikiSpecialGeneratedData( + com.hfstudio.guidenh.guide.layout.flatbuffers.MediaWikiSpecialGeneratedData obj) { + int o = __offset(38); + return o != 0 ? obj.__assign(__indirect(o + bb_pos), bb) : null; + } + + /** + * Shared gutter marker declaration for a list item (null = no marker). + * Java declares it (LytListItem marker text plus the COL_MARKER style), Rust computes the + * shape and position and emits an ordinary glyph quad, and Java renders it without a + * special path. + */ + public com.hfstudio.guidenh.guide.layout.flatbuffers.ListMarkerData listMarker() { + return listMarker(new com.hfstudio.guidenh.guide.layout.flatbuffers.ListMarkerData()); + } + + public com.hfstudio.guidenh.guide.layout.flatbuffers.ListMarkerData listMarker( + com.hfstudio.guidenh.guide.layout.flatbuffers.ListMarkerData obj) { + int o = __offset(40); + return o != 0 ? obj.__assign(__indirect(o + bb_pos), bb) : null; + } + + public static int createFlatNode(FlatBufferBuilder builder, int styleOffset, byte nodeType, int textOffset, + int imageOffset, int slotOffset, int break_Offset, int customOffset, int latexOffset, byte customLayout, + int childrenOffset, int recipeBoxOffset, int pieChartOffset, int chartDataOffset, int structureViewDataOffset, + int guidebookSceneDataOffset, int functionGraphDataOffset, int mediawikiGeneratedListDataOffset, + int mediawikiSpecialGeneratedDataOffset, int listMarkerOffset) { + builder.startTable(19); + FlatNode.addListMarker(builder, listMarkerOffset); + FlatNode.addMediawikiSpecialGeneratedData(builder, mediawikiSpecialGeneratedDataOffset); + FlatNode.addMediawikiGeneratedListData(builder, mediawikiGeneratedListDataOffset); + FlatNode.addFunctionGraphData(builder, functionGraphDataOffset); + FlatNode.addGuidebookSceneData(builder, guidebookSceneDataOffset); + FlatNode.addStructureViewData(builder, structureViewDataOffset); + FlatNode.addChartData(builder, chartDataOffset); + FlatNode.addPieChart(builder, pieChartOffset); + FlatNode.addRecipeBox(builder, recipeBoxOffset); + FlatNode.addChildren(builder, childrenOffset); + FlatNode.addLatex(builder, latexOffset); + FlatNode.addCustom(builder, customOffset); + FlatNode.addBreak_(builder, break_Offset); + FlatNode.addSlot(builder, slotOffset); + FlatNode.addImage(builder, imageOffset); + FlatNode.addText(builder, textOffset); + FlatNode.addStyle(builder, styleOffset); + FlatNode.addCustomLayout(builder, customLayout); + FlatNode.addNodeType(builder, nodeType); + return FlatNode.endFlatNode(builder); + } + + public static void startFlatNode(FlatBufferBuilder builder) { + builder.startTable(19); + } + + public static void addStyle(FlatBufferBuilder builder, int styleOffset) { + builder.addOffset(0, styleOffset, 0); + } + + public static void addNodeType(FlatBufferBuilder builder, byte nodeType) { + builder.addByte(1, nodeType, 0); + } + + public static void addText(FlatBufferBuilder builder, int textOffset) { + builder.addOffset(2, textOffset, 0); + } + + public static void addImage(FlatBufferBuilder builder, int imageOffset) { + builder.addOffset(3, imageOffset, 0); + } + + public static void addSlot(FlatBufferBuilder builder, int slotOffset) { + builder.addOffset(4, slotOffset, 0); + } + + public static void addBreak_(FlatBufferBuilder builder, int break_Offset) { + builder.addOffset(5, break_Offset, 0); + } + + public static void addCustom(FlatBufferBuilder builder, int customOffset) { + builder.addOffset(6, customOffset, 0); + } + + public static void addLatex(FlatBufferBuilder builder, int latexOffset) { + builder.addOffset(7, latexOffset, 0); + } + + public static void addCustomLayout(FlatBufferBuilder builder, byte customLayout) { + builder.addByte(8, customLayout, 0); + } + + public static void addChildren(FlatBufferBuilder builder, int childrenOffset) { + builder.addOffset(9, childrenOffset, 0); + } + + public static int createChildrenVector(FlatBufferBuilder builder, long[] data) { + builder.startVector(4, data.length, 4); + for (int i = data.length - 1; i >= 0; i--) builder.addInt((int) data[i]); + return builder.endVector(); + } + + public static void startChildrenVector(FlatBufferBuilder builder, int numElems) { + builder.startVector(4, numElems, 4); + } + + public static void addRecipeBox(FlatBufferBuilder builder, int recipeBoxOffset) { + builder.addOffset(10, recipeBoxOffset, 0); + } + + public static void addPieChart(FlatBufferBuilder builder, int pieChartOffset) { + builder.addOffset(11, pieChartOffset, 0); + } + + public static void addChartData(FlatBufferBuilder builder, int chartDataOffset) { + builder.addOffset(12, chartDataOffset, 0); + } + + public static void addStructureViewData(FlatBufferBuilder builder, int structureViewDataOffset) { + builder.addOffset(13, structureViewDataOffset, 0); + } + + public static void addGuidebookSceneData(FlatBufferBuilder builder, int guidebookSceneDataOffset) { + builder.addOffset(14, guidebookSceneDataOffset, 0); + } + + public static void addFunctionGraphData(FlatBufferBuilder builder, int functionGraphDataOffset) { + builder.addOffset(15, functionGraphDataOffset, 0); + } + + public static void addMediawikiGeneratedListData(FlatBufferBuilder builder, int mediawikiGeneratedListDataOffset) { + builder.addOffset(16, mediawikiGeneratedListDataOffset, 0); + } + + public static void addMediawikiSpecialGeneratedData(FlatBufferBuilder builder, + int mediawikiSpecialGeneratedDataOffset) { + builder.addOffset(17, mediawikiSpecialGeneratedDataOffset, 0); + } + + public static void addListMarker(FlatBufferBuilder builder, int listMarkerOffset) { + builder.addOffset(18, listMarkerOffset, 0); + } + + public static int endFlatNode(FlatBufferBuilder builder) { + int o = builder.endTable(); + return o; + } + + public static final class Vector extends BaseVector { + + public Vector __assign(int _vector, int _element_size, ByteBuffer _bb) { + __reset(_vector, _element_size, _bb); + return this; + } + + public FlatNode get(int j) { + return get(new FlatNode(), j); + } + + public FlatNode get(FlatNode obj, int j) { + return obj.__assign(__indirect(__element(j), bb), bb); + } + } +} diff --git a/src/main/java/com/hfstudio/guidenh/guide/layout/flatbuffers/FloatClip.java b/src/main/java/com/hfstudio/guidenh/guide/layout/flatbuffers/FloatClip.java new file mode 100644 index 00000000..0226778e --- /dev/null +++ b/src/main/java/com/hfstudio/guidenh/guide/layout/flatbuffers/FloatClip.java @@ -0,0 +1,114 @@ +// automatically generated by the FlatBuffers compiler, do not modify + +package com.hfstudio.guidenh.guide.layout.flatbuffers; + +import java.nio.ByteBuffer; +import java.nio.ByteOrder; + +import com.google.flatbuffers.BaseVector; +import com.google.flatbuffers.Constants; +import com.google.flatbuffers.FlatBufferBuilder; +import com.google.flatbuffers.Table; + +/** + * One forbidden interval the text must not occupy, in paragraph-relative + * coordinates (typically the intersection of a float's rect and the paragraph). The Rust line + * breaker subtracts clips from the available width per line: x<=0 is a + * left-side clip (text starts right of x+width), otherwise a right-side + * clip (text ends at x). + */ +@SuppressWarnings("unused") +public final class FloatClip extends Table { + + public static void ValidateVersion() { + Constants.FLATBUFFERS_23_5_26(); + } + + public static FloatClip getRootAsFloatClip(ByteBuffer _bb) { + return getRootAsFloatClip(_bb, new FloatClip()); + } + + public static FloatClip getRootAsFloatClip(ByteBuffer _bb, FloatClip obj) { + _bb.order(ByteOrder.LITTLE_ENDIAN); + return (obj.__assign(_bb.getInt(_bb.position()) + _bb.position(), _bb)); + } + + public void __init(int _i, ByteBuffer _bb) { + __reset(_i, _bb); + } + + public FloatClip __assign(int _i, ByteBuffer _bb) { + __init(_i, _bb); + return this; + } + + public float yTop() { + int o = __offset(4); + return o != 0 ? bb.getFloat(o + bb_pos) : 0.0f; + } + + public float yBottom() { + int o = __offset(6); + return o != 0 ? bb.getFloat(o + bb_pos) : 0.0f; + } + + public float x() { + int o = __offset(8); + return o != 0 ? bb.getFloat(o + bb_pos) : 0.0f; + } + + public float width() { + int o = __offset(10); + return o != 0 ? bb.getFloat(o + bb_pos) : 0.0f; + } + + public static int createFloatClip(FlatBufferBuilder builder, float yTop, float yBottom, float x, float width) { + builder.startTable(4); + FloatClip.addWidth(builder, width); + FloatClip.addX(builder, x); + FloatClip.addYBottom(builder, yBottom); + FloatClip.addYTop(builder, yTop); + return FloatClip.endFloatClip(builder); + } + + public static void startFloatClip(FlatBufferBuilder builder) { + builder.startTable(4); + } + + public static void addYTop(FlatBufferBuilder builder, float yTop) { + builder.addFloat(0, yTop, 0.0f); + } + + public static void addYBottom(FlatBufferBuilder builder, float yBottom) { + builder.addFloat(1, yBottom, 0.0f); + } + + public static void addX(FlatBufferBuilder builder, float x) { + builder.addFloat(2, x, 0.0f); + } + + public static void addWidth(FlatBufferBuilder builder, float width) { + builder.addFloat(3, width, 0.0f); + } + + public static int endFloatClip(FlatBufferBuilder builder) { + int o = builder.endTable(); + return o; + } + + public static final class Vector extends BaseVector { + + public Vector __assign(int _vector, int _element_size, ByteBuffer _bb) { + __reset(_vector, _element_size, _bb); + return this; + } + + public FloatClip get(int j) { + return get(new FloatClip(), j); + } + + public FloatClip get(FloatClip obj, int j) { + return obj.__assign(__indirect(__element(j), bb), bb); + } + } +} diff --git a/src/main/java/com/hfstudio/guidenh/guide/layout/flatbuffers/FontRequest.java b/src/main/java/com/hfstudio/guidenh/guide/layout/flatbuffers/FontRequest.java new file mode 100644 index 00000000..1aa5c284 --- /dev/null +++ b/src/main/java/com/hfstudio/guidenh/guide/layout/flatbuffers/FontRequest.java @@ -0,0 +1,216 @@ +// automatically generated by the FlatBuffers compiler, do not modify + +package com.hfstudio.guidenh.guide.layout.flatbuffers; + +import java.nio.ByteBuffer; +import java.nio.ByteOrder; + +import com.google.flatbuffers.BaseVector; +import com.google.flatbuffers.Constants; +import com.google.flatbuffers.FlatBufferBuilder; +import com.google.flatbuffers.FloatVector; +import com.google.flatbuffers.IntVector; +import com.google.flatbuffers.Table; + +@SuppressWarnings("unused") +public final class FontRequest extends Table { + + public static void ValidateVersion() { + Constants.FLATBUFFERS_23_5_26(); + } + + public static FontRequest getRootAsFontRequest(ByteBuffer _bb) { + return getRootAsFontRequest(_bb, new FontRequest()); + } + + public static FontRequest getRootAsFontRequest(ByteBuffer _bb, FontRequest obj) { + _bb.order(ByteOrder.LITTLE_ENDIAN); + return (obj.__assign(_bb.getInt(_bb.position()) + _bb.position(), _bb)); + } + + public void __init(int _i, ByteBuffer _bb) { + __reset(_i, _bb); + } + + public FontRequest __assign(int _i, ByteBuffer _bb) { + __init(_i, _bb); + return this; + } + + public long fontId() { + int o = __offset(4); + return o != 0 ? (long) bb.getInt(o + bb_pos) & 0xFFFFFFFFL : 0L; + } + + public float fontSize() { + int o = __offset(6); + return o != 0 ? bb.getFloat(o + bb_pos) : 0.0f; + } + + public long glyphIds(int j) { + int o = __offset(8); + return o != 0 ? (long) bb.getInt(__vector(o) + j * 4) & 0xFFFFFFFFL : 0; + } + + public int glyphIdsLength() { + int o = __offset(8); + return o != 0 ? __vector_len(o) : 0; + } + + public IntVector glyphIdsVector() { + return glyphIdsVector(new IntVector()); + } + + public IntVector glyphIdsVector(IntVector obj) { + int o = __offset(8); + return o != 0 ? obj.__assign(__vector(o), bb) : null; + } + + public ByteBuffer glyphIdsAsByteBuffer() { + return __vector_as_bytebuffer(8, 4); + } + + public ByteBuffer glyphIdsInByteBuffer(ByteBuffer _bb) { + return __vector_in_bytebuffer(_bb, 8, 4); + } + + public float posX(int j) { + int o = __offset(10); + return o != 0 ? bb.getFloat(__vector(o) + j * 4) : 0; + } + + public int posXLength() { + int o = __offset(10); + return o != 0 ? __vector_len(o) : 0; + } + + public FloatVector posXVector() { + return posXVector(new FloatVector()); + } + + public FloatVector posXVector(FloatVector obj) { + int o = __offset(10); + return o != 0 ? obj.__assign(__vector(o), bb) : null; + } + + public ByteBuffer posXAsByteBuffer() { + return __vector_as_bytebuffer(10, 4); + } + + public ByteBuffer posXInByteBuffer(ByteBuffer _bb) { + return __vector_in_bytebuffer(_bb, 10, 4); + } + + public float posY(int j) { + int o = __offset(12); + return o != 0 ? bb.getFloat(__vector(o) + j * 4) : 0; + } + + public int posYLength() { + int o = __offset(12); + return o != 0 ? __vector_len(o) : 0; + } + + public FloatVector posYVector() { + return posYVector(new FloatVector()); + } + + public FloatVector posYVector(FloatVector obj) { + int o = __offset(12); + return o != 0 ? obj.__assign(__vector(o), bb) : null; + } + + public ByteBuffer posYAsByteBuffer() { + return __vector_as_bytebuffer(12, 4); + } + + public ByteBuffer posYInByteBuffer(ByteBuffer _bb) { + return __vector_in_bytebuffer(_bb, 12, 4); + } + + public static int createFontRequest(FlatBufferBuilder builder, long fontId, float fontSize, int glyphIdsOffset, + int posXOffset, int posYOffset) { + builder.startTable(5); + FontRequest.addPosY(builder, posYOffset); + FontRequest.addPosX(builder, posXOffset); + FontRequest.addGlyphIds(builder, glyphIdsOffset); + FontRequest.addFontSize(builder, fontSize); + FontRequest.addFontId(builder, fontId); + return FontRequest.endFontRequest(builder); + } + + public static void startFontRequest(FlatBufferBuilder builder) { + builder.startTable(5); + } + + public static void addFontId(FlatBufferBuilder builder, long fontId) { + builder.addInt(0, (int) fontId, (int) 0L); + } + + public static void addFontSize(FlatBufferBuilder builder, float fontSize) { + builder.addFloat(1, fontSize, 0.0f); + } + + public static void addGlyphIds(FlatBufferBuilder builder, int glyphIdsOffset) { + builder.addOffset(2, glyphIdsOffset, 0); + } + + public static int createGlyphIdsVector(FlatBufferBuilder builder, long[] data) { + builder.startVector(4, data.length, 4); + for (int i = data.length - 1; i >= 0; i--) builder.addInt((int) data[i]); + return builder.endVector(); + } + + public static void startGlyphIdsVector(FlatBufferBuilder builder, int numElems) { + builder.startVector(4, numElems, 4); + } + + public static void addPosX(FlatBufferBuilder builder, int posXOffset) { + builder.addOffset(3, posXOffset, 0); + } + + public static int createPosXVector(FlatBufferBuilder builder, float[] data) { + builder.startVector(4, data.length, 4); + for (int i = data.length - 1; i >= 0; i--) builder.addFloat(data[i]); + return builder.endVector(); + } + + public static void startPosXVector(FlatBufferBuilder builder, int numElems) { + builder.startVector(4, numElems, 4); + } + + public static void addPosY(FlatBufferBuilder builder, int posYOffset) { + builder.addOffset(4, posYOffset, 0); + } + + public static int createPosYVector(FlatBufferBuilder builder, float[] data) { + builder.startVector(4, data.length, 4); + for (int i = data.length - 1; i >= 0; i--) builder.addFloat(data[i]); + return builder.endVector(); + } + + public static void startPosYVector(FlatBufferBuilder builder, int numElems) { + builder.startVector(4, numElems, 4); + } + + public static int endFontRequest(FlatBufferBuilder builder) { + int o = builder.endTable(); + return o; + } + + public static final class Vector extends BaseVector { + + public Vector __assign(int _vector, int _element_size, ByteBuffer _bb) { + __reset(_vector, _element_size, _bb); + return this; + } + + public FontRequest get(int j) { + return get(new FontRequest(), j); + } + + public FontRequest get(FontRequest obj, int j) { + return obj.__assign(__indirect(__element(j), bb), bb); + } + } +} diff --git a/src/main/java/com/hfstudio/guidenh/guide/layout/flatbuffers/FunctionGraphData.java b/src/main/java/com/hfstudio/guidenh/guide/layout/flatbuffers/FunctionGraphData.java new file mode 100644 index 00000000..4900663f --- /dev/null +++ b/src/main/java/com/hfstudio/guidenh/guide/layout/flatbuffers/FunctionGraphData.java @@ -0,0 +1,174 @@ +// automatically generated by the FlatBuffers compiler, do not modify + +package com.hfstudio.guidenh.guide.layout.flatbuffers; + +import java.nio.ByteBuffer; +import java.nio.ByteOrder; + +import com.google.flatbuffers.BaseVector; +import com.google.flatbuffers.Constants; +import com.google.flatbuffers.FlatBufferBuilder; +import com.google.flatbuffers.FloatVector; +import com.google.flatbuffers.Table; + +/** + * Function graph sizing data (node_type 28). + * Mirrors LytFunctionGraph.computeLayout term for term. + * Minecraft-dependent values (label widths, line heights) are precomputed + * by Java; Rust applies the responsive formula (scale_width, legend + * wrapping, scale_body_height_for_width) with available_width and visual_scale. + */ +@SuppressWarnings("unused") +public final class FunctionGraphData extends Table { + + public static void ValidateVersion() { + Constants.FLATBUFFERS_23_5_26(); + } + + public static FunctionGraphData getRootAsFunctionGraphData(ByteBuffer _bb) { + return getRootAsFunctionGraphData(_bb, new FunctionGraphData()); + } + + public static FunctionGraphData getRootAsFunctionGraphData(ByteBuffer _bb, FunctionGraphData obj) { + _bb.order(ByteOrder.LITTLE_ENDIAN); + return (obj.__assign(_bb.getInt(_bb.position()) + _bb.position(), _bb)); + } + + public void __init(int _i, ByteBuffer _bb) { + __reset(_i, _bb); + } + + public FunctionGraphData __assign(int _i, ByteBuffer _bb) { + __init(_i, _bb); + return this; + } + + /** + * Intrinsic base width: explicitWidth > 0 ? explicitWidth : DEFAULT_WIDTH. + */ + public float baseWidth() { + int o = __offset(4); + return o != 0 ? bb.getFloat(o + bb_pos) : 0.0f; + } + + /** + * Intrinsic base height: explicitHeight > 0 ? explicitHeight : DEFAULT_HEIGHT. + */ + public float baseHeight() { + int o = __offset(6); + return o != 0 ? bb.getFloat(o + bb_pos) : 0.0f; + } + + /** + * lineHeight(TITLE_STYLE) + TITLE_GAP if title present and non-empty, else 0. + */ + public float titleChrome() { + int o = __offset(8); + return o != 0 ? bb.getFloat(o + bb_pos) : 0.0f; + } + + /** + * Row height for legend wrapping: max(LEGEND_SWATCH_SIZE, lineHeight(LEGEND_LABEL_STYLE)). + */ + public float legendRowHeight() { + int o = __offset(10); + return o != 0 ? bb.getFloat(o + bb_pos) : 0.0f; + } + + /** + * Per-plot legend item widths (0 for plots without labels): + * LEGEND_SWATCH_SIZE + LEGEND_SWATCH_TEXT_GAP + measureWidth(label, style). + */ + public float labelItemWidths(int j) { + int o = __offset(12); + return o != 0 ? bb.getFloat(__vector(o) + j * 4) : 0; + } + + public int labelItemWidthsLength() { + int o = __offset(12); + return o != 0 ? __vector_len(o) : 0; + } + + public FloatVector labelItemWidthsVector() { + return labelItemWidthsVector(new FloatVector()); + } + + public FloatVector labelItemWidthsVector(FloatVector obj) { + int o = __offset(12); + return o != 0 ? obj.__assign(__vector(o), bb) : null; + } + + public ByteBuffer labelItemWidthsAsByteBuffer() { + return __vector_as_bytebuffer(12, 4); + } + + public ByteBuffer labelItemWidthsInByteBuffer(ByteBuffer _bb) { + return __vector_in_bytebuffer(_bb, 12, 4); + } + + public static int createFunctionGraphData(FlatBufferBuilder builder, float baseWidth, float baseHeight, + float titleChrome, float legendRowHeight, int labelItemWidthsOffset) { + builder.startTable(5); + FunctionGraphData.addLabelItemWidths(builder, labelItemWidthsOffset); + FunctionGraphData.addLegendRowHeight(builder, legendRowHeight); + FunctionGraphData.addTitleChrome(builder, titleChrome); + FunctionGraphData.addBaseHeight(builder, baseHeight); + FunctionGraphData.addBaseWidth(builder, baseWidth); + return FunctionGraphData.endFunctionGraphData(builder); + } + + public static void startFunctionGraphData(FlatBufferBuilder builder) { + builder.startTable(5); + } + + public static void addBaseWidth(FlatBufferBuilder builder, float baseWidth) { + builder.addFloat(0, baseWidth, 0.0f); + } + + public static void addBaseHeight(FlatBufferBuilder builder, float baseHeight) { + builder.addFloat(1, baseHeight, 0.0f); + } + + public static void addTitleChrome(FlatBufferBuilder builder, float titleChrome) { + builder.addFloat(2, titleChrome, 0.0f); + } + + public static void addLegendRowHeight(FlatBufferBuilder builder, float legendRowHeight) { + builder.addFloat(3, legendRowHeight, 0.0f); + } + + public static void addLabelItemWidths(FlatBufferBuilder builder, int labelItemWidthsOffset) { + builder.addOffset(4, labelItemWidthsOffset, 0); + } + + public static int createLabelItemWidthsVector(FlatBufferBuilder builder, float[] data) { + builder.startVector(4, data.length, 4); + for (int i = data.length - 1; i >= 0; i--) builder.addFloat(data[i]); + return builder.endVector(); + } + + public static void startLabelItemWidthsVector(FlatBufferBuilder builder, int numElems) { + builder.startVector(4, numElems, 4); + } + + public static int endFunctionGraphData(FlatBufferBuilder builder) { + int o = builder.endTable(); + return o; + } + + public static final class Vector extends BaseVector { + + public Vector __assign(int _vector, int _element_size, ByteBuffer _bb) { + __reset(_vector, _element_size, _bb); + return this; + } + + public FunctionGraphData get(int j) { + return get(new FunctionGraphData(), j); + } + + public FunctionGraphData get(FunctionGraphData obj, int j) { + return obj.__assign(__indirect(__element(j), bb), bb); + } + } +} diff --git a/src/main/java/com/hfstudio/guidenh/guide/layout/flatbuffers/GlyphBitmap.java b/src/main/java/com/hfstudio/guidenh/guide/layout/flatbuffers/GlyphBitmap.java new file mode 100644 index 00000000..6599b4cb --- /dev/null +++ b/src/main/java/com/hfstudio/guidenh/guide/layout/flatbuffers/GlyphBitmap.java @@ -0,0 +1,146 @@ +// automatically generated by the FlatBuffers compiler, do not modify + +package com.hfstudio.guidenh.guide.layout.flatbuffers; + +import java.nio.ByteBuffer; +import java.nio.ByteOrder; + +import com.google.flatbuffers.BaseVector; +import com.google.flatbuffers.ByteVector; +import com.google.flatbuffers.Constants; +import com.google.flatbuffers.FlatBufferBuilder; +import com.google.flatbuffers.Table; + +/** + * A unique rasterized glyph bitmap, at render_scale resolution. + * Placement is already baked into the owning PlacedGlyph's quad. + */ +@SuppressWarnings("unused") +public final class GlyphBitmap extends Table { + + public static void ValidateVersion() { + Constants.FLATBUFFERS_23_5_26(); + } + + public static GlyphBitmap getRootAsGlyphBitmap(ByteBuffer _bb) { + return getRootAsGlyphBitmap(_bb, new GlyphBitmap()); + } + + public static GlyphBitmap getRootAsGlyphBitmap(ByteBuffer _bb, GlyphBitmap obj) { + _bb.order(ByteOrder.LITTLE_ENDIAN); + return (obj.__assign(_bb.getInt(_bb.position()) + _bb.position(), _bb)); + } + + public void __init(int _i, ByteBuffer _bb) { + __reset(_i, _bb); + } + + public GlyphBitmap __assign(int _i, ByteBuffer _bb) { + __init(_i, _bb); + return this; + } + + public long key() { + int o = __offset(4); + return o != 0 ? bb.getLong(o + bb_pos) : 0L; + } + + public long w() { + int o = __offset(6); + return o != 0 ? (long) bb.getInt(o + bb_pos) & 0xFFFFFFFFL : 0L; + } + + public long h() { + int o = __offset(8); + return o != 0 ? (long) bb.getInt(o + bb_pos) & 0xFFFFFFFFL : 0L; + } + + public int rgba(int j) { + int o = __offset(10); + return o != 0 ? bb.get(__vector(o) + j * 1) & 0xFF : 0; + } + + public int rgbaLength() { + int o = __offset(10); + return o != 0 ? __vector_len(o) : 0; + } + + public ByteVector rgbaVector() { + return rgbaVector(new ByteVector()); + } + + public ByteVector rgbaVector(ByteVector obj) { + int o = __offset(10); + return o != 0 ? obj.__assign(__vector(o), bb) : null; + } + + public ByteBuffer rgbaAsByteBuffer() { + return __vector_as_bytebuffer(10, 1); + } + + public ByteBuffer rgbaInByteBuffer(ByteBuffer _bb) { + return __vector_in_bytebuffer(_bb, 10, 1); + } + + public static int createGlyphBitmap(FlatBufferBuilder builder, long key, long w, long h, int rgbaOffset) { + builder.startTable(4); + GlyphBitmap.addKey(builder, key); + GlyphBitmap.addRgba(builder, rgbaOffset); + GlyphBitmap.addH(builder, h); + GlyphBitmap.addW(builder, w); + return GlyphBitmap.endGlyphBitmap(builder); + } + + public static void startGlyphBitmap(FlatBufferBuilder builder) { + builder.startTable(4); + } + + public static void addKey(FlatBufferBuilder builder, long key) { + builder.addLong(0, key, 0L); + } + + public static void addW(FlatBufferBuilder builder, long w) { + builder.addInt(1, (int) w, (int) 0L); + } + + public static void addH(FlatBufferBuilder builder, long h) { + builder.addInt(2, (int) h, (int) 0L); + } + + public static void addRgba(FlatBufferBuilder builder, int rgbaOffset) { + builder.addOffset(3, rgbaOffset, 0); + } + + public static int createRgbaVector(FlatBufferBuilder builder, byte[] data) { + return builder.createByteVector(data); + } + + public static int createRgbaVector(FlatBufferBuilder builder, ByteBuffer data) { + return builder.createByteVector(data); + } + + public static void startRgbaVector(FlatBufferBuilder builder, int numElems) { + builder.startVector(1, numElems, 1); + } + + public static int endGlyphBitmap(FlatBufferBuilder builder) { + int o = builder.endTable(); + return o; + } + + public static final class Vector extends BaseVector { + + public Vector __assign(int _vector, int _element_size, ByteBuffer _bb) { + __reset(_vector, _element_size, _bb); + return this; + } + + public GlyphBitmap get(int j) { + return get(new GlyphBitmap(), j); + } + + public GlyphBitmap get(GlyphBitmap obj, int j) { + return obj.__assign(__indirect(__element(j), bb), bb); + } + } +} diff --git a/src/main/java/com/hfstudio/guidenh/guide/layout/flatbuffers/GlyphPixels.java b/src/main/java/com/hfstudio/guidenh/guide/layout/flatbuffers/GlyphPixels.java new file mode 100644 index 00000000..8f3a3946 --- /dev/null +++ b/src/main/java/com/hfstudio/guidenh/guide/layout/flatbuffers/GlyphPixels.java @@ -0,0 +1,163 @@ +// automatically generated by the FlatBuffers compiler, do not modify + +package com.hfstudio.guidenh.guide.layout.flatbuffers; + +import java.nio.ByteBuffer; +import java.nio.ByteOrder; + +import com.google.flatbuffers.BaseVector; +import com.google.flatbuffers.ByteVector; +import com.google.flatbuffers.Constants; +import com.google.flatbuffers.FlatBufferBuilder; +import com.google.flatbuffers.Table; + +@SuppressWarnings("unused") +public final class GlyphPixels extends Table { + + public static void ValidateVersion() { + Constants.FLATBUFFERS_23_5_26(); + } + + public static GlyphPixels getRootAsGlyphPixels(ByteBuffer _bb) { + return getRootAsGlyphPixels(_bb, new GlyphPixels()); + } + + public static GlyphPixels getRootAsGlyphPixels(ByteBuffer _bb, GlyphPixels obj) { + _bb.order(ByteOrder.LITTLE_ENDIAN); + return (obj.__assign(_bb.getInt(_bb.position()) + _bb.position(), _bb)); + } + + public void __init(int _i, ByteBuffer _bb) { + __reset(_i, _bb); + } + + public GlyphPixels __assign(int _i, ByteBuffer _bb) { + __init(_i, _bb); + return this; + } + + public long fontId() { + int o = __offset(4); + return o != 0 ? (long) bb.getInt(o + bb_pos) & 0xFFFFFFFFL : 0L; + } + + public float fontSize() { + int o = __offset(6); + return o != 0 ? bb.getFloat(o + bb_pos) : 0.0f; + } + + public long glyphId() { + int o = __offset(8); + return o != 0 ? (long) bb.getInt(o + bb_pos) & 0xFFFFFFFFL : 0L; + } + + public long width() { + int o = __offset(10); + return o != 0 ? (long) bb.getInt(o + bb_pos) & 0xFFFFFFFFL : 0L; + } + + public long height() { + int o = __offset(12); + return o != 0 ? (long) bb.getInt(o + bb_pos) & 0xFFFFFFFFL : 0L; + } + + public int rgba(int j) { + int o = __offset(14); + return o != 0 ? bb.get(__vector(o) + j * 1) & 0xFF : 0; + } + + public int rgbaLength() { + int o = __offset(14); + return o != 0 ? __vector_len(o) : 0; + } + + public ByteVector rgbaVector() { + return rgbaVector(new ByteVector()); + } + + public ByteVector rgbaVector(ByteVector obj) { + int o = __offset(14); + return o != 0 ? obj.__assign(__vector(o), bb) : null; + } + + public ByteBuffer rgbaAsByteBuffer() { + return __vector_as_bytebuffer(14, 1); + } + + public ByteBuffer rgbaInByteBuffer(ByteBuffer _bb) { + return __vector_in_bytebuffer(_bb, 14, 1); + } + + public static int createGlyphPixels(FlatBufferBuilder builder, long fontId, float fontSize, long glyphId, + long width, long height, int rgbaOffset) { + builder.startTable(6); + GlyphPixels.addRgba(builder, rgbaOffset); + GlyphPixels.addHeight(builder, height); + GlyphPixels.addWidth(builder, width); + GlyphPixels.addGlyphId(builder, glyphId); + GlyphPixels.addFontSize(builder, fontSize); + GlyphPixels.addFontId(builder, fontId); + return GlyphPixels.endGlyphPixels(builder); + } + + public static void startGlyphPixels(FlatBufferBuilder builder) { + builder.startTable(6); + } + + public static void addFontId(FlatBufferBuilder builder, long fontId) { + builder.addInt(0, (int) fontId, (int) 0L); + } + + public static void addFontSize(FlatBufferBuilder builder, float fontSize) { + builder.addFloat(1, fontSize, 0.0f); + } + + public static void addGlyphId(FlatBufferBuilder builder, long glyphId) { + builder.addInt(2, (int) glyphId, (int) 0L); + } + + public static void addWidth(FlatBufferBuilder builder, long width) { + builder.addInt(3, (int) width, (int) 0L); + } + + public static void addHeight(FlatBufferBuilder builder, long height) { + builder.addInt(4, (int) height, (int) 0L); + } + + public static void addRgba(FlatBufferBuilder builder, int rgbaOffset) { + builder.addOffset(5, rgbaOffset, 0); + } + + public static int createRgbaVector(FlatBufferBuilder builder, byte[] data) { + return builder.createByteVector(data); + } + + public static int createRgbaVector(FlatBufferBuilder builder, ByteBuffer data) { + return builder.createByteVector(data); + } + + public static void startRgbaVector(FlatBufferBuilder builder, int numElems) { + builder.startVector(1, numElems, 1); + } + + public static int endGlyphPixels(FlatBufferBuilder builder) { + int o = builder.endTable(); + return o; + } + + public static final class Vector extends BaseVector { + + public Vector __assign(int _vector, int _element_size, ByteBuffer _bb) { + __reset(_vector, _element_size, _bb); + return this; + } + + public GlyphPixels get(int j) { + return get(new GlyphPixels(), j); + } + + public GlyphPixels get(GlyphPixels obj, int j) { + return obj.__assign(__indirect(__element(j), bb), bb); + } + } +} diff --git a/src/main/java/com/hfstudio/guidenh/guide/layout/flatbuffers/GlyphRun.java b/src/main/java/com/hfstudio/guidenh/guide/layout/flatbuffers/GlyphRun.java new file mode 100644 index 00000000..5631c967 --- /dev/null +++ b/src/main/java/com/hfstudio/guidenh/guide/layout/flatbuffers/GlyphRun.java @@ -0,0 +1,158 @@ +// automatically generated by the FlatBuffers compiler, do not modify + +package com.hfstudio.guidenh.guide.layout.flatbuffers; + +import java.nio.ByteBuffer; +import java.nio.ByteOrder; + +import com.google.flatbuffers.BaseVector; +import com.google.flatbuffers.Constants; +import com.google.flatbuffers.FlatBufferBuilder; +import com.google.flatbuffers.Table; + +/** + * One glyph run of a paragraph, grouped by span: `argb` tints the (white) + * atlas bitmaps, `shear` asks the engine for a synthetic-italic slant. + * Default values keep legacy single-style runs white and unslanted. + */ +@SuppressWarnings("unused") +public final class GlyphRun extends Table { + + public static void ValidateVersion() { + Constants.FLATBUFFERS_23_5_26(); + } + + public static GlyphRun getRootAsGlyphRun(ByteBuffer _bb) { + return getRootAsGlyphRun(_bb, new GlyphRun()); + } + + public static GlyphRun getRootAsGlyphRun(ByteBuffer _bb, GlyphRun obj) { + _bb.order(ByteOrder.LITTLE_ENDIAN); + return (obj.__assign(_bb.getInt(_bb.position()) + _bb.position(), _bb)); + } + + public void __init(int _i, ByteBuffer _bb) { + __reset(_i, _bb); + } + + public GlyphRun __assign(int _i, ByteBuffer _bb) { + __init(_i, _bb); + return this; + } + + public long nodeIndex() { + int o = __offset(4); + return o != 0 ? (long) bb.getInt(o + bb_pos) & 0xFFFFFFFFL : 0L; + } + + public com.hfstudio.guidenh.guide.layout.flatbuffers.PlacedGlyph glyphs(int j) { + return glyphs(new com.hfstudio.guidenh.guide.layout.flatbuffers.PlacedGlyph(), j); + } + + public com.hfstudio.guidenh.guide.layout.flatbuffers.PlacedGlyph glyphs( + com.hfstudio.guidenh.guide.layout.flatbuffers.PlacedGlyph obj, int j) { + int o = __offset(6); + return o != 0 ? obj.__assign(__indirect(__vector(o) + j * 4), bb) : null; + } + + public int glyphsLength() { + int o = __offset(6); + return o != 0 ? __vector_len(o) : 0; + } + + public com.hfstudio.guidenh.guide.layout.flatbuffers.PlacedGlyph.Vector glyphsVector() { + return glyphsVector(new com.hfstudio.guidenh.guide.layout.flatbuffers.PlacedGlyph.Vector()); + } + + public com.hfstudio.guidenh.guide.layout.flatbuffers.PlacedGlyph.Vector glyphsVector( + com.hfstudio.guidenh.guide.layout.flatbuffers.PlacedGlyph.Vector obj) { + int o = __offset(6); + return o != 0 ? obj.__assign(__vector(o), 4, bb) : null; + } + + public long argb() { + int o = __offset(8); + return o != 0 ? (long) bb.getInt(o + bb_pos) & 0xFFFFFFFFL : 4294967295L; + } + + public boolean shear() { + int o = __offset(10); + return o != 0 ? 0 != bb.get(o + bb_pos) : false; + } + + /** + * Baseline Y of this run in absolute document coordinates, already quantized onto the + * display pixel grid by dividing by render_scale and truncating. Default 0 keeps older + * data compatible; the Java reader ignores the field until it consumes it. + */ + public float baseline() { + int o = __offset(12); + return o != 0 ? bb.getFloat(o + bb_pos) : 0.0f; + } + + public static int createGlyphRun(FlatBufferBuilder builder, long nodeIndex, int glyphsOffset, long argb, + boolean shear, float baseline) { + builder.startTable(5); + GlyphRun.addBaseline(builder, baseline); + GlyphRun.addArgb(builder, argb); + GlyphRun.addGlyphs(builder, glyphsOffset); + GlyphRun.addNodeIndex(builder, nodeIndex); + GlyphRun.addShear(builder, shear); + return GlyphRun.endGlyphRun(builder); + } + + public static void startGlyphRun(FlatBufferBuilder builder) { + builder.startTable(5); + } + + public static void addNodeIndex(FlatBufferBuilder builder, long nodeIndex) { + builder.addInt(0, (int) nodeIndex, (int) 0L); + } + + public static void addGlyphs(FlatBufferBuilder builder, int glyphsOffset) { + builder.addOffset(1, glyphsOffset, 0); + } + + public static int createGlyphsVector(FlatBufferBuilder builder, int[] data) { + builder.startVector(4, data.length, 4); + for (int i = data.length - 1; i >= 0; i--) builder.addOffset(data[i]); + return builder.endVector(); + } + + public static void startGlyphsVector(FlatBufferBuilder builder, int numElems) { + builder.startVector(4, numElems, 4); + } + + public static void addArgb(FlatBufferBuilder builder, long argb) { + builder.addInt(2, (int) argb, (int) 4294967295L); + } + + public static void addShear(FlatBufferBuilder builder, boolean shear) { + builder.addBoolean(3, shear, false); + } + + public static void addBaseline(FlatBufferBuilder builder, float baseline) { + builder.addFloat(4, baseline, 0.0f); + } + + public static int endGlyphRun(FlatBufferBuilder builder) { + int o = builder.endTable(); + return o; + } + + public static final class Vector extends BaseVector { + + public Vector __assign(int _vector, int _element_size, ByteBuffer _bb) { + __reset(_vector, _element_size, _bb); + return this; + } + + public GlyphRun get(int j) { + return get(new GlyphRun(), j); + } + + public GlyphRun get(GlyphRun obj, int j) { + return obj.__assign(__indirect(__element(j), bb), bb); + } + } +} diff --git a/src/main/java/com/hfstudio/guidenh/guide/layout/flatbuffers/GuidebookSceneData.java b/src/main/java/com/hfstudio/guidenh/guide/layout/flatbuffers/GuidebookSceneData.java new file mode 100644 index 00000000..4699975a --- /dev/null +++ b/src/main/java/com/hfstudio/guidenh/guide/layout/flatbuffers/GuidebookSceneData.java @@ -0,0 +1,209 @@ +// automatically generated by the FlatBuffers compiler, do not modify + +package com.hfstudio.guidenh.guide.layout.flatbuffers; + +import java.nio.ByteBuffer; +import java.nio.ByteOrder; + +import com.google.flatbuffers.BaseVector; +import com.google.flatbuffers.Constants; +import com.google.flatbuffers.FlatBufferBuilder; +import com.google.flatbuffers.Table; + +/** + * Guidebook scene sizing data (node_type 27). + * Mirrors LytGuidebookScene.computeLayout term for term. + * Minecraft-dependent values (block stats geometry, button visibility, + * bottom control areas) are precomputed by Java; Rust applies the + * responsive scene sizing formula (scale_width, dock clamping, + * computeResponsiveSceneHeight) with available_width and visual_scale. + */ +@SuppressWarnings("unused") +public final class GuidebookSceneData extends Table { + + public static void ValidateVersion() { + Constants.FLATBUFFERS_23_5_26(); + } + + public static GuidebookSceneData getRootAsGuidebookSceneData(ByteBuffer _bb) { + return getRootAsGuidebookSceneData(_bb, new GuidebookSceneData()); + } + + public static GuidebookSceneData getRootAsGuidebookSceneData(ByteBuffer _bb, GuidebookSceneData obj) { + _bb.order(ByteOrder.LITTLE_ENDIAN); + return (obj.__assign(_bb.getInt(_bb.position()) + _bb.position(), _bb)); + } + + public void __init(int _i, ByteBuffer _bb) { + __reset(_i, _bb); + } + + public GuidebookSceneData __assign(int _i, ByteBuffer _bb) { + __init(_i, _bb); + return this; + } + + /** + * Intrinsic scene pixel width (setSceneSize or DEFAULT_WIDTH = 256). + */ + public float sceneWidth() { + int o = __offset(4); + return o != 0 ? bb.getFloat(o + bb_pos) : 0.0f; + } + + /** + * Intrinsic scene pixel height (setSceneSize or DEFAULT_HEIGHT = 192). + */ + public float sceneHeight() { + int o = __offset(6); + return o != 0 ? bb.getFloat(o + bb_pos) : 0.0f; + } + + /** + * Horizontal space reserved for the floating button column: + * interactive && sceneButtonsVisible ? (BTN_OUTSIDE_GAP + BTN_SIZE) : 0. + */ + public float buttonColumnReserve() { + int o = __offset(8); + return o != 0 ? bb.getFloat(o + bb_pos) : 0.0f; + } + + /** + * Total pixel height of scene buttons when stacked vertically: + * interactive && sceneButtonsVisible ? (BTN_SIZE * count + BTN_GAP * max(0, count-1)) : 0. + */ + public float buttonsTotalHeight() { + int o = __offset(10); + return o != 0 ? bb.getFloat(o + bb_pos) : 0.0f; + } + + /** + * Block-stats left dock size + BLOCK_STATS_DOCK_GAP, or 0. + */ + public float leftDock() { + int o = __offset(12); + return o != 0 ? bb.getFloat(o + bb_pos) : 0.0f; + } + + /** + * Block-stats right dock size + BLOCK_STATS_DOCK_GAP, or 0. + */ + public float rightDock() { + int o = __offset(14); + return o != 0 ? bb.getFloat(o + bb_pos) : 0.0f; + } + + /** + * Block-stats top dock size + BLOCK_STATS_DOCK_GAP, or 0. + */ + public float topDock() { + int o = __offset(16); + return o != 0 ? bb.getFloat(o + bb_pos) : 0.0f; + } + + /** + * Block-stats bottom dock size + BLOCK_STATS_DOCK_GAP, or 0. + */ + public float bottomDock() { + int o = __offset(18); + return o != 0 ? bb.getFloat(o + bb_pos) : 0.0f; + } + + /** + * Bottom control area height from getBottomControlAreaHeight(). + */ + public float bottomControlAreaHeight() { + int o = __offset(20); + return o != 0 ? bb.getFloat(o + bb_pos) : 0.0f; + } + + /** + * Whether the layout should reserve room for bottom controls. + */ + public boolean reserveBottomControl() { + int o = __offset(22); + return o != 0 ? 0 != bb.get(o + bb_pos) : false; + } + + public static int createGuidebookSceneData(FlatBufferBuilder builder, float sceneWidth, float sceneHeight, + float buttonColumnReserve, float buttonsTotalHeight, float leftDock, float rightDock, float topDock, + float bottomDock, float bottomControlAreaHeight, boolean reserveBottomControl) { + builder.startTable(10); + GuidebookSceneData.addBottomControlAreaHeight(builder, bottomControlAreaHeight); + GuidebookSceneData.addBottomDock(builder, bottomDock); + GuidebookSceneData.addTopDock(builder, topDock); + GuidebookSceneData.addRightDock(builder, rightDock); + GuidebookSceneData.addLeftDock(builder, leftDock); + GuidebookSceneData.addButtonsTotalHeight(builder, buttonsTotalHeight); + GuidebookSceneData.addButtonColumnReserve(builder, buttonColumnReserve); + GuidebookSceneData.addSceneHeight(builder, sceneHeight); + GuidebookSceneData.addSceneWidth(builder, sceneWidth); + GuidebookSceneData.addReserveBottomControl(builder, reserveBottomControl); + return GuidebookSceneData.endGuidebookSceneData(builder); + } + + public static void startGuidebookSceneData(FlatBufferBuilder builder) { + builder.startTable(10); + } + + public static void addSceneWidth(FlatBufferBuilder builder, float sceneWidth) { + builder.addFloat(0, sceneWidth, 0.0f); + } + + public static void addSceneHeight(FlatBufferBuilder builder, float sceneHeight) { + builder.addFloat(1, sceneHeight, 0.0f); + } + + public static void addButtonColumnReserve(FlatBufferBuilder builder, float buttonColumnReserve) { + builder.addFloat(2, buttonColumnReserve, 0.0f); + } + + public static void addButtonsTotalHeight(FlatBufferBuilder builder, float buttonsTotalHeight) { + builder.addFloat(3, buttonsTotalHeight, 0.0f); + } + + public static void addLeftDock(FlatBufferBuilder builder, float leftDock) { + builder.addFloat(4, leftDock, 0.0f); + } + + public static void addRightDock(FlatBufferBuilder builder, float rightDock) { + builder.addFloat(5, rightDock, 0.0f); + } + + public static void addTopDock(FlatBufferBuilder builder, float topDock) { + builder.addFloat(6, topDock, 0.0f); + } + + public static void addBottomDock(FlatBufferBuilder builder, float bottomDock) { + builder.addFloat(7, bottomDock, 0.0f); + } + + public static void addBottomControlAreaHeight(FlatBufferBuilder builder, float bottomControlAreaHeight) { + builder.addFloat(8, bottomControlAreaHeight, 0.0f); + } + + public static void addReserveBottomControl(FlatBufferBuilder builder, boolean reserveBottomControl) { + builder.addBoolean(9, reserveBottomControl, false); + } + + public static int endGuidebookSceneData(FlatBufferBuilder builder) { + int o = builder.endTable(); + return o; + } + + public static final class Vector extends BaseVector { + + public Vector __assign(int _vector, int _element_size, ByteBuffer _bb) { + __reset(_vector, _element_size, _bb); + return this; + } + + public GuidebookSceneData get(int j) { + return get(new GuidebookSceneData(), j); + } + + public GuidebookSceneData get(GuidebookSceneData obj, int j) { + return obj.__assign(__indirect(__element(j), bb), bb); + } + } +} diff --git a/src/main/java/com/hfstudio/guidenh/guide/layout/flatbuffers/ImageData.java b/src/main/java/com/hfstudio/guidenh/guide/layout/flatbuffers/ImageData.java new file mode 100644 index 00000000..287b54f8 --- /dev/null +++ b/src/main/java/com/hfstudio/guidenh/guide/layout/flatbuffers/ImageData.java @@ -0,0 +1,168 @@ +// automatically generated by the FlatBuffers compiler, do not modify + +package com.hfstudio.guidenh.guide.layout.flatbuffers; + +import java.nio.ByteBuffer; +import java.nio.ByteOrder; + +import com.google.flatbuffers.BaseVector; +import com.google.flatbuffers.Constants; +import com.google.flatbuffers.FlatBufferBuilder; +import com.google.flatbuffers.Table; + +@SuppressWarnings("unused") +public final class ImageData extends Table { + + public static void ValidateVersion() { + Constants.FLATBUFFERS_23_5_26(); + } + + public static ImageData getRootAsImageData(ByteBuffer _bb) { + return getRootAsImageData(_bb, new ImageData()); + } + + public static ImageData getRootAsImageData(ByteBuffer _bb, ImageData obj) { + _bb.order(ByteOrder.LITTLE_ENDIAN); + return (obj.__assign(_bb.getInt(_bb.position()) + _bb.position(), _bb)); + } + + public void __init(int _i, ByteBuffer _bb) { + __reset(_i, _bb); + } + + public ImageData __assign(int _i, ByteBuffer _bb) { + __init(_i, _bb); + return this; + } + + public float naturalW() { + int o = __offset(4); + return o != 0 ? bb.getFloat(o + bb_pos) : 0.0f; + } + + public float naturalH() { + int o = __offset(6); + return o != 0 ? bb.getFloat(o + bb_pos) : 0.0f; + } + + public int cropX() { + int o = __offset(8); + return o != 0 ? bb.getInt(o + bb_pos) : 0; + } + + public int cropY() { + int o = __offset(10); + return o != 0 ? bb.getInt(o + bb_pos) : 0; + } + + public int cropW() { + int o = __offset(12); + return o != 0 ? bb.getInt(o + bb_pos) : -1; + } + + public int cropH() { + int o = __offset(14); + return o != 0 ? bb.getInt(o + bb_pos) : -1; + } + + public float scaleX() { + int o = __offset(16); + return o != 0 ? bb.getFloat(o + bb_pos) : 1.0f; + } + + public float scaleY() { + int o = __offset(18); + return o != 0 ? bb.getFloat(o + bb_pos) : 1.0f; + } + + public float explicitW() { + int o = __offset(20); + return o != 0 ? bb.getFloat(o + bb_pos) : -1.0f; + } + + public float explicitH() { + int o = __offset(22); + return o != 0 ? bb.getFloat(o + bb_pos) : -1.0f; + } + + public static int createImageData(FlatBufferBuilder builder, float naturalW, float naturalH, int cropX, int cropY, + int cropW, int cropH, float scaleX, float scaleY, float explicitW, float explicitH) { + builder.startTable(10); + ImageData.addExplicitH(builder, explicitH); + ImageData.addExplicitW(builder, explicitW); + ImageData.addScaleY(builder, scaleY); + ImageData.addScaleX(builder, scaleX); + ImageData.addCropH(builder, cropH); + ImageData.addCropW(builder, cropW); + ImageData.addCropY(builder, cropY); + ImageData.addCropX(builder, cropX); + ImageData.addNaturalH(builder, naturalH); + ImageData.addNaturalW(builder, naturalW); + return ImageData.endImageData(builder); + } + + public static void startImageData(FlatBufferBuilder builder) { + builder.startTable(10); + } + + public static void addNaturalW(FlatBufferBuilder builder, float naturalW) { + builder.addFloat(0, naturalW, 0.0f); + } + + public static void addNaturalH(FlatBufferBuilder builder, float naturalH) { + builder.addFloat(1, naturalH, 0.0f); + } + + public static void addCropX(FlatBufferBuilder builder, int cropX) { + builder.addInt(2, cropX, 0); + } + + public static void addCropY(FlatBufferBuilder builder, int cropY) { + builder.addInt(3, cropY, 0); + } + + public static void addCropW(FlatBufferBuilder builder, int cropW) { + builder.addInt(4, cropW, -1); + } + + public static void addCropH(FlatBufferBuilder builder, int cropH) { + builder.addInt(5, cropH, -1); + } + + public static void addScaleX(FlatBufferBuilder builder, float scaleX) { + builder.addFloat(6, scaleX, 1.0f); + } + + public static void addScaleY(FlatBufferBuilder builder, float scaleY) { + builder.addFloat(7, scaleY, 1.0f); + } + + public static void addExplicitW(FlatBufferBuilder builder, float explicitW) { + builder.addFloat(8, explicitW, -1.0f); + } + + public static void addExplicitH(FlatBufferBuilder builder, float explicitH) { + builder.addFloat(9, explicitH, -1.0f); + } + + public static int endImageData(FlatBufferBuilder builder) { + int o = builder.endTable(); + return o; + } + + public static final class Vector extends BaseVector { + + public Vector __assign(int _vector, int _element_size, ByteBuffer _bb) { + __reset(_vector, _element_size, _bb); + return this; + } + + public ImageData get(int j) { + return get(new ImageData(), j); + } + + public ImageData get(ImageData obj, int j) { + return obj.__assign(__indirect(__element(j), bb), bb); + } + } +} diff --git a/src/main/java/com/hfstudio/guidenh/guide/layout/flatbuffers/InlineBlockRef.java b/src/main/java/com/hfstudio/guidenh/guide/layout/flatbuffers/InlineBlockRef.java new file mode 100644 index 00000000..0794d686 --- /dev/null +++ b/src/main/java/com/hfstudio/guidenh/guide/layout/flatbuffers/InlineBlockRef.java @@ -0,0 +1,146 @@ +// automatically generated by the FlatBuffers compiler, do not modify + +package com.hfstudio.guidenh.guide.layout.flatbuffers; + +import java.nio.ByteBuffer; +import java.nio.ByteOrder; + +import com.google.flatbuffers.BaseVector; +import com.google.flatbuffers.Constants; +import com.google.flatbuffers.FlatBufferBuilder; +import com.google.flatbuffers.Table; + +/** + * Inline block reference: pairs a U+FFFC placeholder (in document order) with + * the inner block's flat index and its vertical alignment request. + * align: 0 = block bottom sits 2px below the text baseline (default) + * 1 = baseline ascent: block top sits `param` px above the text baseline + * (LaTeX math-baseline alignment: param = formula ascent above its + * math baseline, so the formula's baseline lands on the text's) + * 2 = center on the text line, then shift down by `param` px (item icons) + * 3 = float left: block floats to paragraph left edge, text wraps right + * 4 = float right: block floats to paragraph right edge, text wraps left + */ +@SuppressWarnings("unused") +public final class InlineBlockRef extends Table { + + public static void ValidateVersion() { + Constants.FLATBUFFERS_23_5_26(); + } + + public static InlineBlockRef getRootAsInlineBlockRef(ByteBuffer _bb) { + return getRootAsInlineBlockRef(_bb, new InlineBlockRef()); + } + + public static InlineBlockRef getRootAsInlineBlockRef(ByteBuffer _bb, InlineBlockRef obj) { + _bb.order(ByteOrder.LITTLE_ENDIAN); + return (obj.__assign(_bb.getInt(_bb.position()) + _bb.position(), _bb)); + } + + public void __init(int _i, ByteBuffer _bb) { + __reset(_i, _bb); + } + + public InlineBlockRef __assign(int _i, ByteBuffer _bb) { + __init(_i, _bb); + return this; + } + + public long node() { + int o = __offset(4); + return o != 0 ? (long) bb.getInt(o + bb_pos) & 0xFFFFFFFFL : 0L; + } + + public byte align() { + int o = __offset(6); + return o != 0 ? bb.get(o + bb_pos) : 0; + } + + public float param() { + int o = __offset(8); + return o != 0 ? bb.getFloat(o + bb_pos) : 0.0f; + } + + /** + * Inline atom metrics: the ink box of an inline atom (icon or formula), declared by Java + * and read by Rust. Left, right and bottom edges in the 16-unit icon space + * (inkLeft/inkRight/inkBottom). The top edge does not take part in alignment and is + * derived by the consumer from the baseline anchor. Default 0 means not declared, which + * leaves the behavior for older data unchanged. + */ + public float inkLeft() { + int o = __offset(10); + return o != 0 ? bb.getFloat(o + bb_pos) : 0.0f; + } + + public float inkRight() { + int o = __offset(12); + return o != 0 ? bb.getFloat(o + bb_pos) : 0.0f; + } + + public float inkBottom() { + int o = __offset(14); + return o != 0 ? bb.getFloat(o + bb_pos) : 0.0f; + } + + public static int createInlineBlockRef(FlatBufferBuilder builder, long node, byte align, float param, float inkLeft, + float inkRight, float inkBottom) { + builder.startTable(6); + InlineBlockRef.addInkBottom(builder, inkBottom); + InlineBlockRef.addInkRight(builder, inkRight); + InlineBlockRef.addInkLeft(builder, inkLeft); + InlineBlockRef.addParam(builder, param); + InlineBlockRef.addNode(builder, node); + InlineBlockRef.addAlign(builder, align); + return InlineBlockRef.endInlineBlockRef(builder); + } + + public static void startInlineBlockRef(FlatBufferBuilder builder) { + builder.startTable(6); + } + + public static void addNode(FlatBufferBuilder builder, long node) { + builder.addInt(0, (int) node, (int) 0L); + } + + public static void addAlign(FlatBufferBuilder builder, byte align) { + builder.addByte(1, align, 0); + } + + public static void addParam(FlatBufferBuilder builder, float param) { + builder.addFloat(2, param, 0.0f); + } + + public static void addInkLeft(FlatBufferBuilder builder, float inkLeft) { + builder.addFloat(3, inkLeft, 0.0f); + } + + public static void addInkRight(FlatBufferBuilder builder, float inkRight) { + builder.addFloat(4, inkRight, 0.0f); + } + + public static void addInkBottom(FlatBufferBuilder builder, float inkBottom) { + builder.addFloat(5, inkBottom, 0.0f); + } + + public static int endInlineBlockRef(FlatBufferBuilder builder) { + int o = builder.endTable(); + return o; + } + + public static final class Vector extends BaseVector { + + public Vector __assign(int _vector, int _element_size, ByteBuffer _bb) { + __reset(_vector, _element_size, _bb); + return this; + } + + public InlineBlockRef get(int j) { + return get(new InlineBlockRef(), j); + } + + public InlineBlockRef get(InlineBlockRef obj, int j) { + return obj.__assign(__indirect(__element(j), bb), bb); + } + } +} diff --git a/src/main/java/com/hfstudio/guidenh/guide/layout/flatbuffers/LatexDisplayData.java b/src/main/java/com/hfstudio/guidenh/guide/layout/flatbuffers/LatexDisplayData.java new file mode 100644 index 00000000..c1e3d299 --- /dev/null +++ b/src/main/java/com/hfstudio/guidenh/guide/layout/flatbuffers/LatexDisplayData.java @@ -0,0 +1,176 @@ +// automatically generated by the FlatBuffers compiler, do not modify + +package com.hfstudio.guidenh.guide.layout.flatbuffers; + +import java.nio.ByteBuffer; +import java.nio.ByteOrder; + +import com.google.flatbuffers.BaseVector; +import com.google.flatbuffers.Constants; +import com.google.flatbuffers.FlatBufferBuilder; +import com.google.flatbuffers.Table; + +@SuppressWarnings("unused") +public final class LatexDisplayData extends Table { + + public static void ValidateVersion() { + Constants.FLATBUFFERS_23_5_26(); + } + + public static LatexDisplayData getRootAsLatexDisplayData(ByteBuffer _bb) { + return getRootAsLatexDisplayData(_bb, new LatexDisplayData()); + } + + public static LatexDisplayData getRootAsLatexDisplayData(ByteBuffer _bb, LatexDisplayData obj) { + _bb.order(ByteOrder.LITTLE_ENDIAN); + return (obj.__assign(_bb.getInt(_bb.position()) + _bb.position(), _bb)); + } + + public void __init(int _i, ByteBuffer _bb) { + __reset(_i, _bb); + } + + public LatexDisplayData __assign(int _i, ByteBuffer _bb) { + __init(_i, _bb); + return this; + } + + public String formula() { + int o = __offset(4); + return o != 0 ? __string(o + bb_pos) : null; + } + + public ByteBuffer formulaAsByteBuffer() { + return __vector_as_bytebuffer(4, 1); + } + + public ByteBuffer formulaInByteBuffer(ByteBuffer _bb) { + return __vector_in_bytebuffer(_bb, 4, 1); + } + + public long fillColorArgb() { + int o = __offset(6); + return o != 0 ? (long) bb.getInt(o + bb_pos) & 0xFFFFFFFFL : 4294967295L; + } + + /** + * DEPRECATED: legacy jlatexmath render-size passthrough. Java no longer writes it + * (the slot stays absent = default 100.0, no wire bytes) and Rust never reads it. + * Kept for wire compatibility with old serialized data. + */ + public float sourceScale() { + int o = __offset(8); + return o != 0 ? bb.getFloat(o + bb_pos) : 100.0f; + } + + public float userScale() { + int o = __offset(10); + return o != 0 ? bb.getFloat(o + bb_pos) : 1.0f; + } + + public int offsetX() { + int o = __offset(12); + return o != 0 ? bb.getInt(o + bb_pos) : 0; + } + + public int offsetY() { + int o = __offset(14); + return o != 0 ? bb.getInt(o + bb_pos) : 0; + } + + public float rawW() { + int o = __offset(16); + return o != 0 ? bb.getFloat(o + bb_pos) : 0.0f; + } + + public float rawH() { + int o = __offset(18); + return o != 0 ? bb.getFloat(o + bb_pos) : 0.0f; + } + + /** + * DEPRECATED: legacy reference-height passthrough. Java no longer writes it + * (the slot stays absent = default 0.0, no wire bytes) and Rust never reads it. + * Kept for wire compatibility with old serialized data. + */ + public float refH() { + int o = __offset(20); + return o != 0 ? bb.getFloat(o + bb_pos) : 0.0f; + } + + public static int createLatexDisplayData(FlatBufferBuilder builder, int formulaOffset, long fillColorArgb, + float sourceScale, float userScale, int offsetX, int offsetY, float rawW, float rawH, float refH) { + builder.startTable(9); + LatexDisplayData.addRefH(builder, refH); + LatexDisplayData.addRawH(builder, rawH); + LatexDisplayData.addRawW(builder, rawW); + LatexDisplayData.addOffsetY(builder, offsetY); + LatexDisplayData.addOffsetX(builder, offsetX); + LatexDisplayData.addUserScale(builder, userScale); + LatexDisplayData.addSourceScale(builder, sourceScale); + LatexDisplayData.addFillColorArgb(builder, fillColorArgb); + LatexDisplayData.addFormula(builder, formulaOffset); + return LatexDisplayData.endLatexDisplayData(builder); + } + + public static void startLatexDisplayData(FlatBufferBuilder builder) { + builder.startTable(9); + } + + public static void addFormula(FlatBufferBuilder builder, int formulaOffset) { + builder.addOffset(0, formulaOffset, 0); + } + + public static void addFillColorArgb(FlatBufferBuilder builder, long fillColorArgb) { + builder.addInt(1, (int) fillColorArgb, (int) 4294967295L); + } + + public static void addSourceScale(FlatBufferBuilder builder, float sourceScale) { + builder.addFloat(2, sourceScale, 100.0f); + } + + public static void addUserScale(FlatBufferBuilder builder, float userScale) { + builder.addFloat(3, userScale, 1.0f); + } + + public static void addOffsetX(FlatBufferBuilder builder, int offsetX) { + builder.addInt(4, offsetX, 0); + } + + public static void addOffsetY(FlatBufferBuilder builder, int offsetY) { + builder.addInt(5, offsetY, 0); + } + + public static void addRawW(FlatBufferBuilder builder, float rawW) { + builder.addFloat(6, rawW, 0.0f); + } + + public static void addRawH(FlatBufferBuilder builder, float rawH) { + builder.addFloat(7, rawH, 0.0f); + } + + public static void addRefH(FlatBufferBuilder builder, float refH) { + builder.addFloat(8, refH, 0.0f); + } + + public static int endLatexDisplayData(FlatBufferBuilder builder) { + int o = builder.endTable(); + return o; + } + + public static final class Vector extends BaseVector { + + public Vector __assign(int _vector, int _element_size, ByteBuffer _bb) { + __reset(_vector, _element_size, _bb); + return this; + } + + public LatexDisplayData get(int j) { + return get(new LatexDisplayData(), j); + } + + public LatexDisplayData get(LatexDisplayData obj, int j) { + return obj.__assign(__indirect(__element(j), bb), bb); + } + } +} diff --git a/src/main/java/com/hfstudio/guidenh/guide/layout/flatbuffers/LayoutInput.java b/src/main/java/com/hfstudio/guidenh/guide/layout/flatbuffers/LayoutInput.java new file mode 100644 index 00000000..3f4e6c47 --- /dev/null +++ b/src/main/java/com/hfstudio/guidenh/guide/layout/flatbuffers/LayoutInput.java @@ -0,0 +1,176 @@ +// automatically generated by the FlatBuffers compiler, do not modify + +package com.hfstudio.guidenh.guide.layout.flatbuffers; + +import java.nio.ByteBuffer; +import java.nio.ByteOrder; + +import com.google.flatbuffers.BaseVector; +import com.google.flatbuffers.Constants; +import com.google.flatbuffers.FlatBufferBuilder; +import com.google.flatbuffers.Table; + +@SuppressWarnings("unused") +public final class LayoutInput extends Table { + + public static void ValidateVersion() { + Constants.FLATBUFFERS_23_5_26(); + } + + public static LayoutInput getRootAsLayoutInput(ByteBuffer _bb) { + return getRootAsLayoutInput(_bb, new LayoutInput()); + } + + public static LayoutInput getRootAsLayoutInput(ByteBuffer _bb, LayoutInput obj) { + _bb.order(ByteOrder.LITTLE_ENDIAN); + return (obj.__assign(_bb.getInt(_bb.position()) + _bb.position(), _bb)); + } + + public void __init(int _i, ByteBuffer _bb) { + __reset(_i, _bb); + } + + public LayoutInput __assign(int _i, ByteBuffer _bb) { + __init(_i, _bb); + return this; + } + + public float availableWidth() { + int o = __offset(4); + return o != 0 ? bb.getFloat(o + bb_pos) : 0.0f; + } + + public float visualScale() { + int o = __offset(6); + return o != 0 ? bb.getFloat(o + bb_pos) : 1.0f; + } + + public float renderScale() { + int o = __offset(8); + return o != 0 ? bb.getFloat(o + bb_pos) : 1.0f; + } + + public byte justify() { + int o = __offset(10); + return o != 0 ? bb.get(o + bb_pos) : 1; + } + + public com.hfstudio.guidenh.guide.layout.flatbuffers.FlatNode nodes(int j) { + return nodes(new com.hfstudio.guidenh.guide.layout.flatbuffers.FlatNode(), j); + } + + public com.hfstudio.guidenh.guide.layout.flatbuffers.FlatNode nodes( + com.hfstudio.guidenh.guide.layout.flatbuffers.FlatNode obj, int j) { + int o = __offset(12); + return o != 0 ? obj.__assign(__indirect(__vector(o) + j * 4), bb) : null; + } + + public int nodesLength() { + int o = __offset(12); + return o != 0 ? __vector_len(o) : 0; + } + + public com.hfstudio.guidenh.guide.layout.flatbuffers.FlatNode.Vector nodesVector() { + return nodesVector(new com.hfstudio.guidenh.guide.layout.flatbuffers.FlatNode.Vector()); + } + + public com.hfstudio.guidenh.guide.layout.flatbuffers.FlatNode.Vector nodesVector( + com.hfstudio.guidenh.guide.layout.flatbuffers.FlatNode.Vector obj) { + int o = __offset(12); + return o != 0 ? obj.__assign(__vector(o), 4, bb) : null; + } + + /** + * DEPRECATED (GLYPH_SHEAR_K is now render-only): Java stopped declaring it + * (LayoutTreeSerializer no longer writes it, so the slot stays absent = default 0.25, no + * wire bytes) and Rust no longer reads it from the wire; the engine owns the constant + * SHEAR_K = 0.25 in layout.rs. + * Historical meaning: synthetic shear factor K, which the italic-to-upright boundary + * correction used to compute the top shear offset of the last glyph as + * K * (shearBase - glyphTop). + * Kept for wire compatibility with old serialized data. + */ + public float shearK() { + int o = __offset(14); + return o != 0 ? bb.getFloat(o + bb_pos) : 0.25f; + } + + public static int createLayoutInput(FlatBufferBuilder builder, float availableWidth, float visualScale, + float renderScale, byte justify, int nodesOffset, float shearK) { + builder.startTable(6); + LayoutInput.addShearK(builder, shearK); + LayoutInput.addNodes(builder, nodesOffset); + LayoutInput.addRenderScale(builder, renderScale); + LayoutInput.addVisualScale(builder, visualScale); + LayoutInput.addAvailableWidth(builder, availableWidth); + LayoutInput.addJustify(builder, justify); + return LayoutInput.endLayoutInput(builder); + } + + public static void startLayoutInput(FlatBufferBuilder builder) { + builder.startTable(6); + } + + public static void addAvailableWidth(FlatBufferBuilder builder, float availableWidth) { + builder.addFloat(0, availableWidth, 0.0f); + } + + public static void addVisualScale(FlatBufferBuilder builder, float visualScale) { + builder.addFloat(1, visualScale, 1.0f); + } + + public static void addRenderScale(FlatBufferBuilder builder, float renderScale) { + builder.addFloat(2, renderScale, 1.0f); + } + + public static void addJustify(FlatBufferBuilder builder, byte justify) { + builder.addByte(3, justify, 1); + } + + public static void addNodes(FlatBufferBuilder builder, int nodesOffset) { + builder.addOffset(4, nodesOffset, 0); + } + + public static int createNodesVector(FlatBufferBuilder builder, int[] data) { + builder.startVector(4, data.length, 4); + for (int i = data.length - 1; i >= 0; i--) builder.addOffset(data[i]); + return builder.endVector(); + } + + public static void startNodesVector(FlatBufferBuilder builder, int numElems) { + builder.startVector(4, numElems, 4); + } + + public static void addShearK(FlatBufferBuilder builder, float shearK) { + builder.addFloat(5, shearK, 0.25f); + } + + public static int endLayoutInput(FlatBufferBuilder builder) { + int o = builder.endTable(); + return o; + } + + public static void finishLayoutInputBuffer(FlatBufferBuilder builder, int offset) { + builder.finish(offset); + } + + public static void finishSizePrefixedLayoutInputBuffer(FlatBufferBuilder builder, int offset) { + builder.finishSizePrefixed(offset); + } + + public static final class Vector extends BaseVector { + + public Vector __assign(int _vector, int _element_size, ByteBuffer _bb) { + __reset(_vector, _element_size, _bb); + return this; + } + + public LayoutInput get(int j) { + return get(new LayoutInput(), j); + } + + public LayoutInput get(LayoutInput obj, int j) { + return obj.__assign(__indirect(__element(j), bb), bb); + } + } +} diff --git a/src/main/java/com/hfstudio/guidenh/guide/layout/flatbuffers/LayoutResult.java b/src/main/java/com/hfstudio/guidenh/guide/layout/flatbuffers/LayoutResult.java new file mode 100644 index 00000000..ad5dbf40 --- /dev/null +++ b/src/main/java/com/hfstudio/guidenh/guide/layout/flatbuffers/LayoutResult.java @@ -0,0 +1,256 @@ +// automatically generated by the FlatBuffers compiler, do not modify + +package com.hfstudio.guidenh.guide.layout.flatbuffers; + +import java.nio.ByteBuffer; +import java.nio.ByteOrder; + +import com.google.flatbuffers.BaseVector; +import com.google.flatbuffers.Constants; +import com.google.flatbuffers.FlatBufferBuilder; +import com.google.flatbuffers.Table; + +@SuppressWarnings("unused") +public final class LayoutResult extends Table { + + public static void ValidateVersion() { + Constants.FLATBUFFERS_23_5_26(); + } + + public static LayoutResult getRootAsLayoutResult(ByteBuffer _bb) { + return getRootAsLayoutResult(_bb, new LayoutResult()); + } + + public static LayoutResult getRootAsLayoutResult(ByteBuffer _bb, LayoutResult obj) { + _bb.order(ByteOrder.LITTLE_ENDIAN); + return (obj.__assign(_bb.getInt(_bb.position()) + _bb.position(), _bb)); + } + + public void __init(int _i, ByteBuffer _bb) { + __reset(_i, _bb); + } + + public LayoutResult __assign(int _i, ByteBuffer _bb) { + __init(_i, _bb); + return this; + } + + public com.hfstudio.guidenh.guide.layout.flatbuffers.FlatLayout nodes(int j) { + return nodes(new com.hfstudio.guidenh.guide.layout.flatbuffers.FlatLayout(), j); + } + + public com.hfstudio.guidenh.guide.layout.flatbuffers.FlatLayout nodes( + com.hfstudio.guidenh.guide.layout.flatbuffers.FlatLayout obj, int j) { + int o = __offset(4); + return o != 0 ? obj.__assign(__indirect(__vector(o) + j * 4), bb) : null; + } + + public int nodesLength() { + int o = __offset(4); + return o != 0 ? __vector_len(o) : 0; + } + + public com.hfstudio.guidenh.guide.layout.flatbuffers.FlatLayout.Vector nodesVector() { + return nodesVector(new com.hfstudio.guidenh.guide.layout.flatbuffers.FlatLayout.Vector()); + } + + public com.hfstudio.guidenh.guide.layout.flatbuffers.FlatLayout.Vector nodesVector( + com.hfstudio.guidenh.guide.layout.flatbuffers.FlatLayout.Vector obj) { + int o = __offset(4); + return o != 0 ? obj.__assign(__vector(o), 4, bb) : null; + } + + public com.hfstudio.guidenh.guide.layout.flatbuffers.GlyphRun glyphRuns(int j) { + return glyphRuns(new com.hfstudio.guidenh.guide.layout.flatbuffers.GlyphRun(), j); + } + + public com.hfstudio.guidenh.guide.layout.flatbuffers.GlyphRun glyphRuns( + com.hfstudio.guidenh.guide.layout.flatbuffers.GlyphRun obj, int j) { + int o = __offset(6); + return o != 0 ? obj.__assign(__indirect(__vector(o) + j * 4), bb) : null; + } + + public int glyphRunsLength() { + int o = __offset(6); + return o != 0 ? __vector_len(o) : 0; + } + + public com.hfstudio.guidenh.guide.layout.flatbuffers.GlyphRun.Vector glyphRunsVector() { + return glyphRunsVector(new com.hfstudio.guidenh.guide.layout.flatbuffers.GlyphRun.Vector()); + } + + public com.hfstudio.guidenh.guide.layout.flatbuffers.GlyphRun.Vector glyphRunsVector( + com.hfstudio.guidenh.guide.layout.flatbuffers.GlyphRun.Vector obj) { + int o = __offset(6); + return o != 0 ? obj.__assign(__vector(o), 4, bb) : null; + } + + public com.hfstudio.guidenh.guide.layout.flatbuffers.GlyphBitmap bitmaps(int j) { + return bitmaps(new com.hfstudio.guidenh.guide.layout.flatbuffers.GlyphBitmap(), j); + } + + public com.hfstudio.guidenh.guide.layout.flatbuffers.GlyphBitmap bitmaps( + com.hfstudio.guidenh.guide.layout.flatbuffers.GlyphBitmap obj, int j) { + int o = __offset(8); + return o != 0 ? obj.__assign(__indirect(__vector(o) + j * 4), bb) : null; + } + + public int bitmapsLength() { + int o = __offset(8); + return o != 0 ? __vector_len(o) : 0; + } + + public com.hfstudio.guidenh.guide.layout.flatbuffers.GlyphBitmap.Vector bitmapsVector() { + return bitmapsVector(new com.hfstudio.guidenh.guide.layout.flatbuffers.GlyphBitmap.Vector()); + } + + public com.hfstudio.guidenh.guide.layout.flatbuffers.GlyphBitmap.Vector bitmapsVector( + com.hfstudio.guidenh.guide.layout.flatbuffers.GlyphBitmap.Vector obj) { + int o = __offset(8); + return o != 0 ? obj.__assign(__vector(o), 4, bb) : null; + } + + public com.hfstudio.guidenh.guide.layout.flatbuffers.DecorationRect decorations(int j) { + return decorations(new com.hfstudio.guidenh.guide.layout.flatbuffers.DecorationRect(), j); + } + + public com.hfstudio.guidenh.guide.layout.flatbuffers.DecorationRect decorations( + com.hfstudio.guidenh.guide.layout.flatbuffers.DecorationRect obj, int j) { + int o = __offset(10); + return o != 0 ? obj.__assign(__indirect(__vector(o) + j * 4), bb) : null; + } + + public int decorationsLength() { + int o = __offset(10); + return o != 0 ? __vector_len(o) : 0; + } + + public com.hfstudio.guidenh.guide.layout.flatbuffers.DecorationRect.Vector decorationsVector() { + return decorationsVector(new com.hfstudio.guidenh.guide.layout.flatbuffers.DecorationRect.Vector()); + } + + public com.hfstudio.guidenh.guide.layout.flatbuffers.DecorationRect.Vector decorationsVector( + com.hfstudio.guidenh.guide.layout.flatbuffers.DecorationRect.Vector obj) { + int o = __offset(10); + return o != 0 ? obj.__assign(__vector(o), 4, bb) : null; + } + + public float contentHeight() { + int o = __offset(12); + return o != 0 ? bb.getFloat(o + bb_pos) : 0.0f; + } + + public String debugInfo() { + int o = __offset(14); + return o != 0 ? __string(o + bb_pos) : null; + } + + public ByteBuffer debugInfoAsByteBuffer() { + return __vector_as_bytebuffer(14, 1); + } + + public ByteBuffer debugInfoInByteBuffer(ByteBuffer _bb) { + return __vector_in_bytebuffer(_bb, 14, 1); + } + + public static int createLayoutResult(FlatBufferBuilder builder, int nodesOffset, int glyphRunsOffset, + int bitmapsOffset, int decorationsOffset, float contentHeight, int debugInfoOffset) { + builder.startTable(6); + LayoutResult.addDebugInfo(builder, debugInfoOffset); + LayoutResult.addContentHeight(builder, contentHeight); + LayoutResult.addDecorations(builder, decorationsOffset); + LayoutResult.addBitmaps(builder, bitmapsOffset); + LayoutResult.addGlyphRuns(builder, glyphRunsOffset); + LayoutResult.addNodes(builder, nodesOffset); + return LayoutResult.endLayoutResult(builder); + } + + public static void startLayoutResult(FlatBufferBuilder builder) { + builder.startTable(6); + } + + public static void addNodes(FlatBufferBuilder builder, int nodesOffset) { + builder.addOffset(0, nodesOffset, 0); + } + + public static int createNodesVector(FlatBufferBuilder builder, int[] data) { + builder.startVector(4, data.length, 4); + for (int i = data.length - 1; i >= 0; i--) builder.addOffset(data[i]); + return builder.endVector(); + } + + public static void startNodesVector(FlatBufferBuilder builder, int numElems) { + builder.startVector(4, numElems, 4); + } + + public static void addGlyphRuns(FlatBufferBuilder builder, int glyphRunsOffset) { + builder.addOffset(1, glyphRunsOffset, 0); + } + + public static int createGlyphRunsVector(FlatBufferBuilder builder, int[] data) { + builder.startVector(4, data.length, 4); + for (int i = data.length - 1; i >= 0; i--) builder.addOffset(data[i]); + return builder.endVector(); + } + + public static void startGlyphRunsVector(FlatBufferBuilder builder, int numElems) { + builder.startVector(4, numElems, 4); + } + + public static void addBitmaps(FlatBufferBuilder builder, int bitmapsOffset) { + builder.addOffset(2, bitmapsOffset, 0); + } + + public static int createBitmapsVector(FlatBufferBuilder builder, int[] data) { + builder.startVector(4, data.length, 4); + for (int i = data.length - 1; i >= 0; i--) builder.addOffset(data[i]); + return builder.endVector(); + } + + public static void startBitmapsVector(FlatBufferBuilder builder, int numElems) { + builder.startVector(4, numElems, 4); + } + + public static void addDecorations(FlatBufferBuilder builder, int decorationsOffset) { + builder.addOffset(3, decorationsOffset, 0); + } + + public static int createDecorationsVector(FlatBufferBuilder builder, int[] data) { + builder.startVector(4, data.length, 4); + for (int i = data.length - 1; i >= 0; i--) builder.addOffset(data[i]); + return builder.endVector(); + } + + public static void startDecorationsVector(FlatBufferBuilder builder, int numElems) { + builder.startVector(4, numElems, 4); + } + + public static void addContentHeight(FlatBufferBuilder builder, float contentHeight) { + builder.addFloat(4, contentHeight, 0.0f); + } + + public static void addDebugInfo(FlatBufferBuilder builder, int debugInfoOffset) { + builder.addOffset(5, debugInfoOffset, 0); + } + + public static int endLayoutResult(FlatBufferBuilder builder) { + int o = builder.endTable(); + return o; + } + + public static final class Vector extends BaseVector { + + public Vector __assign(int _vector, int _element_size, ByteBuffer _bb) { + __reset(_vector, _element_size, _bb); + return this; + } + + public LayoutResult get(int j) { + return get(new LayoutResult(), j); + } + + public LayoutResult get(LayoutResult obj, int j) { + return obj.__assign(__indirect(__element(j), bb), bb); + } + } +} diff --git a/src/main/java/com/hfstudio/guidenh/guide/layout/flatbuffers/ListMarkerData.java b/src/main/java/com/hfstudio/guidenh/guide/layout/flatbuffers/ListMarkerData.java new file mode 100644 index 00000000..fa835960 --- /dev/null +++ b/src/main/java/com/hfstudio/guidenh/guide/layout/flatbuffers/ListMarkerData.java @@ -0,0 +1,128 @@ +// automatically generated by the FlatBuffers compiler, do not modify + +package com.hfstudio.guidenh.guide.layout.flatbuffers; + +import java.nio.ByteBuffer; +import java.nio.ByteOrder; + +import com.google.flatbuffers.BaseVector; +import com.google.flatbuffers.Constants; +import com.google.flatbuffers.FlatBufferBuilder; +import com.google.flatbuffers.Table; + +/** + * Shared gutter marker declaration for a list item. + * Java writes this field only for a LytListItem that uses a shared marker (ordered "N." + * with start semantics, unordered U+2022); subclasses with a custom gutter marker (task + * checkbox and similar) leave it null, which means no marker declaration and no change in + * behavior. + * + * Rust consumption (Rust owns all geometry): + * * shape `text` through the same shaping pipeline as the body text (parley + * layout_styled, no line breaking); + * * vertical baseline = the baseline of the content's first line (the first line of the + * first content child that has glyph content; see list_marker_pass in layout.rs for the + * fallback); + * * horizontal right alignment = the marker's ink right edge lands on the list item's own + * left edge, which is the document text line of that nesting level (L1=14, L2=24, + * L3=34); the whole box hangs into the left margin, so no gutter constant is needed; + * * the marker glyphs join the page-wide output stream as ordinary glyph quads (no new + * primitive type), with the run color taken from `style.color` (Java declares the gray + * COL_MARKER). + * The marker never takes part in line breaking and does not affect the geometry of any + * content child: content bounds stay byte-for-byte identical. + */ +@SuppressWarnings("unused") +public final class ListMarkerData extends Table { + + public static void ValidateVersion() { + Constants.FLATBUFFERS_23_5_26(); + } + + public static ListMarkerData getRootAsListMarkerData(ByteBuffer _bb) { + return getRootAsListMarkerData(_bb, new ListMarkerData()); + } + + public static ListMarkerData getRootAsListMarkerData(ByteBuffer _bb, ListMarkerData obj) { + _bb.order(ByteOrder.LITTLE_ENDIAN); + return (obj.__assign(_bb.getInt(_bb.position()) + _bb.position(), _bb)); + } + + public void __init(int _i, ByteBuffer _bb) { + __reset(_i, _bb); + } + + public ListMarkerData __assign(int _i, ByteBuffer _bb) { + __init(_i, _bb); + return this; + } + + /** + * Marker text: ordered = "N." (N carries the start value), unordered = U+2022. + */ + public String text() { + int o = __offset(4); + return o != 0 ? __string(o + bb_pos) : null; + } + + public ByteBuffer textAsByteBuffer() { + return __vector_as_bytebuffer(4, 1); + } + + public ByteBuffer textInByteBuffer(ByteBuffer _bb) { + return __vector_in_bytebuffer(_bb, 4, 1); + } + + /** + * Marker style: body font style (BASE_FONT_SIZE 11 / regular), color COL_MARKER gray. + */ + public com.hfstudio.guidenh.guide.layout.flatbuffers.TextStyle style() { + return style(new com.hfstudio.guidenh.guide.layout.flatbuffers.TextStyle()); + } + + public com.hfstudio.guidenh.guide.layout.flatbuffers.TextStyle style( + com.hfstudio.guidenh.guide.layout.flatbuffers.TextStyle obj) { + int o = __offset(6); + return o != 0 ? obj.__assign(__indirect(o + bb_pos), bb) : null; + } + + public static int createListMarkerData(FlatBufferBuilder builder, int textOffset, int styleOffset) { + builder.startTable(2); + ListMarkerData.addStyle(builder, styleOffset); + ListMarkerData.addText(builder, textOffset); + return ListMarkerData.endListMarkerData(builder); + } + + public static void startListMarkerData(FlatBufferBuilder builder) { + builder.startTable(2); + } + + public static void addText(FlatBufferBuilder builder, int textOffset) { + builder.addOffset(0, textOffset, 0); + } + + public static void addStyle(FlatBufferBuilder builder, int styleOffset) { + builder.addOffset(1, styleOffset, 0); + } + + public static int endListMarkerData(FlatBufferBuilder builder) { + int o = builder.endTable(); + return o; + } + + public static final class Vector extends BaseVector { + + public Vector __assign(int _vector, int _element_size, ByteBuffer _bb) { + __reset(_vector, _element_size, _bb); + return this; + } + + public ListMarkerData get(int j) { + return get(new ListMarkerData(), j); + } + + public ListMarkerData get(ListMarkerData obj, int j) { + return obj.__assign(__indirect(__element(j), bb), bb); + } + } +} diff --git a/src/main/java/com/hfstudio/guidenh/guide/layout/flatbuffers/MediaWikiGeneratedListData.java b/src/main/java/com/hfstudio/guidenh/guide/layout/flatbuffers/MediaWikiGeneratedListData.java new file mode 100644 index 00000000..a8a5b46b --- /dev/null +++ b/src/main/java/com/hfstudio/guidenh/guide/layout/flatbuffers/MediaWikiGeneratedListData.java @@ -0,0 +1,94 @@ +// automatically generated by the FlatBuffers compiler, do not modify + +package com.hfstudio.guidenh.guide.layout.flatbuffers; + +import java.nio.ByteBuffer; +import java.nio.ByteOrder; + +import com.google.flatbuffers.BaseVector; +import com.google.flatbuffers.Constants; +import com.google.flatbuffers.FlatBufferBuilder; +import com.google.flatbuffers.Table; + +/** + * MediaWiki generated list sizing data (node_type 29). + * Mirrors MediaWikiGeneratedListBlock.computeLayout term for term. + * The column-planning algorithm depends on Java object data (entry sort keys, + * titles, section grouping); Rust cannot replicate it. Java precomputes the + * max column content height (the tallest column's content, excluding + * TOP_PADDING/BOTTOM_PADDING) via a getter set during computeLayout. + * Rust adds the padding constants to produce the total block height. + * Width is always availableWidth: the block fills the parent's content box. + */ +@SuppressWarnings("unused") +public final class MediaWikiGeneratedListData extends Table { + + public static void ValidateVersion() { + Constants.FLATBUFFERS_23_5_26(); + } + + public static MediaWikiGeneratedListData getRootAsMediaWikiGeneratedListData(ByteBuffer _bb) { + return getRootAsMediaWikiGeneratedListData(_bb, new MediaWikiGeneratedListData()); + } + + public static MediaWikiGeneratedListData getRootAsMediaWikiGeneratedListData(ByteBuffer _bb, + MediaWikiGeneratedListData obj) { + _bb.order(ByteOrder.LITTLE_ENDIAN); + return (obj.__assign(_bb.getInt(_bb.position()) + _bb.position(), _bb)); + } + + public void __init(int _i, ByteBuffer _bb) { + __reset(_i, _bb); + } + + public MediaWikiGeneratedListData __assign(int _i, ByteBuffer _bb) { + __init(_i, _bb); + return this; + } + + /** + * Content height of the tallest column (not including TOP_PADDING=6 and + * BOTTOM_PADDING=6). When entries are empty, this is ROW_HEIGHT=20. + * Mirrors maxColumnHeight or ROW_HEIGHT in computeLayout, which is the + * max over columns of (columnY - y - TOP_PADDING). + */ + public float maxContentHeight() { + int o = __offset(4); + return o != 0 ? bb.getFloat(o + bb_pos) : 0.0f; + } + + public static int createMediaWikiGeneratedListData(FlatBufferBuilder builder, float maxContentHeight) { + builder.startTable(1); + MediaWikiGeneratedListData.addMaxContentHeight(builder, maxContentHeight); + return MediaWikiGeneratedListData.endMediaWikiGeneratedListData(builder); + } + + public static void startMediaWikiGeneratedListData(FlatBufferBuilder builder) { + builder.startTable(1); + } + + public static void addMaxContentHeight(FlatBufferBuilder builder, float maxContentHeight) { + builder.addFloat(0, maxContentHeight, 0.0f); + } + + public static int endMediaWikiGeneratedListData(FlatBufferBuilder builder) { + int o = builder.endTable(); + return o; + } + + public static final class Vector extends BaseVector { + + public Vector __assign(int _vector, int _element_size, ByteBuffer _bb) { + __reset(_vector, _element_size, _bb); + return this; + } + + public MediaWikiGeneratedListData get(int j) { + return get(new MediaWikiGeneratedListData(), j); + } + + public MediaWikiGeneratedListData get(MediaWikiGeneratedListData obj, int j) { + return obj.__assign(__indirect(__element(j), bb), bb); + } + } +} diff --git a/src/main/java/com/hfstudio/guidenh/guide/layout/flatbuffers/MediaWikiSpecialGeneratedData.java b/src/main/java/com/hfstudio/guidenh/guide/layout/flatbuffers/MediaWikiSpecialGeneratedData.java new file mode 100644 index 00000000..9bc7a078 --- /dev/null +++ b/src/main/java/com/hfstudio/guidenh/guide/layout/flatbuffers/MediaWikiSpecialGeneratedData.java @@ -0,0 +1,612 @@ +// automatically generated by the FlatBuffers compiler, do not modify + +package com.hfstudio.guidenh.guide.layout.flatbuffers; + +import java.nio.ByteBuffer; +import java.nio.ByteOrder; + +import com.google.flatbuffers.BaseVector; +import com.google.flatbuffers.ByteVector; +import com.google.flatbuffers.Constants; +import com.google.flatbuffers.FlatBufferBuilder; +import com.google.flatbuffers.FloatVector; +import com.google.flatbuffers.IntVector; +import com.google.flatbuffers.Table; + +/** + * MediaWiki special generated block sizing data (node_type 30). + * Java passes width-independent font facts (title widths, subtitle word widths, + * line heights, estimated heights). Rust computes columnWidth from availableWidth, + * wraps subtitle words per entry, packs columns (shortest-column-first), and + * produces maxColumnHeight. Width is always availableWidth: the block fills + * the parent's content box. + */ +@SuppressWarnings("unused") +public final class MediaWikiSpecialGeneratedData extends Table { + + public static void ValidateVersion() { + Constants.FLATBUFFERS_23_5_26(); + } + + public static MediaWikiSpecialGeneratedData getRootAsMediaWikiSpecialGeneratedData(ByteBuffer _bb) { + return getRootAsMediaWikiSpecialGeneratedData(_bb, new MediaWikiSpecialGeneratedData()); + } + + public static MediaWikiSpecialGeneratedData getRootAsMediaWikiSpecialGeneratedData(ByteBuffer _bb, + MediaWikiSpecialGeneratedData obj) { + _bb.order(ByteOrder.LITTLE_ENDIAN); + return (obj.__assign(_bb.getInt(_bb.position()) + _bb.position(), _bb)); + } + + public void __init(int _i, ByteBuffer _bb) { + __reset(_i, _bb); + } + + public MediaWikiSpecialGeneratedData __assign(int _i, ByteBuffer _bb) { + __init(_i, _bb); + return this; + } + + /** + * DEPRECATED: Rust now computes maxColumnHeight internally. + * Kept for backward compatibility with old serialized data. + */ + public float maxContentHeight() { + int o = __offset(4); + return o != 0 ? bb.getFloat(o + bb_pos) : 0.0f; + } + + /** + * resolveColumnCount result (depends only on definition.name()/kind). + */ + public int columnCount() { + int o = __offset(6); + return o != 0 ? bb.getInt(o + bb_pos) : 1; + } + + /** + * hasMore flag from the visible result: adds LOAD_MORE_HEIGHT after the + * column content when true. + */ + public boolean hasMore() { + int o = __offset(8); + return o != 0 ? 0 != bb.get(o + bb_pos) : false; + } + + /** + * Number of groups (for grouped/group-index kinds; flat builds one group + * per column, see group_flat_entry_count). + */ + public int groupCount() { + int o = __offset(10); + return o != 0 ? bb.getInt(o + bb_pos) : 0; + } + + /** + * Per-group title measureWidth: 0 means no title (no header row). + * Length = group_count. + */ + public float groupTitleWidths(int j) { + int o = __offset(12); + return o != 0 ? bb.getFloat(__vector(o) + j * 4) : 0; + } + + public int groupTitleWidthsLength() { + int o = __offset(12); + return o != 0 ? __vector_len(o) : 0; + } + + public FloatVector groupTitleWidthsVector() { + return groupTitleWidthsVector(new FloatVector()); + } + + public FloatVector groupTitleWidthsVector(FloatVector obj) { + int o = __offset(12); + return o != 0 ? obj.__assign(__vector(o), bb) : null; + } + + public ByteBuffer groupTitleWidthsAsByteBuffer() { + return __vector_as_bytebuffer(12, 4); + } + + public ByteBuffer groupTitleWidthsInByteBuffer(ByteBuffer _bb) { + return __vector_in_bytebuffer(_bb, 12, 4); + } + + /** + * Per-group entry count in the visible result's entry order. + * Length = group_count. + */ + public int groupEntryCounts(int j) { + int o = __offset(14); + return o != 0 ? bb.getInt(__vector(o) + j * 4) : 0; + } + + public int groupEntryCountsLength() { + int o = __offset(14); + return o != 0 ? __vector_len(o) : 0; + } + + public IntVector groupEntryCountsVector() { + return groupEntryCountsVector(new IntVector()); + } + + public IntVector groupEntryCountsVector(IntVector obj) { + int o = __offset(14); + return o != 0 ? obj.__assign(__vector(o), bb) : null; + } + + public ByteBuffer groupEntryCountsAsByteBuffer() { + return __vector_as_bytebuffer(14, 4); + } + + public ByteBuffer groupEntryCountsInByteBuffer(ByteBuffer _bb) { + return __vector_in_bytebuffer(_bb, 14, 4); + } + + /** + * Per-group estimated total height (estimateHeight), width-independent, + * used by Rust's shortest-column-first packing. Length = group_count. + */ + public float groupEstimatedHeights(int j) { + int o = __offset(16); + return o != 0 ? bb.getFloat(__vector(o) + j * 4) : 0; + } + + public int groupEstimatedHeightsLength() { + int o = __offset(16); + return o != 0 ? __vector_len(o) : 0; + } + + public FloatVector groupEstimatedHeightsVector() { + return groupEstimatedHeightsVector(new FloatVector()); + } + + public FloatVector groupEstimatedHeightsVector(FloatVector obj) { + int o = __offset(16); + return o != 0 ? obj.__assign(__vector(o), bb) : null; + } + + public ByteBuffer groupEstimatedHeightsAsByteBuffer() { + return __vector_as_bytebuffer(16, 4); + } + + public ByteBuffer groupEstimatedHeightsInByteBuffer(ByteBuffer _bb) { + return __vector_in_bytebuffer(_bb, 16, 4); + } + + /** + * Total number of entries across all groups. Entries are indexed 0..N-1 + * in the order they appear in the visible result (group-major). + */ + public int totalEntryCount() { + int o = __offset(18); + return o != 0 ? bb.getInt(o + bb_pos) : 0; + } + + /** + * Per-entry title pixel width: measureWidth(title, LINK_STYLE). + * Width-independent font fact. Length = total_entry_count. + */ + public float entryTitleWidths(int j) { + int o = __offset(20); + return o != 0 ? bb.getFloat(__vector(o) + j * 4) : 0; + } + + public int entryTitleWidthsLength() { + int o = __offset(20); + return o != 0 ? __vector_len(o) : 0; + } + + public FloatVector entryTitleWidthsVector() { + return entryTitleWidthsVector(new FloatVector()); + } + + public FloatVector entryTitleWidthsVector(FloatVector obj) { + int o = __offset(20); + return o != 0 ? obj.__assign(__vector(o), bb) : null; + } + + public ByteBuffer entryTitleWidthsAsByteBuffer() { + return __vector_as_bytebuffer(20, 4); + } + + public ByteBuffer entryTitleWidthsInByteBuffer(ByteBuffer _bb) { + return __vector_in_bytebuffer(_bb, 20, 4); + } + + /** + * Per-entry icon presence (1 = has icon). Length = total_entry_count. + */ + public byte entryHasIcon(int j) { + int o = __offset(22); + return o != 0 ? bb.get(__vector(o) + j * 1) : 0; + } + + public int entryHasIconLength() { + int o = __offset(22); + return o != 0 ? __vector_len(o) : 0; + } + + public ByteVector entryHasIconVector() { + return entryHasIconVector(new ByteVector()); + } + + public ByteVector entryHasIconVector(ByteVector obj) { + int o = __offset(22); + return o != 0 ? obj.__assign(__vector(o), bb) : null; + } + + public ByteBuffer entryHasIconAsByteBuffer() { + return __vector_as_bytebuffer(22, 1); + } + + public ByteBuffer entryHasIconInByteBuffer(ByteBuffer _bb) { + return __vector_in_bytebuffer(_bb, 22, 1); + } + + /** + * Per-entry estimated height (estimateEntryHeight), width-independent. + * Length = total_entry_count. + */ + public float entryEstimatedHeights(int j) { + int o = __offset(24); + return o != 0 ? bb.getFloat(__vector(o) + j * 4) : 0; + } + + public int entryEstimatedHeightsLength() { + int o = __offset(24); + return o != 0 ? __vector_len(o) : 0; + } + + public FloatVector entryEstimatedHeightsVector() { + return entryEstimatedHeightsVector(new FloatVector()); + } + + public FloatVector entryEstimatedHeightsVector(FloatVector obj) { + int o = __offset(24); + return o != 0 ? obj.__assign(__vector(o), bb) : null; + } + + public ByteBuffer entryEstimatedHeightsAsByteBuffer() { + return __vector_as_bytebuffer(24, 4); + } + + public ByteBuffer entryEstimatedHeightsInByteBuffer(ByteBuffer _bb) { + return __vector_in_bytebuffer(_bb, 24, 4); + } + + /** + * Per-entry raw subtitle line count (GuideStringLines.splitLines result, + * 0 = no subtitle). Length = total_entry_count. + */ + public int entrySubtitleLineCounts(int j) { + int o = __offset(26); + return o != 0 ? bb.getInt(__vector(o) + j * 4) : 0; + } + + public int entrySubtitleLineCountsLength() { + int o = __offset(26); + return o != 0 ? __vector_len(o) : 0; + } + + public IntVector entrySubtitleLineCountsVector() { + return entrySubtitleLineCountsVector(new IntVector()); + } + + public IntVector entrySubtitleLineCountsVector(IntVector obj) { + int o = __offset(26); + return o != 0 ? obj.__assign(__vector(o), bb) : null; + } + + public ByteBuffer entrySubtitleLineCountsAsByteBuffer() { + return __vector_as_bytebuffer(26, 4); + } + + public ByteBuffer entrySubtitleLineCountsInByteBuffer(ByteBuffer _bb) { + return __vector_in_bytebuffer(_bb, 26, 4); + } + + /** + * Per-raw-subtitle-line word count (GuideStringLines.splitLines then + * split by \\s+), concatenated across all entries' raw lines. + */ + public int subtitleLineWordCounts(int j) { + int o = __offset(28); + return o != 0 ? bb.getInt(__vector(o) + j * 4) : 0; + } + + public int subtitleLineWordCountsLength() { + int o = __offset(28); + return o != 0 ? __vector_len(o) : 0; + } + + public IntVector subtitleLineWordCountsVector() { + return subtitleLineWordCountsVector(new IntVector()); + } + + public IntVector subtitleLineWordCountsVector(IntVector obj) { + int o = __offset(28); + return o != 0 ? obj.__assign(__vector(o), bb) : null; + } + + public ByteBuffer subtitleLineWordCountsAsByteBuffer() { + return __vector_as_bytebuffer(28, 4); + } + + public ByteBuffer subtitleLineWordCountsInByteBuffer(ByteBuffer _bb) { + return __vector_in_bytebuffer(_bb, 28, 4); + } + + /** + * Per-word pixel width (measureWidth(word, SUBTITLE_STYLE)), concatenated + * across all words of all raw lines of all entries. + */ + public float subtitleWordWidths(int j) { + int o = __offset(30); + return o != 0 ? bb.getFloat(__vector(o) + j * 4) : 0; + } + + public int subtitleWordWidthsLength() { + int o = __offset(30); + return o != 0 ? __vector_len(o) : 0; + } + + public FloatVector subtitleWordWidthsVector() { + return subtitleWordWidthsVector(new FloatVector()); + } + + public FloatVector subtitleWordWidthsVector(FloatVector obj) { + int o = __offset(30); + return o != 0 ? obj.__assign(__vector(o), bb) : null; + } + + public ByteBuffer subtitleWordWidthsAsByteBuffer() { + return __vector_as_bytebuffer(30, 4); + } + + public ByteBuffer subtitleWordWidthsInByteBuffer(ByteBuffer _bb) { + return __vector_in_bytebuffer(_bb, 30, 4); + } + + /** + * Width of a space character in SUBTITLE_STYLE (for word-gap calculations). + */ + public float subtitleSpaceWidth() { + int o = __offset(32); + return o != 0 ? bb.getFloat(o + bb_pos) : 4.0f; + } + + /** + * Line height of LINK_STYLE, from GuideText.lineHeight(LINK_STYLE) (= 17 at + * scale 1). Mirrors Java computeEntryHeight's getLineHeight(LINK_STYLE) / + * rowContentHeight's GuideText.lineHeight(rowStyle). Default 10.0 keeps + * old serialized data (which lacked this field) rendering as before. + */ + public float linkLineHeight() { + int o = __offset(34); + return o != 0 ? bb.getFloat(o + bb_pos) : 10.0f; + } + + /** + * Line height of SUBTITLE_STYLE, from GuideText.lineHeight(SUBTITLE_STYLE) + * (= 17 at scale 1). Mirrors Java computeEntryHeight/rowContentHeight's + * GuideText.lineHeight(SUBTITLE_STYLE). Default 10.0 keeps old serialized + * data (which lacked this field) rendering as before. + */ + public float subtitleLineHeight() { + int o = __offset(36); + return o != 0 ? bb.getFloat(o + bb_pos) : 10.0f; + } + + public static int createMediaWikiSpecialGeneratedData(FlatBufferBuilder builder, float maxContentHeight, + int columnCount, boolean hasMore, int groupCount, int groupTitleWidthsOffset, int groupEntryCountsOffset, + int groupEstimatedHeightsOffset, int totalEntryCount, int entryTitleWidthsOffset, int entryHasIconOffset, + int entryEstimatedHeightsOffset, int entrySubtitleLineCountsOffset, int subtitleLineWordCountsOffset, + int subtitleWordWidthsOffset, float subtitleSpaceWidth, float linkLineHeight, float subtitleLineHeight) { + builder.startTable(17); + MediaWikiSpecialGeneratedData.addSubtitleLineHeight(builder, subtitleLineHeight); + MediaWikiSpecialGeneratedData.addLinkLineHeight(builder, linkLineHeight); + MediaWikiSpecialGeneratedData.addSubtitleSpaceWidth(builder, subtitleSpaceWidth); + MediaWikiSpecialGeneratedData.addSubtitleWordWidths(builder, subtitleWordWidthsOffset); + MediaWikiSpecialGeneratedData.addSubtitleLineWordCounts(builder, subtitleLineWordCountsOffset); + MediaWikiSpecialGeneratedData.addEntrySubtitleLineCounts(builder, entrySubtitleLineCountsOffset); + MediaWikiSpecialGeneratedData.addEntryEstimatedHeights(builder, entryEstimatedHeightsOffset); + MediaWikiSpecialGeneratedData.addEntryHasIcon(builder, entryHasIconOffset); + MediaWikiSpecialGeneratedData.addEntryTitleWidths(builder, entryTitleWidthsOffset); + MediaWikiSpecialGeneratedData.addTotalEntryCount(builder, totalEntryCount); + MediaWikiSpecialGeneratedData.addGroupEstimatedHeights(builder, groupEstimatedHeightsOffset); + MediaWikiSpecialGeneratedData.addGroupEntryCounts(builder, groupEntryCountsOffset); + MediaWikiSpecialGeneratedData.addGroupTitleWidths(builder, groupTitleWidthsOffset); + MediaWikiSpecialGeneratedData.addGroupCount(builder, groupCount); + MediaWikiSpecialGeneratedData.addColumnCount(builder, columnCount); + MediaWikiSpecialGeneratedData.addMaxContentHeight(builder, maxContentHeight); + MediaWikiSpecialGeneratedData.addHasMore(builder, hasMore); + return MediaWikiSpecialGeneratedData.endMediaWikiSpecialGeneratedData(builder); + } + + public static void startMediaWikiSpecialGeneratedData(FlatBufferBuilder builder) { + builder.startTable(17); + } + + public static void addMaxContentHeight(FlatBufferBuilder builder, float maxContentHeight) { + builder.addFloat(0, maxContentHeight, 0.0f); + } + + public static void addColumnCount(FlatBufferBuilder builder, int columnCount) { + builder.addInt(1, columnCount, 1); + } + + public static void addHasMore(FlatBufferBuilder builder, boolean hasMore) { + builder.addBoolean(2, hasMore, false); + } + + public static void addGroupCount(FlatBufferBuilder builder, int groupCount) { + builder.addInt(3, groupCount, 0); + } + + public static void addGroupTitleWidths(FlatBufferBuilder builder, int groupTitleWidthsOffset) { + builder.addOffset(4, groupTitleWidthsOffset, 0); + } + + public static int createGroupTitleWidthsVector(FlatBufferBuilder builder, float[] data) { + builder.startVector(4, data.length, 4); + for (int i = data.length - 1; i >= 0; i--) builder.addFloat(data[i]); + return builder.endVector(); + } + + public static void startGroupTitleWidthsVector(FlatBufferBuilder builder, int numElems) { + builder.startVector(4, numElems, 4); + } + + public static void addGroupEntryCounts(FlatBufferBuilder builder, int groupEntryCountsOffset) { + builder.addOffset(5, groupEntryCountsOffset, 0); + } + + public static int createGroupEntryCountsVector(FlatBufferBuilder builder, int[] data) { + builder.startVector(4, data.length, 4); + for (int i = data.length - 1; i >= 0; i--) builder.addInt(data[i]); + return builder.endVector(); + } + + public static void startGroupEntryCountsVector(FlatBufferBuilder builder, int numElems) { + builder.startVector(4, numElems, 4); + } + + public static void addGroupEstimatedHeights(FlatBufferBuilder builder, int groupEstimatedHeightsOffset) { + builder.addOffset(6, groupEstimatedHeightsOffset, 0); + } + + public static int createGroupEstimatedHeightsVector(FlatBufferBuilder builder, float[] data) { + builder.startVector(4, data.length, 4); + for (int i = data.length - 1; i >= 0; i--) builder.addFloat(data[i]); + return builder.endVector(); + } + + public static void startGroupEstimatedHeightsVector(FlatBufferBuilder builder, int numElems) { + builder.startVector(4, numElems, 4); + } + + public static void addTotalEntryCount(FlatBufferBuilder builder, int totalEntryCount) { + builder.addInt(7, totalEntryCount, 0); + } + + public static void addEntryTitleWidths(FlatBufferBuilder builder, int entryTitleWidthsOffset) { + builder.addOffset(8, entryTitleWidthsOffset, 0); + } + + public static int createEntryTitleWidthsVector(FlatBufferBuilder builder, float[] data) { + builder.startVector(4, data.length, 4); + for (int i = data.length - 1; i >= 0; i--) builder.addFloat(data[i]); + return builder.endVector(); + } + + public static void startEntryTitleWidthsVector(FlatBufferBuilder builder, int numElems) { + builder.startVector(4, numElems, 4); + } + + public static void addEntryHasIcon(FlatBufferBuilder builder, int entryHasIconOffset) { + builder.addOffset(9, entryHasIconOffset, 0); + } + + public static int createEntryHasIconVector(FlatBufferBuilder builder, byte[] data) { + return builder.createByteVector(data); + } + + public static int createEntryHasIconVector(FlatBufferBuilder builder, ByteBuffer data) { + return builder.createByteVector(data); + } + + public static void startEntryHasIconVector(FlatBufferBuilder builder, int numElems) { + builder.startVector(1, numElems, 1); + } + + public static void addEntryEstimatedHeights(FlatBufferBuilder builder, int entryEstimatedHeightsOffset) { + builder.addOffset(10, entryEstimatedHeightsOffset, 0); + } + + public static int createEntryEstimatedHeightsVector(FlatBufferBuilder builder, float[] data) { + builder.startVector(4, data.length, 4); + for (int i = data.length - 1; i >= 0; i--) builder.addFloat(data[i]); + return builder.endVector(); + } + + public static void startEntryEstimatedHeightsVector(FlatBufferBuilder builder, int numElems) { + builder.startVector(4, numElems, 4); + } + + public static void addEntrySubtitleLineCounts(FlatBufferBuilder builder, int entrySubtitleLineCountsOffset) { + builder.addOffset(11, entrySubtitleLineCountsOffset, 0); + } + + public static int createEntrySubtitleLineCountsVector(FlatBufferBuilder builder, int[] data) { + builder.startVector(4, data.length, 4); + for (int i = data.length - 1; i >= 0; i--) builder.addInt(data[i]); + return builder.endVector(); + } + + public static void startEntrySubtitleLineCountsVector(FlatBufferBuilder builder, int numElems) { + builder.startVector(4, numElems, 4); + } + + public static void addSubtitleLineWordCounts(FlatBufferBuilder builder, int subtitleLineWordCountsOffset) { + builder.addOffset(12, subtitleLineWordCountsOffset, 0); + } + + public static int createSubtitleLineWordCountsVector(FlatBufferBuilder builder, int[] data) { + builder.startVector(4, data.length, 4); + for (int i = data.length - 1; i >= 0; i--) builder.addInt(data[i]); + return builder.endVector(); + } + + public static void startSubtitleLineWordCountsVector(FlatBufferBuilder builder, int numElems) { + builder.startVector(4, numElems, 4); + } + + public static void addSubtitleWordWidths(FlatBufferBuilder builder, int subtitleWordWidthsOffset) { + builder.addOffset(13, subtitleWordWidthsOffset, 0); + } + + public static int createSubtitleWordWidthsVector(FlatBufferBuilder builder, float[] data) { + builder.startVector(4, data.length, 4); + for (int i = data.length - 1; i >= 0; i--) builder.addFloat(data[i]); + return builder.endVector(); + } + + public static void startSubtitleWordWidthsVector(FlatBufferBuilder builder, int numElems) { + builder.startVector(4, numElems, 4); + } + + public static void addSubtitleSpaceWidth(FlatBufferBuilder builder, float subtitleSpaceWidth) { + builder.addFloat(14, subtitleSpaceWidth, 4.0f); + } + + public static void addLinkLineHeight(FlatBufferBuilder builder, float linkLineHeight) { + builder.addFloat(15, linkLineHeight, 10.0f); + } + + public static void addSubtitleLineHeight(FlatBufferBuilder builder, float subtitleLineHeight) { + builder.addFloat(16, subtitleLineHeight, 10.0f); + } + + public static int endMediaWikiSpecialGeneratedData(FlatBufferBuilder builder) { + int o = builder.endTable(); + return o; + } + + public static final class Vector extends BaseVector { + + public Vector __assign(int _vector, int _element_size, ByteBuffer _bb) { + __reset(_vector, _element_size, _bb); + return this; + } + + public MediaWikiSpecialGeneratedData get(int j) { + return get(new MediaWikiSpecialGeneratedData(), j); + } + + public MediaWikiSpecialGeneratedData get(MediaWikiSpecialGeneratedData obj, int j) { + return obj.__assign(__indirect(__element(j), bb), bb); + } + } +} diff --git a/src/main/java/com/hfstudio/guidenh/guide/layout/flatbuffers/PieChartData.java b/src/main/java/com/hfstudio/guidenh/guide/layout/flatbuffers/PieChartData.java new file mode 100644 index 00000000..31444987 --- /dev/null +++ b/src/main/java/com/hfstudio/guidenh/guide/layout/flatbuffers/PieChartData.java @@ -0,0 +1,196 @@ +// automatically generated by the FlatBuffers compiler, do not modify + +package com.hfstudio.guidenh.guide.layout.flatbuffers; + +import java.nio.ByteBuffer; +import java.nio.ByteOrder; + +import com.google.flatbuffers.BaseVector; +import com.google.flatbuffers.Constants; +import com.google.flatbuffers.FlatBufferBuilder; +import com.google.flatbuffers.FloatVector; +import com.google.flatbuffers.Table; + +@SuppressWarnings("unused") +public final class PieChartData extends Table { + + public static void ValidateVersion() { + Constants.FLATBUFFERS_23_5_26(); + } + + public static PieChartData getRootAsPieChartData(ByteBuffer _bb) { + return getRootAsPieChartData(_bb, new PieChartData()); + } + + public static PieChartData getRootAsPieChartData(ByteBuffer _bb, PieChartData obj) { + _bb.order(ByteOrder.LITTLE_ENDIAN); + return (obj.__assign(_bb.getInt(_bb.position()) + _bb.position(), _bb)); + } + + public void __init(int _i, ByteBuffer _bb) { + __reset(_i, _bb); + } + + public PieChartData __assign(int _i, ByteBuffer _bb) { + __init(_i, _bb); + return this; + } + + /** + * preferred_width = (explicitWidth > 0 ? explicitWidth : DEFAULT_WIDTH) + extraPlotWidth. + * Java-precomputed input to the sizing formula. + */ + public float preferredWidth() { + int o = __offset(4); + return o != 0 ? bb.getFloat(o + bb_pos) : 0.0f; + } + + /** + * total_height = explicitHeight > 0 ? explicitHeight : DEFAULT_HEIGHT. + * Java-precomputed height baseline (before body scaling). + */ + public float totalHeight() { + int o = __offset(6); + return o != 0 ? bb.getFloat(o + bb_pos) : 0.0f; + } + + /** + * DEPRECATED: Rust now computes chrome internally from the final width w. + * Kept for backward compatibility with old serialized data. + */ + public float chromeHeight() { + int o = __offset(8); + return o != 0 ? bb.getFloat(o + bb_pos) : 0.0f; + } + + /** + * Title lineHeight(TITLE_STYLE) + TITLE_GAP; 0 if no title (width-independent). + */ + public float titleChrome() { + int o = __offset(10); + return o != 0 ? bb.getFloat(o + bb_pos) : 0.0f; + } + + /** + * Legend position: 0=NONE 1=TOP 2=BOTTOM 3=LEFT 4=RIGHT. + */ + public byte legendPosition() { + int o = __offset(12); + return o != 0 ? bb.get(o + bb_pos) : 0; + } + + /** + * Row height for legend wrapping: max(LEGEND_SWATCH_SIZE, lineHeight(LEGEND_LABEL_STYLE)). + */ + public float legendRowHeight() { + int o = __offset(14); + return o != 0 ? bb.getFloat(o + bb_pos) : 10.0f; + } + + /** + * Per-entry legend label widths: LEGEND_SWATCH_SIZE + SWATCH_TEXT_GAP + measureWidth(label, style). + */ + public float legendLabelWidths(int j) { + int o = __offset(16); + return o != 0 ? bb.getFloat(__vector(o) + j * 4) : 0; + } + + public int legendLabelWidthsLength() { + int o = __offset(16); + return o != 0 ? __vector_len(o) : 0; + } + + public FloatVector legendLabelWidthsVector() { + return legendLabelWidthsVector(new FloatVector()); + } + + public FloatVector legendLabelWidthsVector(FloatVector obj) { + int o = __offset(16); + return o != 0 ? obj.__assign(__vector(o), bb) : null; + } + + public ByteBuffer legendLabelWidthsAsByteBuffer() { + return __vector_as_bytebuffer(16, 4); + } + + public ByteBuffer legendLabelWidthsInByteBuffer(ByteBuffer _bb) { + return __vector_in_bytebuffer(_bb, 16, 4); + } + + public static int createPieChartData(FlatBufferBuilder builder, float preferredWidth, float totalHeight, + float chromeHeight, float titleChrome, byte legendPosition, float legendRowHeight, + int legendLabelWidthsOffset) { + builder.startTable(7); + PieChartData.addLegendLabelWidths(builder, legendLabelWidthsOffset); + PieChartData.addLegendRowHeight(builder, legendRowHeight); + PieChartData.addTitleChrome(builder, titleChrome); + PieChartData.addChromeHeight(builder, chromeHeight); + PieChartData.addTotalHeight(builder, totalHeight); + PieChartData.addPreferredWidth(builder, preferredWidth); + PieChartData.addLegendPosition(builder, legendPosition); + return PieChartData.endPieChartData(builder); + } + + public static void startPieChartData(FlatBufferBuilder builder) { + builder.startTable(7); + } + + public static void addPreferredWidth(FlatBufferBuilder builder, float preferredWidth) { + builder.addFloat(0, preferredWidth, 0.0f); + } + + public static void addTotalHeight(FlatBufferBuilder builder, float totalHeight) { + builder.addFloat(1, totalHeight, 0.0f); + } + + public static void addChromeHeight(FlatBufferBuilder builder, float chromeHeight) { + builder.addFloat(2, chromeHeight, 0.0f); + } + + public static void addTitleChrome(FlatBufferBuilder builder, float titleChrome) { + builder.addFloat(3, titleChrome, 0.0f); + } + + public static void addLegendPosition(FlatBufferBuilder builder, byte legendPosition) { + builder.addByte(4, legendPosition, 0); + } + + public static void addLegendRowHeight(FlatBufferBuilder builder, float legendRowHeight) { + builder.addFloat(5, legendRowHeight, 10.0f); + } + + public static void addLegendLabelWidths(FlatBufferBuilder builder, int legendLabelWidthsOffset) { + builder.addOffset(6, legendLabelWidthsOffset, 0); + } + + public static int createLegendLabelWidthsVector(FlatBufferBuilder builder, float[] data) { + builder.startVector(4, data.length, 4); + for (int i = data.length - 1; i >= 0; i--) builder.addFloat(data[i]); + return builder.endVector(); + } + + public static void startLegendLabelWidthsVector(FlatBufferBuilder builder, int numElems) { + builder.startVector(4, numElems, 4); + } + + public static int endPieChartData(FlatBufferBuilder builder) { + int o = builder.endTable(); + return o; + } + + public static final class Vector extends BaseVector { + + public Vector __assign(int _vector, int _element_size, ByteBuffer _bb) { + __reset(_vector, _element_size, _bb); + return this; + } + + public PieChartData get(int j) { + return get(new PieChartData(), j); + } + + public PieChartData get(PieChartData obj, int j) { + return obj.__assign(__indirect(__element(j), bb), bb); + } + } +} diff --git a/src/main/java/com/hfstudio/guidenh/guide/layout/flatbuffers/PlacedGlyph.java b/src/main/java/com/hfstudio/guidenh/guide/layout/flatbuffers/PlacedGlyph.java new file mode 100644 index 00000000..add54579 --- /dev/null +++ b/src/main/java/com/hfstudio/guidenh/guide/layout/flatbuffers/PlacedGlyph.java @@ -0,0 +1,170 @@ +// automatically generated by the FlatBuffers compiler, do not modify + +package com.hfstudio.guidenh.guide.layout.flatbuffers; + +import java.nio.ByteBuffer; +import java.nio.ByteOrder; + +import com.google.flatbuffers.BaseVector; +import com.google.flatbuffers.Constants; +import com.google.flatbuffers.FlatBufferBuilder; +import com.google.flatbuffers.Table; + +/** + * A glyph quad in absolute document coordinates (top-left origin). + * w/h are the bitmap dimensions divided by render_scale (i.e. document units). + * start/end are the glyph's byte range in the source text (for band-split + * computations; 0 when not meaningful). + */ +@SuppressWarnings("unused") +public final class PlacedGlyph extends Table { + + public static void ValidateVersion() { + Constants.FLATBUFFERS_23_5_26(); + } + + public static PlacedGlyph getRootAsPlacedGlyph(ByteBuffer _bb) { + return getRootAsPlacedGlyph(_bb, new PlacedGlyph()); + } + + public static PlacedGlyph getRootAsPlacedGlyph(ByteBuffer _bb, PlacedGlyph obj) { + _bb.order(ByteOrder.LITTLE_ENDIAN); + return (obj.__assign(_bb.getInt(_bb.position()) + _bb.position(), _bb)); + } + + public void __init(int _i, ByteBuffer _bb) { + __reset(_i, _bb); + } + + public PlacedGlyph __assign(int _i, ByteBuffer _bb) { + __init(_i, _bb); + return this; + } + + public long bitmapKey() { + int o = __offset(4); + return o != 0 ? bb.getLong(o + bb_pos) : 0L; + } + + public float x() { + int o = __offset(6); + return o != 0 ? bb.getFloat(o + bb_pos) : 0.0f; + } + + public float y() { + int o = __offset(8); + return o != 0 ? bb.getFloat(o + bb_pos) : 0.0f; + } + + public float w() { + int o = __offset(10); + return o != 0 ? bb.getFloat(o + bb_pos) : 0.0f; + } + + public float h() { + int o = __offset(12); + return o != 0 ? bb.getFloat(o + bb_pos) : 0.0f; + } + + public long start() { + int o = __offset(14); + return o != 0 ? (long) bb.getInt(o + bb_pos) & 0xFFFFFFFFL : 0L; + } + + public long end() { + int o = __offset(16); + return o != 0 ? (long) bb.getInt(o + bb_pos) & 0xFFFFFFFFL : 0L; + } + + public long lineIndex() { + int o = __offset(18); + return o != 0 ? (long) bb.getInt(o + bb_pos) & 0xFFFFFFFFL : 0L; + } + + /** + * Baseline Y of the line this glyph belongs to, in absolute document coordinates, + * already quantized onto the display pixel grid by dividing by render_scale and + * truncating, the same precision convention as x/y/w/h. Default 0 keeps older data + * compatible; the Java reader ignores the field until it consumes it. + */ + public float baseline() { + int o = __offset(20); + return o != 0 ? bb.getFloat(o + bb_pos) : 0.0f; + } + + public static int createPlacedGlyph(FlatBufferBuilder builder, long bitmapKey, float x, float y, float w, float h, + long start, long end, long lineIndex, float baseline) { + builder.startTable(9); + PlacedGlyph.addBitmapKey(builder, bitmapKey); + PlacedGlyph.addBaseline(builder, baseline); + PlacedGlyph.addLineIndex(builder, lineIndex); + PlacedGlyph.addEnd(builder, end); + PlacedGlyph.addStart(builder, start); + PlacedGlyph.addH(builder, h); + PlacedGlyph.addW(builder, w); + PlacedGlyph.addY(builder, y); + PlacedGlyph.addX(builder, x); + return PlacedGlyph.endPlacedGlyph(builder); + } + + public static void startPlacedGlyph(FlatBufferBuilder builder) { + builder.startTable(9); + } + + public static void addBitmapKey(FlatBufferBuilder builder, long bitmapKey) { + builder.addLong(0, bitmapKey, 0L); + } + + public static void addX(FlatBufferBuilder builder, float x) { + builder.addFloat(1, x, 0.0f); + } + + public static void addY(FlatBufferBuilder builder, float y) { + builder.addFloat(2, y, 0.0f); + } + + public static void addW(FlatBufferBuilder builder, float w) { + builder.addFloat(3, w, 0.0f); + } + + public static void addH(FlatBufferBuilder builder, float h) { + builder.addFloat(4, h, 0.0f); + } + + public static void addStart(FlatBufferBuilder builder, long start) { + builder.addInt(5, (int) start, (int) 0L); + } + + public static void addEnd(FlatBufferBuilder builder, long end) { + builder.addInt(6, (int) end, (int) 0L); + } + + public static void addLineIndex(FlatBufferBuilder builder, long lineIndex) { + builder.addInt(7, (int) lineIndex, (int) 0L); + } + + public static void addBaseline(FlatBufferBuilder builder, float baseline) { + builder.addFloat(8, baseline, 0.0f); + } + + public static int endPlacedGlyph(FlatBufferBuilder builder) { + int o = builder.endTable(); + return o; + } + + public static final class Vector extends BaseVector { + + public Vector __assign(int _vector, int _element_size, ByteBuffer _bb) { + __reset(_vector, _element_size, _bb); + return this; + } + + public PlacedGlyph get(int j) { + return get(new PlacedGlyph(), j); + } + + public PlacedGlyph get(PlacedGlyph obj, int j) { + return obj.__assign(__indirect(__element(j), bb), bb); + } + } +} diff --git a/src/main/java/com/hfstudio/guidenh/guide/layout/flatbuffers/RasterInput.java b/src/main/java/com/hfstudio/guidenh/guide/layout/flatbuffers/RasterInput.java new file mode 100644 index 00000000..bbdbda08 --- /dev/null +++ b/src/main/java/com/hfstudio/guidenh/guide/layout/flatbuffers/RasterInput.java @@ -0,0 +1,107 @@ +// automatically generated by the FlatBuffers compiler, do not modify + +package com.hfstudio.guidenh.guide.layout.flatbuffers; + +import java.nio.ByteBuffer; +import java.nio.ByteOrder; + +import com.google.flatbuffers.BaseVector; +import com.google.flatbuffers.Constants; +import com.google.flatbuffers.FlatBufferBuilder; +import com.google.flatbuffers.Table; + +@SuppressWarnings("unused") +public final class RasterInput extends Table { + + public static void ValidateVersion() { + Constants.FLATBUFFERS_23_5_26(); + } + + public static RasterInput getRootAsRasterInput(ByteBuffer _bb) { + return getRootAsRasterInput(_bb, new RasterInput()); + } + + public static RasterInput getRootAsRasterInput(ByteBuffer _bb, RasterInput obj) { + _bb.order(ByteOrder.LITTLE_ENDIAN); + return (obj.__assign(_bb.getInt(_bb.position()) + _bb.position(), _bb)); + } + + public void __init(int _i, ByteBuffer _bb) { + __reset(_i, _bb); + } + + public RasterInput __assign(int _i, ByteBuffer _bb) { + __init(_i, _bb); + return this; + } + + public com.hfstudio.guidenh.guide.layout.flatbuffers.FontRequest requests(int j) { + return requests(new com.hfstudio.guidenh.guide.layout.flatbuffers.FontRequest(), j); + } + + public com.hfstudio.guidenh.guide.layout.flatbuffers.FontRequest requests( + com.hfstudio.guidenh.guide.layout.flatbuffers.FontRequest obj, int j) { + int o = __offset(4); + return o != 0 ? obj.__assign(__indirect(__vector(o) + j * 4), bb) : null; + } + + public int requestsLength() { + int o = __offset(4); + return o != 0 ? __vector_len(o) : 0; + } + + public com.hfstudio.guidenh.guide.layout.flatbuffers.FontRequest.Vector requestsVector() { + return requestsVector(new com.hfstudio.guidenh.guide.layout.flatbuffers.FontRequest.Vector()); + } + + public com.hfstudio.guidenh.guide.layout.flatbuffers.FontRequest.Vector requestsVector( + com.hfstudio.guidenh.guide.layout.flatbuffers.FontRequest.Vector obj) { + int o = __offset(4); + return o != 0 ? obj.__assign(__vector(o), 4, bb) : null; + } + + public static int createRasterInput(FlatBufferBuilder builder, int requestsOffset) { + builder.startTable(1); + RasterInput.addRequests(builder, requestsOffset); + return RasterInput.endRasterInput(builder); + } + + public static void startRasterInput(FlatBufferBuilder builder) { + builder.startTable(1); + } + + public static void addRequests(FlatBufferBuilder builder, int requestsOffset) { + builder.addOffset(0, requestsOffset, 0); + } + + public static int createRequestsVector(FlatBufferBuilder builder, int[] data) { + builder.startVector(4, data.length, 4); + for (int i = data.length - 1; i >= 0; i--) builder.addOffset(data[i]); + return builder.endVector(); + } + + public static void startRequestsVector(FlatBufferBuilder builder, int numElems) { + builder.startVector(4, numElems, 4); + } + + public static int endRasterInput(FlatBufferBuilder builder) { + int o = builder.endTable(); + return o; + } + + public static final class Vector extends BaseVector { + + public Vector __assign(int _vector, int _element_size, ByteBuffer _bb) { + __reset(_vector, _element_size, _bb); + return this; + } + + public RasterInput get(int j) { + return get(new RasterInput(), j); + } + + public RasterInput get(RasterInput obj, int j) { + return obj.__assign(__indirect(__element(j), bb), bb); + } + } +} diff --git a/src/main/java/com/hfstudio/guidenh/guide/layout/flatbuffers/RasterResult.java b/src/main/java/com/hfstudio/guidenh/guide/layout/flatbuffers/RasterResult.java new file mode 100644 index 00000000..ce37c00a --- /dev/null +++ b/src/main/java/com/hfstudio/guidenh/guide/layout/flatbuffers/RasterResult.java @@ -0,0 +1,107 @@ +// automatically generated by the FlatBuffers compiler, do not modify + +package com.hfstudio.guidenh.guide.layout.flatbuffers; + +import java.nio.ByteBuffer; +import java.nio.ByteOrder; + +import com.google.flatbuffers.BaseVector; +import com.google.flatbuffers.Constants; +import com.google.flatbuffers.FlatBufferBuilder; +import com.google.flatbuffers.Table; + +@SuppressWarnings("unused") +public final class RasterResult extends Table { + + public static void ValidateVersion() { + Constants.FLATBUFFERS_23_5_26(); + } + + public static RasterResult getRootAsRasterResult(ByteBuffer _bb) { + return getRootAsRasterResult(_bb, new RasterResult()); + } + + public static RasterResult getRootAsRasterResult(ByteBuffer _bb, RasterResult obj) { + _bb.order(ByteOrder.LITTLE_ENDIAN); + return (obj.__assign(_bb.getInt(_bb.position()) + _bb.position(), _bb)); + } + + public void __init(int _i, ByteBuffer _bb) { + __reset(_i, _bb); + } + + public RasterResult __assign(int _i, ByteBuffer _bb) { + __init(_i, _bb); + return this; + } + + public com.hfstudio.guidenh.guide.layout.flatbuffers.GlyphPixels glyphs(int j) { + return glyphs(new com.hfstudio.guidenh.guide.layout.flatbuffers.GlyphPixels(), j); + } + + public com.hfstudio.guidenh.guide.layout.flatbuffers.GlyphPixels glyphs( + com.hfstudio.guidenh.guide.layout.flatbuffers.GlyphPixels obj, int j) { + int o = __offset(4); + return o != 0 ? obj.__assign(__indirect(__vector(o) + j * 4), bb) : null; + } + + public int glyphsLength() { + int o = __offset(4); + return o != 0 ? __vector_len(o) : 0; + } + + public com.hfstudio.guidenh.guide.layout.flatbuffers.GlyphPixels.Vector glyphsVector() { + return glyphsVector(new com.hfstudio.guidenh.guide.layout.flatbuffers.GlyphPixels.Vector()); + } + + public com.hfstudio.guidenh.guide.layout.flatbuffers.GlyphPixels.Vector glyphsVector( + com.hfstudio.guidenh.guide.layout.flatbuffers.GlyphPixels.Vector obj) { + int o = __offset(4); + return o != 0 ? obj.__assign(__vector(o), 4, bb) : null; + } + + public static int createRasterResult(FlatBufferBuilder builder, int glyphsOffset) { + builder.startTable(1); + RasterResult.addGlyphs(builder, glyphsOffset); + return RasterResult.endRasterResult(builder); + } + + public static void startRasterResult(FlatBufferBuilder builder) { + builder.startTable(1); + } + + public static void addGlyphs(FlatBufferBuilder builder, int glyphsOffset) { + builder.addOffset(0, glyphsOffset, 0); + } + + public static int createGlyphsVector(FlatBufferBuilder builder, int[] data) { + builder.startVector(4, data.length, 4); + for (int i = data.length - 1; i >= 0; i--) builder.addOffset(data[i]); + return builder.endVector(); + } + + public static void startGlyphsVector(FlatBufferBuilder builder, int numElems) { + builder.startVector(4, numElems, 4); + } + + public static int endRasterResult(FlatBufferBuilder builder) { + int o = builder.endTable(); + return o; + } + + public static final class Vector extends BaseVector { + + public Vector __assign(int _vector, int _element_size, ByteBuffer _bb) { + __reset(_vector, _element_size, _bb); + return this; + } + + public RasterResult get(int j) { + return get(new RasterResult(), j); + } + + public RasterResult get(RasterResult obj, int j) { + return obj.__assign(__indirect(__element(j), bb), bb); + } + } +} diff --git a/src/main/java/com/hfstudio/guidenh/guide/layout/flatbuffers/RecipeBoxData.java b/src/main/java/com/hfstudio/guidenh/guide/layout/flatbuffers/RecipeBoxData.java new file mode 100644 index 00000000..021838bf --- /dev/null +++ b/src/main/java/com/hfstudio/guidenh/guide/layout/flatbuffers/RecipeBoxData.java @@ -0,0 +1,173 @@ +// automatically generated by the FlatBuffers compiler, do not modify + +package com.hfstudio.guidenh.guide.layout.flatbuffers; + +import java.nio.ByteBuffer; +import java.nio.ByteOrder; + +import com.google.flatbuffers.BaseVector; +import com.google.flatbuffers.Constants; +import com.google.flatbuffers.FlatBufferBuilder; +import com.google.flatbuffers.Table; + +@SuppressWarnings("unused") +public final class RecipeBoxData extends Table { + + public static void ValidateVersion() { + Constants.FLATBUFFERS_23_5_26(); + } + + public static RecipeBoxData getRootAsRecipeBoxData(ByteBuffer _bb) { + return getRootAsRecipeBoxData(_bb, new RecipeBoxData()); + } + + public static RecipeBoxData getRootAsRecipeBoxData(ByteBuffer _bb, RecipeBoxData obj) { + _bb.order(ByteOrder.LITTLE_ENDIAN); + return (obj.__assign(_bb.getInt(_bb.position()) + _bb.position(), _bb)); + } + + public void __init(int _i, ByteBuffer _bb) { + __reset(_i, _bb); + } + + public RecipeBoxData __assign(int _i, ByteBuffer _bb) { + __init(_i, _bb); + return this; + } + + /** + * Handler-reported body content width (px), from registry.lookupRecipeHandlerWidth. + */ + public float bodyWidth() { + int o = __offset(4); + return o != 0 ? bb.getFloat(o + bb_pos) : 0.0f; + } + + /** + * Resolved body content height (px), from NeiRecipeLayoutMetrics.resolveBodyHeight. + */ + public float bodyHeight() { + int o = __offset(6); + return o != 0 ? bb.getFloat(o + bb_pos) : 0.0f; + } + + /** + * Additional top inset for GregTech handler (0 for normal handlers). + */ + public float bodyTopInset() { + int o = __offset(8); + return o != 0 ? bb.getFloat(o + bb_pos) : 0.0f; + } + + /** + * Vertical shift applied by the handler registry. + */ + public float bodyYShift() { + int o = __offset(10); + return o != 0 ? bb.getFloat(o + bb_pos) : 0.0f; + } + + /** + * Title text pixel width, computed via Minecraft font metrics (Java-only). + */ + public float titleTextWidth() { + int o = __offset(12); + return o != 0 ? bb.getFloat(o + bb_pos) : 0.0f; + } + + /** + * Icon display size: 8 if an icon (stack or image) is present, else 0. + */ + public float iconSize() { + int o = __offset(14); + return o != 0 ? bb.getFloat(o + bb_pos) : 0.0f; + } + + /** + * Whether the action button (jump to NEI) is shown. + */ + public boolean recipeJumpEnabled() { + int o = __offset(16); + return o != 0 ? 0 != bb.get(o + bb_pos) : false; + } + + /** + * Title bar height = Math.max(ICON_SIZE, FONT_HEIGHT) + TITLE_PAD_TOP + TITLE_PAD_BOTTOM. + */ + public float titleHeight() { + int o = __offset(18); + return o != 0 ? bb.getFloat(o + bb_pos) : 0.0f; + } + + public static int createRecipeBoxData(FlatBufferBuilder builder, float bodyWidth, float bodyHeight, + float bodyTopInset, float bodyYShift, float titleTextWidth, float iconSize, boolean recipeJumpEnabled, + float titleHeight) { + builder.startTable(8); + RecipeBoxData.addTitleHeight(builder, titleHeight); + RecipeBoxData.addIconSize(builder, iconSize); + RecipeBoxData.addTitleTextWidth(builder, titleTextWidth); + RecipeBoxData.addBodyYShift(builder, bodyYShift); + RecipeBoxData.addBodyTopInset(builder, bodyTopInset); + RecipeBoxData.addBodyHeight(builder, bodyHeight); + RecipeBoxData.addBodyWidth(builder, bodyWidth); + RecipeBoxData.addRecipeJumpEnabled(builder, recipeJumpEnabled); + return RecipeBoxData.endRecipeBoxData(builder); + } + + public static void startRecipeBoxData(FlatBufferBuilder builder) { + builder.startTable(8); + } + + public static void addBodyWidth(FlatBufferBuilder builder, float bodyWidth) { + builder.addFloat(0, bodyWidth, 0.0f); + } + + public static void addBodyHeight(FlatBufferBuilder builder, float bodyHeight) { + builder.addFloat(1, bodyHeight, 0.0f); + } + + public static void addBodyTopInset(FlatBufferBuilder builder, float bodyTopInset) { + builder.addFloat(2, bodyTopInset, 0.0f); + } + + public static void addBodyYShift(FlatBufferBuilder builder, float bodyYShift) { + builder.addFloat(3, bodyYShift, 0.0f); + } + + public static void addTitleTextWidth(FlatBufferBuilder builder, float titleTextWidth) { + builder.addFloat(4, titleTextWidth, 0.0f); + } + + public static void addIconSize(FlatBufferBuilder builder, float iconSize) { + builder.addFloat(5, iconSize, 0.0f); + } + + public static void addRecipeJumpEnabled(FlatBufferBuilder builder, boolean recipeJumpEnabled) { + builder.addBoolean(6, recipeJumpEnabled, false); + } + + public static void addTitleHeight(FlatBufferBuilder builder, float titleHeight) { + builder.addFloat(7, titleHeight, 0.0f); + } + + public static int endRecipeBoxData(FlatBufferBuilder builder) { + int o = builder.endTable(); + return o; + } + + public static final class Vector extends BaseVector { + + public Vector __assign(int _vector, int _element_size, ByteBuffer _bb) { + __reset(_vector, _element_size, _bb); + return this; + } + + public RecipeBoxData get(int j) { + return get(new RecipeBoxData(), j); + } + + public RecipeBoxData get(RecipeBoxData obj, int j) { + return obj.__assign(__indirect(__element(j), bb), bb); + } + } +} diff --git a/src/main/java/com/hfstudio/guidenh/guide/layout/flatbuffers/RenderGlyph.java b/src/main/java/com/hfstudio/guidenh/guide/layout/flatbuffers/RenderGlyph.java new file mode 100644 index 00000000..37b7b4e3 --- /dev/null +++ b/src/main/java/com/hfstudio/guidenh/guide/layout/flatbuffers/RenderGlyph.java @@ -0,0 +1,152 @@ +// automatically generated by the FlatBuffers compiler, do not modify + +package com.hfstudio.guidenh.guide.layout.flatbuffers; + +import java.nio.ByteBuffer; +import java.nio.ByteOrder; + +import com.google.flatbuffers.BaseVector; +import com.google.flatbuffers.ByteVector; +import com.google.flatbuffers.Constants; +import com.google.flatbuffers.FlatBufferBuilder; +import com.google.flatbuffers.Table; + +@SuppressWarnings("unused") +public final class RenderGlyph extends Table { + + public static void ValidateVersion() { + Constants.FLATBUFFERS_23_5_26(); + } + + public static RenderGlyph getRootAsRenderGlyph(ByteBuffer _bb) { + return getRootAsRenderGlyph(_bb, new RenderGlyph()); + } + + public static RenderGlyph getRootAsRenderGlyph(ByteBuffer _bb, RenderGlyph obj) { + _bb.order(ByteOrder.LITTLE_ENDIAN); + return (obj.__assign(_bb.getInt(_bb.position()) + _bb.position(), _bb)); + } + + public void __init(int _i, ByteBuffer _bb) { + __reset(_i, _bb); + } + + public RenderGlyph __assign(int _i, ByteBuffer _bb) { + __init(_i, _bb); + return this; + } + + public int x() { + int o = __offset(4); + return o != 0 ? bb.getInt(o + bb_pos) : 0; + } + + public int y() { + int o = __offset(6); + return o != 0 ? bb.getInt(o + bb_pos) : 0; + } + + public long w() { + int o = __offset(8); + return o != 0 ? (long) bb.getInt(o + bb_pos) & 0xFFFFFFFFL : 0L; + } + + public long h() { + int o = __offset(10); + return o != 0 ? (long) bb.getInt(o + bb_pos) & 0xFFFFFFFFL : 0L; + } + + public int rgba(int j) { + int o = __offset(12); + return o != 0 ? bb.get(__vector(o) + j * 1) & 0xFF : 0; + } + + public int rgbaLength() { + int o = __offset(12); + return o != 0 ? __vector_len(o) : 0; + } + + public ByteVector rgbaVector() { + return rgbaVector(new ByteVector()); + } + + public ByteVector rgbaVector(ByteVector obj) { + int o = __offset(12); + return o != 0 ? obj.__assign(__vector(o), bb) : null; + } + + public ByteBuffer rgbaAsByteBuffer() { + return __vector_as_bytebuffer(12, 1); + } + + public ByteBuffer rgbaInByteBuffer(ByteBuffer _bb) { + return __vector_in_bytebuffer(_bb, 12, 1); + } + + public static int createRenderGlyph(FlatBufferBuilder builder, int x, int y, long w, long h, int rgbaOffset) { + builder.startTable(5); + RenderGlyph.addRgba(builder, rgbaOffset); + RenderGlyph.addH(builder, h); + RenderGlyph.addW(builder, w); + RenderGlyph.addY(builder, y); + RenderGlyph.addX(builder, x); + return RenderGlyph.endRenderGlyph(builder); + } + + public static void startRenderGlyph(FlatBufferBuilder builder) { + builder.startTable(5); + } + + public static void addX(FlatBufferBuilder builder, int x) { + builder.addInt(0, x, 0); + } + + public static void addY(FlatBufferBuilder builder, int y) { + builder.addInt(1, y, 0); + } + + public static void addW(FlatBufferBuilder builder, long w) { + builder.addInt(2, (int) w, (int) 0L); + } + + public static void addH(FlatBufferBuilder builder, long h) { + builder.addInt(3, (int) h, (int) 0L); + } + + public static void addRgba(FlatBufferBuilder builder, int rgbaOffset) { + builder.addOffset(4, rgbaOffset, 0); + } + + public static int createRgbaVector(FlatBufferBuilder builder, byte[] data) { + return builder.createByteVector(data); + } + + public static int createRgbaVector(FlatBufferBuilder builder, ByteBuffer data) { + return builder.createByteVector(data); + } + + public static void startRgbaVector(FlatBufferBuilder builder, int numElems) { + builder.startVector(1, numElems, 1); + } + + public static int endRenderGlyph(FlatBufferBuilder builder) { + int o = builder.endTable(); + return o; + } + + public static final class Vector extends BaseVector { + + public Vector __assign(int _vector, int _element_size, ByteBuffer _bb) { + __reset(_vector, _element_size, _bb); + return this; + } + + public RenderGlyph get(int j) { + return get(new RenderGlyph(), j); + } + + public RenderGlyph get(RenderGlyph obj, int j) { + return obj.__assign(__indirect(__element(j), bb), bb); + } + } +} diff --git a/src/main/java/com/hfstudio/guidenh/guide/layout/flatbuffers/RenderResult.java b/src/main/java/com/hfstudio/guidenh/guide/layout/flatbuffers/RenderResult.java new file mode 100644 index 00000000..a06681a0 --- /dev/null +++ b/src/main/java/com/hfstudio/guidenh/guide/layout/flatbuffers/RenderResult.java @@ -0,0 +1,127 @@ +// automatically generated by the FlatBuffers compiler, do not modify + +package com.hfstudio.guidenh.guide.layout.flatbuffers; + +import java.nio.ByteBuffer; +import java.nio.ByteOrder; + +import com.google.flatbuffers.BaseVector; +import com.google.flatbuffers.Constants; +import com.google.flatbuffers.FlatBufferBuilder; +import com.google.flatbuffers.Table; + +@SuppressWarnings("unused") +public final class RenderResult extends Table { + + public static void ValidateVersion() { + Constants.FLATBUFFERS_23_5_26(); + } + + public static RenderResult getRootAsRenderResult(ByteBuffer _bb) { + return getRootAsRenderResult(_bb, new RenderResult()); + } + + public static RenderResult getRootAsRenderResult(ByteBuffer _bb, RenderResult obj) { + _bb.order(ByteOrder.LITTLE_ENDIAN); + return (obj.__assign(_bb.getInt(_bb.position()) + _bb.position(), _bb)); + } + + public void __init(int _i, ByteBuffer _bb) { + __reset(_i, _bb); + } + + public RenderResult __assign(int _i, ByteBuffer _bb) { + __init(_i, _bb); + return this; + } + + public float width() { + int o = __offset(4); + return o != 0 ? bb.getFloat(o + bb_pos) : 0.0f; + } + + public float height() { + int o = __offset(6); + return o != 0 ? bb.getFloat(o + bb_pos) : 0.0f; + } + + public com.hfstudio.guidenh.guide.layout.flatbuffers.RenderGlyph glyphs(int j) { + return glyphs(new com.hfstudio.guidenh.guide.layout.flatbuffers.RenderGlyph(), j); + } + + public com.hfstudio.guidenh.guide.layout.flatbuffers.RenderGlyph glyphs( + com.hfstudio.guidenh.guide.layout.flatbuffers.RenderGlyph obj, int j) { + int o = __offset(8); + return o != 0 ? obj.__assign(__indirect(__vector(o) + j * 4), bb) : null; + } + + public int glyphsLength() { + int o = __offset(8); + return o != 0 ? __vector_len(o) : 0; + } + + public com.hfstudio.guidenh.guide.layout.flatbuffers.RenderGlyph.Vector glyphsVector() { + return glyphsVector(new com.hfstudio.guidenh.guide.layout.flatbuffers.RenderGlyph.Vector()); + } + + public com.hfstudio.guidenh.guide.layout.flatbuffers.RenderGlyph.Vector glyphsVector( + com.hfstudio.guidenh.guide.layout.flatbuffers.RenderGlyph.Vector obj) { + int o = __offset(8); + return o != 0 ? obj.__assign(__vector(o), 4, bb) : null; + } + + public static int createRenderResult(FlatBufferBuilder builder, float width, float height, int glyphsOffset) { + builder.startTable(3); + RenderResult.addGlyphs(builder, glyphsOffset); + RenderResult.addHeight(builder, height); + RenderResult.addWidth(builder, width); + return RenderResult.endRenderResult(builder); + } + + public static void startRenderResult(FlatBufferBuilder builder) { + builder.startTable(3); + } + + public static void addWidth(FlatBufferBuilder builder, float width) { + builder.addFloat(0, width, 0.0f); + } + + public static void addHeight(FlatBufferBuilder builder, float height) { + builder.addFloat(1, height, 0.0f); + } + + public static void addGlyphs(FlatBufferBuilder builder, int glyphsOffset) { + builder.addOffset(2, glyphsOffset, 0); + } + + public static int createGlyphsVector(FlatBufferBuilder builder, int[] data) { + builder.startVector(4, data.length, 4); + for (int i = data.length - 1; i >= 0; i--) builder.addOffset(data[i]); + return builder.endVector(); + } + + public static void startGlyphsVector(FlatBufferBuilder builder, int numElems) { + builder.startVector(4, numElems, 4); + } + + public static int endRenderResult(FlatBufferBuilder builder) { + int o = builder.endTable(); + return o; + } + + public static final class Vector extends BaseVector { + + public Vector __assign(int _vector, int _element_size, ByteBuffer _bb) { + __reset(_vector, _element_size, _bb); + return this; + } + + public RenderResult get(int j) { + return get(new RenderResult(), j); + } + + public RenderResult get(RenderResult obj, int j) { + return obj.__assign(__indirect(__element(j), bb), bb); + } + } +} diff --git a/src/main/java/com/hfstudio/guidenh/guide/layout/flatbuffers/ShapeTextInput.java b/src/main/java/com/hfstudio/guidenh/guide/layout/flatbuffers/ShapeTextInput.java new file mode 100644 index 00000000..c95dc56e --- /dev/null +++ b/src/main/java/com/hfstudio/guidenh/guide/layout/flatbuffers/ShapeTextInput.java @@ -0,0 +1,127 @@ +// automatically generated by the FlatBuffers compiler, do not modify + +package com.hfstudio.guidenh.guide.layout.flatbuffers; + +import java.nio.ByteBuffer; +import java.nio.ByteOrder; + +import com.google.flatbuffers.BaseVector; +import com.google.flatbuffers.Constants; +import com.google.flatbuffers.FlatBufferBuilder; +import com.google.flatbuffers.Table; + +/** + * One-off text shaping for the GuideText unified entry: same shaping and + * rasterization as the layout pipeline's text path, returning atlas-keyed + * quads (buffer-local, origin = text top-left) so Java can reuse the shared + * glyph atlas and DrawGlyphRun execution as-is. + */ +@SuppressWarnings("unused") +public final class ShapeTextInput extends Table { + + public static void ValidateVersion() { + Constants.FLATBUFFERS_23_5_26(); + } + + public static ShapeTextInput getRootAsShapeTextInput(ByteBuffer _bb) { + return getRootAsShapeTextInput(_bb, new ShapeTextInput()); + } + + public static ShapeTextInput getRootAsShapeTextInput(ByteBuffer _bb, ShapeTextInput obj) { + _bb.order(ByteOrder.LITTLE_ENDIAN); + return (obj.__assign(_bb.getInt(_bb.position()) + _bb.position(), _bb)); + } + + public void __init(int _i, ByteBuffer _bb) { + __reset(_i, _bb); + } + + public ShapeTextInput __assign(int _i, ByteBuffer _bb) { + __init(_i, _bb); + return this; + } + + public String text() { + int o = __offset(4); + return o != 0 ? __string(o + bb_pos) : null; + } + + public ByteBuffer textAsByteBuffer() { + return __vector_as_bytebuffer(4, 1); + } + + public ByteBuffer textInByteBuffer(ByteBuffer _bb) { + return __vector_in_bytebuffer(_bb, 4, 1); + } + + public com.hfstudio.guidenh.guide.layout.flatbuffers.TextStyle style() { + return style(new com.hfstudio.guidenh.guide.layout.flatbuffers.TextStyle()); + } + + public com.hfstudio.guidenh.guide.layout.flatbuffers.TextStyle style( + com.hfstudio.guidenh.guide.layout.flatbuffers.TextStyle obj) { + int o = __offset(6); + return o != 0 ? obj.__assign(__indirect(o + bb_pos), bb) : null; + } + + public float maxWidth() { + int o = __offset(8); + return o != 0 ? bb.getFloat(o + bb_pos) : -1.0f; + } + + public float renderScale() { + int o = __offset(10); + return o != 0 ? bb.getFloat(o + bb_pos) : 1.0f; + } + + public static int createShapeTextInput(FlatBufferBuilder builder, int textOffset, int styleOffset, float maxWidth, + float renderScale) { + builder.startTable(4); + ShapeTextInput.addRenderScale(builder, renderScale); + ShapeTextInput.addMaxWidth(builder, maxWidth); + ShapeTextInput.addStyle(builder, styleOffset); + ShapeTextInput.addText(builder, textOffset); + return ShapeTextInput.endShapeTextInput(builder); + } + + public static void startShapeTextInput(FlatBufferBuilder builder) { + builder.startTable(4); + } + + public static void addText(FlatBufferBuilder builder, int textOffset) { + builder.addOffset(0, textOffset, 0); + } + + public static void addStyle(FlatBufferBuilder builder, int styleOffset) { + builder.addOffset(1, styleOffset, 0); + } + + public static void addMaxWidth(FlatBufferBuilder builder, float maxWidth) { + builder.addFloat(2, maxWidth, -1.0f); + } + + public static void addRenderScale(FlatBufferBuilder builder, float renderScale) { + builder.addFloat(3, renderScale, 1.0f); + } + + public static int endShapeTextInput(FlatBufferBuilder builder) { + int o = builder.endTable(); + return o; + } + + public static final class Vector extends BaseVector { + + public Vector __assign(int _vector, int _element_size, ByteBuffer _bb) { + __reset(_vector, _element_size, _bb); + return this; + } + + public ShapeTextInput get(int j) { + return get(new ShapeTextInput(), j); + } + + public ShapeTextInput get(ShapeTextInput obj, int j) { + return obj.__assign(__indirect(__element(j), bb), bb); + } + } +} diff --git a/src/main/java/com/hfstudio/guidenh/guide/layout/flatbuffers/ShapeTextResult.java b/src/main/java/com/hfstudio/guidenh/guide/layout/flatbuffers/ShapeTextResult.java new file mode 100644 index 00000000..eff169a7 --- /dev/null +++ b/src/main/java/com/hfstudio/guidenh/guide/layout/flatbuffers/ShapeTextResult.java @@ -0,0 +1,208 @@ +// automatically generated by the FlatBuffers compiler, do not modify + +package com.hfstudio.guidenh.guide.layout.flatbuffers; + +import java.nio.ByteBuffer; +import java.nio.ByteOrder; + +import com.google.flatbuffers.BaseVector; +import com.google.flatbuffers.Constants; +import com.google.flatbuffers.FlatBufferBuilder; +import com.google.flatbuffers.Table; + +@SuppressWarnings("unused") +public final class ShapeTextResult extends Table { + + public static void ValidateVersion() { + Constants.FLATBUFFERS_23_5_26(); + } + + public static ShapeTextResult getRootAsShapeTextResult(ByteBuffer _bb) { + return getRootAsShapeTextResult(_bb, new ShapeTextResult()); + } + + public static ShapeTextResult getRootAsShapeTextResult(ByteBuffer _bb, ShapeTextResult obj) { + _bb.order(ByteOrder.LITTLE_ENDIAN); + return (obj.__assign(_bb.getInt(_bb.position()) + _bb.position(), _bb)); + } + + public void __init(int _i, ByteBuffer _bb) { + __reset(_i, _bb); + } + + public ShapeTextResult __assign(int _i, ByteBuffer _bb) { + __init(_i, _bb); + return this; + } + + public float width() { + int o = __offset(4); + return o != 0 ? bb.getFloat(o + bb_pos) : 0.0f; + } + + public float height() { + int o = __offset(6); + return o != 0 ? bb.getFloat(o + bb_pos) : 0.0f; + } + + public float ascent() { + int o = __offset(8); + return o != 0 ? bb.getFloat(o + bb_pos) : 0.0f; + } + + public float lineHeight() { + int o = __offset(10); + return o != 0 ? bb.getFloat(o + bb_pos) : 0.0f; + } + + public com.hfstudio.guidenh.guide.layout.flatbuffers.PlacedGlyph glyphs(int j) { + return glyphs(new com.hfstudio.guidenh.guide.layout.flatbuffers.PlacedGlyph(), j); + } + + public com.hfstudio.guidenh.guide.layout.flatbuffers.PlacedGlyph glyphs( + com.hfstudio.guidenh.guide.layout.flatbuffers.PlacedGlyph obj, int j) { + int o = __offset(12); + return o != 0 ? obj.__assign(__indirect(__vector(o) + j * 4), bb) : null; + } + + public int glyphsLength() { + int o = __offset(12); + return o != 0 ? __vector_len(o) : 0; + } + + public com.hfstudio.guidenh.guide.layout.flatbuffers.PlacedGlyph.Vector glyphsVector() { + return glyphsVector(new com.hfstudio.guidenh.guide.layout.flatbuffers.PlacedGlyph.Vector()); + } + + public com.hfstudio.guidenh.guide.layout.flatbuffers.PlacedGlyph.Vector glyphsVector( + com.hfstudio.guidenh.guide.layout.flatbuffers.PlacedGlyph.Vector obj) { + int o = __offset(12); + return o != 0 ? obj.__assign(__vector(o), 4, bb) : null; + } + + public com.hfstudio.guidenh.guide.layout.flatbuffers.GlyphBitmap bitmaps(int j) { + return bitmaps(new com.hfstudio.guidenh.guide.layout.flatbuffers.GlyphBitmap(), j); + } + + public com.hfstudio.guidenh.guide.layout.flatbuffers.GlyphBitmap bitmaps( + com.hfstudio.guidenh.guide.layout.flatbuffers.GlyphBitmap obj, int j) { + int o = __offset(14); + return o != 0 ? obj.__assign(__indirect(__vector(o) + j * 4), bb) : null; + } + + public int bitmapsLength() { + int o = __offset(14); + return o != 0 ? __vector_len(o) : 0; + } + + public com.hfstudio.guidenh.guide.layout.flatbuffers.GlyphBitmap.Vector bitmapsVector() { + return bitmapsVector(new com.hfstudio.guidenh.guide.layout.flatbuffers.GlyphBitmap.Vector()); + } + + public com.hfstudio.guidenh.guide.layout.flatbuffers.GlyphBitmap.Vector bitmapsVector( + com.hfstudio.guidenh.guide.layout.flatbuffers.GlyphBitmap.Vector obj) { + int o = __offset(14); + return o != 0 ? obj.__assign(__vector(o), 4, bb) : null; + } + + public float xHeight() { + int o = __offset(16); + return o != 0 ? bb.getFloat(o + bb_pos) : 0.0f; + } + + public float capHeight() { + int o = __offset(18); + return o != 0 ? bb.getFloat(o + bb_pos) : 0.0f; + } + + public static int createShapeTextResult(FlatBufferBuilder builder, float width, float height, float ascent, + float lineHeight, int glyphsOffset, int bitmapsOffset, float xHeight, float capHeight) { + builder.startTable(8); + ShapeTextResult.addCapHeight(builder, capHeight); + ShapeTextResult.addXHeight(builder, xHeight); + ShapeTextResult.addBitmaps(builder, bitmapsOffset); + ShapeTextResult.addGlyphs(builder, glyphsOffset); + ShapeTextResult.addLineHeight(builder, lineHeight); + ShapeTextResult.addAscent(builder, ascent); + ShapeTextResult.addHeight(builder, height); + ShapeTextResult.addWidth(builder, width); + return ShapeTextResult.endShapeTextResult(builder); + } + + public static void startShapeTextResult(FlatBufferBuilder builder) { + builder.startTable(8); + } + + public static void addWidth(FlatBufferBuilder builder, float width) { + builder.addFloat(0, width, 0.0f); + } + + public static void addHeight(FlatBufferBuilder builder, float height) { + builder.addFloat(1, height, 0.0f); + } + + public static void addAscent(FlatBufferBuilder builder, float ascent) { + builder.addFloat(2, ascent, 0.0f); + } + + public static void addLineHeight(FlatBufferBuilder builder, float lineHeight) { + builder.addFloat(3, lineHeight, 0.0f); + } + + public static void addGlyphs(FlatBufferBuilder builder, int glyphsOffset) { + builder.addOffset(4, glyphsOffset, 0); + } + + public static int createGlyphsVector(FlatBufferBuilder builder, int[] data) { + builder.startVector(4, data.length, 4); + for (int i = data.length - 1; i >= 0; i--) builder.addOffset(data[i]); + return builder.endVector(); + } + + public static void startGlyphsVector(FlatBufferBuilder builder, int numElems) { + builder.startVector(4, numElems, 4); + } + + public static void addBitmaps(FlatBufferBuilder builder, int bitmapsOffset) { + builder.addOffset(5, bitmapsOffset, 0); + } + + public static int createBitmapsVector(FlatBufferBuilder builder, int[] data) { + builder.startVector(4, data.length, 4); + for (int i = data.length - 1; i >= 0; i--) builder.addOffset(data[i]); + return builder.endVector(); + } + + public static void startBitmapsVector(FlatBufferBuilder builder, int numElems) { + builder.startVector(4, numElems, 4); + } + + public static void addXHeight(FlatBufferBuilder builder, float xHeight) { + builder.addFloat(6, xHeight, 0.0f); + } + + public static void addCapHeight(FlatBufferBuilder builder, float capHeight) { + builder.addFloat(7, capHeight, 0.0f); + } + + public static int endShapeTextResult(FlatBufferBuilder builder) { + int o = builder.endTable(); + return o; + } + + public static final class Vector extends BaseVector { + + public Vector __assign(int _vector, int _element_size, ByteBuffer _bb) { + __reset(_vector, _element_size, _bb); + return this; + } + + public ShapeTextResult get(int j) { + return get(new ShapeTextResult(), j); + } + + public ShapeTextResult get(ShapeTextResult obj, int j) { + return obj.__assign(__indirect(__element(j), bb), bb); + } + } +} diff --git a/src/main/java/com/hfstudio/guidenh/guide/layout/flatbuffers/SlotData.java b/src/main/java/com/hfstudio/guidenh/guide/layout/flatbuffers/SlotData.java new file mode 100644 index 00000000..68e58e4f --- /dev/null +++ b/src/main/java/com/hfstudio/guidenh/guide/layout/flatbuffers/SlotData.java @@ -0,0 +1,77 @@ +// automatically generated by the FlatBuffers compiler, do not modify + +package com.hfstudio.guidenh.guide.layout.flatbuffers; + +import java.nio.ByteBuffer; +import java.nio.ByteOrder; + +import com.google.flatbuffers.BaseVector; +import com.google.flatbuffers.Constants; +import com.google.flatbuffers.FlatBufferBuilder; +import com.google.flatbuffers.Table; + +@SuppressWarnings("unused") +public final class SlotData extends Table { + + public static void ValidateVersion() { + Constants.FLATBUFFERS_23_5_26(); + } + + public static SlotData getRootAsSlotData(ByteBuffer _bb) { + return getRootAsSlotData(_bb, new SlotData()); + } + + public static SlotData getRootAsSlotData(ByteBuffer _bb, SlotData obj) { + _bb.order(ByteOrder.LITTLE_ENDIAN); + return (obj.__assign(_bb.getInt(_bb.position()) + _bb.position(), _bb)); + } + + public void __init(int _i, ByteBuffer _bb) { + __reset(_i, _bb); + } + + public SlotData __assign(int _i, ByteBuffer _bb) { + __init(_i, _bb); + return this; + } + + public float slotSize() { + int o = __offset(4); + return o != 0 ? bb.getFloat(o + bb_pos) : 18.0f; + } + + public static int createSlotData(FlatBufferBuilder builder, float slotSize) { + builder.startTable(1); + SlotData.addSlotSize(builder, slotSize); + return SlotData.endSlotData(builder); + } + + public static void startSlotData(FlatBufferBuilder builder) { + builder.startTable(1); + } + + public static void addSlotSize(FlatBufferBuilder builder, float slotSize) { + builder.addFloat(0, slotSize, 18.0f); + } + + public static int endSlotData(FlatBufferBuilder builder) { + int o = builder.endTable(); + return o; + } + + public static final class Vector extends BaseVector { + + public Vector __assign(int _vector, int _element_size, ByteBuffer _bb) { + __reset(_vector, _element_size, _bb); + return this; + } + + public SlotData get(int j) { + return get(new SlotData(), j); + } + + public SlotData get(SlotData obj, int j) { + return obj.__assign(__indirect(__element(j), bb), bb); + } + } +} diff --git a/src/main/java/com/hfstudio/guidenh/guide/layout/flatbuffers/StructureViewData.java b/src/main/java/com/hfstudio/guidenh/guide/layout/flatbuffers/StructureViewData.java new file mode 100644 index 00000000..f4b7c5d8 --- /dev/null +++ b/src/main/java/com/hfstudio/guidenh/guide/layout/flatbuffers/StructureViewData.java @@ -0,0 +1,94 @@ +// automatically generated by the FlatBuffers compiler, do not modify + +package com.hfstudio.guidenh.guide.layout.flatbuffers; + +import java.nio.ByteBuffer; +import java.nio.ByteOrder; + +import com.google.flatbuffers.BaseVector; +import com.google.flatbuffers.Constants; +import com.google.flatbuffers.FlatBufferBuilder; +import com.google.flatbuffers.Table; + +/** + * Isometric structure view sizing data (node_type 26). + * Mirrors LytStructureView.computeLayout: view_width/view_height are the + * block's intrinsic dimensions (setViewSize or DEFAULT_WIDTH/HEIGHT), used + * by the Rust measure function with visual_scale and available_width to + * compute the responsive box. + */ +@SuppressWarnings("unused") +public final class StructureViewData extends Table { + + public static void ValidateVersion() { + Constants.FLATBUFFERS_23_5_26(); + } + + public static StructureViewData getRootAsStructureViewData(ByteBuffer _bb) { + return getRootAsStructureViewData(_bb, new StructureViewData()); + } + + public static StructureViewData getRootAsStructureViewData(ByteBuffer _bb, StructureViewData obj) { + _bb.order(ByteOrder.LITTLE_ENDIAN); + return (obj.__assign(_bb.getInt(_bb.position()) + _bb.position(), _bb)); + } + + public void __init(int _i, ByteBuffer _bb) { + __reset(_i, _bb); + } + + public StructureViewData __assign(int _i, ByteBuffer _bb) { + __init(_i, _bb); + return this; + } + + public float viewWidth() { + int o = __offset(4); + return o != 0 ? bb.getFloat(o + bb_pos) : 0.0f; + } + + public float viewHeight() { + int o = __offset(6); + return o != 0 ? bb.getFloat(o + bb_pos) : 0.0f; + } + + public static int createStructureViewData(FlatBufferBuilder builder, float viewWidth, float viewHeight) { + builder.startTable(2); + StructureViewData.addViewHeight(builder, viewHeight); + StructureViewData.addViewWidth(builder, viewWidth); + return StructureViewData.endStructureViewData(builder); + } + + public static void startStructureViewData(FlatBufferBuilder builder) { + builder.startTable(2); + } + + public static void addViewWidth(FlatBufferBuilder builder, float viewWidth) { + builder.addFloat(0, viewWidth, 0.0f); + } + + public static void addViewHeight(FlatBufferBuilder builder, float viewHeight) { + builder.addFloat(1, viewHeight, 0.0f); + } + + public static int endStructureViewData(FlatBufferBuilder builder) { + int o = builder.endTable(); + return o; + } + + public static final class Vector extends BaseVector { + + public Vector __assign(int _vector, int _element_size, ByteBuffer _bb) { + __reset(_vector, _element_size, _bb); + return this; + } + + public StructureViewData get(int j) { + return get(new StructureViewData(), j); + } + + public StructureViewData get(StructureViewData obj, int j) { + return obj.__assign(__indirect(__element(j), bb), bb); + } + } +} diff --git a/src/main/java/com/hfstudio/guidenh/guide/layout/flatbuffers/Style.java b/src/main/java/com/hfstudio/guidenh/guide/layout/flatbuffers/Style.java new file mode 100644 index 00000000..fa9b008a --- /dev/null +++ b/src/main/java/com/hfstudio/guidenh/guide/layout/flatbuffers/Style.java @@ -0,0 +1,559 @@ +// automatically generated by the FlatBuffers compiler, do not modify + +package com.hfstudio.guidenh.guide.layout.flatbuffers; + +import java.nio.ByteBuffer; +import java.nio.ByteOrder; + +import com.google.flatbuffers.BaseVector; +import com.google.flatbuffers.Constants; +import com.google.flatbuffers.FlatBufferBuilder; +import com.google.flatbuffers.Table; + +@SuppressWarnings("unused") +public final class Style extends Table { + + public static void ValidateVersion() { + Constants.FLATBUFFERS_23_5_26(); + } + + public static Style getRootAsStyle(ByteBuffer _bb) { + return getRootAsStyle(_bb, new Style()); + } + + public static Style getRootAsStyle(ByteBuffer _bb, Style obj) { + _bb.order(ByteOrder.LITTLE_ENDIAN); + return (obj.__assign(_bb.getInt(_bb.position()) + _bb.position(), _bb)); + } + + public void __init(int _i, ByteBuffer _bb) { + __reset(_i, _bb); + } + + public Style __assign(int _i, ByteBuffer _bb) { + __init(_i, _bb); + return this; + } + + public byte display() { + int o = __offset(4); + return o != 0 ? bb.get(o + bb_pos) : 0; + } + + public byte flexDirection() { + int o = __offset(6); + return o != 0 ? bb.get(o + bb_pos) : 1; + } + + public byte flexWrap() { + int o = __offset(8); + return o != 0 ? bb.get(o + bb_pos) : 0; + } + + public byte alignItems() { + int o = __offset(10); + return o != 0 ? bb.get(o + bb_pos) : 0; + } + + public byte alignSelf() { + int o = __offset(12); + return o != 0 ? bb.get(o + bb_pos) : 0; + } + + public byte justifyContent() { + int o = __offset(14); + return o != 0 ? bb.get(o + bb_pos) : 0; + } + + public com.hfstudio.guidenh.guide.layout.flatbuffers.Dimension gapW() { + return gapW(new com.hfstudio.guidenh.guide.layout.flatbuffers.Dimension()); + } + + public com.hfstudio.guidenh.guide.layout.flatbuffers.Dimension gapW( + com.hfstudio.guidenh.guide.layout.flatbuffers.Dimension obj) { + int o = __offset(16); + return o != 0 ? obj.__assign(__indirect(o + bb_pos), bb) : null; + } + + public com.hfstudio.guidenh.guide.layout.flatbuffers.Dimension gapH() { + return gapH(new com.hfstudio.guidenh.guide.layout.flatbuffers.Dimension()); + } + + public com.hfstudio.guidenh.guide.layout.flatbuffers.Dimension gapH( + com.hfstudio.guidenh.guide.layout.flatbuffers.Dimension obj) { + int o = __offset(18); + return o != 0 ? obj.__assign(__indirect(o + bb_pos), bb) : null; + } + + public com.hfstudio.guidenh.guide.layout.flatbuffers.Dimension sizeW() { + return sizeW(new com.hfstudio.guidenh.guide.layout.flatbuffers.Dimension()); + } + + public com.hfstudio.guidenh.guide.layout.flatbuffers.Dimension sizeW( + com.hfstudio.guidenh.guide.layout.flatbuffers.Dimension obj) { + int o = __offset(20); + return o != 0 ? obj.__assign(__indirect(o + bb_pos), bb) : null; + } + + public com.hfstudio.guidenh.guide.layout.flatbuffers.Dimension sizeH() { + return sizeH(new com.hfstudio.guidenh.guide.layout.flatbuffers.Dimension()); + } + + public com.hfstudio.guidenh.guide.layout.flatbuffers.Dimension sizeH( + com.hfstudio.guidenh.guide.layout.flatbuffers.Dimension obj) { + int o = __offset(22); + return o != 0 ? obj.__assign(__indirect(o + bb_pos), bb) : null; + } + + public com.hfstudio.guidenh.guide.layout.flatbuffers.Dimension minW() { + return minW(new com.hfstudio.guidenh.guide.layout.flatbuffers.Dimension()); + } + + public com.hfstudio.guidenh.guide.layout.flatbuffers.Dimension minW( + com.hfstudio.guidenh.guide.layout.flatbuffers.Dimension obj) { + int o = __offset(24); + return o != 0 ? obj.__assign(__indirect(o + bb_pos), bb) : null; + } + + public com.hfstudio.guidenh.guide.layout.flatbuffers.Dimension minH() { + return minH(new com.hfstudio.guidenh.guide.layout.flatbuffers.Dimension()); + } + + public com.hfstudio.guidenh.guide.layout.flatbuffers.Dimension minH( + com.hfstudio.guidenh.guide.layout.flatbuffers.Dimension obj) { + int o = __offset(26); + return o != 0 ? obj.__assign(__indirect(o + bb_pos), bb) : null; + } + + public com.hfstudio.guidenh.guide.layout.flatbuffers.Dimension maxW() { + return maxW(new com.hfstudio.guidenh.guide.layout.flatbuffers.Dimension()); + } + + public com.hfstudio.guidenh.guide.layout.flatbuffers.Dimension maxW( + com.hfstudio.guidenh.guide.layout.flatbuffers.Dimension obj) { + int o = __offset(28); + return o != 0 ? obj.__assign(__indirect(o + bb_pos), bb) : null; + } + + public com.hfstudio.guidenh.guide.layout.flatbuffers.Dimension maxH() { + return maxH(new com.hfstudio.guidenh.guide.layout.flatbuffers.Dimension()); + } + + public com.hfstudio.guidenh.guide.layout.flatbuffers.Dimension maxH( + com.hfstudio.guidenh.guide.layout.flatbuffers.Dimension obj) { + int o = __offset(30); + return o != 0 ? obj.__assign(__indirect(o + bb_pos), bb) : null; + } + + public float aspectRatio() { + int o = __offset(32); + return o != 0 ? bb.getFloat(o + bb_pos) : 0.0f; + } + + public float marginLeft() { + int o = __offset(34); + return o != 0 ? bb.getFloat(o + bb_pos) : 0.0f; + } + + public float marginRight() { + int o = __offset(36); + return o != 0 ? bb.getFloat(o + bb_pos) : 0.0f; + } + + public float marginTop() { + int o = __offset(38); + return o != 0 ? bb.getFloat(o + bb_pos) : 0.0f; + } + + public float marginBottom() { + int o = __offset(40); + return o != 0 ? bb.getFloat(o + bb_pos) : 0.0f; + } + + public boolean marginAutoLeft() { + int o = __offset(42); + return o != 0 ? 0 != bb.get(o + bb_pos) : false; + } + + public boolean marginAutoRight() { + int o = __offset(44); + return o != 0 ? 0 != bb.get(o + bb_pos) : false; + } + + public boolean marginAutoTop() { + int o = __offset(46); + return o != 0 ? 0 != bb.get(o + bb_pos) : false; + } + + public boolean marginAutoBottom() { + int o = __offset(48); + return o != 0 ? 0 != bb.get(o + bb_pos) : false; + } + + public float paddingLeft() { + int o = __offset(50); + return o != 0 ? bb.getFloat(o + bb_pos) : 0.0f; + } + + public float paddingRight() { + int o = __offset(52); + return o != 0 ? bb.getFloat(o + bb_pos) : 0.0f; + } + + public float paddingTop() { + int o = __offset(54); + return o != 0 ? bb.getFloat(o + bb_pos) : 0.0f; + } + + public float paddingBottom() { + int o = __offset(56); + return o != 0 ? bb.getFloat(o + bb_pos) : 0.0f; + } + + public float borderLeft() { + int o = __offset(58); + return o != 0 ? bb.getFloat(o + bb_pos) : 0.0f; + } + + public float borderRight() { + int o = __offset(60); + return o != 0 ? bb.getFloat(o + bb_pos) : 0.0f; + } + + public float borderTop() { + int o = __offset(62); + return o != 0 ? bb.getFloat(o + bb_pos) : 0.0f; + } + + public float borderBottom() { + int o = __offset(64); + return o != 0 ? bb.getFloat(o + bb_pos) : 0.0f; + } + + public byte overflow() { + int o = __offset(66); + return o != 0 ? bb.get(o + bb_pos) : 0; + } + + public float flexGrow() { + int o = __offset(68); + return o != 0 ? bb.getFloat(o + bb_pos) : 0.0f; + } + + public float flexShrink() { + int o = __offset(70); + return o != 0 ? bb.getFloat(o + bb_pos) : 1.0f; + } + + public com.hfstudio.guidenh.guide.layout.flatbuffers.Dimension flexBasis() { + return flexBasis(new com.hfstudio.guidenh.guide.layout.flatbuffers.Dimension()); + } + + public com.hfstudio.guidenh.guide.layout.flatbuffers.Dimension flexBasis( + com.hfstudio.guidenh.guide.layout.flatbuffers.Dimension obj) { + int o = __offset(72); + return o != 0 ? obj.__assign(__indirect(o + bb_pos), bb) : null; + } + + public byte float_() { + int o = __offset(74); + return o != 0 ? bb.get(o + bb_pos) : 0; + } + + public byte clear() { + int o = __offset(76); + return o != 0 ? bb.get(o + bb_pos) : 0; + } + + public byte position() { + int o = __offset(78); + return o != 0 ? bb.get(o + bb_pos) : 0; + } + + public com.hfstudio.guidenh.guide.layout.flatbuffers.Dimension insetTop() { + return insetTop(new com.hfstudio.guidenh.guide.layout.flatbuffers.Dimension()); + } + + public com.hfstudio.guidenh.guide.layout.flatbuffers.Dimension insetTop( + com.hfstudio.guidenh.guide.layout.flatbuffers.Dimension obj) { + int o = __offset(80); + return o != 0 ? obj.__assign(__indirect(o + bb_pos), bb) : null; + } + + public com.hfstudio.guidenh.guide.layout.flatbuffers.Dimension insetRight() { + return insetRight(new com.hfstudio.guidenh.guide.layout.flatbuffers.Dimension()); + } + + public com.hfstudio.guidenh.guide.layout.flatbuffers.Dimension insetRight( + com.hfstudio.guidenh.guide.layout.flatbuffers.Dimension obj) { + int o = __offset(82); + return o != 0 ? obj.__assign(__indirect(o + bb_pos), bb) : null; + } + + public com.hfstudio.guidenh.guide.layout.flatbuffers.Dimension insetBottom() { + return insetBottom(new com.hfstudio.guidenh.guide.layout.flatbuffers.Dimension()); + } + + public com.hfstudio.guidenh.guide.layout.flatbuffers.Dimension insetBottom( + com.hfstudio.guidenh.guide.layout.flatbuffers.Dimension obj) { + int o = __offset(84); + return o != 0 ? obj.__assign(__indirect(o + bb_pos), bb) : null; + } + + public com.hfstudio.guidenh.guide.layout.flatbuffers.Dimension insetLeft() { + return insetLeft(new com.hfstudio.guidenh.guide.layout.flatbuffers.Dimension()); + } + + public com.hfstudio.guidenh.guide.layout.flatbuffers.Dimension insetLeft( + com.hfstudio.guidenh.guide.layout.flatbuffers.Dimension obj) { + int o = __offset(86); + return o != 0 ? obj.__assign(__indirect(o + bb_pos), bb) : null; + } + + public static int createStyle(FlatBufferBuilder builder, byte display, byte flexDirection, byte flexWrap, + byte alignItems, byte alignSelf, byte justifyContent, int gapWOffset, int gapHOffset, int sizeWOffset, + int sizeHOffset, int minWOffset, int minHOffset, int maxWOffset, int maxHOffset, float aspectRatio, + float marginLeft, float marginRight, float marginTop, float marginBottom, boolean marginAutoLeft, + boolean marginAutoRight, boolean marginAutoTop, boolean marginAutoBottom, float paddingLeft, float paddingRight, + float paddingTop, float paddingBottom, float borderLeft, float borderRight, float borderTop, float borderBottom, + byte overflow, float flexGrow, float flexShrink, int flexBasisOffset, byte float_, byte clear, byte position, + int insetTopOffset, int insetRightOffset, int insetBottomOffset, int insetLeftOffset) { + builder.startTable(42); + Style.addInsetLeft(builder, insetLeftOffset); + Style.addInsetBottom(builder, insetBottomOffset); + Style.addInsetRight(builder, insetRightOffset); + Style.addInsetTop(builder, insetTopOffset); + Style.addFlexBasis(builder, flexBasisOffset); + Style.addFlexShrink(builder, flexShrink); + Style.addFlexGrow(builder, flexGrow); + Style.addBorderBottom(builder, borderBottom); + Style.addBorderTop(builder, borderTop); + Style.addBorderRight(builder, borderRight); + Style.addBorderLeft(builder, borderLeft); + Style.addPaddingBottom(builder, paddingBottom); + Style.addPaddingTop(builder, paddingTop); + Style.addPaddingRight(builder, paddingRight); + Style.addPaddingLeft(builder, paddingLeft); + Style.addMarginBottom(builder, marginBottom); + Style.addMarginTop(builder, marginTop); + Style.addMarginRight(builder, marginRight); + Style.addMarginLeft(builder, marginLeft); + Style.addAspectRatio(builder, aspectRatio); + Style.addMaxH(builder, maxHOffset); + Style.addMaxW(builder, maxWOffset); + Style.addMinH(builder, minHOffset); + Style.addMinW(builder, minWOffset); + Style.addSizeH(builder, sizeHOffset); + Style.addSizeW(builder, sizeWOffset); + Style.addGapH(builder, gapHOffset); + Style.addGapW(builder, gapWOffset); + Style.addPosition(builder, position); + Style.addClear(builder, clear); + Style.addFloat(builder, float_); + Style.addOverflow(builder, overflow); + Style.addMarginAutoBottom(builder, marginAutoBottom); + Style.addMarginAutoTop(builder, marginAutoTop); + Style.addMarginAutoRight(builder, marginAutoRight); + Style.addMarginAutoLeft(builder, marginAutoLeft); + Style.addJustifyContent(builder, justifyContent); + Style.addAlignSelf(builder, alignSelf); + Style.addAlignItems(builder, alignItems); + Style.addFlexWrap(builder, flexWrap); + Style.addFlexDirection(builder, flexDirection); + Style.addDisplay(builder, display); + return Style.endStyle(builder); + } + + public static void startStyle(FlatBufferBuilder builder) { + builder.startTable(42); + } + + public static void addDisplay(FlatBufferBuilder builder, byte display) { + builder.addByte(0, display, 0); + } + + public static void addFlexDirection(FlatBufferBuilder builder, byte flexDirection) { + builder.addByte(1, flexDirection, 1); + } + + public static void addFlexWrap(FlatBufferBuilder builder, byte flexWrap) { + builder.addByte(2, flexWrap, 0); + } + + public static void addAlignItems(FlatBufferBuilder builder, byte alignItems) { + builder.addByte(3, alignItems, 0); + } + + public static void addAlignSelf(FlatBufferBuilder builder, byte alignSelf) { + builder.addByte(4, alignSelf, 0); + } + + public static void addJustifyContent(FlatBufferBuilder builder, byte justifyContent) { + builder.addByte(5, justifyContent, 0); + } + + public static void addGapW(FlatBufferBuilder builder, int gapWOffset) { + builder.addOffset(6, gapWOffset, 0); + } + + public static void addGapH(FlatBufferBuilder builder, int gapHOffset) { + builder.addOffset(7, gapHOffset, 0); + } + + public static void addSizeW(FlatBufferBuilder builder, int sizeWOffset) { + builder.addOffset(8, sizeWOffset, 0); + } + + public static void addSizeH(FlatBufferBuilder builder, int sizeHOffset) { + builder.addOffset(9, sizeHOffset, 0); + } + + public static void addMinW(FlatBufferBuilder builder, int minWOffset) { + builder.addOffset(10, minWOffset, 0); + } + + public static void addMinH(FlatBufferBuilder builder, int minHOffset) { + builder.addOffset(11, minHOffset, 0); + } + + public static void addMaxW(FlatBufferBuilder builder, int maxWOffset) { + builder.addOffset(12, maxWOffset, 0); + } + + public static void addMaxH(FlatBufferBuilder builder, int maxHOffset) { + builder.addOffset(13, maxHOffset, 0); + } + + public static void addAspectRatio(FlatBufferBuilder builder, float aspectRatio) { + builder.addFloat(14, aspectRatio, 0.0f); + } + + public static void addMarginLeft(FlatBufferBuilder builder, float marginLeft) { + builder.addFloat(15, marginLeft, 0.0f); + } + + public static void addMarginRight(FlatBufferBuilder builder, float marginRight) { + builder.addFloat(16, marginRight, 0.0f); + } + + public static void addMarginTop(FlatBufferBuilder builder, float marginTop) { + builder.addFloat(17, marginTop, 0.0f); + } + + public static void addMarginBottom(FlatBufferBuilder builder, float marginBottom) { + builder.addFloat(18, marginBottom, 0.0f); + } + + public static void addMarginAutoLeft(FlatBufferBuilder builder, boolean marginAutoLeft) { + builder.addBoolean(19, marginAutoLeft, false); + } + + public static void addMarginAutoRight(FlatBufferBuilder builder, boolean marginAutoRight) { + builder.addBoolean(20, marginAutoRight, false); + } + + public static void addMarginAutoTop(FlatBufferBuilder builder, boolean marginAutoTop) { + builder.addBoolean(21, marginAutoTop, false); + } + + public static void addMarginAutoBottom(FlatBufferBuilder builder, boolean marginAutoBottom) { + builder.addBoolean(22, marginAutoBottom, false); + } + + public static void addPaddingLeft(FlatBufferBuilder builder, float paddingLeft) { + builder.addFloat(23, paddingLeft, 0.0f); + } + + public static void addPaddingRight(FlatBufferBuilder builder, float paddingRight) { + builder.addFloat(24, paddingRight, 0.0f); + } + + public static void addPaddingTop(FlatBufferBuilder builder, float paddingTop) { + builder.addFloat(25, paddingTop, 0.0f); + } + + public static void addPaddingBottom(FlatBufferBuilder builder, float paddingBottom) { + builder.addFloat(26, paddingBottom, 0.0f); + } + + public static void addBorderLeft(FlatBufferBuilder builder, float borderLeft) { + builder.addFloat(27, borderLeft, 0.0f); + } + + public static void addBorderRight(FlatBufferBuilder builder, float borderRight) { + builder.addFloat(28, borderRight, 0.0f); + } + + public static void addBorderTop(FlatBufferBuilder builder, float borderTop) { + builder.addFloat(29, borderTop, 0.0f); + } + + public static void addBorderBottom(FlatBufferBuilder builder, float borderBottom) { + builder.addFloat(30, borderBottom, 0.0f); + } + + public static void addOverflow(FlatBufferBuilder builder, byte overflow) { + builder.addByte(31, overflow, 0); + } + + public static void addFlexGrow(FlatBufferBuilder builder, float flexGrow) { + builder.addFloat(32, flexGrow, 0.0f); + } + + public static void addFlexShrink(FlatBufferBuilder builder, float flexShrink) { + builder.addFloat(33, flexShrink, 1.0f); + } + + public static void addFlexBasis(FlatBufferBuilder builder, int flexBasisOffset) { + builder.addOffset(34, flexBasisOffset, 0); + } + + public static void addFloat(FlatBufferBuilder builder, byte float_) { + builder.addByte(35, float_, 0); + } + + public static void addClear(FlatBufferBuilder builder, byte clear) { + builder.addByte(36, clear, 0); + } + + public static void addPosition(FlatBufferBuilder builder, byte position) { + builder.addByte(37, position, 0); + } + + public static void addInsetTop(FlatBufferBuilder builder, int insetTopOffset) { + builder.addOffset(38, insetTopOffset, 0); + } + + public static void addInsetRight(FlatBufferBuilder builder, int insetRightOffset) { + builder.addOffset(39, insetRightOffset, 0); + } + + public static void addInsetBottom(FlatBufferBuilder builder, int insetBottomOffset) { + builder.addOffset(40, insetBottomOffset, 0); + } + + public static void addInsetLeft(FlatBufferBuilder builder, int insetLeftOffset) { + builder.addOffset(41, insetLeftOffset, 0); + } + + public static int endStyle(FlatBufferBuilder builder) { + int o = builder.endTable(); + return o; + } + + public static final class Vector extends BaseVector { + + public Vector __assign(int _vector, int _element_size, ByteBuffer _bb) { + __reset(_vector, _element_size, _bb); + return this; + } + + public Style get(int j) { + return get(new Style(), j); + } + + public Style get(Style obj, int j) { + return obj.__assign(__indirect(__element(j), bb), bb); + } + } +} diff --git a/src/main/java/com/hfstudio/guidenh/guide/layout/flatbuffers/TextBand.java b/src/main/java/com/hfstudio/guidenh/guide/layout/flatbuffers/TextBand.java new file mode 100644 index 00000000..91442c44 --- /dev/null +++ b/src/main/java/com/hfstudio/guidenh/guide/layout/flatbuffers/TextBand.java @@ -0,0 +1,103 @@ +// automatically generated by the FlatBuffers compiler, do not modify + +package com.hfstudio.guidenh.guide.layout.flatbuffers; + +import java.nio.ByteBuffer; +import java.nio.ByteOrder; + +import com.google.flatbuffers.BaseVector; +import com.google.flatbuffers.Constants; +import com.google.flatbuffers.FlatBufferBuilder; +import com.google.flatbuffers.Table; + +/** + * One float-wrap band: the paragraph's text is shaped in sequential bands at + * per-band widths (CSS float wrapping: narrow beside the float, full width + * below it). Bands are ordered; the first band starts at split_byte 0, each + * later band starts at its split_byte and continues to the next band's start. + */ +@SuppressWarnings("unused") +public final class TextBand extends Table { + + public static void ValidateVersion() { + Constants.FLATBUFFERS_23_5_26(); + } + + public static TextBand getRootAsTextBand(ByteBuffer _bb) { + return getRootAsTextBand(_bb, new TextBand()); + } + + public static TextBand getRootAsTextBand(ByteBuffer _bb, TextBand obj) { + _bb.order(ByteOrder.LITTLE_ENDIAN); + return (obj.__assign(_bb.getInt(_bb.position()) + _bb.position(), _bb)); + } + + public void __init(int _i, ByteBuffer _bb) { + __reset(_i, _bb); + } + + public TextBand __assign(int _i, ByteBuffer _bb) { + __init(_i, _bb); + return this; + } + + public long splitByte() { + int o = __offset(4); + return o != 0 ? (long) bb.getInt(o + bb_pos) & 0xFFFFFFFFL : 0L; + } + + public float width() { + int o = __offset(6); + return o != 0 ? bb.getFloat(o + bb_pos) : 0.0f; + } + + public float marginLeft() { + int o = __offset(8); + return o != 0 ? bb.getFloat(o + bb_pos) : 0.0f; + } + + public static int createTextBand(FlatBufferBuilder builder, long splitByte, float width, float marginLeft) { + builder.startTable(3); + TextBand.addMarginLeft(builder, marginLeft); + TextBand.addWidth(builder, width); + TextBand.addSplitByte(builder, splitByte); + return TextBand.endTextBand(builder); + } + + public static void startTextBand(FlatBufferBuilder builder) { + builder.startTable(3); + } + + public static void addSplitByte(FlatBufferBuilder builder, long splitByte) { + builder.addInt(0, (int) splitByte, (int) 0L); + } + + public static void addWidth(FlatBufferBuilder builder, float width) { + builder.addFloat(1, width, 0.0f); + } + + public static void addMarginLeft(FlatBufferBuilder builder, float marginLeft) { + builder.addFloat(2, marginLeft, 0.0f); + } + + public static int endTextBand(FlatBufferBuilder builder) { + int o = builder.endTable(); + return o; + } + + public static final class Vector extends BaseVector { + + public Vector __assign(int _vector, int _element_size, ByteBuffer _bb) { + __reset(_vector, _element_size, _bb); + return this; + } + + public TextBand get(int j) { + return get(new TextBand(), j); + } + + public TextBand get(TextBand obj, int j) { + return obj.__assign(__indirect(__element(j), bb), bb); + } + } +} diff --git a/src/main/java/com/hfstudio/guidenh/guide/layout/flatbuffers/TextData.java b/src/main/java/com/hfstudio/guidenh/guide/layout/flatbuffers/TextData.java new file mode 100644 index 00000000..c5654350 --- /dev/null +++ b/src/main/java/com/hfstudio/guidenh/guide/layout/flatbuffers/TextData.java @@ -0,0 +1,375 @@ +// automatically generated by the FlatBuffers compiler, do not modify + +package com.hfstudio.guidenh.guide.layout.flatbuffers; + +import java.nio.ByteBuffer; +import java.nio.ByteOrder; + +import com.google.flatbuffers.BaseVector; +import com.google.flatbuffers.Constants; +import com.google.flatbuffers.FlatBufferBuilder; +import com.google.flatbuffers.IntVector; +import com.google.flatbuffers.Table; + +@SuppressWarnings("unused") +public final class TextData extends Table { + + public static void ValidateVersion() { + Constants.FLATBUFFERS_23_5_26(); + } + + public static TextData getRootAsTextData(ByteBuffer _bb) { + return getRootAsTextData(_bb, new TextData()); + } + + public static TextData getRootAsTextData(ByteBuffer _bb, TextData obj) { + _bb.order(ByteOrder.LITTLE_ENDIAN); + return (obj.__assign(_bb.getInt(_bb.position()) + _bb.position(), _bb)); + } + + public void __init(int _i, ByteBuffer _bb) { + __reset(_i, _bb); + } + + public TextData __assign(int _i, ByteBuffer _bb) { + __init(_i, _bb); + return this; + } + + public String text() { + int o = __offset(4); + return o != 0 ? __string(o + bb_pos) : null; + } + + public ByteBuffer textAsByteBuffer() { + return __vector_as_bytebuffer(4, 1); + } + + public ByteBuffer textInByteBuffer(ByteBuffer _bb) { + return __vector_in_bytebuffer(_bb, 4, 1); + } + + public com.hfstudio.guidenh.guide.layout.flatbuffers.TextStyle style() { + return style(new com.hfstudio.guidenh.guide.layout.flatbuffers.TextStyle()); + } + + public com.hfstudio.guidenh.guide.layout.flatbuffers.TextStyle style( + com.hfstudio.guidenh.guide.layout.flatbuffers.TextStyle obj) { + int o = __offset(6); + return o != 0 ? obj.__assign(__indirect(o + bb_pos), bb) : null; + } + + public byte whiteSpace() { + int o = __offset(8); + return o != 0 ? bb.get(o + bb_pos) : 0; + } + + public com.hfstudio.guidenh.guide.layout.flatbuffers.InlineBlockRef inlineBlocks(int j) { + return inlineBlocks(new com.hfstudio.guidenh.guide.layout.flatbuffers.InlineBlockRef(), j); + } + + public com.hfstudio.guidenh.guide.layout.flatbuffers.InlineBlockRef inlineBlocks( + com.hfstudio.guidenh.guide.layout.flatbuffers.InlineBlockRef obj, int j) { + int o = __offset(10); + return o != 0 ? obj.__assign(__indirect(__vector(o) + j * 4), bb) : null; + } + + public int inlineBlocksLength() { + int o = __offset(10); + return o != 0 ? __vector_len(o) : 0; + } + + public com.hfstudio.guidenh.guide.layout.flatbuffers.InlineBlockRef.Vector inlineBlocksVector() { + return inlineBlocksVector(new com.hfstudio.guidenh.guide.layout.flatbuffers.InlineBlockRef.Vector()); + } + + public com.hfstudio.guidenh.guide.layout.flatbuffers.InlineBlockRef.Vector inlineBlocksVector( + com.hfstudio.guidenh.guide.layout.flatbuffers.InlineBlockRef.Vector obj) { + int o = __offset(10); + return o != 0 ? obj.__assign(__vector(o), 4, bb) : null; + } + + public com.hfstudio.guidenh.guide.layout.flatbuffers.TextBand bands(int j) { + return bands(new com.hfstudio.guidenh.guide.layout.flatbuffers.TextBand(), j); + } + + public com.hfstudio.guidenh.guide.layout.flatbuffers.TextBand bands( + com.hfstudio.guidenh.guide.layout.flatbuffers.TextBand obj, int j) { + int o = __offset(12); + return o != 0 ? obj.__assign(__indirect(__vector(o) + j * 4), bb) : null; + } + + public int bandsLength() { + int o = __offset(12); + return o != 0 ? __vector_len(o) : 0; + } + + public com.hfstudio.guidenh.guide.layout.flatbuffers.TextBand.Vector bandsVector() { + return bandsVector(new com.hfstudio.guidenh.guide.layout.flatbuffers.TextBand.Vector()); + } + + public com.hfstudio.guidenh.guide.layout.flatbuffers.TextBand.Vector bandsVector( + com.hfstudio.guidenh.guide.layout.flatbuffers.TextBand.Vector obj) { + int o = __offset(12); + return o != 0 ? obj.__assign(__vector(o), 4, bb) : null; + } + + public com.hfstudio.guidenh.guide.layout.flatbuffers.TextSpan spans(int j) { + return spans(new com.hfstudio.guidenh.guide.layout.flatbuffers.TextSpan(), j); + } + + public com.hfstudio.guidenh.guide.layout.flatbuffers.TextSpan spans( + com.hfstudio.guidenh.guide.layout.flatbuffers.TextSpan obj, int j) { + int o = __offset(14); + return o != 0 ? obj.__assign(__indirect(__vector(o) + j * 4), bb) : null; + } + + public int spansLength() { + int o = __offset(14); + return o != 0 ? __vector_len(o) : 0; + } + + public com.hfstudio.guidenh.guide.layout.flatbuffers.TextSpan.Vector spansVector() { + return spansVector(new com.hfstudio.guidenh.guide.layout.flatbuffers.TextSpan.Vector()); + } + + public com.hfstudio.guidenh.guide.layout.flatbuffers.TextSpan.Vector spansVector( + com.hfstudio.guidenh.guide.layout.flatbuffers.TextSpan.Vector obj) { + int o = __offset(14); + return o != 0 ? obj.__assign(__vector(o), 4, bb) : null; + } + + public com.hfstudio.guidenh.guide.layout.flatbuffers.FloatClip floatClips(int j) { + return floatClips(new com.hfstudio.guidenh.guide.layout.flatbuffers.FloatClip(), j); + } + + public com.hfstudio.guidenh.guide.layout.flatbuffers.FloatClip floatClips( + com.hfstudio.guidenh.guide.layout.flatbuffers.FloatClip obj, int j) { + int o = __offset(16); + return o != 0 ? obj.__assign(__indirect(__vector(o) + j * 4), bb) : null; + } + + public int floatClipsLength() { + int o = __offset(16); + return o != 0 ? __vector_len(o) : 0; + } + + public com.hfstudio.guidenh.guide.layout.flatbuffers.FloatClip.Vector floatClipsVector() { + return floatClipsVector(new com.hfstudio.guidenh.guide.layout.flatbuffers.FloatClip.Vector()); + } + + public com.hfstudio.guidenh.guide.layout.flatbuffers.FloatClip.Vector floatClipsVector( + com.hfstudio.guidenh.guide.layout.flatbuffers.FloatClip.Vector obj) { + int o = __offset(16); + return o != 0 ? obj.__assign(__vector(o), 4, bb) : null; + } + + public com.hfstudio.guidenh.guide.layout.flatbuffers.ClearBreak clears(int j) { + return clears(new com.hfstudio.guidenh.guide.layout.flatbuffers.ClearBreak(), j); + } + + public com.hfstudio.guidenh.guide.layout.flatbuffers.ClearBreak clears( + com.hfstudio.guidenh.guide.layout.flatbuffers.ClearBreak obj, int j) { + int o = __offset(18); + return o != 0 ? obj.__assign(__indirect(__vector(o) + j * 4), bb) : null; + } + + public int clearsLength() { + int o = __offset(18); + return o != 0 ? __vector_len(o) : 0; + } + + public com.hfstudio.guidenh.guide.layout.flatbuffers.ClearBreak.Vector clearsVector() { + return clearsVector(new com.hfstudio.guidenh.guide.layout.flatbuffers.ClearBreak.Vector()); + } + + public com.hfstudio.guidenh.guide.layout.flatbuffers.ClearBreak.Vector clearsVector( + com.hfstudio.guidenh.guide.layout.flatbuffers.ClearBreak.Vector obj) { + int o = __offset(18); + return o != 0 ? obj.__assign(__vector(o), 4, bb) : null; + } + + public long breaks(int j) { + int o = __offset(20); + return o != 0 ? (long) bb.getInt(__vector(o) + j * 4) & 0xFFFFFFFFL : 0; + } + + public int breaksLength() { + int o = __offset(20); + return o != 0 ? __vector_len(o) : 0; + } + + public IntVector breaksVector() { + return breaksVector(new IntVector()); + } + + public IntVector breaksVector(IntVector obj) { + int o = __offset(20); + return o != 0 ? obj.__assign(__vector(o), bb) : null; + } + + public ByteBuffer breaksAsByteBuffer() { + return __vector_as_bytebuffer(20, 4); + } + + public ByteBuffer breaksInByteBuffer(ByteBuffer _bb) { + return __vector_in_bytebuffer(_bb, 20, 4); + } + + public boolean separator() { + int o = __offset(22); + return o != 0 ? 0 != bb.get(o + bb_pos) : false; + } + + public byte alignment() { + int o = __offset(24); + return o != 0 ? bb.get(o + bb_pos) : 0; + } + + public static int createTextData(FlatBufferBuilder builder, int textOffset, int styleOffset, byte whiteSpace, + int inlineBlocksOffset, int bandsOffset, int spansOffset, int floatClipsOffset, int clearsOffset, + int breaksOffset, boolean separator, byte alignment) { + builder.startTable(11); + TextData.addBreaks(builder, breaksOffset); + TextData.addClears(builder, clearsOffset); + TextData.addFloatClips(builder, floatClipsOffset); + TextData.addSpans(builder, spansOffset); + TextData.addBands(builder, bandsOffset); + TextData.addInlineBlocks(builder, inlineBlocksOffset); + TextData.addStyle(builder, styleOffset); + TextData.addText(builder, textOffset); + TextData.addAlignment(builder, alignment); + TextData.addSeparator(builder, separator); + TextData.addWhiteSpace(builder, whiteSpace); + return TextData.endTextData(builder); + } + + public static void startTextData(FlatBufferBuilder builder) { + builder.startTable(11); + } + + public static void addText(FlatBufferBuilder builder, int textOffset) { + builder.addOffset(0, textOffset, 0); + } + + public static void addStyle(FlatBufferBuilder builder, int styleOffset) { + builder.addOffset(1, styleOffset, 0); + } + + public static void addWhiteSpace(FlatBufferBuilder builder, byte whiteSpace) { + builder.addByte(2, whiteSpace, 0); + } + + public static void addInlineBlocks(FlatBufferBuilder builder, int inlineBlocksOffset) { + builder.addOffset(3, inlineBlocksOffset, 0); + } + + public static int createInlineBlocksVector(FlatBufferBuilder builder, int[] data) { + builder.startVector(4, data.length, 4); + for (int i = data.length - 1; i >= 0; i--) builder.addOffset(data[i]); + return builder.endVector(); + } + + public static void startInlineBlocksVector(FlatBufferBuilder builder, int numElems) { + builder.startVector(4, numElems, 4); + } + + public static void addBands(FlatBufferBuilder builder, int bandsOffset) { + builder.addOffset(4, bandsOffset, 0); + } + + public static int createBandsVector(FlatBufferBuilder builder, int[] data) { + builder.startVector(4, data.length, 4); + for (int i = data.length - 1; i >= 0; i--) builder.addOffset(data[i]); + return builder.endVector(); + } + + public static void startBandsVector(FlatBufferBuilder builder, int numElems) { + builder.startVector(4, numElems, 4); + } + + public static void addSpans(FlatBufferBuilder builder, int spansOffset) { + builder.addOffset(5, spansOffset, 0); + } + + public static int createSpansVector(FlatBufferBuilder builder, int[] data) { + builder.startVector(4, data.length, 4); + for (int i = data.length - 1; i >= 0; i--) builder.addOffset(data[i]); + return builder.endVector(); + } + + public static void startSpansVector(FlatBufferBuilder builder, int numElems) { + builder.startVector(4, numElems, 4); + } + + public static void addFloatClips(FlatBufferBuilder builder, int floatClipsOffset) { + builder.addOffset(6, floatClipsOffset, 0); + } + + public static int createFloatClipsVector(FlatBufferBuilder builder, int[] data) { + builder.startVector(4, data.length, 4); + for (int i = data.length - 1; i >= 0; i--) builder.addOffset(data[i]); + return builder.endVector(); + } + + public static void startFloatClipsVector(FlatBufferBuilder builder, int numElems) { + builder.startVector(4, numElems, 4); + } + + public static void addClears(FlatBufferBuilder builder, int clearsOffset) { + builder.addOffset(7, clearsOffset, 0); + } + + public static int createClearsVector(FlatBufferBuilder builder, int[] data) { + builder.startVector(4, data.length, 4); + for (int i = data.length - 1; i >= 0; i--) builder.addOffset(data[i]); + return builder.endVector(); + } + + public static void startClearsVector(FlatBufferBuilder builder, int numElems) { + builder.startVector(4, numElems, 4); + } + + public static void addBreaks(FlatBufferBuilder builder, int breaksOffset) { + builder.addOffset(8, breaksOffset, 0); + } + + public static int createBreaksVector(FlatBufferBuilder builder, long[] data) { + builder.startVector(4, data.length, 4); + for (int i = data.length - 1; i >= 0; i--) builder.addInt((int) data[i]); + return builder.endVector(); + } + + public static void startBreaksVector(FlatBufferBuilder builder, int numElems) { + builder.startVector(4, numElems, 4); + } + + public static void addSeparator(FlatBufferBuilder builder, boolean separator) { + builder.addBoolean(9, separator, false); + } + + public static void addAlignment(FlatBufferBuilder builder, byte alignment) { + builder.addByte(10, alignment, 0); + } + + public static int endTextData(FlatBufferBuilder builder) { + int o = builder.endTable(); + return o; + } + + public static final class Vector extends BaseVector { + + public Vector __assign(int _vector, int _element_size, ByteBuffer _bb) { + __reset(_vector, _element_size, _bb); + return this; + } + + public TextData get(int j) { + return get(new TextData(), j); + } + + public TextData get(TextData obj, int j) { + return obj.__assign(__indirect(__element(j), bb), bb); + } + } +} diff --git a/src/main/java/com/hfstudio/guidenh/guide/layout/flatbuffers/TextSpan.java b/src/main/java/com/hfstudio/guidenh/guide/layout/flatbuffers/TextSpan.java new file mode 100644 index 00000000..06089c32 --- /dev/null +++ b/src/main/java/com/hfstudio/guidenh/guide/layout/flatbuffers/TextSpan.java @@ -0,0 +1,104 @@ +// automatically generated by the FlatBuffers compiler, do not modify + +package com.hfstudio.guidenh.guide.layout.flatbuffers; + +import java.nio.ByteBuffer; +import java.nio.ByteOrder; + +import com.google.flatbuffers.BaseVector; +import com.google.flatbuffers.Constants; +import com.google.flatbuffers.FlatBufferBuilder; +import com.google.flatbuffers.Table; + +/** + * One styled run of a rich paragraph: `text` concatenated over all spans + * equals TextData.text (same document order, U+FFFC placeholders included). + */ +@SuppressWarnings("unused") +public final class TextSpan extends Table { + + public static void ValidateVersion() { + Constants.FLATBUFFERS_23_5_26(); + } + + public static TextSpan getRootAsTextSpan(ByteBuffer _bb) { + return getRootAsTextSpan(_bb, new TextSpan()); + } + + public static TextSpan getRootAsTextSpan(ByteBuffer _bb, TextSpan obj) { + _bb.order(ByteOrder.LITTLE_ENDIAN); + return (obj.__assign(_bb.getInt(_bb.position()) + _bb.position(), _bb)); + } + + public void __init(int _i, ByteBuffer _bb) { + __reset(_i, _bb); + } + + public TextSpan __assign(int _i, ByteBuffer _bb) { + __init(_i, _bb); + return this; + } + + public String text() { + int o = __offset(4); + return o != 0 ? __string(o + bb_pos) : null; + } + + public ByteBuffer textAsByteBuffer() { + return __vector_as_bytebuffer(4, 1); + } + + public ByteBuffer textInByteBuffer(ByteBuffer _bb) { + return __vector_in_bytebuffer(_bb, 4, 1); + } + + public com.hfstudio.guidenh.guide.layout.flatbuffers.TextStyle style() { + return style(new com.hfstudio.guidenh.guide.layout.flatbuffers.TextStyle()); + } + + public com.hfstudio.guidenh.guide.layout.flatbuffers.TextStyle style( + com.hfstudio.guidenh.guide.layout.flatbuffers.TextStyle obj) { + int o = __offset(6); + return o != 0 ? obj.__assign(__indirect(o + bb_pos), bb) : null; + } + + public static int createTextSpan(FlatBufferBuilder builder, int textOffset, int styleOffset) { + builder.startTable(2); + TextSpan.addStyle(builder, styleOffset); + TextSpan.addText(builder, textOffset); + return TextSpan.endTextSpan(builder); + } + + public static void startTextSpan(FlatBufferBuilder builder) { + builder.startTable(2); + } + + public static void addText(FlatBufferBuilder builder, int textOffset) { + builder.addOffset(0, textOffset, 0); + } + + public static void addStyle(FlatBufferBuilder builder, int styleOffset) { + builder.addOffset(1, styleOffset, 0); + } + + public static int endTextSpan(FlatBufferBuilder builder) { + int o = builder.endTable(); + return o; + } + + public static final class Vector extends BaseVector { + + public Vector __assign(int _vector, int _element_size, ByteBuffer _bb) { + __reset(_vector, _element_size, _bb); + return this; + } + + public TextSpan get(int j) { + return get(new TextSpan(), j); + } + + public TextSpan get(TextSpan obj, int j) { + return obj.__assign(__indirect(__element(j), bb), bb); + } + } +} diff --git a/src/main/java/com/hfstudio/guidenh/guide/layout/flatbuffers/TextStyle.java b/src/main/java/com/hfstudio/guidenh/guide/layout/flatbuffers/TextStyle.java new file mode 100644 index 00000000..10a165c6 --- /dev/null +++ b/src/main/java/com/hfstudio/guidenh/guide/layout/flatbuffers/TextStyle.java @@ -0,0 +1,207 @@ +// automatically generated by the FlatBuffers compiler, do not modify + +package com.hfstudio.guidenh.guide.layout.flatbuffers; + +import java.nio.ByteBuffer; +import java.nio.ByteOrder; + +import com.google.flatbuffers.BaseVector; +import com.google.flatbuffers.Constants; +import com.google.flatbuffers.FlatBufferBuilder; +import com.google.flatbuffers.Table; + +@SuppressWarnings("unused") +public final class TextStyle extends Table { + + public static void ValidateVersion() { + Constants.FLATBUFFERS_23_5_26(); + } + + public static TextStyle getRootAsTextStyle(ByteBuffer _bb) { + return getRootAsTextStyle(_bb, new TextStyle()); + } + + public static TextStyle getRootAsTextStyle(ByteBuffer _bb, TextStyle obj) { + _bb.order(ByteOrder.LITTLE_ENDIAN); + return (obj.__assign(_bb.getInt(_bb.position()) + _bb.position(), _bb)); + } + + public void __init(int _i, ByteBuffer _bb) { + __reset(_i, _bb); + } + + public TextStyle __assign(int _i, ByteBuffer _bb) { + __init(_i, _bb); + return this; + } + + public float fontSize() { + int o = __offset(4); + return o != 0 ? bb.getFloat(o + bb_pos) : 14.0f; + } + + public boolean bold() { + int o = __offset(6); + return o != 0 ? 0 != bb.get(o + bb_pos) : false; + } + + public boolean italic() { + int o = __offset(8); + return o != 0 ? 0 != bb.get(o + bb_pos) : false; + } + + public float fontScale() { + int o = __offset(10); + return o != 0 ? bb.getFloat(o + bb_pos) : 1.0f; + } + + public long color() { + int o = __offset(12); + return o != 0 ? (long) bb.getInt(o + bb_pos) & 0xFFFFFFFFL : 4294967295L; + } + + public String fontFamily() { + int o = __offset(14); + return o != 0 ? __string(o + bb_pos) : null; + } + + public ByteBuffer fontFamilyAsByteBuffer() { + return __vector_as_bytebuffer(14, 1); + } + + public ByteBuffer fontFamilyInByteBuffer(ByteBuffer _bb) { + return __vector_in_bytebuffer(_bb, 14, 1); + } + + public boolean underline() { + int o = __offset(16); + return o != 0 ? 0 != bb.get(o + bb_pos) : false; + } + + public boolean strikethrough() { + int o = __offset(18); + return o != 0 ? 0 != bb.get(o + bb_pos) : false; + } + + public long highlightArgb() { + int o = __offset(20); + return o != 0 ? (long) bb.getInt(o + bb_pos) & 0xFFFFFFFFL : 0L; + } + + public boolean inlineCode() { + int o = __offset(22); + return o != 0 ? 0 != bb.get(o + bb_pos) : false; + } + + public float baselineShift() { + int o = __offset(24); + return o != 0 ? bb.getFloat(o + bb_pos) : 0.0f; + } + + public boolean wavyUnderline() { + int o = __offset(26); + return o != 0 ? 0 != bb.get(o + bb_pos) : false; + } + + public boolean dottedUnderline() { + int o = __offset(28); + return o != 0 ? 0 != bb.get(o + bb_pos) : false; + } + + public static int createTextStyle(FlatBufferBuilder builder, float fontSize, boolean bold, boolean italic, + float fontScale, long color, int fontFamilyOffset, boolean underline, boolean strikethrough, long highlightArgb, + boolean inlineCode, float baselineShift, boolean wavyUnderline, boolean dottedUnderline) { + builder.startTable(13); + TextStyle.addBaselineShift(builder, baselineShift); + TextStyle.addHighlightArgb(builder, highlightArgb); + TextStyle.addFontFamily(builder, fontFamilyOffset); + TextStyle.addColor(builder, color); + TextStyle.addFontScale(builder, fontScale); + TextStyle.addFontSize(builder, fontSize); + TextStyle.addDottedUnderline(builder, dottedUnderline); + TextStyle.addWavyUnderline(builder, wavyUnderline); + TextStyle.addInlineCode(builder, inlineCode); + TextStyle.addStrikethrough(builder, strikethrough); + TextStyle.addUnderline(builder, underline); + TextStyle.addItalic(builder, italic); + TextStyle.addBold(builder, bold); + return TextStyle.endTextStyle(builder); + } + + public static void startTextStyle(FlatBufferBuilder builder) { + builder.startTable(13); + } + + public static void addFontSize(FlatBufferBuilder builder, float fontSize) { + builder.addFloat(0, fontSize, 14.0f); + } + + public static void addBold(FlatBufferBuilder builder, boolean bold) { + builder.addBoolean(1, bold, false); + } + + public static void addItalic(FlatBufferBuilder builder, boolean italic) { + builder.addBoolean(2, italic, false); + } + + public static void addFontScale(FlatBufferBuilder builder, float fontScale) { + builder.addFloat(3, fontScale, 1.0f); + } + + public static void addColor(FlatBufferBuilder builder, long color) { + builder.addInt(4, (int) color, (int) 4294967295L); + } + + public static void addFontFamily(FlatBufferBuilder builder, int fontFamilyOffset) { + builder.addOffset(5, fontFamilyOffset, 0); + } + + public static void addUnderline(FlatBufferBuilder builder, boolean underline) { + builder.addBoolean(6, underline, false); + } + + public static void addStrikethrough(FlatBufferBuilder builder, boolean strikethrough) { + builder.addBoolean(7, strikethrough, false); + } + + public static void addHighlightArgb(FlatBufferBuilder builder, long highlightArgb) { + builder.addInt(8, (int) highlightArgb, (int) 0L); + } + + public static void addInlineCode(FlatBufferBuilder builder, boolean inlineCode) { + builder.addBoolean(9, inlineCode, false); + } + + public static void addBaselineShift(FlatBufferBuilder builder, float baselineShift) { + builder.addFloat(10, baselineShift, 0.0f); + } + + public static void addWavyUnderline(FlatBufferBuilder builder, boolean wavyUnderline) { + builder.addBoolean(11, wavyUnderline, false); + } + + public static void addDottedUnderline(FlatBufferBuilder builder, boolean dottedUnderline) { + builder.addBoolean(12, dottedUnderline, false); + } + + public static int endTextStyle(FlatBufferBuilder builder) { + int o = builder.endTable(); + return o; + } + + public static final class Vector extends BaseVector { + + public Vector __assign(int _vector, int _element_size, ByteBuffer _bb) { + __reset(_vector, _element_size, _bb); + return this; + } + + public TextStyle get(int j) { + return get(new TextStyle(), j); + } + + public TextStyle get(TextStyle obj, int j) { + return obj.__assign(__indirect(__element(j), bb), bb); + } + } +} diff --git a/src/main/java/com/hfstudio/guidenh/guide/layout/flatbuffers/ThematicBreakData.java b/src/main/java/com/hfstudio/guidenh/guide/layout/flatbuffers/ThematicBreakData.java new file mode 100644 index 00000000..9d66f0e1 --- /dev/null +++ b/src/main/java/com/hfstudio/guidenh/guide/layout/flatbuffers/ThematicBreakData.java @@ -0,0 +1,77 @@ +// automatically generated by the FlatBuffers compiler, do not modify + +package com.hfstudio.guidenh.guide.layout.flatbuffers; + +import java.nio.ByteBuffer; +import java.nio.ByteOrder; + +import com.google.flatbuffers.BaseVector; +import com.google.flatbuffers.Constants; +import com.google.flatbuffers.FlatBufferBuilder; +import com.google.flatbuffers.Table; + +@SuppressWarnings("unused") +public final class ThematicBreakData extends Table { + + public static void ValidateVersion() { + Constants.FLATBUFFERS_23_5_26(); + } + + public static ThematicBreakData getRootAsThematicBreakData(ByteBuffer _bb) { + return getRootAsThematicBreakData(_bb, new ThematicBreakData()); + } + + public static ThematicBreakData getRootAsThematicBreakData(ByteBuffer _bb, ThematicBreakData obj) { + _bb.order(ByteOrder.LITTLE_ENDIAN); + return (obj.__assign(_bb.getInt(_bb.position()) + _bb.position(), _bb)); + } + + public void __init(int _i, ByteBuffer _bb) { + __reset(_i, _bb); + } + + public ThematicBreakData __assign(int _i, ByteBuffer _bb) { + __init(_i, _bb); + return this; + } + + public float height() { + int o = __offset(4); + return o != 0 ? bb.getFloat(o + bb_pos) : 6.0f; + } + + public static int createThematicBreakData(FlatBufferBuilder builder, float height) { + builder.startTable(1); + ThematicBreakData.addHeight(builder, height); + return ThematicBreakData.endThematicBreakData(builder); + } + + public static void startThematicBreakData(FlatBufferBuilder builder) { + builder.startTable(1); + } + + public static void addHeight(FlatBufferBuilder builder, float height) { + builder.addFloat(0, height, 6.0f); + } + + public static int endThematicBreakData(FlatBufferBuilder builder) { + int o = builder.endTable(); + return o; + } + + public static final class Vector extends BaseVector { + + public Vector __assign(int _vector, int _element_size, ByteBuffer _bb) { + __reset(_vector, _element_size, _bb); + return this; + } + + public ThematicBreakData get(int j) { + return get(new ThematicBreakData(), j); + } + + public ThematicBreakData get(ThematicBreakData obj, int j) { + return obj.__assign(__indirect(__element(j), bb), bb); + } + } +} diff --git a/src/main/java/com/hfstudio/guidenh/guide/layout/flow/FlowBuilder.java b/src/main/java/com/hfstudio/guidenh/guide/layout/flow/FlowBuilder.java deleted file mode 100644 index 9f491aae..00000000 --- a/src/main/java/com/hfstudio/guidenh/guide/layout/flow/FlowBuilder.java +++ /dev/null @@ -1,227 +0,0 @@ -package com.hfstudio.guidenh.guide.layout.flow; - -import java.util.ArrayList; -import java.util.List; -import java.util.function.Consumer; -import java.util.stream.Stream; - -import org.jetbrains.annotations.Nullable; - -import com.hfstudio.guidenh.guide.document.LytRect; -import com.hfstudio.guidenh.guide.document.flow.LytFlowContent; -import com.hfstudio.guidenh.guide.document.flow.LytFlowSpan; -import com.hfstudio.guidenh.guide.document.interaction.FlowInteractionPath; -import com.hfstudio.guidenh.guide.layout.LayoutContext; -import com.hfstudio.guidenh.guide.render.RenderContext; -import com.hfstudio.guidenh.guide.style.TextAlignment; - -public class FlowBuilder { - - /** Sentinel used to force hover-state recalculation after a layout rebuild. */ - private static final FlowInteractionPath HOVER_STATE_DIRTY = FlowInteractionPath.fromPrimary(new LytFlowContent()); - private static final FlowInteractionPath EMPTY_PATH = FlowInteractionPath.empty(); - - private final List lines = new ArrayList<>(); - - private final List rootContent = new ArrayList<>(); - - // Bounding rectangles for any floats in this flow - private final List floats = new ArrayList<>(); - - /** Tracks the last hovered content so element hover flags are only recalculated on changes. */ - private FlowInteractionPath lastHoverPath = HOVER_STATE_DIRTY; - /** Tracks the last revealed content so spoiler reveal flags are only recalculated on changes. */ - private FlowInteractionPath lastRevealPath = HOVER_STATE_DIRTY; - - public void append(LytFlowContent content) { - this.rootContent.add(content); - } - - public LytRect computeLayout(LayoutContext context, int x, int y, int availableWidth, TextAlignment alignment) { - lines.clear(); - floats.clear(); - lastHoverPath = HOVER_STATE_DIRTY; - lastRevealPath = HOVER_STATE_DIRTY; - var lineBuilder = new LineBuilder(context, x, y, availableWidth, lines, floats, alignment); - for (var content : rootContent) { - visitInDocumentOrder(content, lineBuilder); - } - lineBuilder.end(); - - // Build bounding box around all lines - return lineBuilder.getBounds(); - } - - public void render(RenderContext context, @Nullable FlowInteractionPath hoverPath, - @Nullable FlowInteractionPath revealPath) { - updateHoverState(hoverPath, revealPath); - for (var line : lines) { - for (var el = line.firstElement; el != null; el = el.next) { - el.render(context); - } - } - } - - public void renderFloats(RenderContext context, @Nullable FlowInteractionPath hoverPath, - @Nullable FlowInteractionPath revealPath) { - updateHoverState(hoverPath, revealPath); - for (var el : floats) { - el.render(context); - } - } - - @Nullable - public LytRect getFirstLineBounds() { - return lines.isEmpty() ? null : lines.getFirst().bounds; - } - - @Nullable - public LytRect getFirstTextRunBounds() { - if (lines.isEmpty()) { - return null; - } - for (var el = lines.getFirst().firstElement; el != null; el = el.next) { - if (el instanceof LineTextRun textRun && !textRun.text.isEmpty()) { - return textRun.bounds; - } - } - return null; - } - - private void updateHoverState(@Nullable FlowInteractionPath hoverPath, @Nullable FlowInteractionPath revealPath) { - FlowInteractionPath resolvedHoverPath = hoverPath != null ? hoverPath : EMPTY_PATH; - FlowInteractionPath resolvedRevealPath = revealPath != null ? revealPath : EMPTY_PATH; - if (lastHoverPath.equals(resolvedHoverPath) && lastRevealPath.equals(resolvedRevealPath)) { - return; - } - lastHoverPath = resolvedHoverPath; - lastRevealPath = resolvedRevealPath; - for (var line : lines) { - for (var el = line.firstElement; el != null; el = el.next) { - applyHoverState(el, resolvedHoverPath, resolvedRevealPath); - } - } - for (var el : floats) { - applyHoverState(el, resolvedHoverPath, resolvedRevealPath); - } - } - - private void applyHoverState(LineElement element, FlowInteractionPath hoverPath, FlowInteractionPath revealPath) { - var flowContent = element.getFlowContent(); - if (flowContent == null) { - element.containsMouse = false; - element.revealedBySpoiler = false; - return; - } - boolean containsMouse = hoverPath.containsPrimaryOrDescendant(flowContent); - boolean revealedBySpoiler = !containsMouse && revealPath.containsOrAncestors(flowContent); - element.containsMouse = containsMouse; - element.revealedBySpoiler = revealedBySpoiler; - } - - private void visitInDocumentOrder(LytFlowContent content, Consumer visitor) { - if (content instanceof LytFlowSpan flowSpan) { - for (var child : flowSpan.getChildren()) { - visitInDocumentOrder(child, visitor); - } - } else { - visitor.accept(content); - } - } - - @Nullable - public LineElement pick(int x, int y) { - var floatEl = pickFloatingElement(x, y); - if (floatEl != null) { - return floatEl; - } - - for (var line : lines) { - // Floating content overflows the line-box, but still belongs to the line - // otherwise only hit-test line-elements if the line itself is hit - if (line.bounds.contains(x, y)) { - for (var el = line.firstElement; el != null; el = el.next) { - if (el.bounds.contains(x, y)) { - return el; - } - } - } - } - - return null; - } - - public Stream enumerateContentBounds(LytFlowContent content) { - Stream.Builder builder = Stream.builder(); - for (var line : lines) { - for (var el = line.firstElement; el != null; el = el.next) { - if (el.getFlowContent() == content) { - builder.accept(el.bounds); - } - } - } - for (var el : floats) { - if (el.getFlowContent() == content) { - builder.accept(el.bounds); - } - } - return builder.build(); - } - - public @Nullable FlowInteractionPath pickPath(int x, int y) { - var lineElement = pick(x, y); - if (lineElement == null) { - return null; - } - return FlowInteractionPath.fromPrimary(lineElement.getFlowContent()); - } - - @Nullable - public LineBlock pickFloatingElement(int x, int y) { - for (var el : floats) { - if (el.bounds.contains(x, y)) { - return el; - } - } - return null; - } - - public boolean floatsIntersect(LytRect bounds) { - for (var el : floats) { - if (el.bounds.intersects(bounds)) { - return true; - } - } - return false; - } - - public Iterable getContent() { - return rootContent; - } - - public boolean isEmpty() { - return rootContent.isEmpty(); - } - - public void clear() { - this.lines.clear(); - this.rootContent.clear(); - this.floats.clear(); - } - - public void move(int deltaX, int deltaY) { - if (deltaX == 0 && deltaY == 0) { - return; - } - for (var line : this.lines) { - line.bounds = line.bounds.move(deltaX, deltaY); - for (var el = line.firstElement; el != null; el = el.next) { - el.bounds = el.bounds.move(deltaX, deltaY); - if (el instanceof LineBlock lineBlock) { - lineBlock.getBlock() - .moveLayoutPos(deltaX, deltaY); - } - } - } - } -} diff --git a/src/main/java/com/hfstudio/guidenh/guide/layout/flow/Line.java b/src/main/java/com/hfstudio/guidenh/guide/layout/flow/Line.java deleted file mode 100644 index c99e8045..00000000 --- a/src/main/java/com/hfstudio/guidenh/guide/layout/flow/Line.java +++ /dev/null @@ -1,19 +0,0 @@ -package com.hfstudio.guidenh.guide.layout.flow; - -import com.hfstudio.guidenh.guide.document.LytRect; - -/** - * A single typeset line produced by {@link LineBuilder}. - * Kept mutable so {@link FlowBuilder#move} can shift bounds in-place - * without allocating a new object per line. - */ -public class Line { - - public LytRect bounds; - public final LineElement firstElement; - - public Line(LytRect bounds, LineElement firstElement) { - this.bounds = bounds; - this.firstElement = firstElement; - } -} diff --git a/src/main/java/com/hfstudio/guidenh/guide/layout/flow/LineBlock.java b/src/main/java/com/hfstudio/guidenh/guide/layout/flow/LineBlock.java deleted file mode 100644 index 3cc7b60d..00000000 --- a/src/main/java/com/hfstudio/guidenh/guide/layout/flow/LineBlock.java +++ /dev/null @@ -1,36 +0,0 @@ -package com.hfstudio.guidenh.guide.layout.flow; - -import com.hfstudio.guidenh.guide.document.block.LytBlock; -import com.hfstudio.guidenh.guide.layout.LayoutContext; -import com.hfstudio.guidenh.guide.render.RenderContext; - -import lombok.Getter; - -/** - * Standalone block in-line with other content. - */ -public class LineBlock extends LineElement { - - @Getter - private final LytBlock block; - private int layoutOffsetX; - private int layoutOffsetY; - - public LineBlock(LytBlock block) { - this.block = block; - } - - public void setLayoutOffset(int layoutOffsetX, int layoutOffsetY) { - this.layoutOffsetX = layoutOffsetX; - this.layoutOffsetY = layoutOffsetY; - } - - public void layoutBlock(LayoutContext context, int availableWidth) { - block.layout(context, bounds.x() - layoutOffsetX, bounds.y() - layoutOffsetY, availableWidth); - } - - @Override - public void render(RenderContext context) { - block.render(context); - } -} diff --git a/src/main/java/com/hfstudio/guidenh/guide/layout/flow/LineBuilder.java b/src/main/java/com/hfstudio/guidenh/guide/layout/flow/LineBuilder.java deleted file mode 100644 index fc5143e6..00000000 --- a/src/main/java/com/hfstudio/guidenh/guide/layout/flow/LineBuilder.java +++ /dev/null @@ -1,781 +0,0 @@ -package com.hfstudio.guidenh.guide.layout.flow; - -import java.text.BreakIterator; -import java.text.CharacterIterator; -import java.util.Arrays; -import java.util.List; -import java.util.function.Consumer; - -import org.jetbrains.annotations.Nullable; - -import com.hfstudio.guidenh.guide.color.ColorUtils; -import com.hfstudio.guidenh.guide.color.ConstantColor; -import com.hfstudio.guidenh.guide.document.DefaultStyles; -import com.hfstudio.guidenh.guide.document.LytRect; -import com.hfstudio.guidenh.guide.document.block.LytItemImage; -import com.hfstudio.guidenh.guide.document.flow.InlineBlockAlignment; -import com.hfstudio.guidenh.guide.document.flow.LytFlowAnchor; -import com.hfstudio.guidenh.guide.document.flow.LytFlowBreak; -import com.hfstudio.guidenh.guide.document.flow.LytFlowContent; -import com.hfstudio.guidenh.guide.document.flow.LytFlowInlineBlock; -import com.hfstudio.guidenh.guide.document.flow.LytFlowText; -import com.hfstudio.guidenh.guide.document.flow.LytSpoilerSpan; -import com.hfstudio.guidenh.guide.layout.LayoutContext; -import com.hfstudio.guidenh.guide.render.GuideFontCompat; -import com.hfstudio.guidenh.guide.style.ResolvedTextStyle; -import com.hfstudio.guidenh.guide.style.TextAlignment; -import com.hfstudio.guidenh.guide.style.TextStyle; - -/** - * Does inline-flow layout similar to how it is described here: - * ... - */ -public class LineBuilder implements Consumer { - - private static final ThreadLocal LINE_BREAK_ITERATOR = ThreadLocal - .withInitial(BreakIterator::getLineInstance); - private static final ConstantColor SPOILER_MASK_COLOR = new ConstantColor(ColorUtils.BLACK.getColor()); - - private final LayoutContext context; - private final List lines; - // Contains any floating elements we construct as part of processing flow content - private final List floats; - private final int lineBoxX; - private final int startY; - private int innerX; - private int lineBoxY; - private final int lineBoxWidth; - private int remainingLineWidth; - @Nullable - private LineElement openLineElement; - @Nullable - private LineElement openLineTail; - private final TextAlignment alignment; - private final StringBuilder lineBuffer = new StringBuilder(); - private float[] lineBufferPrefixWidths = new float[64]; - - /** Reusable CharacterIterator wrapping {@link #lineBuffer} to avoid String allocation in BreakIterator. */ - private final StringBuilderCharIterator charIterator = new StringBuilderCharIterator(); - - /** - * Incremental min-top, max-bottom, and max-right of elements on the current open line. - * Updated by {@link #appendToOpenLine} and reset by {@link #endLine}. - * Eliminates the O(N) scan in {@link #endLine} for line-height and line-width. - */ - private int openLineMinTop = Integer.MAX_VALUE; - private int openLineMaxBottom; - private int openLineMaxRight; - private boolean openLineHasInlineItemImage; - - public LineBuilder(LayoutContext context, int x, int y, int availableWidth, List lines, - List floats, TextAlignment alignment) { - this.floats = floats; - this.alignment = alignment; - this.context = context; - this.startY = y; - lineBoxX = x; - lineBoxY = y; - lineBoxWidth = availableWidth; - remainingLineWidth = getAvailableHorizontalSpace(); - this.lines = lines; - } - - @Override - public void accept(LytFlowContent content) { - if (content instanceof LytFlowText text) { - appendText(text.getText(), content); - } else if (content instanceof LytFlowBreak) { - appendBreak(content); - } else if (content instanceof LytFlowInlineBlock inlineBlock) { - appendInlineBlock(inlineBlock); - } else if (content instanceof LytFlowAnchor anchor) { - // Simply set the current layout position for the anchor - anchor.setLayoutY(lineBoxY); - } else { - throw new IllegalArgumentException("Don't know how to layout flow content: " + content); - } - } - - private void appendBreak(@Nullable LytFlowContent flowContent) { - // Append an empty line with the default style - if (openLineElement == null) { - openLineElement = new LineTextRun( - "", - DefaultStyles.BASE_STYLE, - DefaultStyles.BASE_STYLE, - DefaultStyles.BASE_STYLE); - openLineElement.flowContent = flowContent; - openLineTail = openLineElement; - } - endLine(); - - // Clear floats, if requested - if (flowContent instanceof LytFlowBreak flowBreak) { - if (flowBreak.isClearLeft() || flowBreak.isClearRight()) { - context.clearFloats(flowBreak.isClearLeft(), flowBreak.isClearRight()) - .ifPresent(floatBottom -> lineBoxY = Math.max(lineBoxY, floatBottom)); - } - } - } - - private void appendInlineBlock(LytFlowInlineBlock inlineBlock) { - var layoutBounds = inlineBlock.getPreferredBounds(lineBoxWidth); - var size = layoutBounds.size(); - var block = inlineBlock.getBlock(); - var marginLeft = block.getMarginLeft(); - var marginRight = block.getMarginRight(); - var marginBottom = block.getMarginBottom(); - - // Is there enough space to have this element here? - var outerWidth = size.width() + marginLeft + marginRight; - ensureSpaceIsAvailable(outerWidth); - - var el = new LineBlock(block); - el.setLayoutOffset(layoutBounds.x(), layoutBounds.y()); - el.bounds = new LytRect(innerX + marginLeft + layoutBounds.x(), layoutBounds.y(), size.width(), size.height()); - el.flowContent = inlineBlock; - - if (inlineBlock.getAlignment() == InlineBlockAlignment.FLOAT_LEFT) { - // Float it to the left of the actual text content. - // endLine will take care of moving any existing text in the line - el.bounds = el.bounds.withX(getInnerLeftEdge() + marginLeft + layoutBounds.x()) - .withY(lineBoxY + layoutBounds.y()); - // Update the layout of the contained block to update its absolute position - el.layoutBlock(context, size.width()); - el.floating = true; - context.addLeftFloat(el.bounds.expand(0, 0, marginRight, marginBottom)); - floats.add(el); - remainingLineWidth -= outerWidth; - } else if (inlineBlock.getAlignment() == InlineBlockAlignment.FLOAT_RIGHT) { - // Float it to the right the actual text content. - el.bounds = el.bounds.withX(getInnerRightEdge() - el.bounds.width() + marginRight + layoutBounds.x()) - .withY(lineBoxY + layoutBounds.y()); - // Update the layout of the contained block to update its absolute position - el.layoutBlock(context, size.width()); - el.floating = true; - context.addRightFloat(el.bounds.expand(marginLeft, 0, 0, marginBottom)); - floats.add(el); - remainingLineWidth -= outerWidth; - } else { - // Treat as a normal inline element for positioning - innerX += size.width(); - appendToOpenLine(el); - - // Since no margin is actually accounted for here, the remaining line width should just - // be reduced - remainingLineWidth -= size.width(); - } - } - - private void ensureSpaceIsAvailable(int width) { - if (width <= remainingLineWidth) { - return; // Got enough - } - - // First, try closing out any open line if we don't have enough space - endLine(); - - if (width <= remainingLineWidth) { - return; // We got enough by ending the current line and advancing to the next - } - - // If we *still* don't have enough room, we need to advance down to clear floats - // as long as any float is still open - var nextFloatEdge = context.getNextFloatBottomEdge(lineBoxY); - while (nextFloatEdge.isPresent()) { - lineBoxY = nextFloatEdge.getAsInt(); - context.clearFloatsAbove(lineBoxY); - remainingLineWidth = getAvailableHorizontalSpace(); - if (width <= remainingLineWidth) { - break; // Finally, we're good! - } - nextFloatEdge = context.getNextFloatBottomEdge(lineBoxY); - } - } - - @Nullable - private LineElement getEndOfOpenLine() { - return openLineTail; - } - - private void appendText(String text, LytFlowContent flowContent) { - String layoutText = GuideFontCompat.preprocessText(text); - var resolvedStyle = flowContent.resolveStyle(); - var resolvedHoverStyle = flowContent.resolveHoverStyle(resolvedStyle); - var spoiler = flowContent.findAncestor(LytSpoilerSpan.class); - ResolvedTextStyle style = resolvedStyle; - ResolvedTextStyle revealStyle = resolvedStyle; - ResolvedTextStyle hoverStyle = resolvedHoverStyle; - if (spoiler != null) { - style = applySpoilerHiddenStyle(resolvedStyle); - revealStyle = applySpoilerRevealStyle(resolvedStyle); - hoverStyle = applySpoilerRevealStyle(resolvedHoverStyle); - } - final var finalStyle = style; - final var finalRevealStyle = revealStyle; - final var finalHoverStyle = hoverStyle; - final boolean inlineCode = finalStyle.inlineCode(); - - char lastChar = '\0'; - var endOfOpenLine = getEndOfOpenLine(); - if (endOfOpenLine instanceof LineTextRun textRun && !textRun.text.isEmpty()) { - lastChar = findLastVisibleChar(textRun.text); - } else if (endOfOpenLine == null || endOfOpenLine.floating) { - // Treat the first text in a line or text directly after a float as if it was after a line-break. - lastChar = '\n'; - } - - iterateRuns(layoutText, finalStyle, lastChar, (run, width, endLine) -> { - if (!run.isEmpty()) { - var el = new LineTextRun(run.toString(), finalStyle, finalRevealStyle, finalHoverStyle); - el.flowContent = flowContent; - int w = Math.round(width); - int h = context.getLineHeight(finalStyle); - if (inlineCode) { - w += LineTextRun.INLINE_CODE_EXTRA_WIDTH; - } - el.bounds = new LytRect(innerX, 0, w, h); - appendToOpenLine(el); - innerX += w; - remainingLineWidth -= w; - } - if (endLine) { - endLine(); - } - }); - } - - private ResolvedTextStyle applySpoilerHiddenStyle(ResolvedTextStyle style) { - return TextStyle.builder() - .backgroundColor(SPOILER_MASK_COLOR) - .color(SPOILER_MASK_COLOR) - .underlined(false) - .wavyUnderline(false) - .dottedUnderline(false) - .strikethrough(false) - .build() - .mergeWith(style); - } - - private ResolvedTextStyle applySpoilerRevealStyle(ResolvedTextStyle style) { - return TextStyle.builder() - .backgroundColor(SPOILER_MASK_COLOR) - .build() - .mergeWith(style); - } - - private void iterateRuns(CharSequence text, ResolvedTextStyle style, char lastChar, LineConsumer consumer) { - float curLineWidth = 0; - - lineBuffer.setLength(0); - lineBufferPrefixWidths[0] = 0f; - - // Hoist whitespace mode flags out of the per-character loop to avoid repeated method calls. - boolean collapseSegmentBreaks = style.whiteSpace() - .isCollapseSegmentBreaks(); - boolean collapseWhitespace = style.whiteSpace() - .isCollapseWhitespace(); - - boolean lastCharWasWhitespace = Character.isWhitespace(lastChar); - boolean canBreakAtStart = lastCharWasWhitespace; - boolean bold = style.bold(); - boolean visibleGlyphSeen = false; - - for (var i = 0; i < text.length(); i++) { - char ch = text.charAt(i); - int codePoint = ch; - - if (GuideFontCompat.isFormattingCodeStart(text, i)) { - appendCharToLineBuffer(ch, 0f); - char formatChar = text.charAt(++i); - appendCharToLineBuffer(formatChar, 0f); - bold = GuideFontCompat.determineBold(bold, formatChar); - continue; - } - - // UTF-16 surrogate handling - if (Character.isHighSurrogate(ch) && i + 1 < text.length()) { - // Always consume the next char if it's a low surrogate - char low = text.charAt(i + 1); - if (Character.isLowSurrogate(low)) { - i++; // Skip the low surrogate - codePoint = Character.toCodePoint(ch, low); - } - } - - // Handle explicit line breaks - if (codePoint == '\n') { - if (collapseSegmentBreaks) { - codePoint = ' '; - } else { - consumer.visitRun(lineBuffer, curLineWidth, true); - lineBuffer.setLength(0); - lineBufferPrefixWidths[0] = 0f; - lastCharWasWhitespace = true; - remainingLineWidth = getAvailableHorizontalSpace(); - continue; - } - } - - if (Character.isWhitespace(codePoint)) { - // Skip if the last one was a space already - if (lastCharWasWhitespace && collapseWhitespace) { - continue; // White space collapsing - } - lastCharWasWhitespace = true; - } else { - lastCharWasWhitespace = false; - } - - var advance = context.getRenderedAdvance(codePoint, style, visibleGlyphSeen); - // Break line if necessary - if (curLineWidth + advance > remainingLineWidth) { - int precedingBreakOpportunity; - - // BreakIterator will only ever break *AFTER* whitespace, but since we ignore the last break opportunity - // we need to also ignore that... - if (lastCharWasWhitespace) { - precedingBreakOpportunity = lineBuffer.length(); - } else { - // Find break opportunities and include the current character in it. - // Append the char temporarily to avoid a string-concatenation allocation. - var breakIterator = LINE_BREAK_ITERATOR.get(); - lineBuffer.append((char) codePoint); - charIterator.reset(lineBuffer); - breakIterator.setText(charIterator); - precedingBreakOpportunity = breakIterator.preceding(lineBuffer.length()); - lineBuffer.setLength(lineBuffer.length() - 1); - } - - // If the preceding text chunk ended on a whitespace, we can break there if the - // current word does not offer us any opportunity to. - if (precedingBreakOpportunity > 0 || precedingBreakOpportunity == 0 && canBreakAtStart) { - // Determine width up until the break opportunity. - float widthAtBreakOpportunity = lineBufferPrefixWidths[precedingBreakOpportunity]; - - consumer - .visitRun(lineBuffer.subSequence(0, precedingBreakOpportunity), widthAtBreakOpportunity, true); - deleteLineBufferPrefix(precedingBreakOpportunity); - if (!lineBuffer.isEmpty() && Character.isWhitespace(lineBuffer.charAt(0))) { - deleteLineBufferPrefix(1); - } - curLineWidth = rebuildLineBufferWidths(style); - bold = resolveTrailingBold(style, lineBuffer); - visibleGlyphSeen = containsVisibleGlyph(lineBuffer); - } else { - // We exceeded the line length, but did not find a break opportunity - // this causes a forced break mid-word - consumer.visitRun(lineBuffer, curLineWidth, true); - lineBuffer.setLength(0); - lineBufferPrefixWidths[0] = 0f; - curLineWidth = 0; - bold = style.bold(); - visibleGlyphSeen = false; - } - remainingLineWidth = getAvailableHorizontalSpace(); - // If a white-space character broke the line, ignore it as it - // would otherwise be at the start of the next line - if (lastCharWasWhitespace) { - continue; - } - } - curLineWidth += advance; - appendCodePointToLineBuffer(codePoint, advance); - if (advance > 0f) { - visibleGlyphSeen = true; - } - } - - if (!lineBuffer.isEmpty()) { - consumer.visitRun(lineBuffer, curLineWidth, false); - } - } - - private void appendCodePointToLineBuffer(int codePoint, float advance) { - int previousLength = lineBuffer.length(); - float bufferWidth = lineBufferPrefixWidths[previousLength] + advance; - lineBuffer.appendCodePoint(codePoint); - int newLength = lineBuffer.length(); - ensureLineBufferPrefixCapacity(newLength + 1); - for (int i = previousLength + 1; i <= newLength; i++) { - lineBufferPrefixWidths[i] = bufferWidth; - } - } - - private void appendCharToLineBuffer(char character, float advance) { - int previousLength = lineBuffer.length(); - float bufferWidth = lineBufferPrefixWidths[previousLength] + advance; - lineBuffer.append(character); - ensureLineBufferPrefixCapacity(previousLength + 2); - lineBufferPrefixWidths[previousLength + 1] = bufferWidth; - } - - private float deleteLineBufferPrefix(int charCount) { - if (charCount <= 0) { - return 0f; - } - float removedWidth = lineBufferPrefixWidths[charCount]; - int remainingChars = lineBuffer.length() - charCount; - if (remainingChars > 0) { - System.arraycopy(lineBufferPrefixWidths, charCount + 1, lineBufferPrefixWidths, 1, remainingChars); - for (int i = 1; i <= remainingChars; i++) { - lineBufferPrefixWidths[i] -= removedWidth; - } - } - lineBuffer.delete(0, charCount); - lineBufferPrefixWidths[0] = 0f; - return removedWidth; - } - - private float rebuildLineBufferWidths(ResolvedTextStyle style) { - ensureLineBufferPrefixCapacity(lineBuffer.length() + 1); - lineBufferPrefixWidths[0] = 0f; - float width = 0f; - boolean bold = style.bold(); - boolean visibleGlyphSeen = false; - for (int index = 0; index < lineBuffer.length(); index++) { - char character = lineBuffer.charAt(index); - if (GuideFontCompat.isFormattingCodeStart(lineBuffer, index)) { - lineBufferPrefixWidths[index + 1] = width; - char formatChar = lineBuffer.charAt(index + 1); - bold = GuideFontCompat.determineBold(bold, formatChar); - lineBufferPrefixWidths[index + 2] = width; - index++; - continue; - } - int codePoint = character; - if (Character.isHighSurrogate(character) && index + 1 < lineBuffer.length()) { - char low = lineBuffer.charAt(index + 1); - if (Character.isLowSurrogate(low)) { - codePoint = Character.toCodePoint(character, low); - } - } - float advance = context.getRenderedAdvance(codePoint, style, visibleGlyphSeen); - width += Math.max(0f, advance); - int charCount = Character.charCount(codePoint); - for (int offset = 1; offset <= charCount; offset++) { - lineBufferPrefixWidths[index + offset] = width; - } - index += charCount - 1; - if (advance > 0f) { - visibleGlyphSeen = true; - } - } - return width; - } - - private boolean resolveTrailingBold(ResolvedTextStyle style, CharSequence text) { - boolean bold = style.bold(); - for (int index = 0; index < text.length() - 1; index++) { - if (!GuideFontCompat.isFormattingCodeStart(text, index)) { - continue; - } - bold = GuideFontCompat.determineBold(bold, text.charAt(index + 1)); - index++; - } - return bold; - } - - private boolean containsVisibleGlyph(CharSequence text) { - for (int index = 0; index < text.length(); index++) { - if (GuideFontCompat.isFormattingCodeStart(text, index)) { - index++; - continue; - } - return true; - } - return false; - } - - private char findLastVisibleChar(CharSequence text) { - for (int index = text.length() - 1; index >= 0; index--) { - char character = text.charAt(index); - if (character == GuideFontCompat.FORMATTING_CHAR && index + 1 < text.length()) { - continue; - } - if (index > 0 && text.charAt(index - 1) == GuideFontCompat.FORMATTING_CHAR) { - index--; - continue; - } - return character; - } - return '\0'; - } - - private void ensureLineBufferPrefixCapacity(int requiredLength) { - if (requiredLength <= lineBufferPrefixWidths.length) { - return; - } - int newLength = lineBufferPrefixWidths.length; - while (newLength < requiredLength) { - newLength <<= 1; - } - lineBufferPrefixWidths = Arrays.copyOf(lineBufferPrefixWidths, newLength); - } - - private void endLine() { - if (openLineElement == null) { - return; - } - - context.clearFloatsAbove(lineBoxY); - if (openLineHasInlineItemImage) { - alignInlineItemImages(); - recomputeOpenLineMetrics(); - } - - // Use incrementally tracked values instead of rescanning the linked list. - var lineHeight = Math.max(1, openLineMaxBottom - openLineMinTop); - var lineWidth = openLineMaxRight; - - var textAreaStart = getInnerLeftEdge(); - var textAreaEnd = getInnerRightEdge(); - - // Apply alignment - int xTranslation = textAreaStart; - if (alignment == TextAlignment.RIGHT) { - xTranslation = textAreaEnd - lineWidth; - } else if (alignment == TextAlignment.CENTER) { - xTranslation = textAreaStart + ((textAreaEnd - textAreaStart) - lineWidth) / 2; - } - int yTranslation = lineBoxY - openLineMinTop; - - // reposition all line elements - int actualRight = lineBoxX; - for (var el = openLineElement; el != null; el = el.next) { - el.bounds = el.bounds.move(xTranslation, yTranslation); - // Ensure that inline blocks update their blocks absolute position - if (el instanceof LineBlock lineBlock) { - lineBlock.layoutBlock(context, el.bounds.width()); - } - - actualRight = Math.max(actualRight, el.bounds.right()); - } - - var lineBounds = new LytRect(lineBoxX, lineBoxY, actualRight - lineBoxX, lineHeight); - var line = new Line(lineBounds, openLineElement); - lines.add(line); - - // Advance vertically - lineBoxY = line.bounds.bottom(); - - // Close out any floats that are above the fold - context.clearFloatsAbove(lineBoxY); - - // Reset horizontal position - openLineElement = null; - openLineTail = null; - innerX = 0; - openLineMinTop = Integer.MAX_VALUE; - openLineMaxBottom = 0; - openLineMaxRight = 0; - openLineHasInlineItemImage = false; - - // Recompute now that floats may have been closed, what the horizontal space really is - remainingLineWidth = getInnerRightEdge() - getInnerLeftEdge(); - } - - // How much horizontal space is available in a new line, accounting for active floats that take up space - private int getAvailableHorizontalSpace() { - return Math.max(0, getInnerRightEdge() - getInnerLeftEdge()); - } - - // Absolute X coord of the beginning of the text area of the current line box - private int getInnerLeftEdge() { - return context.getLeftFloatRightEdgeOr(lineBoxX); - } - - // Absolute X coord of the end of the text area of the current line box - private int getInnerRightEdge() { - return context.getRightFloatLeftEdgeOr(lineBoxX + lineBoxWidth); - } - - private void appendToOpenLine(LineElement el) { - if (openLineElement != null) { - if (openLineTail != null) { - openLineTail.next = el; - } - } else { - openLineElement = el; - } - openLineTail = el; - int top = el.bounds.y(); - if (top < openLineMinTop) openLineMinTop = top; - int bottom = el.bounds.bottom(); - if (bottom > openLineMaxBottom) openLineMaxBottom = bottom; - int right = el.bounds.right(); - if (right > openLineMaxRight) openLineMaxRight = right; - if (isInlineItemImage(el)) { - openLineHasInlineItemImage = true; - } - } - - private void alignInlineItemImages() { - int targetHeight = getTextLineHeightOrDefault(); - for (var el = openLineElement; el != null; el = el.next) { - if (el instanceof LineBlock lineBlock && lineBlock.getBlock() instanceof LytItemImage itemImage - && itemImage.isInline() - && itemImage.isShowingIcon()) { - int offset = getInlineItemYOffset(itemImage); - targetHeight = Math.max(targetHeight, el.bounds.height() + Math.abs(offset) * 2); - } - } - - for (var el = openLineElement; el != null; el = el.next) { - if (el instanceof LineTextRun) { - el.bounds = el.bounds.withY((targetHeight - el.bounds.height()) / 2); - } else if (el instanceof LineBlock lineBlock && lineBlock.getBlock() instanceof LytItemImage itemImage - && itemImage.isInline() - && itemImage.isShowingIcon()) { - int centeredTop = (targetHeight - el.bounds.height()) / 2; - el.bounds = el.bounds.withY(centeredTop + getInlineItemYOffset(itemImage)); - } - } - } - - private int getInlineItemYOffset(LytItemImage itemImage) { - return itemImage.getInlineVerticalOffset(); - } - - private int getTextLineHeightOrDefault() { - int lineHeight = 0; - for (var el = openLineElement; el != null; el = el.next) { - if (el instanceof LineTextRun) { - lineHeight = Math.max(lineHeight, el.bounds.height()); - } - } - return lineHeight > 0 ? lineHeight : context.getLineHeight(DefaultStyles.BASE_STYLE); - } - - private void recomputeOpenLineMetrics() { - openLineMinTop = Integer.MAX_VALUE; - openLineMaxBottom = 0; - openLineMaxRight = 0; - for (var el = openLineElement; el != null; el = el.next) { - int top = el.bounds.y(); - if (top < openLineMinTop) openLineMinTop = top; - int bottom = el.bounds.bottom(); - if (bottom > openLineMaxBottom) openLineMaxBottom = bottom; - int right = el.bounds.right(); - if (right > openLineMaxRight) openLineMaxRight = right; - } - } - - private boolean isInlineItemImage(LineElement element) { - return element instanceof LineBlock lineBlock && lineBlock.getBlock() instanceof LytItemImage itemImage - && itemImage.isInline() - && itemImage.isShowingIcon(); - } - - public void end() { - endLine(); - } - - public LytRect getBounds() { - int width = 0; - for (var line : lines) { - width = Math.max(width, line.bounds.width()); - } - return new LytRect(lineBoxX, startY, width, lineBoxY - startY); - } - - @FunctionalInterface - private interface LineConsumer { - - void visitRun(CharSequence run, float width, boolean end); - } - - /** - * A {@link CharacterIterator} that wraps a {@link StringBuilder} without copying it to a {@link String}. - * Reuse by calling {@link #reset} before each use. - */ - private static class StringBuilderCharIterator implements CharacterIterator { - - private StringBuilder sb; - private int begin; - private int end; - private int pos; - - /** Points this iterator at the full content of {@code buf}. */ - public void reset(StringBuilder buf) { - this.sb = buf; - this.begin = 0; - this.end = buf.length(); - this.pos = 0; - } - - @Override - public char first() { - pos = begin; - return current(); - } - - @Override - public char last() { - if (end == begin) { - pos = end; - return DONE; - } - pos = end - 1; - return current(); - } - - @Override - public char current() { - return (pos >= begin && pos < end) ? sb.charAt(pos) : DONE; - } - - @Override - public char next() { - if (pos < end - 1) { - pos++; - return current(); - } - pos = end; - return DONE; - } - - @Override - public char previous() { - if (pos > begin) { - pos--; - return current(); - } - return DONE; - } - - @Override - public char setIndex(int position) { - if (position < begin || position > end) { - throw new IllegalArgumentException("Invalid index: " + position); - } - pos = position; - return current(); - } - - @Override - public int getBeginIndex() { - return begin; - } - - @Override - public int getEndIndex() { - return end; - } - - @Override - public int getIndex() { - return pos; - } - - @Override - public Object clone() { - try { - return super.clone(); - } catch (CloneNotSupportedException e) { - throw new AssertionError(e); - } - } - } -} diff --git a/src/main/java/com/hfstudio/guidenh/guide/layout/flow/LineElement.java b/src/main/java/com/hfstudio/guidenh/guide/layout/flow/LineElement.java deleted file mode 100644 index 0daec8c5..00000000 --- a/src/main/java/com/hfstudio/guidenh/guide/layout/flow/LineElement.java +++ /dev/null @@ -1,39 +0,0 @@ -package com.hfstudio.guidenh.guide.layout.flow; - -import org.jetbrains.annotations.Nullable; - -import com.hfstudio.guidenh.guide.document.LytRect; -import com.hfstudio.guidenh.guide.document.flow.LytFlowContent; -import com.hfstudio.guidenh.guide.render.RenderContext; - -public abstract class LineElement { - - /** - * Next Element in flow direction. - */ - @Nullable - public LineElement next; - - public LytRect bounds = LytRect.empty(); - - /** - * The original flow content this line element is associated with. - */ - @Nullable - public LytFlowContent flowContent; - - public boolean containsMouse; - public boolean revealedBySpoiler; - - public boolean floating; - - @Nullable - public LytFlowContent getFlowContent() { - return flowContent; - } - - /** - * Render any other content individually. - */ - public void render(RenderContext context) {} -} diff --git a/src/main/java/com/hfstudio/guidenh/guide/layout/flow/LineTextRun.java b/src/main/java/com/hfstudio/guidenh/guide/layout/flow/LineTextRun.java deleted file mode 100644 index f1e759b3..00000000 --- a/src/main/java/com/hfstudio/guidenh/guide/layout/flow/LineTextRun.java +++ /dev/null @@ -1,58 +0,0 @@ -package com.hfstudio.guidenh.guide.layout.flow; - -import com.hfstudio.guidenh.guide.color.ColorUtils; -import com.hfstudio.guidenh.guide.color.ColorValue; -import com.hfstudio.guidenh.guide.document.LytRect; -import com.hfstudio.guidenh.guide.render.RenderContext; -import com.hfstudio.guidenh.guide.style.ResolvedTextStyle; - -public class LineTextRun extends LineElement { - - public static final int INLINE_CODE_PAD_X = 3; - public static final int INLINE_CODE_EXTRA_WIDTH = INLINE_CODE_PAD_X * 2; - public static final int INLINE_CODE_BACKGROUND_LIGHT = ColorUtils.ARGB_1AF0F6FF.getColor(); - public static final int INLINE_CODE_BACKGROUND_DARK = ColorUtils.ARGB_1A6FB6FF.getColor(); - - public final String text; - public final ResolvedTextStyle style; - public final ResolvedTextStyle revealStyle; - public final ResolvedTextStyle hoverStyle; - - public LineTextRun(String text, ResolvedTextStyle style, ResolvedTextStyle revealStyle, - ResolvedTextStyle hoverStyle) { - this.text = text; - this.style = style; - this.revealStyle = revealStyle; - this.hoverStyle = hoverStyle; - } - - @Override - public void render(RenderContext context) { - var resolvedStyle = containsMouse ? hoverStyle : revealedBySpoiler ? revealStyle : this.style; - boolean inlineCode = resolvedStyle.inlineCode(); - ColorValue backgroundColor = resolvedStyle.backgroundColor(); - if (!inlineCode && backgroundColor == null) { - context.drawText(text, bounds.x(), bounds.y(), resolvedStyle); - return; - } - LytRect rect = bounds; - int width = rect.width(); - int height = rect.height(); - if (width > 0 && height > 0) { - int backgroundY = rect.y() - 1; - if (inlineCode) { - int backgroundColorArgb = INLINE_CODE_BACKGROUND_DARK; - context.fillRect(rect.x(), backgroundY, width, height, backgroundColorArgb); - } else { - context.fillRect(rect.x() - 1, backgroundY, width + 2, height, backgroundColor); - } - } - int textX = inlineCode ? rect.x() + INLINE_CODE_PAD_X : rect.x(); - context.drawText(text, textX, rect.y(), resolvedStyle); - } - - @Override - public String toString() { - return "TextRun[" + text + "]"; - } -} diff --git a/src/main/java/com/hfstudio/guidenh/guide/mediawiki/MediaWikiGeneratedListBlock.java b/src/main/java/com/hfstudio/guidenh/guide/mediawiki/MediaWikiGeneratedListBlock.java index 3d39583b..4ca92835 100644 --- a/src/main/java/com/hfstudio/guidenh/guide/mediawiki/MediaWikiGeneratedListBlock.java +++ b/src/main/java/com/hfstudio/guidenh/guide/mediawiki/MediaWikiGeneratedListBlock.java @@ -4,6 +4,10 @@ import java.util.List; import java.util.Optional; +import net.minecraft.client.Minecraft; +import net.minecraft.client.renderer.texture.ITextureObject; +import net.minecraft.util.ResourceLocation; + import org.jetbrains.annotations.Nullable; import com.hfstudio.guidenh.guide.GuidePageIcon; @@ -12,7 +16,6 @@ import com.hfstudio.guidenh.guide.color.ConstantColor; import com.hfstudio.guidenh.guide.document.DefaultStyles; import com.hfstudio.guidenh.guide.document.LytRect; -import com.hfstudio.guidenh.guide.document.block.BorderRenderer; import com.hfstudio.guidenh.guide.document.block.LytBlock; import com.hfstudio.guidenh.guide.document.flow.LytFlowContent; import com.hfstudio.guidenh.guide.document.interaction.GuideTooltip; @@ -20,7 +23,12 @@ import com.hfstudio.guidenh.guide.internal.GuidebookText; import com.hfstudio.guidenh.guide.internal.debug.DebugComponent; import com.hfstudio.guidenh.guide.layout.LayoutContext; +import com.hfstudio.guidenh.guide.render.GuidePageTexture; +import com.hfstudio.guidenh.guide.render.GuideRenderPrimitive; +import com.hfstudio.guidenh.guide.render.GuideText; +import com.hfstudio.guidenh.guide.render.PrimitiveCollector; import com.hfstudio.guidenh.guide.render.RenderContext; +import com.hfstudio.guidenh.guide.style.BorderStyle; import com.hfstudio.guidenh.guide.style.ResolvedTextStyle; import com.hfstudio.guidenh.guide.style.TextStyle; import com.hfstudio.guidenh.guide.ui.GuideUiHost; @@ -56,7 +64,6 @@ public class MediaWikiGeneratedListBlock extends LytBlock implements Interactive .build() .mergeWith(DefaultStyles.BASE_STYLE); private static final ResolvedTextStyle EMPTY_STYLE = DefaultStyles.BODY_TEXT.mergeWith(DefaultStyles.BASE_STYLE); - private static final BorderRenderer BORDER_RENDERER = new BorderRenderer(); private final List entries = new ArrayList<>(); private final List rowLayouts = new ArrayList<>(); @@ -65,47 +72,103 @@ public class MediaWikiGeneratedListBlock extends LytBlock implements Interactive @Nullable private RowLayout hoveredRow; + /** Precomputed max column content height for Rust MeasureFunc. Set during computeLayout. */ + private int maxPrecomputedContentHeight = 0; + public void setEntries(List entries) { this.entries.clear(); if (entries != null) { this.entries.addAll(entries); } + // Invalidate precomputed height when entries change + this.maxPrecomputedContentHeight = 0; } public void setRows(int rows) { this.rows = MediaWikiListPlanner.sanitizeRows(rows); + // Invalidate precomputed height when row count changes + this.maxPrecomputedContentHeight = 0; } public void setEmptyText(String emptyText) { this.emptyText = emptyText != null && !emptyText.isEmpty() ? emptyText : GuidebookText.MediaWikiNoPages.text(); } + /** + * Returns the precomputed max column content height (tallest column's content, + * excluding TOP_PADDING and BOTTOM_PADDING). Used by the Rust MeasureFunc. + * Equals ROW_HEIGHT when entries is empty. + *

          + * Computed lazily when no layout pass has been run (value is 0). The + * calculation depends only on block fields ({@link #entries}, {@link #rows}) + * and constants, not on a {@link LayoutContext}, so it works without the + * Java layout pre-pass. + */ + public int getMaxPrecomputedContentHeight() { + if (maxPrecomputedContentHeight <= 0) { + maxPrecomputedContentHeight = computeMaxPrecomputedContentHeight(); + } + return maxPrecomputedContentHeight; + } + + /** + * Computes the max column content height from block state alone (no + * {@link LayoutContext} needed). The height is independent of availableWidth + * because column packing uses row count (not pixel width) to + * distribute entries; each entry occupies a fixed {@link #ROW_HEIGHT}. + */ + private int computeMaxPrecomputedContentHeight() { + if (entries.isEmpty()) { + return ROW_HEIGHT; + } + int columnCount = Math.max(1, rows); + List columns = MediaWikiListPlanner.planColumns(entries, columnCount); + int maxColumnHeight = 0; + for (MediaWikiListPlanner.MediaWikiListColumn column : columns) { + int columnHeight = 0; + for (MediaWikiListPlanner.MediaWikiListSection section : column.sections()) { + if (!section.entries() + .isEmpty()) { + columnHeight += SECTION_GAP_TOP; + } + columnHeight += HEADER_HEIGHT + SECTION_GAP_BOTTOM; + columnHeight += section.entries() + .size() * ROW_HEIGHT; + } + maxColumnHeight = Math.max(maxColumnHeight, columnHeight); + } + return maxColumnHeight; + } + @Override - protected LytRect computeLayout(LayoutContext context, int x, int y, int availableWidth) { + protected void afterExternalLayout() { + // Recompute row layouts from Rust-computed bounds (the Java pre-pass + // no longer calls computeLayout). Only block fields and constants are + // used, so no LayoutContext is needed (heights are fixed constants). + if (bounds.isEmpty()) return; + recomputeRowLayouts(bounds.x(), bounds.y(), bounds.width()); + } + + /** Shared row-layout computation used by both the pre-pass and afterExternalLayout. */ + private void recomputeRowLayouts(int x, int y, int availableWidth) { rowLayouts.clear(); hoveredRow = null; - int columnCount = Math.max(1, rows); int innerWidth = Math.max(0, availableWidth - SIDE_PADDING * 2); int columnWidth = Math.max(1, (innerWidth - COLUMN_GAP * (columnCount - 1)) / columnCount); - if (entries.isEmpty()) { rowLayouts .add(new RowLayout(new LytRect(x + SIDE_PADDING, y + TOP_PADDING, innerWidth, ROW_HEIGHT), null, null)); - return new LytRect(x, y, availableWidth, TOP_PADDING + ROW_HEIGHT + BOTTOM_PADDING); + return; } - List columns = MediaWikiListPlanner.planColumns(entries, columnCount); - int maxColumnHeight = 0; for (int columnIndex = 0; columnIndex < columns.size(); columnIndex++) { int columnX = x + SIDE_PADDING + columnIndex * (columnWidth + COLUMN_GAP); int columnY = y + TOP_PADDING; - for (MediaWikiListPlanner.MediaWikiListSection section : columns.get(columnIndex) + for (var section : columns.get(columnIndex) .sections()) { if (!section.entries() - .isEmpty()) { - columnY += SECTION_GAP_TOP; - } + .isEmpty()) columnY += SECTION_GAP_TOP; rowLayouts .add(new RowLayout(new LytRect(columnX, columnY, columnWidth, HEADER_HEIGHT), null, section.key())); columnY += HEADER_HEIGHT + SECTION_GAP_BOTTOM; @@ -114,9 +177,15 @@ protected LytRect computeLayout(LayoutContext context, int x, int y, int availab columnY += ROW_HEIGHT; } } - maxColumnHeight = Math.max(maxColumnHeight, columnY - y - TOP_PADDING); } - return new LytRect(x, y, availableWidth, TOP_PADDING + maxColumnHeight + BOTTOM_PADDING); + } + + @Override + protected LytRect computeLayout(LayoutContext context, int x, int y, int availableWidth) { + recomputeRowLayouts(x, y, availableWidth); + // maxPrecomputedContentHeight has a lazy getter, so it will be + // computed on demand if not set here. + return new LytRect(x, y, availableWidth, TOP_PADDING + getMaxPrecomputedContentHeight() + BOTTOM_PADDING); } @Override @@ -125,10 +194,15 @@ protected void onLayoutMoved(int deltaX, int deltaY) { } @Override - public void render(RenderContext context) { - renderBorders(context); + public boolean usePrimitives() { + return true; + } + + @Override + public void computePrimitives(PrimitiveCollector c) { + emitBorders(c); for (RowLayout rowLayout : rowLayouts) { - if (!context.intersectsViewport(rowLayout.bounds())) { + if (c.isCulled(rowLayout.bounds())) { continue; } @@ -138,11 +212,11 @@ public void render(RenderContext context) { int emptyTextY = rowLayout.bounds() .y() + verticalCenterOffset( - context, EMPTY_STYLE, rowLayout.bounds() .height()); - context.drawText( + emitText( + c, emptyText, rowLayout.bounds() .x(), @@ -155,11 +229,11 @@ public void render(RenderContext context) { int headerTextY = rowLayout.bounds() .y() + verticalCenterOffset( - context, HEADER_STYLE, rowLayout.bounds() .height()); - context.drawText( + emitText( + c, rowLayout.header(), rowLayout.bounds() .x(), @@ -173,7 +247,6 @@ public void render(RenderContext context) { int rowTextY = rowLayout.bounds() .y() + verticalCenterOffset( - context, rowStyle, rowLayout.bounds() .height()); @@ -183,12 +256,18 @@ public void render(RenderContext context) { .y() + (rowLayout.bounds() .height() - LIST_MARKER_SIZE) / 2; - context.fillRect(markerX, markerY, LIST_MARKER_SIZE, LIST_MARKER_SIZE, LIST_MARKER_COLOR); + c.emit( + new GuideRenderPrimitive.FillRect( + markerX, + markerY, + LIST_MARKER_SIZE, + LIST_MARKER_SIZE, + LIST_MARKER_COLOR.resolve())); int textX = markerX + LIST_MARKER_SIZE + LIST_MARKER_GAP; GuidePageIcon icon = entry.icon(); if (icon != null) { - renderIcon( - context, + emitIcon( + c, icon, textX, rowLayout.bounds() @@ -198,17 +277,18 @@ public void render(RenderContext context) { textX += ICON_SIZE + ICON_GAP; } - String renderedTitle = clipToWidth( - context, + String renderedTitle = GuideText.clipToWidth( entry.title(), Math.max( 1, rowLayout.bounds() - .right() - textX)); + .right() - textX), + rowStyle, + GuideText.ClipSuffix.DOTS3); int clickableWidth = textX - rowLayout.bounds() .x(); if (renderedTitle != null && !renderedTitle.isEmpty()) { - clickableWidth += context.getStringWidth(renderedTitle, rowStyle); + clickableWidth += GuideText.measureWidth(renderedTitle, rowStyle); } rowLayout.setClickableBounds( new LytRect( @@ -219,10 +299,17 @@ public void render(RenderContext context) { Math.max(0, clickableWidth), rowLayout.bounds() .height())); - context.drawText(renderedTitle, textX, rowTextY, rowStyle); + emitText(c, renderedTitle, textX, rowTextY, rowStyle); } } + /** + * Migrated to {@link #computePrimitives}; the legacy path is unreachable + * (the collector only invokes it when {@link #usePrimitives()} is false). + */ + @Override + public void render(RenderContext context) {} + @Override public boolean mouseClicked(GuideUiHost screen, int x, int y, int button, boolean doubleClick) { if (button != 0) { @@ -271,44 +358,88 @@ private RowLayout findClickableRow(int x, int y) { return null; } - private void renderIcon(RenderContext context, GuidePageIcon icon, int x, int y) { + private void emitIcon(PrimitiveCollector c, GuidePageIcon icon, int x, int y) { if (icon.isItemIcon() && icon.resolveCurrentItemStack() != null) { - context.renderItemIcon(icon.resolveCurrentItemStack(), x, y); + c.emit(new GuideRenderPrimitive.RenderItem(icon.resolveCurrentItemStack(), x, y)); return; } - if (icon.resolveCurrentTexture() != null) { - context.fillTexturedRect(new LytRect(x, y, ICON_SIZE, ICON_SIZE), icon.resolveCurrentTexture()); + GuidePageTexture texture = icon.resolveCurrentTexture(); + if (texture == null || texture.isMissing()) { + return; + } + ResourceLocation resolvedTexture = texture.getTexture(); + int texId = resolvedTexture != null ? getGlTextureId(resolvedTexture) : -1; + if (texId >= 0) { + c.emit(new GuideRenderPrimitive.BlitTexture(texId, x, y, ICON_SIZE, ICON_SIZE, 0f, 0f, 1f, 1f)); } } - private int verticalCenterOffset(RenderContext context, ResolvedTextStyle style, int boxHeight) { - return Math.max(0, (boxHeight - context.getLineHeight(style)) / 2); + private int verticalCenterOffset(ResolvedTextStyle style, int boxHeight) { + return Math.max(0, (boxHeight - GuideText.lineHeight(style)) / 2); } - private void renderBorders(RenderContext context) { + private void emitBorders(PrimitiveCollector c) { if (getBorderTop().width() <= 0 && getBorderLeft().width() <= 0 && getBorderRight().width() <= 0 && getBorderBottom().width() <= 0) { return; } - BORDER_RENDERER.render(context, bounds, getBorderTop(), getBorderLeft(), getBorderRight(), getBorderBottom()); + c.emit( + new GuideRenderPrimitive.DrawBorder( + bounds.x(), + bounds.y(), + bounds.width(), + bounds.height(), + getBorderTop().width(), + getBorderLeft().width(), + getBorderBottom().width(), + getBorderRight().width(), + resolveBorderArgb())); } - private String clipToWidth(RenderContext context, String text, int maxWidth) { - if (text == null || text.isEmpty() || context.getStringWidth(text, LINK_STYLE) <= maxWidth) { - return text == null ? "" : text; + private int resolveBorderArgb() { + // DrawBorder is single-color; use the first side that declares one. + // This block's callers set top+bottom with the same color + // (ColorUtils.TABLE_BORDER), so the single color is exact here. + BorderStyle[] sides = { getBorderTop(), getBorderLeft(), getBorderRight(), getBorderBottom() }; + for (BorderStyle side : sides) { + var color = side.color(); + if (color != null) { + return color.resolve(); + } } + return 0xFF000000; + } - int ellipsisWidth = context.getStringWidth("...", LINK_STYLE); - if (ellipsisWidth >= maxWidth) { - return ""; + /** + * Emits {@code text} through the unified {@link GuideText} glyph pipeline + * at document position {@code (x, lineTop)}; {@code lineTop} is the line + * top (baseline = lineTop + ascent × fontScale), not the MC baseline. + *

          + * GuideText does not paint decorations; the underline of + * {@link #HOVER_LINK_STYLE} is therefore drawn manually as a 1px + * {@link GuideRenderPrimitive.FillRect} 1px below the baseline, spanning + * the measured text width (same width the glyph run occupies). + */ + private static void emitText(PrimitiveCollector c, String text, int x, int lineTop, ResolvedTextStyle style) { + GuideText.emitText(c, text, x, lineTop, style); + if (style != null && style.underlined() && text != null && !text.isEmpty()) { + int width = GuideText.measureWidth(text, style); + int underlineY = Math.round(GuideText.baselineOf(lineTop, style)) + 1; + c.emit(new GuideRenderPrimitive.FillRect(x, underlineY, width, 1, GuideText.resolveColor(style))); } + } - int end = text.length(); - while (end > 0 && context.getStringWidth(text.substring(0, end), LINK_STYLE) + ellipsisWidth > maxWidth) { - end--; + private static int getGlTextureId(ResourceLocation res) { + try { + ITextureObject tex = Minecraft.getMinecraft() + .getTextureManager() + .getTexture(res); + return tex != null ? tex.getGlTextureId() : -1; + } catch (Throwable t) { + // Headless (unit tests) or texture unavailable: skip drawing. + return -1; } - return end <= 0 ? "..." : text.substring(0, end) + "..."; } private static class RowLayout { diff --git a/src/main/java/com/hfstudio/guidenh/guide/mediawiki/MediaWikiListPlanner.java b/src/main/java/com/hfstudio/guidenh/guide/mediawiki/MediaWikiListPlanner.java index 4b1be8bb..be5f162d 100644 --- a/src/main/java/com/hfstudio/guidenh/guide/mediawiki/MediaWikiListPlanner.java +++ b/src/main/java/com/hfstudio/guidenh/guide/mediawiki/MediaWikiListPlanner.java @@ -55,7 +55,7 @@ public static List buildGroups(List entr return groups; } - /** Pixel cost constants — must match MediaWikiGeneratedListBlock layout. */ + /** Pixel cost constants; must match the MediaWikiGeneratedListBlock layout. */ private static final int SECTION_HEADER_HEIGHT = 28; // GAP_TOP(5) + HEADER_HEIGHT(20) + GAP_BOTTOM(3) private static final int ROW_HEIGHT = 20; diff --git a/src/main/java/com/hfstudio/guidenh/guide/mediawiki/MediaWikiSpecialGeneratedBlock.java b/src/main/java/com/hfstudio/guidenh/guide/mediawiki/MediaWikiSpecialGeneratedBlock.java index f1b252fb..25645e6f 100644 --- a/src/main/java/com/hfstudio/guidenh/guide/mediawiki/MediaWikiSpecialGeneratedBlock.java +++ b/src/main/java/com/hfstudio/guidenh/guide/mediawiki/MediaWikiSpecialGeneratedBlock.java @@ -7,6 +7,8 @@ import java.util.Map; import java.util.Optional; +import net.minecraft.client.Minecraft; +import net.minecraft.client.renderer.texture.ITextureObject; import net.minecraft.util.ResourceLocation; import org.jetbrains.annotations.Nullable; @@ -17,7 +19,6 @@ import com.hfstudio.guidenh.guide.color.ConstantColor; import com.hfstudio.guidenh.guide.document.DefaultStyles; import com.hfstudio.guidenh.guide.document.LytRect; -import com.hfstudio.guidenh.guide.document.block.BorderRenderer; import com.hfstudio.guidenh.guide.document.block.LytBlock; import com.hfstudio.guidenh.guide.document.flow.LytFlowContent; import com.hfstudio.guidenh.guide.document.interaction.GuideTooltip; @@ -25,8 +26,14 @@ import com.hfstudio.guidenh.guide.document.interaction.TextTooltip; import com.hfstudio.guidenh.guide.internal.GuidebookText; import com.hfstudio.guidenh.guide.internal.util.GuideStringLines; +import com.hfstudio.guidenh.guide.layout.FontMetrics; import com.hfstudio.guidenh.guide.layout.LayoutContext; +import com.hfstudio.guidenh.guide.render.GuidePageTexture; +import com.hfstudio.guidenh.guide.render.GuideRenderPrimitive; +import com.hfstudio.guidenh.guide.render.GuideText; +import com.hfstudio.guidenh.guide.render.PrimitiveCollector; import com.hfstudio.guidenh.guide.render.RenderContext; +import com.hfstudio.guidenh.guide.style.BorderStyle; import com.hfstudio.guidenh.guide.style.ResolvedTextStyle; import com.hfstudio.guidenh.guide.style.TextStyle; import com.hfstudio.guidenh.guide.ui.GuideUiHost; @@ -36,6 +43,262 @@ public class MediaWikiSpecialGeneratedBlock extends LytBlock implements Interact private static final int SPECIAL_PAGES_GROUP_COLUMNS = 2; private static final int TOP_PADDING = 6; private static final int BOTTOM_PADDING = 6; + + /** Precomputed max column content height for Rust MeasureFunc. Set during computeLayout. */ + private int maxPrecomputedContentHeight = 0; + + /** Font facts collected during computeLayout, consumed by Rust measure via serialization. */ + @Nullable + private FontFacts collectedFontFacts; + + /** + * Returns the font facts, collecting them on demand if no prior layout has + * cached them. Called by the serializer (which runs after computeLayout and + * does NOT have a LayoutContext). Uses {@link GuideText} static methods so + * no {@link LayoutContext} is required. + */ + public FontFacts getCollectedFontFacts() { + if (collectedFontFacts == null) { + collectedFontFacts = collectFontFacts(); + } + return collectedFontFacts; + } + + // ── Serialization helpers for Rust-side measurement ──────────────────── + + /** + * Populates a FontFactsCollector with width-independent font-measurement + * data consumed by the Rust measure function. + */ + public FontFacts collectFontFacts() { + return new FontFacts(collectFontFactsImpl()); + } + + /** + * Contains all per-entry font facts plus column-planning data for Rust. + * Exposed as a single object so the serializer calls one method. + */ + public record FontFacts(FontFactsImpl impl) { + + public record FontFactsImpl(int columnCount, boolean hasMore, int groupCount, float[] groupTitleWidths, + int[] groupEntryCounts, float[] groupEstimatedHeights, int totalEntryCount, float[] entryTitleWidths, + byte[] entryHasIcon, float[] entryEstimatedHeights, int[] entrySubtitleLineCounts, + int[] subtitleLineWordCounts, float[] subtitleWordWidths, float subtitleSpaceWidth, float linkLineHeight, + float subtitleLineHeight) {} + } + + private FontFacts.FontFactsImpl collectFontFactsImpl() { + MediaWikiSpecialPageResult visibleResult = applyVisibility(result, searchQuery); + int columnCount = resolveColumnCount(visibleResult); + boolean hasMoreFlag = visibleResult != null && visibleResult.hasMore(); + boolean isGrouped = visibleResult != null && (visibleResult.kind() == MediaWikiSpecialPageKind.GROUPED + || visibleResult.kind() == MediaWikiSpecialPageKind.GROUP_INDEX); + + if (isEmpty(visibleResult)) { + return new FontFacts.FontFactsImpl( + columnCount, + false, + 0, + new float[0], + new int[0], + new float[0], + 0, + new float[0], + new byte[0], + new float[0], + new int[0], + new int[0], + new float[0], + 0f, + GuideText.lineHeight(LINK_STYLE), + GuideText.lineHeight(SUBTITLE_STYLE)); + } + + if (!isGrouped) { + // ── Flat entries: distribute evenly ── + List entries = visibleResult.flatEntries() != null ? visibleResult.flatEntries() + : List.of(); + int totalEntryCount = entries.size(); + int perColumn = Math.max(1, (totalEntryCount + columnCount - 1) / columnCount); + // Build per-column groups (one group per column, no title). + int actualGroupCount = 0; + for (int ci = 0; ci < columnCount; ci++) { + int start = ci * perColumn; + if (start >= totalEntryCount) break; + actualGroupCount++; + } + int[] groupEntryCounts = new int[actualGroupCount]; + float[] groupTitleWidths = new float[actualGroupCount]; + float[] groupEstimatedHeights = new float[actualGroupCount]; + int entryIdx = 0; + for (int gi = 0; gi < actualGroupCount; gi++) { + int start = gi * perColumn; + int end = Math.min(entries.size(), start + perColumn); + int cnt = end - start; + groupEntryCounts[gi] = cnt; + groupTitleWidths[gi] = 0f; // no title + // Estimate height for this group's entries. + float estH = 0f; + for (int ei = start; ei < end; ei++) { + estH += estimateEntryHeight(entries.get(ei)) + ENTRY_GAP; + } + estH += GROUP_MARGIN; + groupEstimatedHeights[gi] = estH; + } + + float[] entryTitleWidths = new float[totalEntryCount]; + byte[] entryHasIcon = new byte[totalEntryCount]; + float[] entryEstimatedHeights = new float[totalEntryCount]; + int[] entrySubtitleLineCounts = new int[totalEntryCount]; + java.util.ArrayList lineWordCounts = new java.util.ArrayList<>(); + java.util.ArrayList wordWidths = new java.util.ArrayList<>(); + float spaceWidth = 0f; + + for (int ei = 0; ei < totalEntryCount; ei++) { + MediaWikiSpecialListEntry e = entries.get(ei); + entryTitleWidths[ei] = GuideText.measureWidth(e.title() != null ? e.title() : "", LINK_STYLE); + entryHasIcon[ei] = (byte) (e.icon() != null ? 1 : 0); + entryEstimatedHeights[ei] = estimateEntryHeight(e); + // Subtitle word data + String sub = e.subtitle(); + if (sub == null || sub.isEmpty()) { + entrySubtitleLineCounts[ei] = 0; + } else { + java.util.List rawLines = GuideStringLines.splitLines(sub); + entrySubtitleLineCounts[ei] = rawLines.size(); + for (String rawLine : rawLines) { + String trimmed = rawLine.trim(); + if (trimmed.isEmpty()) { + continue; + } + String[] words = trimmed.split("\\s+"); + lineWordCounts.add(words.length); + for (String word : words) { + if (word.isEmpty()) continue; + float w = GuideText.measureWidth(word, SUBTITLE_STYLE); + wordWidths.add(w); + } + } + } + } + // Measure space width from the first subtitle word if available, else default. + spaceWidth = GuideText.measureWidth(" ", SUBTITLE_STYLE); + if (spaceWidth <= 0f) spaceWidth = 4f; // fallback + + int[] lwc = new int[lineWordCounts.size()]; + for (int i = 0; i < lwc.length; i++) lwc[i] = lineWordCounts.get(i); + float[] ww = new float[wordWidths.size()]; + for (int i = 0; i < ww.length; i++) ww[i] = wordWidths.get(i); + + return new FontFacts.FontFactsImpl( + columnCount, + hasMoreFlag, + actualGroupCount, + groupTitleWidths, + groupEntryCounts, + groupEstimatedHeights, + totalEntryCount, + entryTitleWidths, + entryHasIcon, + entryEstimatedHeights, + entrySubtitleLineCounts, + lwc, + ww, + spaceWidth, + GuideText.lineHeight(LINK_STYLE), + GuideText.lineHeight(SUBTITLE_STYLE)); + } else { + // ── Grouped entries: one group per result group ── + java.util.List groups = buildGroups(visibleResult); + int groupCount = groups.size(); + + float[] groupTitleWidths = new float[groupCount]; + int[] groupEntryCounts = new int[groupCount]; + float[] groupEstimatedHeights = new float[groupCount]; + + // First pass: compute per-group metadata and total entry count. + int totalEntryCount = 0; + for (int gi = 0; gi < groupCount; gi++) { + GroupLayout g = groups.get(gi); + float tw = 0f; + if (g.title() != null && !g.title() + .isEmpty()) { + tw = GuideText.measureWidth(g.title(), HEADER_STYLE); + } + groupTitleWidths[gi] = tw; + groupEntryCounts[gi] = g.entries() + .size(); + totalEntryCount += g.entries() + .size(); + groupEstimatedHeights[gi] = estimateHeight(g); + } + + float[] entryTitleWidths = new float[totalEntryCount]; + byte[] entryHasIcon = new byte[totalEntryCount]; + float[] entryEstimatedHeights = new float[totalEntryCount]; + int[] entrySubtitleLineCounts = new int[totalEntryCount]; + java.util.ArrayList lineWordCounts = new java.util.ArrayList<>(); + java.util.ArrayList wordWidths = new java.util.ArrayList<>(); + float spaceWidth = 0f; + int entryIdx = 0; + + for (int gi = 0; gi < groupCount; gi++) { + GroupLayout g = groups.get(gi); + for (MediaWikiSpecialListEntry e : g.entries()) { + entryTitleWidths[entryIdx] = GuideText.measureWidth(e.title() != null ? e.title() : "", LINK_STYLE); + entryHasIcon[entryIdx] = (byte) (e.icon() != null ? 1 : 0); + entryEstimatedHeights[entryIdx] = estimateEntryHeight(e); + + String sub = e.subtitle(); + if (sub == null || sub.isEmpty()) { + entrySubtitleLineCounts[entryIdx] = 0; + } else { + java.util.List rawLines = GuideStringLines.splitLines(sub); + entrySubtitleLineCounts[entryIdx] = rawLines.size(); + for (String rawLine : rawLines) { + String trimmed = rawLine.trim(); + if (trimmed.isEmpty()) continue; + String[] words = trimmed.split("\\s+"); + lineWordCounts.add(words.length); + for (String word : words) { + if (word.isEmpty()) continue; + float w = GuideText.measureWidth(word, SUBTITLE_STYLE); + wordWidths.add(w); + } + } + } + entryIdx++; + } + } + + spaceWidth = GuideText.measureWidth(" ", SUBTITLE_STYLE); + if (spaceWidth <= 0f) spaceWidth = 4f; + + int[] lwc = new int[lineWordCounts.size()]; + for (int i = 0; i < lwc.length; i++) lwc[i] = lineWordCounts.get(i); + float[] ww = new float[wordWidths.size()]; + for (int i = 0; i < ww.length; i++) ww[i] = wordWidths.get(i); + + return new FontFacts.FontFactsImpl( + columnCount, + hasMoreFlag, + groupCount, + groupTitleWidths, + groupEntryCounts, + groupEstimatedHeights, + totalEntryCount, + entryTitleWidths, + entryHasIcon, + entryEstimatedHeights, + entrySubtitleLineCounts, + lwc, + ww, + spaceWidth, + GuideText.lineHeight(LINK_STYLE), + GuideText.lineHeight(SUBTITLE_STYLE)); + } + } + private static final int SIDE_PADDING = 2; private static final int COLUMN_GAP = 10; private static final int GROUP_MARGIN = 6; @@ -53,7 +316,6 @@ public class MediaWikiSpecialGeneratedBlock extends LytBlock implements Interact private static final int HEADER_MARGIN_BOTTOM = 5; private static final int LOAD_MORE_HEIGHT = 18; private static final int LOAD_MORE_MARGIN_TOP = 2; - private static final String ELLIPSIS = "..."; private static final ConstantColor LIST_MARKER_COLOR = ConstantColor.WHITE; private static final ResolvedTextStyle LINK_STYLE = TextStyle.builder() .apply(DefaultStyles.BODY_TEXT) @@ -76,7 +338,6 @@ public class MediaWikiSpecialGeneratedBlock extends LytBlock implements Interact .build() .mergeWith(DefaultStyles.BASE_STYLE); private static final ResolvedTextStyle EMPTY_STYLE = DefaultStyles.BODY_TEXT.mergeWith(DefaultStyles.BASE_STYLE); - private static final BorderRenderer BORDER_RENDERER = new BorderRenderer(); private final List rowLayouts = new ArrayList<>(); private MediaWikiSpecialPageResult result = MediaWikiSpecialPageModels.info( @@ -113,6 +374,8 @@ public void setResult(MediaWikiSpecialPageResult result) { definition = result.definition(); visibilityCache = null; currentVisibleCount = resolveDefaultVisibleCount(result); + maxPrecomputedContentHeight = 0; + collectedFontFacts = null; } } @@ -128,12 +391,66 @@ public void setResolverContext(MediaWikiListContext listContext, MediaWikiSpecia public void setRows(int rows) { this.rows = Math.max(1, rows); + maxPrecomputedContentHeight = 0; + collectedFontFacts = null; } public void setEmptyText(String emptyText) { this.emptyText = emptyText != null && !emptyText.isEmpty() ? emptyText : GuidebookText.MediaWikiNoPages.text(); } + /** + * Returns the precomputed max column content height (tallest column's content, + * excluding TOP_PADDING and BOTTOM_PADDING). Used by the Rust MeasureFunc. + * Equals ENTRY_HEIGHT when the visible result is empty. + *

          + * Computed lazily when the cached value is zero (no layout pre-pass has run, + * or state was invalidated). Uses {@link #estimateEntryHeight} so no + * {@link LayoutContext} is needed. + */ + public int getMaxPrecomputedContentHeight() { + if (maxPrecomputedContentHeight <= 0) { + maxPrecomputedContentHeight = computeMaxPrecomputedContentHeight(); + } + return maxPrecomputedContentHeight; + } + + /** + * Computes max column content height from block state alone, without + * requiring a {@link LayoutContext}. Falls back to {@link #estimateEntryHeight} + * for entry heights (which does not need text-width measurement). + */ + private int computeMaxPrecomputedContentHeight() { + MediaWikiSpecialPageResult visibleResult = applyVisibility(result, searchQuery); + int columnCount = resolveColumnCount(visibleResult); + int innerWidth = 0; // not used for height calculation with estimateEntryHeight + + if (isEmpty(visibleResult)) { + return ENTRY_HEIGHT; + } + + List> columns = layoutColumns(visibleResult); + int maxColumnHeight = 0; + for (int columnIndex = 0; columnIndex < columns.size(); columnIndex++) { + int columnY = 0; + for (GroupLayout group : columns.get(columnIndex)) { + if (group.title() != null) { + columnY += HEADER_MARGIN_TOP + HEADER_HEIGHT + HEADER_MARGIN_BOTTOM; + } + for (MediaWikiSpecialListEntry entry : group.entries()) { + columnY += estimateEntryHeight(entry); + columnY += ENTRY_GAP; + } + columnY += GROUP_MARGIN; + } + maxColumnHeight = Math.max(maxColumnHeight, columnY); + } + if (visibleResult.hasMore()) { + maxColumnHeight += LOAD_MORE_MARGIN_TOP + LOAD_MORE_HEIGHT; + } + return maxColumnHeight; + } + public void setSearchQuery(String searchQuery) { String nextSearchQuery = searchQuery != null ? searchQuery : ""; if (this.searchQuery.equals(nextSearchQuery)) { @@ -148,6 +465,8 @@ public void setSearchQuery(String searchQuery) { currentVisibleCount = resolveDefaultVisibleCount(result); } visibilityCache = null; + maxPrecomputedContentHeight = 0; + collectedFontFacts = null; invalidateLayout(); } @@ -177,23 +496,28 @@ private void refreshResolverBackedResult(String queryText) { } @Override - protected LytRect computeLayout(LayoutContext context, int x, int y, int availableWidth) { + protected void afterExternalLayout() { + // Recompute row layouts from Rust-computed bounds (the Java pre-pass + // no longer calls computeLayout). Uses computeEntryHeight with a + // GuideText fallback LayoutContext (no real FontMetrics available). + if (bounds.isEmpty()) return; + recomputeRowLayouts(bounds.x(), bounds.y(), bounds.width()); + } + + /** Shared row-layout computation for both the pre-pass and afterExternalLayout. */ + private void recomputeRowLayouts(int x, int y, int availableWidth) { rowLayouts.clear(); hoveredRow = null; - MediaWikiSpecialPageResult visibleResult = applyVisibility(result, searchQuery); int columnCount = resolveColumnCount(visibleResult); int innerWidth = Math.max(0, availableWidth - SIDE_PADDING * 2); int columnWidth = Math.max(1, (innerWidth - COLUMN_GAP * (columnCount - 1)) / columnCount); - if (isEmpty(visibleResult)) { rowLayouts .add(new RowLayout(new LytRect(x + SIDE_PADDING, y + TOP_PADDING, innerWidth, ENTRY_HEIGHT), null)); - return new LytRect(x, y, availableWidth, TOP_PADDING + ENTRY_HEIGHT + BOTTOM_PADDING); + return; } - List> columns = layoutColumns(visibleResult); - int maxColumnHeight = 0; for (int columnIndex = 0; columnIndex < columns.size(); columnIndex++) { int columnX = x + SIDE_PADDING + columnIndex * (columnWidth + COLUMN_GAP); int columnY = y + TOP_PADDING; @@ -206,7 +530,18 @@ protected LytRect computeLayout(LayoutContext context, int x, int y, int availab columnY += HEADER_MARGIN_TOP + HEADER_HEIGHT + HEADER_MARGIN_BOTTOM; } for (MediaWikiSpecialListEntry entry : group.entries()) { - int entryHeight = computeEntryHeight(context, entry, columnWidth); + int entryHeight = computeEntryHeight(new LayoutContext(new FontMetrics() { + + @Override + public float getAdvance(int cp, ResolvedTextStyle s) { + return GuideText.measureWidth(new String(Character.toChars(cp)), s); + } + + @Override + public int getLineHeight(ResolvedTextStyle s) { + return GuideText.lineHeight(s); + } + }), entry, columnWidth); rowLayouts.add( new RowLayout( new LytRect(columnX, columnY, columnWidth, entryHeight), @@ -223,9 +558,14 @@ protected LytRect computeLayout(LayoutContext context, int x, int y, int availab } columnY += GROUP_MARGIN; } - maxColumnHeight = Math.max(maxColumnHeight, columnY - y - TOP_PADDING); } if (visibleResult.hasMore()) { + int maxColumnHeight = rowLayouts.stream() + .mapToInt( + rl -> rl.bounds() + .bottom() - y) + .max() + .orElse(0); rowLayouts.add( new RowLayout( new LytRect( @@ -234,9 +574,15 @@ protected LytRect computeLayout(LayoutContext context, int x, int y, int availab innerWidth, LOAD_MORE_HEIGHT), RenderRow.loadMore())); - maxColumnHeight += LOAD_MORE_MARGIN_TOP + LOAD_MORE_HEIGHT; } - return new LytRect(x, y, availableWidth, TOP_PADDING + maxColumnHeight + BOTTOM_PADDING); + } + + @Override + protected LytRect computeLayout(LayoutContext context, int x, int y, int availableWidth) { + recomputeRowLayouts(x, y, availableWidth); + // maxPrecomputedContentHeight has a lazy getter, so it will be + // computed on demand if not set here. + return new LytRect(x, y, availableWidth, TOP_PADDING + getMaxPrecomputedContentHeight() + BOTTOM_PADDING); } @Override @@ -245,10 +591,15 @@ protected void onLayoutMoved(int deltaX, int deltaY) { } @Override - public void render(RenderContext context) { - renderBorders(context); + public boolean usePrimitives() { + return true; + } + + @Override + public void computePrimitives(PrimitiveCollector c) { + emitBorders(c); for (RowLayout rowLayout : rowLayouts) { - if (!context.intersectsViewport(rowLayout.bounds())) { + if (c.isCulled(rowLayout.bounds())) { continue; } @@ -258,11 +609,11 @@ public void render(RenderContext context) { int emptyTextY = rowLayout.bounds() .y() + verticalCenterOffset( - context, EMPTY_STYLE, rowLayout.bounds() .height()); - context.drawText( + emitText( + c, emptyText, rowLayout.bounds() .x(), @@ -273,20 +624,20 @@ public void render(RenderContext context) { if (row.header()) { rowLayout.setClickableBounds(LytRect.empty()); - String renderedHeader = clipToWidth( - context, + String renderedHeader = GuideText.clipToWidth( row.title(), rowLayout.bounds() .width(), - HEADER_STYLE); + HEADER_STYLE, + GuideText.ClipSuffix.DOTS3); int headerTextY = rowLayout.bounds() .y() + verticalCenterOffset( - context, HEADER_STYLE, rowLayout.bounds() .height()); - context.drawText( + emitText( + c, renderedHeader, rowLayout.bounds() .x(), @@ -301,11 +652,11 @@ public void render(RenderContext context) { int loadMoreY = rowLayout.bounds() .y() + verticalCenterOffset( - context, rowStyle, rowLayout.bounds() .height()); - context.drawText( + emitText( + c, GuidebookText.SpecialPageShowMore.text(), rowLayout.bounds() .x(), @@ -325,28 +676,34 @@ public void render(RenderContext context) { - LIST_MARKER_SIZE - LIST_MARKER_GAP - (row.icon() != null ? ICON_SIZE + ICON_GAP : 0)); - List subtitleLines = wrapLines(context, row.subtitle(), textMaxWidth, SUBTITLE_STYLE); + List subtitleLines = GuideText.wrap(row.subtitle(), textMaxWidth, SUBTITLE_STYLE); int markerX = rowLayout.bounds() .x(); int contentTop = rowLayout.bounds() - .y() + rowContentTop(context, rowStyle, subtitleLines); - int contentHeight = rowContentHeight(context, rowStyle, subtitleLines); + .y() + rowContentTop(rowStyle, subtitleLines); + int contentHeight = rowContentHeight(rowStyle, subtitleLines); int markerY = contentTop + Math.max(0, (contentHeight - LIST_MARKER_SIZE) / 2); - context.fillRect(markerX, markerY, LIST_MARKER_SIZE, LIST_MARKER_SIZE, LIST_MARKER_COLOR); + c.emit( + new GuideRenderPrimitive.FillRect( + markerX, + markerY, + LIST_MARKER_SIZE, + LIST_MARKER_SIZE, + LIST_MARKER_COLOR.resolve())); int textX = markerX + LIST_MARKER_SIZE + LIST_MARKER_GAP; if (row.icon() != null) { - renderIcon(context, row.icon(), textX, contentTop + Math.max(0, (contentHeight - ICON_SIZE) / 2)); + emitIcon(c, row.icon(), textX, contentTop + Math.max(0, (contentHeight - ICON_SIZE) / 2)); textX += ICON_SIZE + ICON_GAP; } - String renderedTitle = clipToWidth( - context, + String renderedTitle = GuideText.clipToWidth( row.title(), Math.max( 1, rowLayout.bounds() .right() - textX), - rowStyle); + rowStyle, + GuideText.ClipSuffix.DOTS3); String fullTitle = row.title() != null ? row.title() : ""; boolean titleClipped = isClipped(renderedTitle, fullTitle); String fullSubtitle = row.subtitle() != null ? row.subtitle() : ""; @@ -355,36 +712,35 @@ public void render(RenderContext context) { : rowLayout.bounds() .y() + verticalCenterOffset( - context, rowStyle, rowLayout.bounds() .height()); - context.drawText(renderedTitle, textX, titleY, rowStyle); + emitText(c, renderedTitle, textX, titleY, rowStyle); int clickableWidth = textX - rowLayout.bounds() .x(); if (renderedTitle != null && !renderedTitle.isEmpty()) { - clickableWidth += context.getStringWidth(renderedTitle, rowStyle); + clickableWidth += GuideText.measureWidth(renderedTitle, rowStyle); } - int clickableHeight = context.getLineHeight(rowStyle); + int clickableHeight = GuideText.lineHeight(rowStyle); if (row.subtitle() != null && !row.subtitle() .isEmpty()) { boolean subtitleClipped = areLinesClipped(subtitleLines, fullSubtitle); - int subtitleY = contentTop + context.getLineHeight(rowStyle) + TITLE_SUBTITLE_GAP; + int subtitleY = contentTop + GuideText.lineHeight(rowStyle) + TITLE_SUBTITLE_GAP; for (String subtitleLine : subtitleLines) { - context.drawText(subtitleLine, textX, subtitleY, SUBTITLE_STYLE); + emitText(c, subtitleLine, textX, subtitleY, SUBTITLE_STYLE); clickableWidth = Math.max( clickableWidth, textX - rowLayout.bounds() - .x() + context.getStringWidth(subtitleLine, SUBTITLE_STYLE)); - subtitleY += context.getLineHeight(SUBTITLE_STYLE); + .x() + GuideText.measureWidth(subtitleLine, SUBTITLE_STYLE)); + subtitleY += GuideText.lineHeight(SUBTITLE_STYLE); } clickableWidth = Math.max( clickableWidth, textX - rowLayout.bounds() .x()); - clickableHeight = rowContentHeight(context, rowStyle, subtitleLines); + clickableHeight = rowContentHeight(rowStyle, subtitleLines); rowLayout.setTooltip( titleClipped || subtitleClipped ? new TextTooltip(fullTitle + "\n" + fullSubtitle) : titleClipped ? new TextTooltip(fullTitle) @@ -408,6 +764,13 @@ public void render(RenderContext context) { } } + /** + * Migrated to {@link #computePrimitives}; the legacy path is unreachable + * (the collector only invokes it when {@link #usePrimitives()} is false). + */ + @Override + public void render(RenderContext context) {} + @Override public boolean mouseClicked(GuideUiHost screen, int x, int y, int button, boolean doubleClick) { if (button != 0) { @@ -670,6 +1033,8 @@ private void loadMore() { Integer.MAX_VALUE - MediaWikiSpecialPageQuery.PAGE_SIZE, currentVisibleCount + MediaWikiSpecialPageQuery.PAGE_SIZE); visibilityCache = null; + maxPrecomputedContentHeight = 0; + collectedFontFacts = null; invalidateLayout(); } @@ -707,30 +1072,30 @@ private RowLayout findTooltipRow(int x, int y) { return findClickableRow(x, y); } - private int verticalCenterOffset(RenderContext context, ResolvedTextStyle style, int boxHeight) { - return Math.max(0, (boxHeight - context.getLineHeight(style)) / 2); + private int verticalCenterOffset(ResolvedTextStyle style, int boxHeight) { + return Math.max(0, (boxHeight - GuideText.lineHeight(style)) / 2); } - private int rowContentTop(RenderContext context, ResolvedTextStyle style, List subtitleLines) { + private int rowContentTop(ResolvedTextStyle style, List subtitleLines) { if (subtitleLines.isEmpty()) { return 0; } return ENTRY_VERTICAL_PADDING_TOP; } - private int rowContentHeight(RenderContext context, ResolvedTextStyle style, List subtitleLines) { + private int rowContentHeight(ResolvedTextStyle style, List subtitleLines) { if (subtitleLines.isEmpty()) { - return Math.max(context.getLineHeight(style), ICON_SIZE); + return Math.max(GuideText.lineHeight(style), ICON_SIZE); } return Math.max( ICON_SIZE, - context.getLineHeight(style) + TITLE_SUBTITLE_GAP - + context.getLineHeight(SUBTITLE_STYLE) * subtitleLines.size()); + GuideText.lineHeight(style) + TITLE_SUBTITLE_GAP + + GuideText.lineHeight(SUBTITLE_STYLE) * subtitleLines.size()); } private int computeEntryHeight(LayoutContext context, MediaWikiSpecialListEntry entry, int columnWidth) { int textMaxWidth = computeTextMaxWidth(columnWidth, entry.icon() != null); - List subtitleLines = wrapLines(context, entry.subtitle(), textMaxWidth, SUBTITLE_STYLE); + List subtitleLines = GuideText.wrap(entry.subtitle(), textMaxWidth, SUBTITLE_STYLE); if (subtitleLines.isEmpty()) { return ENTRY_HEIGHT; } @@ -750,9 +1115,10 @@ private int estimateEntryHeight(MediaWikiSpecialListEntry entry) { 1, GuideStringLines.splitLines(entry.subtitle()) .size()); + int lineHeight = GuideText.lineHeight(SUBTITLE_STYLE); return Math.max( ENTRY_HEIGHT, - ENTRY_VERTICAL_PADDING_TOP + Math.max(ICON_SIZE, 9 + TITLE_SUBTITLE_GAP + 9 * lineCount) + ENTRY_VERTICAL_PADDING_TOP + Math.max(ICON_SIZE, lineHeight + TITLE_SUBTITLE_GAP + lineHeight * lineCount) + ENTRY_VERTICAL_PADDING_BOTTOM); } @@ -760,132 +1126,6 @@ private int computeTextMaxWidth(int columnWidth, boolean hasIcon) { return Math.max(1, columnWidth - LIST_MARKER_SIZE - LIST_MARKER_GAP - (hasIcon ? ICON_SIZE + ICON_GAP : 0)); } - private List wrapLines(LayoutContext context, @Nullable String text, int maxWidth, - ResolvedTextStyle style) { - if (text == null || text.isEmpty()) { - return List.of(); - } - ArrayList lines = new ArrayList<>(); - for (String rawLine : GuideStringLines.splitLines(text)) { - appendWrappedLine(context, rawLine, maxWidth, style, lines); - } - return lines; - } - - private List wrapLines(RenderContext context, @Nullable String text, int maxWidth, - ResolvedTextStyle style) { - if (text == null || text.isEmpty()) { - return List.of(); - } - ArrayList lines = new ArrayList<>(); - for (String rawLine : GuideStringLines.splitLines(text)) { - appendWrappedLine(context, rawLine, maxWidth, style, lines); - } - return lines; - } - - private void appendWrappedLine(LayoutContext context, String rawLine, int maxWidth, ResolvedTextStyle style, - List output) { - String line = rawLine != null ? rawLine.trim() : ""; - if (line.isEmpty()) { - return; - } - if (measureTextWidth(context, style, line) <= maxWidth) { - output.add(line); - return; - } - String[] words = line.split("\\s+"); - StringBuilder current = new StringBuilder(); - for (String word : words) { - if (word == null || word.isEmpty()) { - continue; - } - String candidate = current.isEmpty() ? word : current + " " + word; - if (measureTextWidth(context, style, candidate) <= maxWidth) { - current.setLength(0); - current.append(candidate); - continue; - } - if (!current.isEmpty()) { - output.add(current.toString()); - current.setLength(0); - } - appendBrokenWord(context, word, maxWidth, style, output); - } - if (!current.isEmpty()) { - output.add(current.toString()); - } - } - - private void appendWrappedLine(RenderContext context, String rawLine, int maxWidth, ResolvedTextStyle style, - List output) { - String line = rawLine != null ? rawLine.trim() : ""; - if (line.isEmpty()) { - return; - } - if (context.getStringWidth(line, style) <= maxWidth) { - output.add(line); - return; - } - String[] words = line.split("\\s+"); - StringBuilder current = new StringBuilder(); - for (String word : words) { - if (word == null || word.isEmpty()) { - continue; - } - String candidate = current.isEmpty() ? word : current + " " + word; - if (context.getStringWidth(candidate, style) <= maxWidth) { - current.setLength(0); - current.append(candidate); - continue; - } - if (!current.isEmpty()) { - output.add(current.toString()); - current.setLength(0); - } - appendBrokenWord(context, word, maxWidth, style, output); - } - if (!current.isEmpty()) { - output.add(current.toString()); - } - } - - private void appendBrokenWord(LayoutContext context, String word, int maxWidth, ResolvedTextStyle style, - List output) { - if (measureTextWidth(context, style, word) <= maxWidth) { - output.add(word); - return; - } - int start = 0; - while (start < word.length()) { - int end = start + 1; - while (end <= word.length() && measureTextWidth(context, style, word.substring(start, end)) <= maxWidth) { - end++; - } - int safeEnd = Math.max(start + 1, end - 1); - output.add(word.substring(start, safeEnd)); - start = safeEnd; - } - } - - private void appendBrokenWord(RenderContext context, String word, int maxWidth, ResolvedTextStyle style, - List output) { - if (context.getStringWidth(word, style) <= maxWidth) { - output.add(word); - return; - } - int start = 0; - while (start < word.length()) { - int end = start + 1; - while (end <= word.length() && context.getStringWidth(word.substring(start, end), style) <= maxWidth) { - end++; - } - int safeEnd = Math.max(start + 1, end - 1); - output.add(word.substring(start, safeEnd)); - start = safeEnd; - } - } - private boolean areLinesClipped(List renderedLines, String originalText) { if (originalText == null || originalText.isEmpty()) { return false; @@ -903,61 +1143,91 @@ private boolean areLinesClipped(List renderedLines, String originalText) return false; } - private int measureTextWidth(LayoutContext context, ResolvedTextStyle style, String text) { - if (text == null || text.isEmpty()) { - return 0; - } - float width = 0f; - int offset = 0; - while (offset < text.length()) { - int codePoint = text.codePointAt(offset); - width += context.getAdvance(codePoint, style); - offset += Character.charCount(codePoint); - } - return Math.round(width); - } - - private void renderIcon(RenderContext context, GuidePageIcon icon, int x, int y) { + private void emitIcon(PrimitiveCollector c, GuidePageIcon icon, int x, int y) { if (icon.isItemIcon() && icon.resolveCurrentItemStack() != null) { - context.renderItemIcon(icon.resolveCurrentItemStack(), x, y); + c.emit(new GuideRenderPrimitive.RenderItem(icon.resolveCurrentItemStack(), x, y)); + return; + } + GuidePageTexture texture = icon.resolveCurrentTexture(); + if (texture == null || texture.isMissing()) { return; } - if (icon.resolveCurrentTexture() != null) { - context.fillTexturedRect(new LytRect(x, y, ICON_SIZE, ICON_SIZE), icon.resolveCurrentTexture()); + ResourceLocation resolvedTexture = texture.getTexture(); + int texId = resolvedTexture != null ? getGlTextureId(resolvedTexture) : -1; + if (texId >= 0) { + c.emit(new GuideRenderPrimitive.BlitTexture(texId, x, y, ICON_SIZE, ICON_SIZE, 0f, 0f, 1f, 1f)); } } - private void renderBorders(RenderContext context) { + private void emitBorders(PrimitiveCollector c) { if (getBorderTop().width() <= 0 && getBorderLeft().width() <= 0 && getBorderRight().width() <= 0 && getBorderBottom().width() <= 0) { return; } - BORDER_RENDERER.render(context, bounds, getBorderTop(), getBorderLeft(), getBorderRight(), getBorderBottom()); + c.emit( + new GuideRenderPrimitive.DrawBorder( + bounds.x(), + bounds.y(), + bounds.width(), + bounds.height(), + getBorderTop().width(), + getBorderLeft().width(), + getBorderBottom().width(), + getBorderRight().width(), + resolveBorderArgb())); } - private String clipToWidth(RenderContext context, String text, int maxWidth, ResolvedTextStyle style) { - if (text == null || text.isEmpty() || context.getStringWidth(text, style) <= maxWidth) { - return text == null ? "" : text; + private int resolveBorderArgb() { + // DrawBorder is single-color; use the first side that declares one. + // This block's callers set top+bottom with the same color + // (ColorUtils.TABLE_BORDER), so the single color is exact here. + BorderStyle[] sides = { getBorderTop(), getBorderLeft(), getBorderRight(), getBorderBottom() }; + for (BorderStyle side : sides) { + var color = side.color(); + if (color != null) { + return color.resolve(); + } } + return 0xFF000000; + } - int ellipsisWidth = context.getStringWidth("...", style); - if (ellipsisWidth >= maxWidth) { - return ELLIPSIS; + private boolean isClipped(String rendered, String original) { + if (original == null || original.isEmpty()) { + return false; } + return rendered != null && !rendered.equals(original); + } - int end = text.length(); - while (end > 0 && context.getStringWidth(text.substring(0, end), style) + ellipsisWidth > maxWidth) { - end--; + /** + * Emits {@code text} through the unified {@link GuideText} glyph pipeline + * at document position {@code (x, lineTop)}; {@code lineTop} is the line + * top (baseline = lineTop + ascent × fontScale), not the MC baseline. + *

          + * GuideText does not paint decorations; the underline of + * {@link #HOVER_LINK_STYLE} is therefore drawn manually as a 1px + * {@link GuideRenderPrimitive.FillRect} 1px below the baseline, spanning + * the measured text width (same width the glyph run occupies). + */ + private static void emitText(PrimitiveCollector c, String text, int x, int lineTop, ResolvedTextStyle style) { + GuideText.emitText(c, text, x, lineTop, style); + if (style != null && style.underlined() && text != null && !text.isEmpty()) { + int width = GuideText.measureWidth(text, style); + int underlineY = Math.round(GuideText.baselineOf(lineTop, style)) + 1; + c.emit(new GuideRenderPrimitive.FillRect(x, underlineY, width, 1, GuideText.resolveColor(style))); } - return end <= 0 ? ELLIPSIS : text.substring(0, end) + ELLIPSIS; } - private boolean isClipped(String rendered, String original) { - if (original == null || original.isEmpty()) { - return false; + private static int getGlTextureId(ResourceLocation res) { + try { + ITextureObject tex = Minecraft.getMinecraft() + .getTextureManager() + .getTexture(res); + return tex != null ? tex.getGlTextureId() : -1; + } catch (Throwable t) { + // Headless (unit tests) or texture unavailable: skip drawing. + return -1; } - return rendered != null && rendered.endsWith("...") && !rendered.equals(original); } private record GroupLayout(@Nullable String title, List entries) {} diff --git a/src/main/java/com/hfstudio/guidenh/guide/render/DecorationRasterizer.java b/src/main/java/com/hfstudio/guidenh/guide/render/DecorationRasterizer.java new file mode 100644 index 00000000..16e6be97 --- /dev/null +++ b/src/main/java/com/hfstudio/guidenh/guide/render/DecorationRasterizer.java @@ -0,0 +1,247 @@ +package com.hfstudio.guidenh.guide.render; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; + +/** + * Pure-function rasterizer for wavy (kind 4) and dotted (kind 5) text + * decorations. Converts a decoration band rectangle (document-pixel + * coordinates) into a list of axis-aligned fragments with per-fragment + * coverage alpha, ready to be emitted by any draw backend. + *

          + * No GL / Minecraft dependencies -- callable from both the batched + * Tessellator pipeline ({@link GuideRenderEngine#drawDecorationLine}) and the + * legacy {@code Gui.drawRect} backends ({@link GuideRenderEngine#drawTextDecorations}, + * {@link VanillaRenderContext#drawText}). This is the single source of the + * decoration geometry -- the three call sites previously each carried their own + * byte-identical hard-edge copy (per-doc-pixel axis-aligned rects, zero + * anti-aliasing), which produced 3-4 layers of 2x4px stepped hard blocks for + * the wave and 3x3 hard squares for the dots at scale=2. + *

          + * Coverage model: the brush is sampled at sub-pixel resolution + * ({@value #WAVE_SAMPLE_STEP} doc-px steps for the wave, 4x4 sub-samples per + * pixel for the dots); each output fragment's alpha (0-255) is the fraction of + * sub-samples whose brush covers the fragment's pixel. The wave sine is no + * longer rounded to integer rows, so adjacent columns transition through + * intermediate alpha levels instead of discrete hard steps. Dots are + * rasterized as circles with soft edge falloff instead of 3x3 hard squares. + *

          + * Alpha composite semantics: the fragment's {@code alpha} is pure + * brush coverage -- it is NOT the decoration's final opacity. Each + * backend must combine it with the decoration tint's own alpha byte as + * {@code finalAlpha = round(coverage * tintAlpha / 255)} and only then replace + * the alpha byte of the tint ARGB ({@code (finalAlpha << 24) | (tintArgb & 0xFFFFFF)}). + * This multiplies (never replaces) the tint's opacity, so a semi-transparent + * text color keeps its transparency while the coverage still shapes the brush + * edge. All three call sites ({@link GuideRenderEngine#drawDecorationLine}, + * {@link GuideRenderEngine#drawTextDecorations}, + * {@link VanillaRenderContext#drawText}) implement exactly this composite. + * When the tint is opaque (alpha 255) the composite equals the coverage + * exactly -- the headless main path is pixel-identical to the pre-composite + * output. + *

          + * Geometry is conserved from the legacy copies: the wave is a ± + * {@value #WAVE_AMPLITUDE}px, {@value #WAVE_THICKNESS}px-thick, 8-phase sine + * (period {@value #WAVE_PERIOD}px) whose 2px line center sits at + * {@code bandY + 1 + sin(phase) * AMPLITUDE}; dots keep the 4px cadence + * starting at {@code bandX + DOT_PITCH / 2} with a 3px footprint centered at + * {@code bandY + 0.5}. + */ +public final class DecorationRasterizer { + + /** Wave period in doc px (angle advance 2π/8 = π/4 per px). */ + public static final float WAVE_PERIOD = 8f; + /** Wave amplitude in doc px (±). */ + public static final float WAVE_AMPLITUDE = 2f; + /** Wave line thickness in doc px. */ + public static final float WAVE_THICKNESS = 2f; + /** Sub-pixel sampling step along the band (≤ 0.5 doc px). */ + public static final float WAVE_SAMPLE_STEP = 0.25f; + /** Wave line half-thickness in doc px. */ + private static final float WAVE_HALF = WAVE_THICKNESS / 2f; + + /** Dot pitch (center-to-center) in doc px. */ + public static final float DOT_PITCH = 4f; + /** Dot radius in doc px (diameter = 3px, matching the legacy 3x3 dot). */ + public static final float DOT_RADIUS = 1.5f; + /** First dot left edge offset from the band start (legacy {@code step/2}). */ + private static final float DOT_START_OFFSET = DOT_PITCH / 2f; + /** + * Dot center Y relative to the band top: the legacy 3x3 square spanned + * [bandY-1, bandY+2), so its center sat at bandY+0.5. + */ + private static final float DOT_CENTER_Y_OFFSET = -1f + DOT_RADIUS; + /** Per-pixel sub-sampling grid for the dot circle (edge falloff). */ + private static final int DOT_SUBSAMPLES = 4; + + /** A rasterized fragment in document-pixel coordinates (alpha 0-255). */ + public record Fragment(int x, int y, int w, int h, int alpha) {} + + private DecorationRasterizer() {} + + /** + * Rasterize a decoration band. + * + * @param bandX band left edge (doc px) + * @param bandY band top edge (doc px) + * @param bandW band width (doc px) + * @param kind decoration kind (4 = wavy, 5 = dotted) + * @return fragments ordered by (y, x); adjacent equal-alpha fragments are + * merged along x so a long saturated run collapses to one quad + * instead of one quad per doc px + */ + public static List rasterize(float bandX, float bandY, float bandW, int kind) { + if (bandW <= 0f) { + return List.of(); + } + switch (kind) { + case 4: + return rasterizeWave(bandX, bandY, bandW); + case 5: + return rasterizeDots(bandX, bandY, bandW); + default: + return List.of(); + } + } + + // Wavy decoration: per-column coverage of a continuous sine band. + + /** + * Wavy: per-column coverage of a continuous sine band. For each output + * column the sine is sampled at {@code 1 / WAVE_SAMPLE_STEP} sub-pixel + * positions; each sample's 2px-thick interval contributes its vertical + * overlap to the covered rows. A row's alpha is the average overlap across + * the column's sub-samples -- the integer rounding of the legacy copies is + * gone, so the 3-4 discrete vertical layers become a coverage gradient. + */ + private static List rasterizeWave(float bandX, float bandY, float bandW) { + int colStart = (int) Math.floor(bandX); + int colEnd = (int) Math.ceil(bandX + bandW); + if (colEnd <= colStart) { + return List.of(); + } + int cols = colEnd - colStart; + + float angleStep = (float) (Math.PI * 2.0 / WAVE_PERIOD); + // Interval [bandY - AMPLITUDE, bandY + THICKNESS + AMPLITUDE] bounds + // every sine sample: center = bandY + HALF + sin*AMPLITUDE, ± HALF. + int rowStart = (int) Math.floor(bandY - WAVE_AMPLITUDE); + int rowEnd = (int) Math.ceil(bandY + WAVE_THICKNESS + WAVE_AMPLITUDE); + int rows = rowEnd - rowStart; + if (rows <= 0) { + return List.of(); + } + + int samples = Math.max(2, (int) Math.round(1f / WAVE_SAMPLE_STEP)); + float[] colCoverage = new float[rows]; + List frags = new ArrayList<>(cols * 3); + for (int c = 0; c < cols; c++) { + Arrays.fill(colCoverage, 0f); + for (int k = 0; k < samples; k++) { + float xSub = colStart + c + (k + 0.5f) / samples; + float phase = (xSub - bandX) * angleStep; + float center = bandY + WAVE_HALF + (float) Math.sin(phase) * WAVE_AMPLITUDE; + float lo = center - WAVE_HALF; + float hi = center + WAVE_HALF; + int r0 = Math.max(rowStart, (int) Math.floor(lo)); + int r1 = Math.min(rowEnd, (int) Math.ceil(hi)); + for (int r = r0; r < r1; r++) { + float rowLo = r; + float overlap = Math.min(hi, rowLo + 1f) - Math.max(lo, rowLo); + if (overlap > 0f) { + colCoverage[r - rowStart] += overlap; + } + } + } + for (int r = 0; r < rows; r++) { + int alpha = Math.round(colCoverage[r] / samples * 255f); + if (alpha <= 0) { + continue; + } + frags.add(new Fragment(colStart + c, rowStart + r, 1, 1, alpha)); + } + } + return mergeRuns(frags); + } + + // Dotted decoration: circular dots with soft edge falloff. + + /** + * Dotted: circular dots with soft edge falloff. Each dot keeps the legacy + * 4px cadence ({@code dotLeft = bandX + DOT_PITCH/2; dotLeft + dotSize <= bandX + bandW}) + * and 3px footprint, but the 3x3 hard square is replaced by a circle of + * radius {@value #DOT_RADIUS}px rasterized with a 4x4 sub-pixel grid -- the + * corner pixels get partial coverage, so the dot reads round with a fading + * edge instead of a hard block. + */ + private static List rasterizeDots(float bandX, float bandY, float bandW) { + float dotSize = DOT_RADIUS * 2f; + float dotCy = bandY + DOT_CENTER_Y_OFFSET; + float r2 = DOT_RADIUS * DOT_RADIUS; + List frags = new ArrayList<>(); + for (float dotLeft = bandX + DOT_START_OFFSET; dotLeft + dotSize <= bandX + bandW; dotLeft += DOT_PITCH) { + float dotCx = dotLeft + DOT_RADIUS; + int px0 = (int) Math.floor(dotCx - DOT_RADIUS); + int px1 = (int) Math.ceil(dotCx + DOT_RADIUS); + int py0 = (int) Math.floor(dotCy - DOT_RADIUS); + int py1 = (int) Math.ceil(dotCy + DOT_RADIUS); + for (int py = py0; py < py1; py++) { + for (int px = px0; px < px1; px++) { + float cov = 0f; + for (int i = 0; i < DOT_SUBSAMPLES; i++) { + float sx = px + (i + 0.5f) / DOT_SUBSAMPLES; + for (int j = 0; j < DOT_SUBSAMPLES; j++) { + float sy = py + (j + 0.5f) / DOT_SUBSAMPLES; + float dx = sx - dotCx; + float dy = sy - dotCy; + if (dx * dx + dy * dy <= r2) { + cov += 1f; + } + } + } + int alpha = Math.round(cov / (DOT_SUBSAMPLES * DOT_SUBSAMPLES) * 255f); + if (alpha <= 0) { + continue; + } + frags.add(new Fragment(px, py, 1, 1, alpha)); + } + } + } + return mergeRuns(frags); + } + + // Fragment merging: adjacent equal-alpha runs collapse into one quad. + + /** + * Merge adjacent (x-contiguous) fragments that share the same row and + * alpha into a single wider fragment, bounding the emitted vertex count. + * Without this, a saturated middle row of a long wave band would emit one + * quad per doc px; with it, an N-px same-alpha run collapses to one quad. + */ + private static List mergeRuns(List frags) { + if (frags.size() < 2) { + return frags; + } + frags.sort((a, b) -> { + int c = Integer.compare(a.y, b.y); + return c != 0 ? c : Integer.compare(a.x, b.x); + }); + List out = new ArrayList<>(frags.size()); + Fragment cur = null; + for (Fragment f : frags) { + if (cur != null && cur.y == f.y && cur.alpha == f.alpha && cur.x + cur.w == f.x) { + cur = new Fragment(cur.x, cur.y, cur.w + f.w, cur.h, cur.alpha); + } else { + if (cur != null) { + out.add(cur); + } + cur = f; + } + } + if (cur != null) { + out.add(cur); + } + return out; + } +} diff --git a/src/main/java/com/hfstudio/guidenh/guide/render/GlyphRunData.java b/src/main/java/com/hfstudio/guidenh/guide/render/GlyphRunData.java new file mode 100644 index 00000000..479a3d2e --- /dev/null +++ b/src/main/java/com/hfstudio/guidenh/guide/render/GlyphRunData.java @@ -0,0 +1,29 @@ +package com.hfstudio.guidenh.guide.render; + +import java.util.List; + +import com.github.bsideup.jabel.Desugar; + +/** + * A paragraph's complete Rust text output: glyph runs grouped by span, plus + * the span decoration rects. {@code backgrounds} (highlight / inline-code) + * render before the glyph runs, {@code lines} (underline / strikethrough) + * after them. {@code separators} are kind=3 rects for heading separator + * lines, consumed by {@code LytHeading.separatorExtent()}. {@code decorations} + * are kind=4 (wavy) / kind=5 (dotted) underline decorations, rendered as + * batched sine / dot brushes by {@code GuideRenderEngine.drawDecorationLine}. + */ +@Desugar +public record GlyphRunData(List runs, List backgrounds, + List lines, List separators, + List decorations) { + + /** + * Convenience constructor for callers without wavy/dotted (kind 4/5) + * decorations -- keeps existing call sites source-compatible. + */ + public GlyphRunData(List runs, List backgrounds, + List lines, List separators) { + this(runs, backgrounds, lines, separators, List.of()); + } +} diff --git a/src/main/java/com/hfstudio/guidenh/guide/render/GlyphRunGroup.java b/src/main/java/com/hfstudio/guidenh/guide/render/GlyphRunGroup.java new file mode 100644 index 00000000..24a6f092 --- /dev/null +++ b/src/main/java/com/hfstudio/guidenh/guide/render/GlyphRunGroup.java @@ -0,0 +1,13 @@ +package com.hfstudio.guidenh.guide.render; + +import java.util.List; + +import com.github.bsideup.jabel.Desugar; + +/** + * One span's worth of a paragraph's glyph run: the glyphs plus how to draw + * them. {@code argb} tints the (white) atlas bitmaps; {@code shear} asks the + * engine for a synthetic-italic slant (fake-oblique, mirroring MC §o). + */ +@Desugar +public record GlyphRunGroup(List glyphs, int argb, boolean shear) {} diff --git a/src/main/java/com/hfstudio/guidenh/guide/render/GlyphRunHolder.java b/src/main/java/com/hfstudio/guidenh/guide/render/GlyphRunHolder.java new file mode 100644 index 00000000..d9df87f8 --- /dev/null +++ b/src/main/java/com/hfstudio/guidenh/guide/render/GlyphRunHolder.java @@ -0,0 +1,16 @@ +package com.hfstudio.guidenh.guide.render; + +import org.jetbrains.annotations.Nullable; + +/** + * Interface for text nodes that hold glyph data from Rust cosmic-text shaping. + */ +public interface GlyphRunHolder { + + /** Set the glyph data from the measureLayout result (null to clear). */ + void setGlyphData(@Nullable GlyphRunData data); + + /** Get the glyph data, or null if not yet measured / opaque. */ + @Nullable + GlyphRunData getGlyphData(); +} diff --git a/src/main/java/com/hfstudio/guidenh/guide/render/GuideGlyphAtlas.java b/src/main/java/com/hfstudio/guidenh/guide/render/GuideGlyphAtlas.java new file mode 100644 index 00000000..522072b0 --- /dev/null +++ b/src/main/java/com/hfstudio/guidenh/guide/render/GuideGlyphAtlas.java @@ -0,0 +1,447 @@ +package com.hfstudio.guidenh.guide.render; + +import java.nio.ByteBuffer; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; + +import javax.annotation.Nullable; + +import net.minecraft.client.renderer.GLAllocation; + +import org.lwjgl.opengl.GL11; +import org.lwjgl.opengl.GL12; + +import com.hfstudio.guidenh.guide.scene.support.GuideDebugLog; + +/** + * Glyph atlas: manages a bounded set of GL textures ({@link #MAX_PAGES} pages, + * each a 2048² RGBA texture) containing rasterized glyphs from Rust + * cosmic-text. + *

          + * Upload strategy: each glyph is row-packed into the current packing page. When + * the current page fills a new page is opened (up to {@link #MAX_PAGES}); once + * all pages are in use, the least-recently-used page is evicted (GL texture + * deleted, that page's glyph cache cleared, cursor reset) and reused. + *

          + * Capacity constraint: capacity pressure NEVER triggers a whole-atlas + * clear: a clear would invalidate every atlas key already emitted this frame, + * and whole + * paragraphs would vanish until the next layout. Eviction drops at most ONE + * page of cached glyphs at a time; the evicted keys simply miss at draw time + * (one glyph, once). + *

          + * Structurally-unfittable glyphs (a single glyph larger than a page) are + * recorded in a failure set: the same key is silently dropped on subsequent + * uploads (one WARN per key), and the failure set is cleared by the first page + * eviction so failed keys may retry after capacity is released. + */ +public class GuideGlyphAtlas { + + /** + * Default atlas instance for measureLayout processing. + * The render engine can override with setGlobalInstance(). + */ + private static GuideGlyphAtlas globalInstance = new GuideGlyphAtlas(); + + public static GuideGlyphAtlas instance() { + return globalInstance; + } + + public static void setGlobalInstance(GuideGlyphAtlas atlas) { + globalInstance = atlas; + } + + public GuideGlyphAtlas() {} + + private static final int ATLAS_SIZE = 2048; + private static final int PADDING = 1; + /** + * Page cap: 2048² RGBA = 16 MB/page → 128 MB GPU budget at the cap. + * Doubled from 4 to 8 (capacity stop-gap): a live render_scale=4 document + * rasterizes glyphs at ~44 ppem, which exceeds the old 4-page capacity + * (≈ 8,100 glyphs) and triggered silent whole-page LRU evictions mid-draw. + */ + public static final int MAX_PAGES = 8; + + private final List pages = new ArrayList<>(); + /** Index of the current packing page, or -1 before the first page is opened. */ + private int currentPage = -1; + + /** Monotonic clock driving page {@code accessStamp} (LRU recency). */ + private long accessClock; + + /** + * Keys that are structurally unfittable (a single glyph larger than a + * page). While present, uploads of the same key return null silently. + * Cleared by the first page eviction (capacity was released, failed keys + * may retry). Evicted keys do NOT enter this set. + */ + private final Set failureSet = new HashSet<>(); + + /** Keys whose one-time WARN was already emitted (never cleared). */ + private final Set failureWarned = new HashSet<>(); + + /** + * Eviction-log aggregation window: within one window multiple page + * evictions (capacity churn) collapse into a single WARN with aggregated + * totals, so a page-fill storm can never flood the log one WARN per + * eviction. + */ + private static final long EVICTION_LOG_WINDOW_NANOS = 1_000_000_000L; + /** Nanosecond timestamp of the last emitted eviction WARN (0 = never emitted yet). */ + private long lastEvictionWarnNanos = 0L; + /** Glyphs dropped by evictions since the last emitted WARN. */ + private int evictionWarnGlyphCount; + /** Pages evicted since the last emitted WARN. */ + private int evictionWarnPageCount; + + /** Test hook: when true, all GL calls are skipped (packing/UV bookkeeping still runs). */ + private boolean headless; + + /** One atlas texture plus its independent pack cursor and glyph cache. */ + private static final class Page { + + ByteBuffer atlasBuffer; + int textureId = -1; + int cursorX = PADDING; + int cursorY = PADDING; + int currentRowHeight = 0; + long accessStamp; + final Map glyphCache = new HashMap<>(); + } + + /** Test hook for headless environments (unit tests without a GL context). */ + public void setHeadless(boolean headless) { + this.headless = headless; + } + + /** + * Upload a glyph bitmap to the atlas and return its page + UV coordinates. + * {@code key} is the opaque bitmap key from LayoutResult (content-stable + * across layout rebuilds); repeated uploads of the same key are no-ops. + * Returns null when the glyph is structurally unfittable (dropped once with + * a WARN, then silently for the same key until a page eviction clears the + * failure set). + */ + @Nullable + public synchronized GlyphSlot upload(long key, byte[] rgba, int w, int h) { + if (failureSet.contains(key)) { + return null; + } + // Already packed: refresh the owning page's recency and return its slot. + GlyphSlot existing = lookup(key); + if (existing != null) { + return existing; + } + // Structural limit: a single glyph larger than a page can never fit. + if (w > ATLAS_SIZE - 2 * PADDING || h > ATLAS_SIZE - 2 * PADDING) { + markFailed(key, w, h); + return null; + } + int pageIndex = resolvePackingPage(w, h); + if (pageIndex < 0) { + // Defensive: even after opening/evicting a page the glyph did not + // fit (should be unreachable for structurally-fittable glyphs). + markFailed(key, w, h); + return null; + } + return packInto(pageIndex, key, rgba, w, h); + } + + /** + * Locate or create a page that can host a glyph of size {@code w}x{@code h}. + * Opens new pages up to {@link #MAX_PAGES}; once the page budget is + * exhausted the least-recently-used page is evicted and reused. Never clears + * the whole atlas. + */ + private int resolvePackingPage(int w, int h) { + for (int attempt = 0; attempt <= MAX_PAGES; attempt++) { + Page page = currentPage >= 0 ? pages.get(currentPage) : null; + if (page != null && packFits(page, w, h)) { + return currentPage; + } + if (pages.size() < MAX_PAGES) { + currentPage = openNewPage(); + continue; + } + int victim = findOldestPage(); + if (victim < 0) { + return -1; + } + evictPage(victim); + currentPage = victim; + // A page's capacity was released: previously-failed keys may retry. + failureSet.clear(); + } + return -1; + } + + /** Look up a glyph bitmap's page + UV coordinates in the atlas. */ + public @Nullable GlyphSlot lookup(long key) { + for (int i = 0; i < pages.size(); i++) { + Page page = pages.get(i); + GlyphUV uv = page.glyphCache.get(key); + if (uv != null) { + // Refresh page recency: the page that just served a glyph is + // hot and must not be the first LRU victim. + page.accessStamp = ++accessClock; + return new GlyphSlot(i, uv); + } + } + return null; + } + + /** Get the GL texture name for the given page (created on first use). */ + public int getTextureId(int pageId) { + if (pageId < 0 || pageId >= pages.size()) { + return -1; + } + Page page = pages.get(pageId); + ensureTexture(page); + return page.textureId; + } + + /** Test hook: number of currently allocated pages. */ + public int pageCount() { + return pages.size(); + } + + /** Test hook: whether {@code key} is currently in the failure set. */ + public boolean isFailed(long key) { + return failureSet.contains(key); + } + + /** Clear the atlas: per-page caches cleared and pixels zeroed (caller-initiated). */ + public void clear() { + for (Page page : pages) { + page.glyphCache.clear(); + for (int i = 0; i < page.atlasBuffer.capacity(); i++) { + page.atlasBuffer.put(i, (byte) 0); + } + page.cursorX = PADDING; + page.cursorY = PADDING; + page.currentRowHeight = 0; + if (page.textureId >= 0 && !headless) { + GL11.glBindTexture(GL11.GL_TEXTURE_2D, page.textureId); + GL11.glTexSubImage2D( + GL11.GL_TEXTURE_2D, + 0, + 0, + 0, + ATLAS_SIZE, + ATLAS_SIZE, + GL11.GL_RGBA, + GL11.GL_UNSIGNED_BYTE, + page.atlasBuffer); + } + } + } + + /** Delete every page's GL texture and drop their caches. */ + public void delete() { + for (Page page : pages) { + if (page.textureId >= 0) { + GL11.glDeleteTextures(page.textureId); + page.textureId = -1; + } + page.glyphCache.clear(); + } + } + + // Packing internals: page cursor, fits check, eviction. + + private int openNewPage() { + Page page = new Page(); + page.atlasBuffer = GLAllocation.createDirectByteBuffer(ATLAS_SIZE * ATLAS_SIZE * 4); + page.accessStamp = ++accessClock; + pages.add(page); + return pages.size() - 1; + } + + /** + * Whether {@code w}x{@code h} fits on the page's current row (wrapping to a + * new row first, mirroring the pre-pagination single-atlas cursor advance). + */ + private boolean packFits(Page page, int w, int h) { + if (page.cursorX + w + PADDING > ATLAS_SIZE) { + page.cursorX = PADDING; + page.cursorY += page.currentRowHeight + PADDING; + page.currentRowHeight = 0; + } + return page.cursorY + h + PADDING <= ATLAS_SIZE; + } + + private int findOldestPage() { + int oldest = -1; + long oldestStamp = Long.MAX_VALUE; + for (int i = 0; i < pages.size(); i++) { + long stamp = pages.get(i).accessStamp; + if (stamp < oldestStamp) { + oldestStamp = stamp; + oldest = i; + } + } + return oldest; + } + + /** + * Evict one page: delete its GL texture, drop its glyph cache, reset the + * pack cursor and zero the pixel buffer (fresh allocation) so stale pixels + * never re-enter the re-created texture. Leaves a throttled WARN trace + * (page / evicted glyph count / reason / pages after eviction). + */ + private void evictPage(int index) { + Page page = pages.get(index); + if (!headless && page.textureId >= 0) { + GL11.glDeleteTextures(page.textureId); + } + page.textureId = -1; + int evictedGlyphs = page.glyphCache.size(); + page.glyphCache.clear(); + page.atlasBuffer = GLAllocation.createDirectByteBuffer(ATLAS_SIZE * ATLAS_SIZE * 4); + page.cursorX = PADDING; + page.cursorY = PADDING; + page.currentRowHeight = 0; + page.accessStamp = ++accessClock; + recordEviction(index, evictedGlyphs); + } + + /** + * Leave a WARN trace when a page is evicted. The reason is always LRU -- + * {@link #resolvePackingPage} evicts the least-recently-used page once the + * page budget is exhausted. Throttled: evictions inside the same + * {@link #EVICTION_LOG_WINDOW_NANOS} window aggregate into one WARN (this + * is a capacity condition, not a per-glyph event), so capacity churn + * cannot flood the log. + */ + private void recordEviction(int pageIndex, int evictedGlyphs) { + evictionWarnGlyphCount += evictedGlyphs; + evictionWarnPageCount++; + long now = System.nanoTime(); + if (now - lastEvictionWarnNanos < EVICTION_LOG_WINDOW_NANOS) { + return; // throttle: absorbed into the next window's aggregated WARN + } + GuideDebugLog.warnAlways( + "[GuideNH] glyph atlas: page evicted (LRU), last victim page={}, {} glyph(s) dropped " + + "across {} eviction(s), pages after eviction={} (MAX_PAGES={})", + pageIndex, + evictionWarnGlyphCount, + evictionWarnPageCount, + pages.size(), + MAX_PAGES); + evictionWarnGlyphCount = 0; + evictionWarnPageCount = 0; + lastEvictionWarnNanos = now; + } + + private GlyphSlot packInto(int pageIndex, long key, byte[] rgba, int w, int h) { + Page page = pages.get(pageIndex); + // Row-wrap (idempotent: resolvePackingPage already advanced the cursor). + if (page.cursorX + w + PADDING > ATLAS_SIZE) { + page.cursorX = PADDING; + page.cursorY += page.currentRowHeight + PADDING; + page.currentRowHeight = 0; + } + + int u = page.cursorX; + int v = page.cursorY; + + // Write glyph pixels into the page buffer + for (int gy = 0; gy < h; gy++) { + for (int gx = 0; gx < w; gx++) { + int srcIdx = (gy * w + gx) * 4; + int dstIdx = ((page.cursorY + gy) * ATLAS_SIZE + (page.cursorX + gx)) * 4; + page.atlasBuffer.put(dstIdx, rgba[srcIdx]); + page.atlasBuffer.put(dstIdx + 1, rgba[srcIdx + 1]); + page.atlasBuffer.put(dstIdx + 2, rgba[srcIdx + 2]); + page.atlasBuffer.put(dstIdx + 3, rgba[srcIdx + 3]); + } + } + + if (h > page.currentRowHeight) page.currentRowHeight = h; + page.cursorX += w + PADDING; + + if (!headless) { + // Upload to the page's GL texture + ensureTexture(page); + GL11.glBindTexture(GL11.GL_TEXTURE_2D, page.textureId); + // Set UNPACK_ROW_LENGTH so glTexSubImage2D reads rows matching the atlas stride, + // not the sub-image width. Position the buffer to the start of the glyph's row. + GL11.glPixelStorei(GL11.GL_UNPACK_ROW_LENGTH, ATLAS_SIZE); + page.atlasBuffer.position((v * ATLAS_SIZE + u) * 4); + GL11.glTexSubImage2D( + GL11.GL_TEXTURE_2D, + 0, + u, + v, + w, + h, + GL11.GL_RGBA, + GL11.GL_UNSIGNED_BYTE, + page.atlasBuffer); + GL11.glPixelStorei(GL11.GL_UNPACK_ROW_LENGTH, 0); + page.atlasBuffer.position(0); + } + + float texSize = ATLAS_SIZE; + GlyphUV uv = new GlyphUV(u / texSize, v / texSize, (u + w) / texSize, (v + h) / texSize); + page.glyphCache.put(key, uv); + page.accessStamp = ++accessClock; + return new GlyphSlot(pageIndex, uv); + } + + private void markFailed(long key, int w, int h) { + failureSet.add(key); + if (failureWarned.add(key)) { + // One-shot WARN per key (mirrors Rust CLAMP_REPORTED): a structural + // drop is a hard, un-recoverable property of the glyph, not a + // capacity condition -- flood-logging it per frame is noise. + GuideDebugLog.warnAlways( + "[GuideNH] glyph atlas: oversized glyph dropped, key={} ({}x{}) exceeds page {}x{}", + key, + w, + h, + ATLAS_SIZE, + ATLAS_SIZE); + } + } + + private void ensureTexture(Page page) { + if (page.textureId >= 0 || headless) return; + page.textureId = GL11.glGenTextures(); + GL11.glBindTexture(GL11.GL_TEXTURE_2D, page.textureId); + GL11.glTexParameteri(GL11.GL_TEXTURE_2D, GL11.GL_TEXTURE_MIN_FILTER, GL11.GL_NEAREST); + GL11.glTexParameteri(GL11.GL_TEXTURE_2D, GL11.GL_TEXTURE_MAG_FILTER, GL11.GL_NEAREST); + GL11.glTexParameteri(GL11.GL_TEXTURE_2D, GL11.GL_TEXTURE_WRAP_S, GL12.GL_CLAMP_TO_EDGE); + GL11.glTexParameteri(GL11.GL_TEXTURE_2D, GL11.GL_TEXTURE_WRAP_T, GL12.GL_CLAMP_TO_EDGE); + GL11.glTexImage2D( + GL11.GL_TEXTURE_2D, + 0, + GL11.GL_RGBA8, + ATLAS_SIZE, + ATLAS_SIZE, + 0, + GL11.GL_RGBA, + GL11.GL_UNSIGNED_BYTE, + (java.nio.ByteBuffer) null); + GL11.glTexSubImage2D( + GL11.GL_TEXTURE_2D, + 0, + 0, + 0, + ATLAS_SIZE, + ATLAS_SIZE, + GL11.GL_RGBA, + GL11.GL_UNSIGNED_BYTE, + page.atlasBuffer); + } + + /** Page index + UV coordinates of a packed glyph. */ + public record GlyphSlot(int pageId, GlyphUV uv) {} + + public record GlyphUV(float u, float v, float u2, float v2) {} +} diff --git a/src/main/java/com/hfstudio/guidenh/guide/render/GuideRenderEngine.java b/src/main/java/com/hfstudio/guidenh/guide/render/GuideRenderEngine.java new file mode 100644 index 00000000..e53227a3 --- /dev/null +++ b/src/main/java/com/hfstudio/guidenh/guide/render/GuideRenderEngine.java @@ -0,0 +1,1056 @@ +package com.hfstudio.guidenh.guide.render; + +import java.util.ArrayDeque; +import java.util.Deque; +import java.util.List; + +import net.minecraft.client.Minecraft; +import net.minecraft.client.gui.FontRenderer; +import net.minecraft.client.gui.Gui; +import net.minecraft.client.renderer.OpenGlHelper; +import net.minecraft.client.renderer.RenderHelper; +import net.minecraft.client.renderer.Tessellator; +import net.minecraft.client.renderer.entity.RenderItem; + +import org.lwjgl.opengl.GL11; + +import com.hfstudio.guidenh.guide.document.LytRect; +import com.hfstudio.guidenh.guide.latex.GuideLatexRenderer; +import com.hfstudio.guidenh.guide.scene.support.GuideDebugLog; +import com.hfstudio.guidenh.guide.style.ResolvedTextStyle; + +/** + * Central render engine that accepts GuideRenderPrimitives and batches them + * into optimized Tessellator draw calls. + *

          + * Lifecycle: beginFrame() -> execute() -> endFrame() (once per document render). + *

          + * Coordinate contract: all primitives use absolute document coordinates. + * The engine converts to screen GUI coordinates via its transform stack using + * {@code screen = doc * scale + (tx, ty)} -- the same math as + * {@link VanillaRenderContext#toScreenRect}. GL pixels are obtained by + * multiplying with the display scale factor where needed (glScissor). + */ +public class GuideRenderEngine { + + private static final RenderItem ITEM_RENDERER = new RenderItem(); + /** + * Drop-shadow offset in document units (MC drawStringWithShadow x+1/y+1 + * parity). Applied in document space before the document→screen transform, + * so scale=1 → 1px and headless scale=2 → 2 device pixels. For sheared + * (italic) runs this is the vertical drop (+1*scale) and the nominal + * non-slant horizontal shift; the per-glyph shear transform makes the + * actual horizontal drop (1-K)*scale (K = {@link #SHEAR_K}) -- + * the same geometry as MC's italic shadow. + */ + private static final float SHADOW_OFFSET = 1f; + /** + * Composite shear coefficient K for the draw-time synthetic italic slant. + * The serializer no longer declares it (the {@code LayoutInput.shear_k} + * wire field is deprecated with its slot preserved), and the Rust-side + * boundary correction hardcodes the same value (layout.rs + * {@code SHEAR_K = 0.25}); slanting at draw time is a render-side + * responsibility, so K lives only here. + */ + private static final float SHEAR_K = 0.25f; + + private final GuideGlyphAtlas glyphAtlas; + private final GuidebookSceneRenderer sceneRenderer; + + private final Deque transformStack = new ArrayDeque<>(); + /** Scissor rects in screen GUI coordinates (not GL pixels). */ + private final Deque scissorStack = new ArrayDeque<>(); + + private LytRect viewport; + private int guiScale = 1; + + /** Current batch state for Tessellator session sharing. */ + private BatchState batchState = BatchState.IDLE; + /** Texture ID for the current textured batch (−1 when not textured). */ + private int batchTexId = -1; + + private enum BatchState { + IDLE, + /** Accumulating untextured quads (FillRect, GradientFill, DrawBorder, DrawLine). */ + SHAPE_QUADS, + /** Accumulating textured quads (BlitTexture, DrawGlyphRun). */ + TEXTURED_QUADS + } + + public GuideRenderEngine(GuideGlyphAtlas glyphAtlas, GuidebookSceneRenderer sceneRenderer) { + this.glyphAtlas = glyphAtlas; + this.sceneRenderer = sceneRenderer; + } + + /** Begin a new frame. Clears all state stacks and establishes the GL baseline. */ + public void beginFrame(LytRect viewport, float displayScale) { + this.viewport = viewport; + this.guiScale = Math.max(1, Math.round(displayScale)); + resetStacks(); + applyFrameBaselineGlState(); + } + + /** + * The frame's baseline GL state -- the single state contract the engine + * owns. Established at frame start, re-established after every escape + * (HostDraw, item/3D rendering) and at frame end, so no primitive and no + * escape hatch can leak enables into the stream. Keep in sync with + * beginShape/beginTextured scopes. + */ + private void applyFrameBaselineGlState() { + GL11.glEnable(GL11.GL_TEXTURE_2D); + GL11.glEnable(GL11.GL_BLEND); + GL11.glBlendFunc(GL11.GL_SRC_ALPHA, GL11.GL_ONE_MINUS_SRC_ALPHA); + GL11.glDisable(GL11.GL_ALPHA_TEST); + GL11.glDisable(GL11.GL_CULL_FACE); + GL11.glDisable(GL11.GL_DEPTH_TEST); + GL11.glDisable(GL11.GL_LIGHTING); + GL11.glColor4f(1f, 1f, 1f, 1f); + } + + private void resetStacks() { + transformStack.clear(); + transformStack.push(new Transform(0f, 0f, 1.0f)); + scissorStack.clear(); + batchState = BatchState.IDLE; + batchTexId = -1; + } + + /** Execute a batch of primitives. Flushes the Tessellator before returning so + * subsequent drawing (e.g. legacy scrollbar rendering) does not hit "Already + * tesselating!". */ + public void execute(List primitives) { + // Safety flush: close any Tessellator batch left open from a prior frame. + try { Tessellator.instance.draw(); } catch (IllegalStateException e) { /* not drawing */ } + try { + for (GuideRenderPrimitive p : primitives) { + switch (p) { + case GuideRenderPrimitive.PushTransform t -> pushTransform(t); + case GuideRenderPrimitive.PopTransform __ -> popTransform(); + case GuideRenderPrimitive.PushScissor s -> pushScissor(s); + case GuideRenderPrimitive.PopScissor __ -> popScissor(); + case GuideRenderPrimitive.PushScreenScissor ss -> pushScreenScissor(ss); + case GuideRenderPrimitive.PopScreenScissor __ -> popScissor(); + case GuideRenderPrimitive.FillRect f -> drawFillRect(f); + case GuideRenderPrimitive.GradientFill g -> drawGradientFill(g); + case GuideRenderPrimitive.DrawBorder db -> drawBorder(db); + case GuideRenderPrimitive.BlitTexture bt -> drawBlitTexture(bt); + case GuideRenderPrimitive.DrawGlyphRun dg -> drawGlyphRun(dg); + case GuideRenderPrimitive.DrawDecorationLine dd -> drawDecorationLine(dd); + case GuideRenderPrimitive.DrawLine dl -> drawLine(dl); + case GuideRenderPrimitive.DrawTriangle dt -> drawTriangle(dt); + case GuideRenderPrimitive.DrawCircle dc -> drawCircle(dc); + case GuideRenderPrimitive.DrawCircleOutline dco -> drawCircleOutline(dco); + case GuideRenderPrimitive.DrawPolygon dp -> drawPolygon(dp); + case GuideRenderPrimitive.RenderItem ri -> drawRenderItem(ri); + case GuideRenderPrimitive.DrawText dtxt -> drawText(dtxt); + case GuideRenderPrimitive.RenderScene3D s3 -> drawScene3D(s3); + case GuideRenderPrimitive.HostDraw hd -> drawHostDraw(hd); + } + } + } finally { + // Close the current batch even when a drawXxx method throws mid-batch, + // so the Tessellator never leaks in DRAWING state after execute() returns. + flush(); + // Reset stacks to frame-initial state. A prior frame that threw mid-execute + // can leave the stacks unbalanced; resetting here guarantees every execute() + // starts with a clean slate regardless of what happened last frame. + resetStacks(); + // Do not leak GL state to subsequent legacy drawing. + restoreGlState(); + } + } + + /** End the frame. Flush any remaining batch. */ + public void endFrame() { + flush(); + } + + /** Restore the GL state expected by vanilla GUI drawing after execute(). */ + private void restoreGlState() { + OpenGlHelper.setActiveTexture(OpenGlHelper.defaultTexUnit); + // Mirror the frame baseline exactly: nothing the execute pass + // touched may leak into subsequent legacy drawing. + applyFrameBaselineGlState(); + GL11.glDisable(GL11.GL_SCISSOR_TEST); + } + + // Transform stack: push/pop and document-to-screen composition. + + private Transform currentTransform() { + Transform t = transformStack.peek(); + if (t == null) { + // Safety fallback: identity transform when no beginFrame() was called. + t = new Transform(0f, 0f, 1.0f); + } + return t; + } + + private void pushTransform(GuideRenderPrimitive.PushTransform t) { + flush(); + Transform parent = currentTransform(); + // screen = doc * (parent.scale * t.scale) + (parent.t + t.t * parent.scale) + transformStack.push( + new Transform( + parent.tx + t.tx() * parent.scale, + parent.ty + t.ty() * parent.scale, + parent.scale * t.scale())); + } + + private void popTransform() { + flush(); + if (transformStack.size() > 1) { + transformStack.pop(); + } + } + + // Scissor stack, in screen GUI coordinates. + + /** Intersect {@code screenRect} with the current scissor, push, and apply. */ + private void pushScissorRect(LytRect screenRect) { + flush(); + LytRect sr = screenRect; + if (!scissorStack.isEmpty()) { + LytRect parent = scissorStack.peek(); + int x1 = Math.max(sr.x(), parent.x()); + int y1 = Math.max(sr.y(), parent.y()); + int x2 = Math.min(sr.right(), parent.right()); + int y2 = Math.min(sr.bottom(), parent.bottom()); + sr = new LytRect(x1, y1, Math.max(0, x2 - x1), Math.max(0, y2 - y1)); + } + scissorStack.push(sr); + GL11.glEnable(GL11.GL_SCISSOR_TEST); + glScissor(sr); + } + + private void pushScissor(GuideRenderPrimitive.PushScissor s) { + pushScissorRect(toScreen(s.x(), s.y(), s.w(), s.h())); + } + + /** + * Screen-space scissor: coordinates are already in screen GUI space, no + * transform-stack conversion. Used for the fixed viewport clip. + */ + private void pushScreenScissor(GuideRenderPrimitive.PushScreenScissor ss) { + pushScissorRect(new LytRect(ss.x(), ss.y(), ss.w(), ss.h())); + } + + private void popScissor() { + flush(); + if (scissorStack.isEmpty()) { + GL11.glDisable(GL11.GL_SCISSOR_TEST); + return; + } + scissorStack.pop(); + if (!scissorStack.isEmpty()) { + GL11.glEnable(GL11.GL_SCISSOR_TEST); + glScissor(scissorStack.peek()); + } else { + GL11.glDisable(GL11.GL_SCISSOR_TEST); + } + } + + /** + * Re-apply the current scissor state after external code (HostDraw legacy + * rendering, 3D scene) may have changed it. + */ + private void reapplyScissor() { + if (!scissorStack.isEmpty()) { + GL11.glEnable(GL11.GL_SCISSOR_TEST); + glScissor(scissorStack.peek()); + } else { + GL11.glDisable(GL11.GL_SCISSOR_TEST); + } + } + + private void glScissor(LytRect r) { + int sx = r.x() * guiScale; + int sy = Minecraft.getMinecraft().displayHeight - r.bottom() * guiScale; + // Zero-area scissors must stay zero; a min-1 clamp would leak a 1px + // strip of content that should be fully clipped. + int sw = Math.max(0, r.width() * guiScale); + int sh = Math.max(0, r.height() * guiScale); + GL11.glScissor(sx, Math.max(0, sy), sw, sh); + } + + // Coordinate conversion: document to screen GUI coordinates. + + /** Document -> screen GUI coordinates, integer rect. */ + private LytRect toScreen(int x, int y, int w, int h) { + Transform t = currentTransform(); + return new LytRect( + Math.round(x * t.scale + t.tx), + Math.round(y * t.scale + t.ty), + Math.max(0, Math.round(w * t.scale)), + Math.max(0, Math.round(h * t.scale))); + } + + private float sx(float docX) { + Transform t = currentTransform(); + return docX * t.scale + t.tx; + } + + private float sy(float docY) { + Transform t = currentTransform(); + return docY * t.scale + t.ty; + } + + // Draw helpers shared by the primitive handlers. + + /** + * Shape-primitive state scope. The ENGINE owns GL state -- every shape draw + * (fills, lines, circles, polygons) runs inside this scope, which fully + * controls the state legacy rendering proved necessary + * (VanillaRenderContext.beginShapeDraw): TEXTURE_2D off, BLEND on, + * ALPHA_TEST / CULL_FACE / DEPTH_TEST off. CULL_FACE matters: shared-state + * leakage (item rendering, HostDraw legacy) leaves it enabled, and without + * this scope all CW-wound primitives (lines, circles) are culled away. + * Individual draw methods must NOT touch GL state themselves. + */ + private void beginShape() { + GL11.glPushAttrib(GL11.GL_ENABLE_BIT | GL11.GL_CURRENT_BIT | GL11.GL_COLOR_BUFFER_BIT); + GL11.glDisable(GL11.GL_TEXTURE_2D); + GL11.glEnable(GL11.GL_BLEND); + GL11.glBlendFunc(GL11.GL_SRC_ALPHA, GL11.GL_ONE_MINUS_SRC_ALPHA); + GL11.glDisable(GL11.GL_ALPHA_TEST); + GL11.glDisable(GL11.GL_CULL_FACE); + GL11.glDisable(GL11.GL_DEPTH_TEST); + } + + private void endShape() { + GL11.glPopAttrib(); + } + + /** + * Submit the current batched primitives to GL and reset batch state. + * Safe to call when no batch is open (no-op in that case). + */ + private void flush() { + if (batchState == BatchState.IDLE) return; + Tessellator tess = Tessellator.instance; + tess.draw(); + if (batchState == BatchState.SHAPE_QUADS) { + endShape(); + } else if (batchState == BatchState.TEXTURED_QUADS) { + endTextured(); + } + batchState = BatchState.IDLE; + batchTexId = -1; + } + + /** + * Start or join a shape-quads batch (FillRect, GradientFill, DrawBorder, DrawLine). + * Flushes any incompatible batch first. + */ + private void beginShapeQuads() { + if (batchState == BatchState.SHAPE_QUADS) return; + flush(); + beginShape(); + Tessellator.instance.startDrawingQuads(); + batchState = BatchState.SHAPE_QUADS; + } + + /** + * Start or join a textured-quads batch (BlitTexture, DrawGlyphRun). + * Flushes any incompatible batch first (different type or different texture). + */ + private void beginTexturedQuads(int texId) { + if (batchState == BatchState.TEXTURED_QUADS && batchTexId == texId) return; + flush(); + beginTextured(); + GL11.glBindTexture(GL11.GL_TEXTURE_2D, texId); + Tessellator.instance.startDrawingQuads(); + batchState = BatchState.TEXTURED_QUADS; + batchTexId = texId; + } + + private void color(int argb) { + float a = ((argb >> 24) & 0xFF) / 255f; + float r = ((argb >> 16) & 0xFF) / 255f; + float g = ((argb >> 8) & 0xFF) / 255f; + float b = (argb & 0xFF) / 255f; + GL11.glColor4f(r, g, b, a); + } + + /** + * Apply color to the Tessellator's per-vertex color channel. Required in + * 1.7.10: once anything has put the shared Tessellator into hasColor state + * (vanilla Gui.drawRect, item rendering, ...), it ignores the global GL + * color and uses the last per-vertex color instead -- shape primitives drawn + * without this come out with a stale (usually dark or transparent) color. + * Mirrors VanillaRenderContext.tessColor. + */ + private static void tessColor(Tessellator tess, int argb) { + int a = (argb >>> 24) & 0xFF; + int r = (argb >>> 16) & 0xFF; + int g = (argb >>> 8) & 0xFF; + int b = argb & 0xFF; + if (a == 0) { + a = 0xFF; + } + tess.setColorRGBA_I((r << 16) | (g << 8) | b, a); + } + + /** + * MC 1.7.10 drop-shadow tint: alpha preserved (a==0 → opaque), each RGB + * channel ×25/100 (integer math; equivalent to MC's + * {@code (color & 0xFCFCFC) >> 2}). The atlas is a white single-channel + * bitmap, so a dark tint yields a dark shadow with the text's alpha. + */ + private static int shadowArgb(int argb) { + int a = (argb >>> 24) & 0xFF; + if (a == 0) { + a = 0xFF; + } + int r = ((argb >>> 16) & 0xFF) * 25 / 100; + int g = ((argb >>> 8) & 0xFF) * 25 / 100; + int b = (argb & 0xFF) * 25 / 100; + return (a << 24) | (r << 16) | (g << 8) | b; + } + + // Draw primitives. + + private void drawFillRect(GuideRenderPrimitive.FillRect f) { + LytRect r = toScreen(f.x(), f.y(), f.w(), f.h()); + beginShapeQuads(); + color(f.argb()); + Tessellator tess = Tessellator.instance; + tessColor(tess, f.argb()); + tess.addVertex(r.x(), r.y() + r.height(), 0); + tess.addVertex(r.x() + r.width(), r.y() + r.height(), 0); + tess.addVertex(r.x() + r.width(), r.y(), 0); + tess.addVertex(r.x(), r.y(), 0); + } + + /** + * Wavy (kind=4) and dotted (kind=5) underline decorations emitted from + * Rust span geometry. Both paint as batched shape quads (no per-pixel + * Gui.drawRect), mirroring {@link #drawFillRect}'s + * beginShapeQuads/color/tessColor/addVertex pattern. + *

          + * The brush geometry is rasterized once by {@link DecorationRasterizer} + * (the single shared source of wave/dot shape -- no per-call-site hardcoded + * copies) into doc-px fragments carrying coverage alpha: the wavy line is a + * sub-pixel-sampled sine and the dots are soft-edged circles. Each fragment + * is emitted as a quad with per-vertex alpha; the rasterizer merges + * adjacent same-alpha columns so a saturated run stays one quad instead of + * one quad per doc px. + *

          + * Colors are run through {@link #brightenDecorationColor} exactly like the + * legacy decorations path so they read against the dark page background. + */ + private void drawDecorationLine(GuideRenderPrimitive.DrawDecorationLine d) { + List fragments = DecorationRasterizer.rasterize(d.x(), d.y(), d.w(), d.kind()); + if (fragments.isEmpty()) { + return; + } + int argb = brightenDecorationColor(d.argb()); + beginShapeQuads(); + Tessellator tess = Tessellator.instance; + for (DecorationRasterizer.Fragment f : fragments) { + // Composite the coverage with the tint's own alpha byte: + // final = round(coverage × tintAlpha / 255). Replacing the alpha + // byte (as before) discarded a semi-transparent text color's + // opacity; an opaque tint (alpha 255) yields exactly the coverage. + int fragArgb = decorationArgb(f.alpha(), argb); + color(fragArgb); + tessColor(tess, fragArgb); + float x0 = sx(f.x()); + float x1 = sx(f.x() + f.w()); + float y0 = sy(f.y()); + float y1 = sy(f.y() + f.h()); + tess.addVertex(x0, y1, 0); + tess.addVertex(x1, y1, 0); + tess.addVertex(x1, y0, 0); + tess.addVertex(x0, y0, 0); + } + } + + private void drawGradientFill(GuideRenderPrimitive.GradientFill g) { + LytRect r = toScreen(g.x(), g.y(), g.w(), g.h()); + beginShapeQuads(); + Tessellator tess = Tessellator.instance; + color(g.argbBottom()); + tessColor(tess, g.argbBottom()); + tess.addVertex(r.x(), r.y() + r.height(), 0); + tess.addVertex(r.x() + r.width(), r.y() + r.height(), 0); + color(g.argbTop()); + tessColor(tess, g.argbTop()); + tess.addVertex(r.x() + r.width(), r.y(), 0); + tess.addVertex(r.x(), r.y(), 0); + } + + private void drawBorder(GuideRenderPrimitive.DrawBorder db) { + int x = db.x(), y = db.y(), w = db.w(), h = db.h(); + int argb = db.argb(); + beginShapeQuads(); + Tessellator tess = Tessellator.instance; + color(argb); + tessColor(tess, argb); + // Aggregate all four sides into a single begin/end pair. Each side that + // is visible adds exactly one quad (4 vertices); at most 4 quads / 16 + // vertices total. + if (db.top() > 0) { + LytRect r = toScreen(x, y, w, db.top()); + tess.addVertex(r.x(), r.y() + r.height(), 0); + tess.addVertex(r.x() + r.width(), r.y() + r.height(), 0); + tess.addVertex(r.x() + r.width(), r.y(), 0); + tess.addVertex(r.x(), r.y(), 0); + } + if (db.bottom() > 0) { + LytRect r = toScreen(x, y + h - db.bottom(), w, db.bottom()); + tess.addVertex(r.x(), r.y() + r.height(), 0); + tess.addVertex(r.x() + r.width(), r.y() + r.height(), 0); + tess.addVertex(r.x() + r.width(), r.y(), 0); + tess.addVertex(r.x(), r.y(), 0); + } + if (db.left() > 0) { + LytRect r = toScreen(x, y + db.top(), db.left(), h - db.top() - db.bottom()); + tess.addVertex(r.x(), r.y() + r.height(), 0); + tess.addVertex(r.x() + r.width(), r.y() + r.height(), 0); + tess.addVertex(r.x() + r.width(), r.y(), 0); + tess.addVertex(r.x(), r.y(), 0); + } + if (db.right() > 0) { + LytRect r = toScreen(x + w - db.right(), y + db.top(), db.right(), h - db.top() - db.bottom()); + tess.addVertex(r.x(), r.y() + r.height(), 0); + tess.addVertex(r.x() + r.width(), r.y() + r.height(), 0); + tess.addVertex(r.x() + r.width(), r.y(), 0); + tess.addVertex(r.x(), r.y(), 0); + } + } + + /** + * Textured-primitive state scope (Blit/GlyphRun). The ENGINE owns GL state: + * texture on, blend on, ALPHA_TEST / CULL_FACE / DEPTH_TEST off. Item + * rendering and HostDraw legacy can leak any of these into the stream, and + * without this scope textured quads get culled or their AA edges cut. + */ + private void beginTextured() { + GL11.glPushAttrib(GL11.GL_ENABLE_BIT | GL11.GL_CURRENT_BIT | GL11.GL_COLOR_BUFFER_BIT); + GL11.glEnable(GL11.GL_TEXTURE_2D); + GL11.glEnable(GL11.GL_BLEND); + GL11.glBlendFunc(GL11.GL_SRC_ALPHA, GL11.GL_ONE_MINUS_SRC_ALPHA); + GL11.glDisable(GL11.GL_ALPHA_TEST); + GL11.glDisable(GL11.GL_CULL_FACE); + GL11.glDisable(GL11.GL_DEPTH_TEST); + } + + private void endTextured() { + GL11.glPopAttrib(); + } + + private void drawBlitTexture(GuideRenderPrimitive.BlitTexture bt) { + int texId = bt.texId(); + if (GuideLatexRenderer.INSTANCE.isLatexBlitToken(texId)) { + // Deferred latex blit: the block emitted a stable negative token + // instead of a texture id. Resolve the real texture now, rasterized + // at the current device output scale (transform × guiScale) so the + // formula texture maps 1:1 onto device pixels, matching the text + // path's font_size × render_scale rasterization (parley_text.rs:834). + // The flush is required before texture creation, which binds/unbinds + // GL textures underneath an open batch. + flush(); + float rasterScale = deviceRasterScale(); + int[] resolved = GuideLatexRenderer.INSTANCE.resolveLatexTexture(texId, rasterScale); + if (resolved == null) { + return; + } + texId = resolved[0]; + } + LytRect r = toScreen(bt.x(), bt.y(), bt.w(), bt.h()); + beginTexturedQuads(texId); + color(bt.argb()); + Tessellator tess = Tessellator.instance; + tessColor(tess, bt.argb()); + tess.addVertexWithUV(r.x(), r.y() + r.height(), 0, bt.u(), bt.v2()); + tess.addVertexWithUV(r.x() + r.width(), r.y() + r.height(), 0, bt.u2(), bt.v2()); + tess.addVertexWithUV(r.x() + r.width(), r.y(), 0, bt.u2(), bt.v()); + tess.addVertexWithUV(r.x(), r.y(), 0, bt.u(), bt.v()); + } + + /** + * Device pixel scale applied to the current transform stack: document units + * to device pixels. This is the real output scale of the blit quad, the + * correct {@code rasterScale} for deferred latex texture rasterization. + * + *

          + * {@code guiScale} is the display scale the engine was framed with: + * in-game {@code DisplayScale.scaleFactor()} (device px per GUI unit, the + * same source the text path rasterizes glyphs at, see {@link GuideText}); + * headless {@code 1.0f}, where the offscreen FBO is a 1:1 pixel canvas. + * The transform stack carries the document-to-screen multiplier: in-game + * the root zoom, headless the {@code PushTransform(0,0,scale)} wrap applied + * by {@code DocumentOffscreenFramebuffer.wrapForTile}. Their product is the + * number of device pixels one document pixel occupies -- exactly what the + * formula texture must be rasterized at for a 1:1 texel/device mapping. + */ + private float deviceRasterScale() { + return guiScale * currentTransform().scale; + } + + private void drawGlyphRun(GuideRenderPrimitive.DrawGlyphRun dg) { + List glyphs = dg.glyphs(); + if (glyphs == null || glyphs.isEmpty()) return; + Transform t = currentTransform(); + boolean shear = dg.shear(); + int missingAtlas = 0; + if (dg.shadow()) { + // Drop-shadow pass first (painted below the main text): the run + // offset by SHADOW_OFFSET and tinted by shadowArgb. Non-slanted + // runs shift +1/+1 document units; sheared runs shift horizontally + // by (1-K)*scale (K = SHEAR_K) and vertically + // +1*scale -- the shear transform of the vertical drop, same + // geometry as MC italic shadow. + missingAtlas = emitGlyphQuads(glyphs, t, shear, shadowArgb(dg.argb()), SHADOW_OFFSET, SHADOW_OFFSET); + } + missingAtlas = Math.max(missingAtlas, emitGlyphQuads(glyphs, t, shear, dg.argb(), 0f, 0f)); + if (missingAtlas > 0 && GuideDebugLog.isLayoutOverlayEnabled()) { + GuideDebugLog.warnAlways( + "[TRC] DrawGlyphRun: {} glyphs missing from atlas (run size={})", + missingAtlas, + glyphs.size()); + } + } + + /** + * Emit one glyph pass into the current textured-quads session: every glyph + * translated by {@code dx}/{@code dy} document units (before the + * document→screen transform) and tinted by {@code argb}. When {@code shear} + * is on, the shear baseline is computed from the un-offset glyph geometry + * ({@code g.y() + g.h()}, before dx/dy), so both the shadow and main passes + * share the same baseline and the offset pass is a rigid translation of the + * un-offset run -- the slant angle is preserved independently of the offset + * (dx/dy do not change the italic tilt). Returns the number of glyphs whose + * atlas bitmap is missing (for the debug warning). + *

          + * Glyphs resolve to per-page atlas textures: whenever a glyph belongs to a + * different page than the current batch, the batch is flushed and rebound to + * that page's texture (multi-texture atlas pagination). + */ + private int emitGlyphQuads(List glyphs, Transform t, boolean shear, int argb, + float dx, float dy) { + float shearBaseY = 0f; + if (shear) { + for (GuideRenderPrimitive.PlacedGlyph g : glyphs) { + shearBaseY = Math.max(shearBaseY, g.y() + g.h()); + } + shearBaseY = shearBaseY * t.scale + t.ty; + } + color(argb); + Tessellator tess = Tessellator.instance; + int missingAtlas = 0; + int batchPage = -1; + for (GuideRenderPrimitive.PlacedGlyph g : glyphs) { + // Resolve the glyph's page + UV coordinates from the atlas. + GuideGlyphAtlas.GlyphSlot slot = glyphAtlas.lookup(g.atlasKey()); + if (slot == null) { + missingAtlas++; + continue; + } + if (slot.pageId() != batchPage) { + // Page switch: flush and rebind to the glyph's page texture. + beginTexturedQuads(glyphAtlas.getTextureId(slot.pageId())); + batchPage = slot.pageId(); + // Per-vertex color must be set AFTER the batch starts: + // Tessellator.startDrawing resets hasColor, so a tessColor set + // before beginTexturedQuads would be wiped on the first batch + // (verts submitted without color data → invisible). + tessColor(tess, argb); + } + GuideGlyphAtlas.GlyphUV uv = slot.uv(); + // Keep subpixel precision: vertices are float, only the atlas UV is fixed. + float x = (g.x() + dx) * t.scale + t.tx; + float y = (g.y() + dy) * t.scale + t.ty; + float w = g.w() * t.scale; + float h = g.h() * t.scale; + float xTop = x; + float xBottom = x; + if (shear) { + // K is the render-only shear coefficient (SHEAR_K = 0.25); the + // Rust-side boundary correction hardcodes the same value. + xTop = x + SHEAR_K * (shearBaseY - y); + xBottom = x + SHEAR_K * (shearBaseY - y - h); + } + // Atlas rows are written top-to-bottom: uv.v = glyph top row, + // uv.v2 = glyph bottom row. Screen Y increases downward, so screen + // top pairs with uv.v and screen bottom with uv.v2 -- same convention + // as VanillaRenderContext.blitTexture. + tess.addVertexWithUV(xBottom, y + h, 0, uv.u(), uv.v2()); // bottom-left → glyph bottom row + tess.addVertexWithUV(xBottom + w, y + h, 0, uv.u2(), uv.v2()); // bottom-right → glyph bottom row + tess.addVertexWithUV(xTop + w, y, 0, uv.u2(), uv.v()); // top-right → glyph top row + tess.addVertexWithUV(xTop, y, 0, uv.u(), uv.v()); // top-left → glyph top row + } + return missingAtlas; + } + + private void drawLine(GuideRenderPrimitive.DrawLine dl) { + float x1 = sx(dl.x1()), y1 = sy(dl.y1()); + float x2 = sx(dl.x2()), y2 = sy(dl.y2()); + float dx = x2 - x1; + float dy = y2 - y1; + float len = (float) Math.sqrt(dx * dx + dy * dy); + if (len < 1e-4f) return; + + float half = Math.max(0.5f, dl.thickness() * currentTransform().scale * 0.5f); + float nx = -dy / len * half; + float ny = dx / len * half; + + beginShapeQuads(); + color(dl.argb()); + Tessellator tess = Tessellator.instance; + tessColor(tess, dl.argb()); + tess.addVertex(x1 - nx, y1 - ny, 0); + tess.addVertex(x2 - nx, y2 - ny, 0); + tess.addVertex(x2 + nx, y2 + ny, 0); + tess.addVertex(x1 + nx, y1 + ny, 0); + } + + private void drawTriangle(GuideRenderPrimitive.DrawTriangle dt) { + flush(); + beginShape(); + color(dt.argb()); + Tessellator tess = Tessellator.instance; + tess.startDrawing(GL11.GL_TRIANGLES); + tessColor(tess, dt.argb()); + tess.addVertex(sx(dt.x1()), sy(dt.y1()), 0); + tess.addVertex(sx(dt.x2()), sy(dt.y2()), 0); + tess.addVertex(sx(dt.x3()), sy(dt.y3()), 0); + tess.draw(); + endShape(); + } + + private static final int CIRCLE_SEGMENTS = 32; + + private void drawCircle(GuideRenderPrimitive.DrawCircle dc) { + flush(); + float cx = sx(dc.cx()), cy = sy(dc.cy()); + float radius = dc.radius() * currentTransform().scale; + beginShape(); + color(dc.argb()); + Tessellator tess = Tessellator.instance; + if (dc.filled()) { + tess.startDrawing(GL11.GL_TRIANGLE_FAN); + tessColor(tess, dc.argb()); + tess.addVertex(cx, cy, 0); + for (int i = 0; i <= CIRCLE_SEGMENTS; i++) { + double a = Math.PI * 2.0 * i / CIRCLE_SEGMENTS; + tess.addVertex(cx + (float) (Math.cos(a) * radius), cy + (float) (Math.sin(a) * radius), 0); + } + tess.draw(); + } else { + tess.startDrawing(GL11.GL_TRIANGLE_STRIP); + tessColor(tess, dc.argb()); + for (int i = 0; i <= CIRCLE_SEGMENTS; i++) { + double a = Math.PI * 2.0 * i / CIRCLE_SEGMENTS; + float cos = (float) Math.cos(a); + float sin = (float) Math.sin(a); + tess.addVertex(cx + cos * (radius - 1), cy + sin * (radius - 1), 0); + tess.addVertex(cx + cos * (radius + 1), cy + sin * (radius + 1), 0); + } + tess.draw(); + } + endShape(); + } + + private void drawCircleOutline(GuideRenderPrimitive.DrawCircleOutline dco) { + flush(); + float cx = sx(dco.cx()), cy = sy(dco.cy()); + float scale = currentTransform().scale; + float half = Math.max(0.5f, dco.thickness() * scale * 0.5f); + float radius = dco.radius() * scale; + float inner = Math.max(0f, radius - half); + float outer = radius + half; + beginShape(); + color(dco.argb()); + Tessellator tess = Tessellator.instance; + tess.startDrawing(GL11.GL_TRIANGLE_STRIP); + tessColor(tess, dco.argb()); + for (int i = 0; i <= CIRCLE_SEGMENTS; i++) { + double a = Math.PI * 2.0 * i / CIRCLE_SEGMENTS; + float cos = (float) Math.cos(a); + float sin = (float) Math.sin(a); + tess.addVertex(cx + cos * inner, cy + sin * inner, 0); + tess.addVertex(cx + cos * outer, cy + sin * outer, 0); + } + tess.draw(); + endShape(); + } + + private void drawPolygon(GuideRenderPrimitive.DrawPolygon dp) { + flush(); + float[] xs = dp.xs(); + float[] ys = dp.ys(); + if (xs == null || ys == null || xs.length < 3) return; + int n = Math.min(xs.length, ys.length); + beginShape(); + color(dp.argb()); + Tessellator tess = Tessellator.instance; + tess.startDrawing(GL11.GL_TRIANGLE_FAN); + tessColor(tess, dp.argb()); + tess.addVertex(sx(xs[0]), sy(ys[0]), 0); + for (int i = 1; i < n; i++) { + tess.addVertex(sx(xs[i]), sy(ys[i]), 0); + } + tess.draw(); + endShape(); + } + + private void drawRenderItem(GuideRenderPrimitive.RenderItem ri) { + flush(); + if (ri.stack() == null) return; + var mc = Minecraft.getMinecraft(); + FontRenderer fr = mc.fontRenderer; + float scale = currentTransform().scale; + // Match VanillaRenderContext.renderItemInternal state handling; the item is + // drawn at the transformed position with zoom applied via the modelview. + GL11.glPushMatrix(); + GL11.glTranslatef(sx(ri.x()), sy(ri.y()), 0f); + GL11.glScalef(scale, scale, 1f); + try { + GL11.glDisable(GL11.GL_BLEND); + GL11.glColor4f(1f, 1f, 1f, 1f); + + RenderHelper.enableGUIStandardItemLighting(); + GL11.glEnable(GL11.GL_LIGHTING); + GL11.glEnable(GL11.GL_NORMALIZE); + GL11.glEnable(GL11.GL_DEPTH_TEST); + GL11.glEnable(GL11.GL_ALPHA_TEST); + + ITEM_RENDERER.zLevel = 100f; + ITEM_RENDERER.renderItemAndEffectIntoGUI(fr, mc.getTextureManager(), ri.stack(), 0, 0); + ITEM_RENDERER.renderItemOverlayIntoGUI(fr, mc.getTextureManager(), ri.stack(), 0, 0); + RenderHelper.disableStandardItemLighting(); + } finally { + ITEM_RENDERER.zLevel = 0f; + GL11.glPopMatrix(); + OpenGlHelper.setActiveTexture(OpenGlHelper.defaultTexUnit); + // Restore the frame baseline: item rendering, especially a modded + // IItemRenderer, may leave CULL_FACE/DEPTH_TEST/ALPHA_TEST enabled, + // which would cull or cut every later primitive this frame. + RenderHelper.disableStandardItemLighting(); + applyFrameBaselineGlState(); + } + } + + private void drawText(GuideRenderPrimitive.DrawText dt) { + flush(); + Minecraft mc = Minecraft.getMinecraft(); + String text = dt.text(); + if (text == null || text.isEmpty()) return; + ResolvedTextStyle style = dt.style(); + int x = Math.round(sx(dt.x())); + int y = Math.round(sy(dt.y())); + + int color = resolveTextColor(style); + String drawn = GuideFontCompat.prepareRenderedText(text, style); + + // Combine the style's own font scale with the current zoom so text + // scales exactly like it did under the legacy GL-matrix pipeline. + float scale = style.fontScale() * currentTransform().scale; + boolean scaled = Math.abs(scale - 1f) > 1e-4f; + if (scaled) { + GL11.glPushMatrix(); + GL11.glTranslatef(x, y, 0f); + GL11.glScalef(scale, scale, 1f); + if (style.dropShadow()) { + mc.fontRenderer.drawStringWithShadow(drawn, 0, 0, color); + } else { + mc.fontRenderer.drawString(drawn, 0, 0, color); + } + GL11.glPopMatrix(); + } else if (style.dropShadow()) { + mc.fontRenderer.drawStringWithShadow(drawn, x, y, color); + } else { + mc.fontRenderer.drawString(drawn, x, y, color); + } + + drawTextDecorations(drawn, x, y, color, style, scale); + } + + private int resolveTextColor(ResolvedTextStyle style) { + int color = style.color() != null ? style.color() + .resolve() : 0xFFFFFFFF; + if ((color >>> 24) == 0) { + color |= 0xFF000000; + } + return color; + } + + private void drawTextDecorations(String text, int x, int y, int color, ResolvedTextStyle style, float scale) { + boolean hasUnderline = style.underlined(); + boolean hasWavyUnderline = style.wavyUnderline(); + boolean hasDottedUnderline = style.dottedUnderline(); + if (!hasUnderline && !hasWavyUnderline && !hasDottedUnderline) return; + + Minecraft mc = Minecraft.getMinecraft(); + int scaledFontHeight = Math.round(mc.fontRenderer.FONT_HEIGHT * scale); + int decorationY = y + scaledFontHeight - 1; + int decoratedWidth = GuideFontCompat.getPreparedStringWidth(mc.fontRenderer, text, style); + + if (hasUnderline) { + Gui.drawRect(x, decorationY, x + decoratedWidth, decorationY + 1, color); + } + if (hasWavyUnderline || hasDottedUnderline) { + // kind 4/5 fragments carry coverage alpha down to ~16/255 (dot + // corners) and below 25 (wave zero crossings). If ALPHA_TEST is + // left enabled by a caller (tooltip escape chain), GL_GREATER 0.1 + // would clip those soft-edge pixels. Scope the draw like beginShape + // so the coverage gradient always reaches the framebuffer. + GL11.glPushAttrib(GL11.GL_ENABLE_BIT); + GL11.glDisable(GL11.GL_ALPHA_TEST); + try { + if (hasWavyUnderline) { + // The old wavy underline was 1x1px with ±1 amplitude, which + // the dark page background swallowed. Use a ±2px, 2px-thick + // 8-phase sine, tinted lighter than the body gray, rasterized + // by the shared DecorationRasterizer with coverage alpha + // (sub-pixel sampling). + int waveColor = brightenDecorationColor(color); + for (DecorationRasterizer.Fragment f : DecorationRasterizer + .rasterize(x, decorationY, decoratedWidth, 4)) { + Gui.drawRect(f.x(), f.y(), f.x() + f.w(), f.y() + f.h(), decorationArgb(f.alpha(), waveColor)); + } + } + if (hasDottedUnderline) { + // One 2x2 dot per character was too sparse; draw 3px + // soft-edged circular dots on a fixed 4px cadence via the + // shared DecorationRasterizer, tinted lighter than the body + // gray. + int dotColor = brightenDecorationColor(color); + for (DecorationRasterizer.Fragment f : DecorationRasterizer + .rasterize(x, decorationY, decoratedWidth, 5)) { + Gui.drawRect(f.x(), f.y(), f.x() + f.w(), f.y() + f.h(), decorationArgb(f.alpha(), dotColor)); + } + } + } finally { + GL11.glPopAttrib(); + } + } + } + + /** + * Wavy/dots decorations are thin; a faint tint is swallowed by the page + * background. Blend the text color toward white so the decoration reads + * against the body gray. + */ + private int brightenDecorationColor(int color) { + int a = color & 0xFF000000; + int r = (color >>> 16) & 0xFF; + int g = (color >>> 8) & 0xFF; + int b = color & 0xFF; + r = r + (255 - r) * 3 / 4; + g = g + (255 - g) * 3 / 4; + b = b + (255 - b) * 3 / 4; + return a | (r << 16) | (g << 8) | b; + } + + /** + * Composite a rasterizer coverage alpha with the decoration tint's own + * alpha byte: final = round(coverage × tintAlpha / 255), round-half-up. + * Pure coverage would discard a semi-transparent text color's opacity (the + * tint's alpha byte was replaced, not multiplied); multiplying keeps the + * text color's transparency semantics while the coverage still shapes the + * brush edge. With an opaque tint (alpha 255) the result equals the + * coverage exactly. + */ + private static int decorationArgb(int coverage, int tintArgb) { + int tintAlpha = (tintArgb >>> 24) & 0xFF; + int alpha = (coverage * tintAlpha + 127) / 255; + return (alpha << 24) | (tintArgb & 0xFFFFFF); + } + + @SuppressWarnings("unchecked") + private void drawScene3D(GuideRenderPrimitive.RenderScene3D s3) { + flush(); + GL11.glPushAttrib(GL11.GL_ALL_ATTRIB_BITS); + GL11.glPushMatrix(); + + LytRect clip = toScreen(s3.clipX(), s3.clipY(), s3.clipW(), s3.clipH()); + if (!scissorStack.isEmpty()) { + LytRect parent = scissorStack.peek(); + int x1 = Math.max(clip.x(), parent.x()); + int y1 = Math.max(clip.y(), parent.y()); + int x2 = Math.min(clip.right(), parent.right()); + int y2 = Math.min(clip.bottom(), parent.bottom()); + clip = new LytRect(x1, y1, Math.max(0, x2 - x1), Math.max(0, y2 - y1)); + } + + sceneRenderer.render( + s3.level(), + s3.camera(), + s3.particles(), + s3.weatherEffects(), + s3.weatherAnimationTick(), + s3.lightDarkMode(), + clip.x(), + clip.y(), + clip.width(), + clip.height(), + viewport.width(), + viewport.height()); + + GL11.glPopMatrix(); + GL11.glPopAttrib(); + + // Restore scissor after 3D rendering + reapplyScissor(); + } + + /** + * Legacy subtree rendering. The GL modelview is set from the current + * transform stack so the callback can render in document coordinates + * ({@code screen = doc * scale + t}). The callback's RenderContext scissor + * stack is seeded with the engine's current scissor so nested legacy + * scissors stay inside the viewport, and GL/scissor state is restored + * afterwards. + */ + private void drawHostDraw(GuideRenderPrimitive.HostDraw hd) { + flush(); + Transform t = currentTransform(); + RenderContext ctx = hd.context(); + if (GuideDebugLog.isLayoutOverlayEnabled()) { + GuideDebugLog.warnAlways( + "[TRC] HostDraw execute transform=({},{},{}) scissorTop={}", + t.tx, + t.ty, + t.scale, + scissorStack.peek()); + } + // Escape-hatch contract: the legacy callback runs with full GL state + // saved/restored, so it cannot leak enables (cull face, depth test, ...) + // into the primitive stream. + GL11.glPushAttrib(GL11.GL_ALL_ATTRIB_BITS); + GL11.glPushMatrix(); + GL11.glTranslatef(t.tx, t.ty, 0f); + GL11.glScalef(t.scale, t.scale, 1f); + boolean seededScissor = false; + if (!scissorStack.isEmpty()) { + ctx.pushScissor(scissorStack.peek()); + seededScissor = true; + } + try { + hd.draw() + .run(); + } finally { + if (seededScissor) { + ctx.popScissor(); + } + // The legacy path may leave the Tessellator mid-batch. + try { + Tessellator.instance.draw(); + } catch (IllegalStateException ignored) {} + GL11.glPopMatrix(); + GL11.glPopAttrib(); + ctx.restoreExternalRenderState(); + // restoreExternalRenderState restores the LEGACY context's expected + // state (it re-enables ALPHA_TEST among others); the engine must + // re-establish its own baseline afterwards, then re-apply the + // engine scissor over the context's (now empty) scissor stack. + applyFrameBaselineGlState(); + reapplyScissor(); + } + } + + private record Transform(float tx, float ty, float scale) {} +} diff --git a/src/main/java/com/hfstudio/guidenh/guide/render/GuideRenderPrimitive.java b/src/main/java/com/hfstudio/guidenh/guide/render/GuideRenderPrimitive.java new file mode 100644 index 00000000..0e2a94c1 --- /dev/null +++ b/src/main/java/com/hfstudio/guidenh/guide/render/GuideRenderPrimitive.java @@ -0,0 +1,146 @@ +package com.hfstudio.guidenh.guide.render; + +import java.util.List; + +import net.minecraft.item.ItemStack; + +import com.hfstudio.guidenh.guide.style.ResolvedTextStyle; + +/** + * Sealed interface for all render primitives consumed by GuideRenderEngine. + *

          + * 20 total: 6 state instructions + 14 draw primitives. + *

          + * All coordinates are absolute document coordinates unless stated otherwise. + * GuideRenderEngine applies the current transform stack to convert to screen coordinates: + * {@code screen = doc * scale + (tx, ty)}. + */ +public sealed interface GuideRenderPrimitive permits GuideRenderPrimitive.PushTransform,GuideRenderPrimitive.PopTransform,GuideRenderPrimitive.PushScissor,GuideRenderPrimitive.PopScissor,GuideRenderPrimitive.PushScreenScissor,GuideRenderPrimitive.PopScreenScissor,GuideRenderPrimitive.FillRect,GuideRenderPrimitive.GradientFill,GuideRenderPrimitive.DrawBorder,GuideRenderPrimitive.BlitTexture,GuideRenderPrimitive.DrawGlyphRun,GuideRenderPrimitive.DrawDecorationLine,GuideRenderPrimitive.DrawLine,GuideRenderPrimitive.DrawTriangle,GuideRenderPrimitive.DrawCircle,GuideRenderPrimitive.DrawCircleOutline,GuideRenderPrimitive.DrawPolygon,GuideRenderPrimitive.RenderItem,GuideRenderPrimitive.DrawText,GuideRenderPrimitive.RenderScene3D,GuideRenderPrimitive.HostDraw { + + /** + * Push a translate+scale onto the transform stack. The new frame maps + * {@code screen = doc * scale + (tx, ty)} and composes with its parent. + */ + record PushTransform(float tx, float ty, float scale) implements GuideRenderPrimitive {} + + /** Pop the top transform from the stack. */ + record PopTransform() implements GuideRenderPrimitive {} + + /** Push a scissor rectangle (absolute document coords). Intersects with parent. */ + record PushScissor(int x, int y, int w, int h) implements GuideRenderPrimitive {} + + /** Pop the top scissor rectangle. */ + record PopScissor() implements GuideRenderPrimitive {} + + /** + * Push a scissor rectangle in screen GUI coordinates (i.e. after the + * document-to-screen transform). Unlike {@link PushScissor}, this is NOT + * converted through the transform stack -- it is only intersected with the + * current scissor and multiplied by the display scale factor before + * {@code glScissor}. Used for the fixed viewport clip, which must not move + * with scroll or zoom (a document-space rect cannot express it exactly + * when zoom != 1). + */ + record PushScreenScissor(int x, int y, int w, int h) implements GuideRenderPrimitive {} + + /** Pop a screen-scissor rectangle. */ + record PopScreenScissor() implements GuideRenderPrimitive {} + + /** Filled rectangle. */ + record FillRect(int x, int y, int w, int h, int argb) implements GuideRenderPrimitive {} + + /** Vertical gradient fill. */ + record GradientFill(int x, int y, int w, int h, int argbTop, int argbBottom) implements GuideRenderPrimitive {} + + /** Border (4 sides, single color). */ + record DrawBorder(int x, int y, int w, int h, int top, int left, int bottom, int right, int argb) + implements GuideRenderPrimitive {} + + /** Textured quad with UV coordinates, tinted by {@code argb} (0xFFFFFFFF = untinted). */ + record BlitTexture(int texId, int x, int y, int w, int h, float u, float v, float u2, float v2, int argb) + implements GuideRenderPrimitive { + + /** Untinted (white) variant. */ + public BlitTexture(int texId, int x, int y, int w, int h, float u, float v, float u2, float v2) { + this(texId, x, y, w, h, u, v, u2, v2, 0xFFFFFFFF); + } + } + + /** + * Glyph run from Rust cosmic-text shaping. Glyph positions are absolute + * document coords. {@code argb} tints the (white) atlas bitmaps -- the + * single color channel for unified-pipeline text. {@code shear} applies a + * synthetic-italic slant at draw time (fake-oblique, mirroring MC §o). + * {@code shadow} asks the engine to paint a dark drop shadow offset +1/+1 + * document units beneath the run (MC drawStringWithShadow parity). + *

          + * No atlas texture is carried: glyphs resolve to per-page atlas textures at + * draw time via {@link GuideGlyphAtlas#lookup}/{@link GuideGlyphAtlas#getTextureId}. + */ + record DrawGlyphRun(List glyphs, int argb, boolean shear, boolean shadow) + implements GuideRenderPrimitive {} + + /** + * Wavy (kind=4) or dotted (kind=5) underline decoration emitted from a + * Rust span's decoration rect. {@code x}/{@code y}/{@code w}/{@code h} are + * the decoration band in absolute document coordinates (top-left origin); + * {@code argb} is the span text color; {@code kind} selects the brush at + * draw time (4 = wavy sine, 5 = dotted). + */ + record DrawDecorationLine(float x, float y, float w, float h, int argb, byte kind) + implements GuideRenderPrimitive {} + + /** Line (thick or thin). */ + record DrawLine(float x1, float y1, float x2, float y2, float thickness, int argb) + implements GuideRenderPrimitive {} + + /** Filled triangle. */ + record DrawTriangle(float x1, float y1, float x2, float y2, float x3, float y3, int argb) + implements GuideRenderPrimitive {} + + /** Circle (filled or outline). */ + record DrawCircle(float cx, float cy, float radius, int argb, boolean filled) implements GuideRenderPrimitive {} + + /** Circle outline only. */ + record DrawCircleOutline(float cx, float cy, float radius, float thickness, int argb) + implements GuideRenderPrimitive {} + + /** Filled polygon. */ + record DrawPolygon(float[] xs, float[] ys, int argb) implements GuideRenderPrimitive {} + + /** Minecraft item stack rendering. */ + record RenderItem(ItemStack stack, int x, int y) implements GuideRenderPrimitive {} + + /** Text via Minecraft FontRenderer with full ResolvedTextStyle. */ + record DrawText(String text, int x, int y, ResolvedTextStyle style) implements GuideRenderPrimitive {} + + /** 3D scene rendering. */ + record RenderScene3D(Object level, Object camera, List annotations, List particles, List weatherEffects, + float weatherAnimationTick, Object lightDarkMode, int clipX, int clipY, int clipW, int clipH) + implements GuideRenderPrimitive {} + + /** + * Legacy subtree rendering escape hatch. Emitted by PrimitiveCollector for + * blocks whose {@code usePrimitives()} is false. The engine sets up the GL + * modelview from its current transform stack (so {@code draw} can render in + * document coordinates), seeds {@code context}'s scissor stack with the + * current engine scissor, invokes {@code draw}, and restores GL state + * afterwards. + */ + record HostDraw(RenderContext context, Runnable draw) implements GuideRenderPrimitive {} + + /** + * A glyph quad at an absolute document coordinate (top-left origin), with the + * key of its bitmap in the GuideGlyphAtlas. Used by DrawGlyphRun. + * {@code lineIndex} is the visual (wrapped) line the glyph belongs to within + * its paragraph, carried so line geometry can be rebuilt from glyph runs. + */ + record PlacedGlyph(long atlasKey, float x, float y, float w, float h, int lineIndex, float baseline) { + + // Local-shape paths (GuideText.emitText) have no Rust baseline to carry; + // The baseline is carried only for the serialized Rust glyph runs. + PlacedGlyph(long atlasKey, float x, float y, float w, float h, int lineIndex) { + this(atlasKey, x, y, w, h, lineIndex, 0f); + } + } +} diff --git a/src/main/java/com/hfstudio/guidenh/guide/render/GuideText.java b/src/main/java/com/hfstudio/guidenh/guide/render/GuideText.java new file mode 100644 index 00000000..0d01d1e8 --- /dev/null +++ b/src/main/java/com/hfstudio/guidenh/guide/render/GuideText.java @@ -0,0 +1,787 @@ +package com.hfstudio.guidenh.guide.render; + +import java.nio.ByteBuffer; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; + +import org.jetbrains.annotations.Nullable; + +import com.google.flatbuffers.FlatBufferBuilder; +import com.hfstudio.guidenh.config.ModConfig; +import com.hfstudio.guidenh.guide.internal.util.DisplayScale; +import com.hfstudio.guidenh.guide.internal.util.GuideStringLines; +import com.hfstudio.guidenh.guide.layout.LayoutBridge; +import com.hfstudio.guidenh.guide.layout.flatbuffers.ShapeTextInput; +import com.hfstudio.guidenh.guide.layout.flatbuffers.ShapeTextResult; +import com.hfstudio.guidenh.guide.layout.flatbuffers.TextStyle; +import com.hfstudio.guidenh.guide.style.ResolvedTextStyle; + +/** + * Unified text pipeline entry: one font, one entry point, one coordinate + * system. + * + *

          + * All measurement (width / line height / baseline) and all text emission in + * the primitive pipeline go through this class, backed by the single Rust + * font system (cosmic-text over the system TTF). No block may reach for + * {@code Minecraft.getMinecraft().fontRenderer} or stash a LayoutContext to + * do text work. + * + *

          + * Coordinate contract. Text coordinates are document-space with the + * origin at the line top. The text baseline is + * {@code lineTop + ascent(style)}; {@link #ascent()} is the single baseline + * authority (cosmic font metrics at the base em size). Minecraft's legacy + * "top + 7" convention exists only inside HostDraw legacy rendering, never + * here. + */ +public final class GuideText { + + /** + * Base em size for guide body text, raised from 9 to 11 by the typography + * pass. This is the value {@link #resolveBaseFontSize()} resolves to when + * the config {@code readerFontSize} is 0 (follow system) -- the default, so + * the default wire bytes are unchanged from earlier builds. + */ + public static final float BASE_FONT_SIZE = 11f; + /** + * Line height at scale 1 for the DEFAULT base font size (readerFontSize = 0): + * round(BASE_FONT_SIZE × 1.55) = round(17.05) = 17 (mirrored by + * parley_text.rs push_defaults FontSizeRelative(1.55)). Kept as a constant + * because {@code RustFontMetrics} references it, and it equals + * {@link #baseLineHeight()} at the default config -- so the default wire + * bytes are unchanged; a configured readerFontSize derives the runtime + * estimate via {@link #baseLineHeight()}. + */ + public static final int BASE_LINE_HEIGHT = 17; + + /** + * Single source of truth for the base em size written to the text wire. + * {@code readerFontSize <= 0} ("follow system") resolves to the built-in + * {@link #BASE_FONT_SIZE} (11f); a positive configured value is used as-is. + * With the default config this returns exactly {@link #BASE_FONT_SIZE}, so + * every wire consumer of the font size is byte-for-byte identical to + * earlier builds. + */ + public static float resolveBaseFontSize() { + int configured = ModConfig.reader.readerFontSize; + return configured > 0 ? (float) configured : BASE_FONT_SIZE; + } + + /** + * Single source of truth for the reader body font family: the + * config-to-wire mapping. {@code "system"} / null / blank resolves to + * {@code null} (= undeclared), and the Rust {@code resolve_family} in + * parley_text.rs:570-576 then falls back to the SansSerif generic, leaving + * the wire bytes unchanged. Any other string passes through as a named + * family. When a passed-through name is absent from the fontique + * collection, parley skips it silently and falls back to the script + * fallback chain (the platform default font, not a hard failure): + * parley-0.11.0 resolve/mod.rs:233-237 (a Named family that is not in the + * collection is never pushed onto the stack), shape/mod.rs:586 (the empty + * stack handled by {@code unwrap_or(&[])}), and fontique query.rs:122-126 + * (the family chain continues into {@code fallback_families}). The default + * "system" resolves to null, which is wire-neutral. + */ + public static @Nullable String resolveBaseFontFamily() { + String configured = ModConfig.reader.readerFontFamily; + if (configured == null || configured.isBlank() || "system".equals(configured)) { + return null; + } + return configured; + } + + /** + * Single source of truth for the reader inline-code font family: the + * config-to-wire mapping. {@code "mono"} is the config-level meaning of + * "platform monospace default", but the Rust {@code resolve_family} only + * recognises the reserved word {@code "monospace"} (the + * {@code Some("monospace")} branch at parley_text.rs:572; {@code "mono"} + * takes the {@code Some(name) => FontFamily::named("mono")} branch, whose + * collection lookup misses). {@code "mono"} is therefore mapped here to + * {@link #MONO_FONT_FAMILY} ("monospace"), so under the default config the + * wire matches the inline_code literal byte for byte. null / blank is + * treated as the default ("monospace"); any other string passes through as + * a named family with the same fallback semantics as the base family. + */ + public static String resolveCodeFontFamily() { + String configured = ModConfig.reader.readerCodeFontFamily; + if (configured == null || configured.isBlank() || "mono".equals(configured)) { + return MONO_FONT_FAMILY; + } + return configured; + } + + /** + * Line height at scale 1 for the CURRENT base font size -- the Java-side + * mirror of Rust's FontSizeRelative(1.55): round(resolveBaseFontSize() × + * 1.55). At the default config this equals the historical constant + * {@link #BASE_LINE_HEIGHT} (17), so default-mode estimates are unchanged; + * a configured readerFontSize derives proportionally. + */ + public static int baseLineHeight() { + return Math.max(1, Math.round(resolveBaseFontSize() * 1.55f)); + } + + /** + * The inline_code monospace family written on the wire: the CSS generic + * name {@code "monospace"}. The Rust {@code resolve_family} maps it to + * GenericFamily::Monospace, which each fontique backend resolves to that + * platform's monospace default (dwrite to Consolas, and so on). Shared by + * the Java-side serialization (LayoutNodeSerializer) and by the cache key + * ({@link #fontFamilyOf} in this class); it is the default wire value of + * {@link #resolveCodeFontFamily()} (the config value {@code "mono"} maps to + * this constant), so the cache key, the wire family and the family actually + * shaped are always the same. + */ + public static final String MONO_FONT_FAMILY = "monospace"; + + /** + * Cache key includes the display pixel ratio: bitmaps are rasterized per + * render scale, so a GUI-scale change must not hit stale entries. + *

          + * The family component is {@link #fontFamilyOf}, the family the style + * actually writes to the wire (inline_code resolves to + * {@link #resolveCodeFontFamily()}, body text to + * {@link #resolveBaseFontFamily()}), so different families never cross-talk + * and entries of a replaced family stop matching after a config change. The + * {@code baseFontSize} component is {@link #resolveBaseFontSize()}, the same + * local variable that {@code shapeUncached} writes to the wire as the font + * size, so key and wire share one source and entries of a replaced size + * stop matching after a config change. + */ + private record ShapeKey(String text, boolean bold, boolean italic, float fontScale, int renderScale, + @Nullable String family, float baseFontSize) {} + + private record AdvanceKey(int codePoint, boolean bold, boolean italic, float fontScale, int renderScale, + @Nullable String family, float baseFontSize) {} + + private static final int CACHE_LIMIT = 4096; + private static final Map shapeCache = new ConcurrentHashMap<>(); + private static final Map advanceCache = new ConcurrentHashMap<>(); + + /** + * Per-family baseline cache key: the composite (family, baseFontSize). + * Indexing by family alone would return a stale baseline after a font size + * change, which is how a cache gets polluted by a key that describes one + * font while the wire carries another. The family component is still + * normalized by {@link #familyKey} (null/blank = body SansSerif generic); + * body metrics ({@code ascentFor} / {@code xHeightFor} with a null family) + * resolve the family to {@link #resolveBaseFontFamily()} first, so the key + * shares one source with the wire and a base-family config change stops + * matching the old family's baseline entries. The {@code baseFontSize} + * component is {@link #resolveBaseFontSize()}, the same source as the value + * {@code shape("x", ...)} writes to the wire in the same frame. + */ + private record FamilyMetricKey(@Nullable String family, float baseFontSize) {} + + /** + * Per-family baseline cache, replacing the earlier global single value + * ({@code cachedBaseAscent}). The key is the family normalized by + * {@link #familyKey} (null/blank = body font SansSerif generic, which + * reproduces the behaviour of the global single value). Distinct families + * such as the monospace family are cached independently and never pollute + * each other. The key is {@link FamilyMetricKey} (family + baseFontSize), + * so a font size change cannot return a stale value, and body metrics are + * dimensioned by {@link #resolveBaseFontFamily()}, so a base-family change + * cannot return a stale baseline either. + */ + private static final Map baseAscentByFamily = new ConcurrentHashMap<>(); + private static final Map xHeightByFamily = new ConcurrentHashMap<>(); + + private GuideText() {} + + /** True when the Rust font system is initialized (in-game; false in some tests). */ + public static boolean isAvailable() { + return LayoutBridge.getFontHandle() != 0; + } + + /** Measured advance width of {@code text} at the given style (cached). */ + public static int measureWidth(@Nullable String text, @Nullable ResolvedTextStyle style) { + if (text == null || text.isEmpty() || !isAvailable()) { + return 0; + } + return Math.round(shape(text, style).width()); + } + + /** + * Line height at the current base font size × fontScale (11px base × 1.55 + * line-height ratio at the default config; see {@link #baseLineHeight()}). + */ + public static int lineHeight(@Nullable ResolvedTextStyle style) { + float scale = style != null ? style.fontScale() : 1f; + return Math.max(1, Math.round(baseLineHeight() * scale)); + } + + /** + * Baseline offset below the line top at scale 1 (cosmic font metrics at + * {@link #BASE_FONT_SIZE}). Multiply by fontScale for scaled styles. + *

          + * Body-font (SansSerif generic) accessor: equivalent to + * {@code ascentFor(null)}. The downstream consumers ({@code baselineOf}, + * {@code LytItemImage}, {@code GuideNavBar}) all measure the body font, so + * the semantics are unchanged when several families are in play. + */ + public static float ascent() { + return ascentFor(null); + } + + /** + * Per-family ascent. A null or blank family falls back to the body font, + * which resolves to {@link #resolveBaseFontFamily()} (the default resolves + * to null, i.e. the SansSerif generic, matching the behaviour of the + * earlier global single-value cache), keeping the metric key and the wire + * on one source. Each family has its own cache + * ({@link #baseAscentByFamily}) and never pollutes another. + */ + public static float ascentFor(@Nullable String family) { + // An explicit family wins; a null family means body text, resolved through resolveBaseFontFamily(). + String effFamily = family != null ? family : resolveBaseFontFamily(); + // Key = (family, baseFontSize). baseFontSize shares one source with the + // value shape("x", ...) writes to the wire in the same frame + // (resolveBaseFontSize only reads config, so it is constant within a + // frame), and effFamily shares one source with the family that + // familyStyle(effFamily) writes to the wire (fontFamilyOf passes a + // non-null style.font() through unchanged). + FamilyMetricKey key = new FamilyMetricKey(familyKey(effFamily), resolveBaseFontSize()); + Float cached = baseAscentByFamily.get(key); + if (cached != null) { + return cached; + } + if (!isAvailable()) { + // Same convention as the legacy MC cell: baseline ≈ top + 7 of 9. + return 7f; + } + float a = shape("x", familyStyle(effFamily)).ascent(); + baseAscentByFamily.put(key, a); + return a; + } + + /** + * Body x-height at scale 1, read from the Rust shape pipeline: the + * first run's skrifa OS/2 {@code sxHeight} at the base em size. Unlike + * {@link #ascent()} (the font ascent, ≈0.75-0.85em -- the top of tall + * letters/ascenders), this measures a lower-case "x", i.e. the height + * lowercase body letters actually occupy. Multiply by fontScale for scaled + * styles. Body-font (SansSerif generic) accessor: equivalent to + * {@code xHeightFor(null)}. + */ + public static float xHeight() { + return xHeightFor(null); + } + + /** + * Per-family x-height. A null or blank family falls back to the body font, + * which resolves to {@link #resolveBaseFontFamily()} (the default resolves + * to null, i.e. the SansSerif generic), keeping the metric key and the wire + * on one source. Each family has its own cache + * ({@link #xHeightByFamily}) and never pollutes another. + */ + public static float xHeightFor(@Nullable String family) { + // An explicit family wins; a null family means body text, resolved through resolveBaseFontFamily(). + String effFamily = family != null ? family : resolveBaseFontFamily(); + // Key = (family, baseFontSize); the same-source argument as ascentFor. + FamilyMetricKey key = new FamilyMetricKey(familyKey(effFamily), resolveBaseFontSize()); + Float cached = xHeightByFamily.get(key); + if (cached != null) { + return cached; + } + float h = shape("x", familyStyle(effFamily)).xHeight(); + xHeightByFamily.put(key, h); + return h; + } + + /** Baseline Y for a line whose top is {@code lineTop} at the given style. */ + public static float baselineOf(float lineTop, @Nullable ResolvedTextStyle style) { + float scale = style != null ? style.fontScale() : 1f; + return lineTop + ascent() * scale; + } + + /** + * Emit {@code text} as an atlas-backed glyph run at document position + * {@code (x, y)} (line-top origin), tinted with the style's color. + *

          + * Shaping results are cached by (text, style); atlas bitmaps dedupe by + * content key, so per-frame emission is cheap. Decorations (underline / + * strikethrough / backgrounds) are NOT emitted here; rich-text spans are + * handled by the span pipeline. + */ + public static void emitText(PrimitiveCollector c, String text, int x, int y, @Nullable ResolvedTextStyle style) { + if (text == null || text.isEmpty() || !isAvailable()) { + return; + } + ShapeTextResult shaped = shape(text, style); + var atlas = GuideGlyphAtlas.instance(); + for (int i = 0; i < shaped.bitmapsLength(); i++) { + var bmp = shaped.bitmaps(i); + if (bmp == null || bmp.rgbaLength() == 0) continue; + byte[] rgba = new byte[bmp.rgbaLength()]; + for (int j = 0; j < rgba.length; j++) { + rgba[j] = (byte) bmp.rgba(j); + } + atlas.upload(bmp.key(), rgba, (int) bmp.w(), (int) bmp.h()); + } + int n = shaped.glyphsLength(); + if (n == 0) { + return; + } + List glyphs = new ArrayList<>(n); + for (int i = 0; i < n; i++) { + var g = shaped.glyphs(i); + if (g == null) continue; + glyphs.add( + new GuideRenderPrimitive.PlacedGlyph( + g.bitmapKey(), + x + g.x(), + y + g.y(), + g.w(), + g.h(), + (int) g.lineIndex())); + } + c.emit( + new GuideRenderPrimitive.DrawGlyphRun( + glyphs, + resolveColor(style), + // Declare the style's italic: it is already declared into + // shaping (shapeUncached writes TextStyle.italic), and a + // hardcoded false here used to silence the MC §o synthetic + // italic slant for labels. The renderer now shears italic runs + // (per-span runs carry their own shear via GlyphRun.shear). + style != null && style.italic(), + style != null && style.dropShadow())); + } + + /** Per-codepoint advance (cached; used by RustFontMetrics). */ + public static float advanceOf(int codePoint, @Nullable ResolvedTextStyle style) { + if (!isAvailable()) { + return 0f; + } + boolean bold = style != null && style.bold(); + boolean italic = style != null && style.italic(); + float scale = style != null ? style.fontScale() : 1f; + // The baseFontSize component shares one source with the value + // shape(s, style) writes to the wire as the font size in the same frame + // (resolveBaseFontSize only reads config, so it is constant within a + // frame). + float baseFontSize = resolveBaseFontSize(); + AdvanceKey key = new AdvanceKey( + codePoint, + bold, + italic, + scale, + DisplayScale.scaleFactor(), + fontFamilyOf(style), + baseFontSize); + Float cached = advanceCache.get(key); + if (cached != null) { + return cached; + } + String s = new String(Character.toChars(codePoint)); + float w = shape(s, style).width(); + if (advanceCache.size() > CACHE_LIMIT) { + advanceCache.clear(); + } + advanceCache.put(key, w); + return w; + } + + /** Truncation suffix policy for {@link #clipToWidth} and {@link #clipToChars}. */ + public enum ClipSuffix { + /** Hard truncation, no suffix appended. */ + NONE, + /** ASCII three dots {@code "..."}. */ + DOTS3, + /** Typographic ellipsis {@code "…"} (U+2026). */ + UNICODE_ELLIPSIS + } + + /** + * Word-first line wrapping: splits {@code text} on whitespace (line + * breaks preserved first via {@link GuideStringLines#splitLines}, then + * words within each line) and packs words into lines of at most + * {@code maxWidth} pixels (measured with {@link #measureWidth}). A word + * that does not fit is broken at codepoint granularity via + * {@link #advanceOf} -- codepoint-aware, so surrogate pairs are never split + * (no {@code charAt} scanning). Output lines carry no leading or trailing + * whitespace; empty input lines are dropped. + * + *

          + * Degradation semantics: when {@link #isAvailable()} is false no + * measurement is possible, so the text is returned untouched as a single + * line (no wrapping). Returns an empty list for {@code null} / empty input. + */ + public static List wrap(String text, int maxWidth, @Nullable ResolvedTextStyle style) { + if (text == null || text.isEmpty()) { + return List.of(); + } + if (!isAvailable()) { + return List.of(text); + } + int budget = Math.max(1, maxWidth); + List lines = new ArrayList<>(); + for (String rawLine : GuideStringLines.splitLines(text)) { + String line = rawLine != null ? rawLine.trim() : ""; + if (line.isEmpty()) { + continue; + } + if (measureWidth(line, style) <= budget) { + lines.add(line); + continue; + } + String[] words = line.split("\\s+"); + StringBuilder current = new StringBuilder(); + for (String word : words) { + if (word.isEmpty()) { + continue; + } + String candidate = current.length() == 0 ? word : current + " " + word; + if (measureWidth(candidate, style) <= budget) { + current.setLength(0); + current.append(candidate); + continue; + } + if (current.length() > 0) { + lines.add(current.toString()); + current.setLength(0); + } + appendBrokenWord(word, budget, style, lines); + } + if (current.length() > 0) { + lines.add(current.toString()); + } + } + return lines; + } + + /** + * Pixel-level truncation: returns the longest codepoint prefix of + * {@code text} whose rendered width, together with {@code suffix}, does + * not exceed {@code maxWidth}. + * + *

          + * Semantic invariant: the result's rendered width is + * {@code <= maxWidth}. The suffix width counts against the budget; when + * the suffix alone does not fit ({@code suffixWidth > maxWidth}), or when + * even a single codepoint plus the suffix overflows, the empty string is + * returned (rather empty than overflowing). When the full text fits, it is + * returned unchanged. + * + *

          + * Implementation: codepoints are accumulated via + * {@link #advanceOf(int, ResolvedTextStyle)} -- never {@code substring} + + * {@link #measureWidth} shrink loops, which would create one unique shape + * cache key per prefix and blow up shaping to O(n²). The final result is + * then re-verified with {@link #measureWidth} and codepoints are backed off + * until it fits, because the per-codepoint advance sum differs from the + * shaped run by subpixels. + * + *

          + * Degradation semantics: when {@link #isAvailable()} is false no + * measurement is possible, so the original text is returned untouched. + */ + public static String clipToWidth(String text, int maxWidth, @Nullable ResolvedTextStyle style, ClipSuffix suffix) { + if (!isAvailable()) { + return text; + } + if (text == null || text.isEmpty()) { + return ""; + } + ClipSuffix eff = suffix != null ? suffix : ClipSuffix.NONE; + String suffixText = suffixText(eff); + int budget = Math.max(0, maxWidth); + int suffixWidth = suffixText.isEmpty() ? 0 : measureWidth(suffixText, style); + if (suffixWidth > budget) { + return ""; + } + if (measureWidth(text, style) <= budget) { + return text; + } + int contentBudget = budget - suffixWidth; + StringBuilder sb = new StringBuilder(); + float accumulated = 0f; + int offset = 0; + while (offset < text.length()) { + int codePoint = text.codePointAt(offset); + accumulated += advanceOf(codePoint, style); + if (accumulated > contentBudget) { + break; + } + sb.appendCodePoint(codePoint); + offset += Character.charCount(codePoint); + } + if (sb.length() == 0) { + // Even a single codepoint plus the suffix cannot fit. + return ""; + } + // Conservative backoff: advanceOf sums and shaped runs differ by + // subpixels; drop codepoints until the measured width fits. + while (sb.length() > 0 && measureWidth(sb.toString() + suffixText, style) > budget) { + int last = sb.codePointBefore(sb.length()); + sb.delete(sb.length() - Character.charCount(last), sb.length()); + } + return sb.length() == 0 ? "" : sb + suffixText; + } + + /** + * Character-level truncation by codepoint count (codepoint-aware; never + * splits a surrogate pair). The suffix is counted against + * {@code maxChars}: when the full text fits within the complete + * budget ({@code maxChars}) it is returned unchanged (no suffix appended); + * otherwise it is truncated to {@code maxChars - suffixCodepoints} + * codepoints and the suffix is appended. When the text does not fit and + * {@code maxChars <= suffix length} (the suffix alone consumes the budget), + * or {@code maxChars <= 0}, the empty string is returned (rather empty than + * overflowing). + * + *

          + * Semantic invariant: the result's codepoint count is + * {@code <= maxChars}. Mirrors {@link #clipToWidth}'s + * fits-in-full-budget semantics -- the fits check uses the complete budget, + * so a text that fits is never truncated to make room for the suffix. + * + *

          + * Corner case: when the full text fits ({@code codePointCount <= + * maxChars}) it is returned unchanged (no suffix appended) even if the + * suffix alone would not fit the budget -- the fits-in-full-budget check + * precedes the suffix-space reservation. + * + *

          + * Pure string logic -- no font measurement involved, so it is unaffected + * by {@link #isAvailable()}. + */ + public static String clipToChars(String text, int maxChars, ClipSuffix suffix) { + if (text == null || text.isEmpty()) { + return ""; + } + ClipSuffix eff = suffix != null ? suffix : ClipSuffix.NONE; + String suffixText = suffixText(eff); + int suffixCount = suffixText.codePointCount(0, suffixText.length()); + if (maxChars <= 0) { + return ""; + } + // Fits-in-full-budget (mirrors clipToWidth's full-fits check): the + // complete text fits within maxChars, so it is returned unchanged. This + // must precede the suffix-budget guard -- a fitting text must not be + // truncated just to reserve space for the suffix. + if (text.codePointCount(0, text.length()) <= maxChars) { + return text; + } + if (suffixCount >= maxChars) { + return ""; + } + StringBuilder sb = new StringBuilder(); + int remaining = maxChars - suffixCount; + int offset = 0; + int taken = 0; + while (offset < text.length() && taken < remaining) { + int codePoint = text.codePointAt(offset); + sb.appendCodePoint(codePoint); + offset += Character.charCount(codePoint); + taken++; + } + return sb + suffixText; + } + + /** Resolve the style's color to ARGB (default opaque white). */ + public static int resolveColor(@Nullable ResolvedTextStyle style) { + int color = style != null && style.color() != null ? style.color() + .resolve() : 0xFFFFFFFF; + if ((color >>> 24) == 0) { + color |= 0xFF000000; + } + return color; + } + + // Internal helpers: shaping, wire values and the metric/shape caches. + + private static String suffixText(ClipSuffix suffix) { + return switch (suffix) { + case NONE -> ""; + case DOTS3 -> "..."; + case UNICODE_ELLIPSIS -> "\u2026"; + }; + } + + /** + * The font family the style actually writes to the wire, shared by the + * cache key and by serialization (LayoutNodeSerializer.buildFbTextStyle), + * so the cache key, the wire family and the family that is actually shaped + * are always the same. Inline code resolves to + * {@link #resolveCodeFontFamily()} (monospace is what inline code means; + * the default "mono" to "monospace" mapping keeps the earlier literal byte + * for byte); otherwise a non-null {@code style.font()} passes through as a + * named family and null falls back to {@link #resolveBaseFontFamily()} (the + * default "system" resolves to null, so non-code pages keep their earlier + * bytes). + */ + @Nullable + static String fontFamilyOf(@Nullable ResolvedTextStyle style) { + if (style == null) { + return null; + } + if (style.inlineCode()) { + return resolveCodeFontFamily(); + } + return style.font() != null ? style.font() : resolveBaseFontFamily(); + } + + /** + * Normalizes a family for the per-family cache keys: null or blank becomes + * null (= body font). + */ + @Nullable + private static String familyKey(@Nullable String family) { + if (family == null || family.isBlank()) { + return null; + } + return family; + } + + /** + * Minimal style carrying the given family, so the shape pipeline resolves + * that family; null falls back to body text, which makes + * {@code shape("x", null)} byte-for-byte identical to the earlier output. + */ + @Nullable + private static ResolvedTextStyle familyStyle(@Nullable String family) { + if (family == null) { + return null; + } + return new ResolvedTextStyle( + 1f, + false, + false, + false, + false, + false, + false, + false, + family, + null, + null, + null, + false, + null, + false, + 0f); + } + + /** + * Codepoint-level line breaking for a single word that does not fit on the + * current line. Accumulates per-codepoint advances via + * {@link #advanceOf} (no substring re-measure loops, no {@code charAt} + * surrogate-pair splitting); a single codepoint wider than the budget is + * emitted on its own line (a glyph cannot be split). + */ + private static void appendBrokenWord(String word, int maxWidth, @Nullable ResolvedTextStyle style, + List output) { + if (measureWidth(word, style) <= maxWidth) { + output.add(word); + return; + } + StringBuilder current = new StringBuilder(); + float accumulated = 0f; + int offset = 0; + while (offset < word.length()) { + int codePoint = word.codePointAt(offset); + float advance = advanceOf(codePoint, style); + if (current.length() > 0 && accumulated + advance > maxWidth) { + output.add(current.toString()); + current.setLength(0); + accumulated = 0f; + } + current.appendCodePoint(codePoint); + accumulated += advance; + offset += Character.charCount(codePoint); + } + if (current.length() > 0) { + output.add(current.toString()); + } + } + + private static ShapeTextResult shape(String text, @Nullable ResolvedTextStyle style) { + boolean bold = style != null && style.bold(); + boolean italic = style != null && style.italic(); + float scale = style != null ? style.fontScale() : 1f; + int renderScale = DisplayScale.scaleFactor(); + // The family dimension is the family the style actually writes to the + // wire (inline_code resolves to resolveCodeFontFamily, body text to + // resolveBaseFontFamily); sharing fontFamilyOf with serialization keeps + // the cache from conflating the monospace and body fonts on the wire, + // and entries of a replaced family stop matching after a config change. + String fontFamily = fontFamilyOf(style); + // baseFontSize is captured once and used both in the key and on the + // wire (passed straight to shapeUncached), so key and wire share one + // source and entries of a replaced size stop matching after a config + // change. + float baseFontSize = resolveBaseFontSize(); + ShapeKey key = new ShapeKey(text, bold, italic, scale, renderScale, fontFamily, baseFontSize); + ShapeTextResult cached = shapeCache.get(key); + if (cached != null) { + return cached; + } + ShapeTextResult shaped = shapeUncached(text, bold, italic, scale, renderScale, fontFamily, baseFontSize); + if (shapeCache.size() > CACHE_LIMIT) { + shapeCache.clear(); + } + shapeCache.put(key, shaped); + return shaped; + } + + private static ShapeTextResult shapeUncached(String text, boolean bold, boolean italic, float fontScale, + int renderScale, @Nullable String fontFamily, float baseFontSize) { + FlatBufferBuilder fbb = new FlatBufferBuilder(1024); + int strOff = fbb.createString(text); + // The wire family is the fontFamilyOf(style) captured once in shape() + // (the same local variable is passed through), so the key and the wire + // agree; an empty string is avoided to prevent a zero offset (the Rust + // clean_family also trims and filters empty names). + int familyOff = fontFamily != null && !fontFamily.isEmpty() ? fbb.createString(fontFamily) : 0; + // The wire font size is the baseFontSize component of the cache key + // (the same local variable is passed through), so the key and the wire + // agree and there is no cache pollution path. + int styleOff = TextStyle.createTextStyle( + fbb, + baseFontSize, + bold, + italic, + fontScale, + 0xFFFFFFFFL, + familyOff, + false, + false, + 0L, + false, + 0.0f, + false, + false); + int inputOff = ShapeTextInput.createShapeTextInput(fbb, strOff, styleOff, -1.0f, renderScale); + fbb.finish(inputOff); + byte[] result = LayoutBridge.shapeText(LayoutBridge.getFontHandle(), fbb.sizedByteArray()); + if (result == null || result.length == 0) { + // Degrade instead of throwing: one bad string must not take + // down the whole layout/render frame. + com.hfstudio.guidenh.guide.scene.support.GuideDebugLog + .warnAlways("GuideText: shapeText failed, degrading to empty (len={})", text.length()); + FlatBufferBuilder empty = new FlatBufferBuilder(64); + // The line height is derived from the current baseFontSize (the + // default gives BASE_LINE_HEIGHT = 17, matching the earlier bytes). + int off = com.hfstudio.guidenh.guide.layout.flatbuffers.ShapeTextResult.createShapeTextResult( + empty, + 0f, + baseLineHeight() * fontScale, + 0f, + baseLineHeight() * fontScale, + 0, + 0, + 0f, + 0f); + empty.finish(off); + return com.hfstudio.guidenh.guide.layout.flatbuffers.ShapeTextResult + .getRootAsShapeTextResult(ByteBuffer.wrap(empty.sizedByteArray())); + } + return ShapeTextResult.getRootAsShapeTextResult(ByteBuffer.wrap(result)); + } +} diff --git a/src/main/java/com/hfstudio/guidenh/guide/render/GuidebookSceneRenderer.java b/src/main/java/com/hfstudio/guidenh/guide/render/GuidebookSceneRenderer.java new file mode 100644 index 00000000..918b29fc --- /dev/null +++ b/src/main/java/com/hfstudio/guidenh/guide/render/GuidebookSceneRenderer.java @@ -0,0 +1,33 @@ +package com.hfstudio.guidenh.guide.render; + +import java.util.List; + +import org.lwjgl.opengl.GL11; + +/** + * Renders GuidebookScene 3D content. + *

          + * Current implementation: minimal GL state management shell. + * Full 3D rendering integration with GuidebookLevelRenderer + * will be wired when RenderScene3D primitives enter the pipeline. + */ +public class GuidebookSceneRenderer { + + /** + * Stub. Sets viewport and saves/restores GL state. + * + * @param level GuidebookLevel (the "fake world") + * @param camera CameraSettings + */ + public void render(Object level, Object camera, List particles, List weatherEffects, + float weatherAnimationTick, Object lightDarkMode, int clipX, int clipY, int clipW, int clipH, int viewportW, + int viewportH) { + if (level == null) return; + + GL11.glViewport(clipX, clipY, Math.max(1, clipW), Math.max(1, clipH)); + + // No-op + // Full implementation delegates to GuidebookLevelRenderer + // and CameraSettings after extracting from VanillaRenderContext. + } +} diff --git a/src/main/java/com/hfstudio/guidenh/guide/render/PrimitiveCollector.java b/src/main/java/com/hfstudio/guidenh/guide/render/PrimitiveCollector.java new file mode 100644 index 00000000..77d17850 --- /dev/null +++ b/src/main/java/com/hfstudio/guidenh/guide/render/PrimitiveCollector.java @@ -0,0 +1,298 @@ +package com.hfstudio.guidenh.guide.render; + +import java.util.ArrayDeque; +import java.util.ArrayList; +import java.util.Deque; +import java.util.List; + +import com.hfstudio.guidenh.guide.document.LytRect; +import com.hfstudio.guidenh.guide.document.block.LytBlock; +import com.hfstudio.guidenh.guide.document.block.LytNode; +import com.hfstudio.guidenh.guide.scene.support.GuideDebugLog; + +/** + * Collects {@link GuideRenderPrimitive} from a {@link LytNode} tree. + * + *

          + * Traversal authority. This collector owns tree traversal. A block's + * {@link LytBlock#computePrimitives} must NOT iterate {@link LytNode#getChildren()} + * -- doing so double-renders the subtree. Blocks with private rendering data + * (e.g. MermaidCanvas) may call {@link #collectFrom} on their internal subtrees + * from within {@code computePrimitives}, typically after {@link #pushTransform}. + * + *

          + * Per-block order (in {@link #collectFrom}): + *

            + *
          1. the block's own primitives via {@link LytBlock#computePrimitives}
          2. + *
          3. {@link LytBlock#getChildrenClipRect()} → PushScissor (children only)
          4. + *
          5. children, in order
          6. + *
          7. PopScissor
          8. + *
          9. {@link LytBlock#emitDecorations} -- outside the children scissor, matching + * the legacy {@code LytBox.render} order (background → children → border)
          10. + *
          + * + *

          + * Legacy fallback. Blocks whose {@link LytBlock#usePrimitives()} returns + * false are rendered through a {@link GuideRenderPrimitive.HostDraw} primitive + * that invokes their legacy {@link LytBlock#render} for the whole subtree; the + * collector does not recurse into their children (the legacy path renders them + * itself). + * + *

          + * Culling. The viewport passed to the constructor is in screen GUI + * coordinates -- the same space the transform stack maps into + * ({@code screen = doc * scale + t}). A block whose transformed bounds lie + * outside the viewport is skipped together with its whole subtree. + */ +public class PrimitiveCollector { + + private final List primitives = new ArrayList<>(); + /** Viewport in screen GUI coordinates, used for culling. */ + private final LytRect viewport; + private final RenderContext legacyContext; + private final Deque cullStack = new ArrayDeque<>(); + /** + * Document-space bounds of blocks culled during the last traversal, + * recorded only when the layout overlay is enabled (diagnostics). + */ + private final List culledDocRects = new ArrayList<>(); + + public PrimitiveCollector(LytRect viewport, RenderContext legacyContext) { + this.viewport = viewport; + this.legacyContext = legacyContext; + cullStack.push(new CullFrame(0, 0, 1.0f)); + } + + // Transform stack (culling and primitive emission). + + private record CullFrame(int dx, int dy, float scale) { + + int x(int localX) { + return dx + Math.round(localX * scale); + } + + int y(int localY) { + return dy + Math.round(localY * scale); + } + + int w(int localW) { + return Math.max(1, Math.round(localW * scale)); + } + + int h(int localH) { + return Math.max(1, Math.round(localH * scale)); + } + } + + /** + * Push a translate + uniform scale onto both the culling stack and the + * primitive list. The new frame maps {@code screen = doc * scale + (dx, dy)} + * and composes with its parent, mirroring GuideRenderEngine's transform stack. + */ + public void pushTransform(float dx, float dy, float scale) { + CullFrame top = cullStack.peek(); + cullStack.push( + new CullFrame(top.dx + Math.round(dx * top.scale), top.dy + Math.round(dy * top.scale), top.scale * scale)); + primitives.add(new GuideRenderPrimitive.PushTransform(dx, dy, scale)); + } + + /** + * Pop the last pushed transform. Only emits PopTransform when a frame was + * actually popped, keeping the engine's transform stack in sync. + */ + public void popTransform() { + if (cullStack.size() > 1) { + cullStack.pop(); + primitives.add(new GuideRenderPrimitive.PopTransform()); + } + } + + // Primitive emission. + + public void emit(GuideRenderPrimitive p) { + primitives.add(p); + } + + /** + * Emit a legacy-render fallback for {@code block}: the engine will invoke + * {@link LytBlock#render} with a GL modelview that maps document coordinates + * to screen, so unmigrated blocks render exactly as before. + *

          + * Diagnostics (-Dguidenh.layoutOverlay=true): before the legacy render, paint + * a magenta marker inset from the block's bounds and log class/bounds/scissor. + * If the marker shows but the block doesn't, the bug is inside the block's + * legacy render; if the marker also fails, the HostDraw GL/context setup is broken. + *

          + * This callback is the only entry into legacy block rendering from the document + * pipeline, so a failing block is contained here: the failure is logged with the + * block type and bounds, and the remaining primitives still execute. The engine's + * HostDraw teardown ({@code GuideRenderEngine#drawHostDraw}) restores GL state and + * the legacy context afterwards either way. + */ + public void emitLegacy(LytBlock block) { + primitives.add(new GuideRenderPrimitive.HostDraw(legacyContext, () -> { + if (GuideDebugLog.isLayoutOverlayEnabled()) { + var b = block.getBounds(); + GuideDebugLog.warnAlways( + "[TRC] legacy render {} bounds={} ctxScissor={}", + block.getClass() + .getSimpleName(), + b, + legacyContext.currentScissor()); + if (b != null && b.width() > 4 && b.height() > 4) { + legacyContext.fillRect(b.x() + 2, b.y() + 2, b.width() - 4, b.height() - 4, 0xFFFF00FF); + } + } + try { + block.render(legacyContext); + } catch (Throwable throwable) { + // A malformed or not-yet-ready interactive scene must not abort the + // whole document render. Continue with later blocks and retain + // enough context to diagnose the offending block from debug.log. + GuideDebugLog.error( + "[PrimitiveCollector] Failed to render block type={} bounds={}", + block.getClass() + .getName(), + block.getBounds(), + throwable); + } + })); + } + + /** + * Emit a PushScissor in document coordinates. + * The render engine converts to screen coordinates via its transform stack. + */ + public void pushScissor(int x, int y, int w, int h) { + primitives.add(new GuideRenderPrimitive.PushScissor(x, y, w, h)); + } + + /** Emit a PopScissor primitive. */ + public void popScissor() { + primitives.add(new GuideRenderPrimitive.PopScissor()); + } + + /** + * Emit a scissor rectangle in screen GUI coordinates. + * The render engine intersects it with the current scissor and applies the + * display scale factor directly -- no transform-stack conversion is applied. + * This is intended for the fixed viewport clip, which must stay fixed + * regardless of scroll offset or zoom (a document-space rect cannot express + * it exactly when zoom != 1). + */ + public void pushScreenScissor(int x, int y, int w, int h) { + primitives.add(new GuideRenderPrimitive.PushScreenScissor(x, y, w, h)); + } + + /** Emit a PopScreenScissor primitive. */ + public void popScreenScissor() { + primitives.add(new GuideRenderPrimitive.PopScreenScissor()); + } + + // Culling against the screen-space viewport. + + /** + * Returns {@code true} when {@code localBounds} (in the current transform + * frame) lies completely outside the screen-space viewport. + */ + public boolean isCulled(LytRect localBounds) { + CullFrame tx = cullStack.peek(); + int x = tx.x(localBounds.x()); + int y = tx.y(localBounds.y()); + int r = x + tx.w(localBounds.width()); + int b = y + tx.h(localBounds.height()); + return r < viewport.x() || b < viewport.y() || x > viewport.right() || y > viewport.bottom(); + } + + public LytRect getViewport() { + return viewport; + } + + // Tree traversal. + + /** + * Recursively collect primitives from {@code root} and its + * {@link LytNode#getChildren() descendants}. See the class javadoc for the + * traversal contract. + * + *

          + * Callers that need a non-identity transform should push it + * before calling this method (e.g. MermaidCanvas pushing an ELK + * position offset for each content block). + */ + public void collectFrom(LytNode root) { + LytRect clipRect = null; + boolean doClip = false; + if (root instanceof LytBlock block) { + var b = block.getCullBounds(); + boolean culled = isCulled(b); + if (culled) { + if (GuideDebugLog.isLayoutOverlayEnabled()) { + if (b != null) culledDocRects.add(b); + GuideDebugLog.warnAlways( + "[TRC] culled {} bounds={} viewport={}", + block.getClass() + .getSimpleName(), + b, + viewport); + } + return; + } + if (!block.usePrimitives()) { + // Legacy fallback: the block renders its whole subtree through + // the old RenderContext path; do not recurse (double render). + if (GuideDebugLog.isLayoutOverlayEnabled()) { + GuideDebugLog.warnAlways( + "[TRC] emitLegacy {} bounds={}", + block.getClass() + .getSimpleName(), + b); + } + emitLegacy(block); + return; + } + block.computePrimitives(this); + if (GuideDebugLog.isLayoutOverlayEnabled()) { + GuideDebugLog.warnAlways( + "[TRC] primitives {} bounds={}", + block.getClass() + .getSimpleName(), + b); + } + clipRect = block.getChildrenClipRect(); + doClip = clipRect != null; + } + // Framework-managed scissor around children traversal + if (doClip) { + pushScissor(clipRect.x(), clipRect.y(), clipRect.width(), clipRect.height()); + } + for (LytNode child : root.getChildren()) { + collectFrom(child); + } + if (doClip) { + popScissor(); + } + if (root instanceof LytBlock block) { + block.emitDecorations(this); + } + } + + // Collected result. + + public List result() { + return List.copyOf(primitives); + } + + /** Document-space bounds of blocks culled during traversal (diagnostics). */ + public List getCulledDocRects() { + return culledDocRects; + } + + /** Clear all collected primitives and reset the cull stack. */ + public void clear() { + primitives.clear(); + culledDocRects.clear(); + cullStack.clear(); + cullStack.push(new CullFrame(0, 0, 1.0f)); + } +} diff --git a/src/main/java/com/hfstudio/guidenh/guide/render/RenderContext.java b/src/main/java/com/hfstudio/guidenh/guide/render/RenderContext.java index d8e29b28..b7d714e9 100644 --- a/src/main/java/com/hfstudio/guidenh/guide/render/RenderContext.java +++ b/src/main/java/com/hfstudio/guidenh/guide/render/RenderContext.java @@ -27,6 +27,32 @@ default int getScrollOffsetY() { return 0; } + /** + * Precise (unrounded) scroll offset in document units. Used by the render + * engine to build the root transform; defaults to the integer scroll offset. + */ + default float getPreciseScrollOffsetY() { + return getScrollOffsetY(); + } + + /** + * Current zoom factor (document -> screen scale). 1.0 means no zoom. + */ + default float getZoom() { + return 1.0f; + } + + /** + * The fixed on-screen viewport rectangle in screen GUI coordinates. Unlike + * {@link #viewport()} (which is in document space and moves with scroll), + * this rect stays fixed regardless of scroll/zoom and is used for the + * outer scissor clip and for primitive culling. Defaults to {@link #viewport()}, + * which is exact when the origin is (0, 0) and zoom is 1. + */ + default LytRect getScreenViewport() { + return viewport(); + } + default LytRect toScreenRect(LytRect rect) { return new LytRect( rect.x() + getDocumentOriginX(), diff --git a/src/main/java/com/hfstudio/guidenh/guide/render/VanillaRenderContext.java b/src/main/java/com/hfstudio/guidenh/guide/render/VanillaRenderContext.java index 24b91da8..0822eeb2 100644 --- a/src/main/java/com/hfstudio/guidenh/guide/render/VanillaRenderContext.java +++ b/src/main/java/com/hfstudio/guidenh/guide/render/VanillaRenderContext.java @@ -36,6 +36,14 @@ public class VanillaRenderContext implements RenderContext { @Setter private LytRect viewport; + /** + * Fixed on-screen viewport rect (screen GUI coordinates), set by the screen + * alongside {@link #viewport}. Used by the primitive pipeline for the outer + * scissor clip and culling; falls back to {@link #viewport} when unset. + */ + @Setter + private LytRect screenViewport; + private final Deque scissorStack = new ArrayDeque<>(); @Getter @@ -105,6 +113,16 @@ public LytRect viewport() { return viewport; } + @Override + public LytRect getScreenViewport() { + return screenViewport != null ? screenViewport : viewport; + } + + @Override + public float getPreciseScrollOffsetY() { + return preciseScrollOffsetY; + } + @Override public int resolveColor(ColorValue ref) { return ref.resolve(); @@ -251,40 +269,76 @@ public void drawText(String text, int x, int y, ResolvedTextStyle style) { if (hasUnderline) { Gui.drawRect(x, decorationY, x + decoratedWidth, decorationY + 1, color); } - if (hasWavyUnderline) { - // Draw a 2px-tall sine-like zig-zag using 1x1 rects: pattern of 4 px period. - for (int i = 0; i < decoratedWidth; i++) { - int phase = i & 3; // 0,1,2,3 - int dy = (phase == 0 || phase == 2) ? 0 : (phase == 1 ? -1 : 1); - Gui.drawRect(x + i, decorationY + dy, x + i + 1, decorationY + dy + 1, color); - } - } - if (hasDottedUnderline) { - // Center a single 2x2 dot under each rendered character cell. - int cursor = 0; - boolean bold = style.bold(); - boolean visibleGlyphSeen = false; - int len = drawn.length(); - for (int i = 0; i < len; i++) { - char c = drawn.charAt(i); - if (GuideFontCompat.isFormattingCodeStart(drawn, i)) { - bold = GuideFontCompat.determineBold(bold, drawn.charAt(i + 1)); - i++; - continue; + // Wavy/dotted coverage-alpha fragments must render even when the caller + // left GL_ALPHA_TEST enabled. The legacy tooltip path + // (GuideScreen.drawContentTooltip) disables only DEPTH_TEST before + // rendering ContentTooltip content, and restoreExternalRenderState() + // re-enables ALPHA_TEST as GL_GREATER 0.1; under that alpha function + // every fragment with alpha <= 25 is clipped, so dot corner pixels + // (alpha ~16) and the wave's weakest rows (alpha < 25) disappear, + // breaking exactly the soft edge this rasterizer produces. Scope the + // draw like beginShapeDraw does. + GL11.glPushAttrib(GL11.GL_ENABLE_BIT); + GL11.glDisable(GL11.GL_ALPHA_TEST); + try { + if (hasWavyUnderline) { + // The old wavy underline was 1x1px with ±1 amplitude, which the + // dark page background swallowed. Use a ±2px, 2px-thick + // 8-phase sine, tinted lighter than the body gray, rasterized by + // the shared DecorationRasterizer with coverage alpha (sub-pixel + // sampling). + int waveColor = brightenDecorationColor(color); + for (DecorationRasterizer.Fragment f : DecorationRasterizer + .rasterize(x, decorationY, decoratedWidth, 4)) { + Gui.drawRect(f.x(), f.y(), f.x() + f.w(), f.y() + f.h(), decorationArgb(f.alpha(), waveColor)); } - float advance = GuideFontCompat.getRenderedAdvance(font(), c, bold, visibleGlyphSeen); - int cw = Math.round(advance * scale); - if (cw <= 0) { - continue; + } + if (hasDottedUnderline) { + // One 2x2 dot per character was too sparse; draw 3px soft-edged + // circular dots on a fixed 4px cadence via the shared + // DecorationRasterizer, tinted lighter than the body gray. + int dotColor = brightenDecorationColor(color); + for (DecorationRasterizer.Fragment f : DecorationRasterizer + .rasterize(x, decorationY, decoratedWidth, 5)) { + Gui.drawRect(f.x(), f.y(), f.x() + f.w(), f.y() + f.h(), decorationArgb(f.alpha(), dotColor)); } - int dotX = x + cursor + Math.max(0, (cw - 2) / 2); - Gui.drawRect(dotX, decorationY, dotX + 2, decorationY + 2, color); - cursor += cw; - visibleGlyphSeen = true; } + } finally { + GL11.glPopAttrib(); } } + /** + * Wavy/dots decorations are thin; a faint tint is swallowed by the page + * background. Blend the text color toward white so the decoration reads + * against the body gray. + */ + private int brightenDecorationColor(int color) { + int a = color & 0xFF000000; + int r = (color >>> 16) & 0xFF; + int g = (color >>> 8) & 0xFF; + int b = color & 0xFF; + r = r + (255 - r) * 3 / 4; + g = g + (255 - g) * 3 / 4; + b = b + (255 - b) * 3 / 4; + return a | (r << 16) | (g << 8) | b; + } + + /** + * Composite a rasterizer coverage alpha with the decoration tint's own + * alpha byte: final = round(coverage × tintAlpha / 255), round-half-up. + * Pure coverage would discard a semi-transparent text color's opacity (the + * tint's alpha byte was replaced, not multiplied); multiplying keeps the + * text color's transparency semantics while the coverage still shapes the + * brush edge. With an opaque tint (alpha 255) the result equals the + * coverage exactly. + */ + private static int decorationArgb(int coverage, int tintArgb) { + int tintAlpha = (tintArgb >>> 24) & 0xFF; + int alpha = (coverage * tintAlpha + 127) / 255; + return (alpha << 24) | (tintArgb & 0xFFFFFF); + } + @Override public int getStringWidth(String text, ResolvedTextStyle style) { return GuideFontCompat.getStringWidth(font(), text, style); diff --git a/src/main/java/com/hfstudio/guidenh/guide/scene/CameraSettings.java b/src/main/java/com/hfstudio/guidenh/guide/scene/CameraSettings.java index 0342ed1c..5200612d 100644 --- a/src/main/java/com/hfstudio/guidenh/guide/scene/CameraSettings.java +++ b/src/main/java/com/hfstudio/guidenh/guide/scene/CameraSettings.java @@ -58,7 +58,7 @@ public void setViewportSize(int width, int height) { public void setPerspectivePreset(PerspectivePreset preset) { switch (preset) { case ISOMETRIC_NORTH_EAST: - setIsometricYawPitchRoll(225, 30, 0); + setIsometricYawPitchRoll(45, 30, 0); break; case ISOMETRIC_NORTH_WEST: setIsometricYawPitchRoll(135, 30, 0); diff --git a/src/main/java/com/hfstudio/guidenh/guide/scene/GuidebookLevelRenderer.java b/src/main/java/com/hfstudio/guidenh/guide/scene/GuidebookLevelRenderer.java index 9bc88a25..864accea 100644 --- a/src/main/java/com/hfstudio/guidenh/guide/scene/GuidebookLevelRenderer.java +++ b/src/main/java/com/hfstudio/guidenh/guide/scene/GuidebookLevelRenderer.java @@ -54,6 +54,18 @@ public class GuidebookLevelRenderer { public static final GuidebookLevelRenderer INSTANCE = new GuidebookLevelRenderer(); public static final int FULL_BRIGHTNESS = 15728880; + /** + * When {@code true}, the {@code enableLightmap}/{@code disableLightmap} pair is + * skipped during rendering. This is used by headless offscreen rendering because + * Angelica's GL state machine (glsm) multi-texture simulation inside the lightmap + * window zeros fragment outputs. Since blocks already render at full brightness + * ({@link #FULL_BRIGHTNESS}), skipping the lightmap pair is visually lossless. + *

          + * Default {@code false}; set to {@code true} in + * {@link com.hfstudio.guidenh.guide.internal.headless.DocumentOffscreenFramebuffer#renderAll} + * before the tile loop and restored in {@code finally}. + */ + public static boolean skipLightmapForOffscreen = false; public static final ResourceLocation RAIN_TEXTURE = new ResourceLocation("textures/environment/rain.png"); public static final ResourceLocation SNOW_TEXTURE = new ResourceLocation("textures/environment/snow.png"); public static final int WEATHER_RENDER_RADIUS = 10; @@ -254,6 +266,7 @@ public void render(GuidebookLevel level, CameraSettings camera, int panelX, int GL11.glDisable(GL_LIGHTING); GL11.glDisable(GL_BLEND); GL11.glEnable(GL_CULL_FACE); + GL11.glPolygonMode(GL11.GL_FRONT_AND_BACK, GL11.GL_FILL); GL11.glEnable(GL_ALPHA_TEST); GL11.glAlphaFunc(GL11.GL_GREATER, 0.1f); GL11.glEnable(GL_TEXTURE_2D); @@ -285,7 +298,10 @@ public void render(GuidebookLevel level, CameraSettings camera, int panelX, int : GuidebookSceneLayerSelection.all(); boolean renderAllFaces = effectiveSelection.shouldRenderAllFaces(); - mc.entityRenderer.enableLightmap(partialTicks); + boolean lightmapSkipped = skipLightmapForOffscreen; + if (!lightmapSkipped) { + mc.entityRenderer.enableLightmap(partialTicks); + } try { setRenderPass(0); GL11.glDisable(GL_BLEND); @@ -330,7 +346,9 @@ public void render(GuidebookLevel level, CameraSettings camera, int panelX, int } } } finally { - mc.entityRenderer.disableLightmap(partialTicks); + if (!lightmapSkipped) { + mc.entityRenderer.disableLightmap(partialTicks); + } } } catch (Throwable t) { log(t); @@ -434,13 +452,19 @@ private void renderBlocksPass(GuidebookLevel level, Iterable filledBlocks boolean filteredLayerMode = renderAllFaces; for (int[] p : filledBlocks) { Block block = level.getBlock(p[0], p[1], p[2]); - if (block == null) continue; + if (block == null) { + continue; + } + // The opaque pass records which blocks also need the translucent pass so the + // deferred pass iterates only those instead of the full visible set. boolean rendersInTranslucentPass = block.canRenderInPass(1); if (translucentBlocks != null && rendersInTranslucentPass) { translucentBlocks.add(p); } boolean rendersInCurrentPass = pass == 1 ? rendersInTranslucentPass : block.canRenderInPass(0); - if (!rendersInCurrentPass) continue; + if (!rendersInCurrentPass) { + continue; + } try { TileEntity tileEntity = level.getTileEntity(p[0], p[1], p[2]); // Promotion and GregTech repair mutate the level and only need to happen @@ -901,7 +925,8 @@ private GuidebookSceneWeatherRenderColumn createWeatherRenderColumn(GuidebookLev GuidebookSceneLayerSelection layerSelection, int[] bounds, GuidebookSceneWeatherType weatherType, int x, int z, float alpha, float centerY, int pooledColumnIndex) { int precipitationBottom = Math.max(bounds[1], level.getPrecipitationHeight(x, z, bounds[1], bounds[4])); - int precipitationTop = bounds[4] + 1; + int headroom = (int) Math.ceil(GuidebookSceneWeatherSupport.resolveSpawnHeadroom(weatherType)); + int precipitationTop = Math.max(bounds[4] + 1, precipitationBottom + headroom); if (precipitationBottom > precipitationTop) { return null; } diff --git a/src/main/java/com/hfstudio/guidenh/guide/scene/GuidebookSceneWeatherSupport.java b/src/main/java/com/hfstudio/guidenh/guide/scene/GuidebookSceneWeatherSupport.java index 269be834..5d31d5c2 100644 --- a/src/main/java/com/hfstudio/guidenh/guide/scene/GuidebookSceneWeatherSupport.java +++ b/src/main/java/com/hfstudio/guidenh/guide/scene/GuidebookSceneWeatherSupport.java @@ -25,8 +25,8 @@ public class GuidebookSceneWeatherSupport { public static final float SNOW_DRIFT = 0.01f; public static final float WEATHER_SURFACE_OFFSET = 0.02f; public static final float SNOW_SURFACE_OFFSET = 0.0f; - public static final float RAIN_SPAWN_HEADROOM = 0.0f; - public static final float SNOW_SPAWN_HEADROOM = 0.0f; + public static final float RAIN_SPAWN_HEADROOM = 4.0f; + public static final float SNOW_SPAWN_HEADROOM = 4.0f; public static final float RAIN_LIGHT_RADIUS_SCALE = 0.5f; public static final float SNOW_LIGHT_RADIUS_SCALE = 0.3f; public static final float RAIN_ALPHA_RADIUS_SCALE = 0.5f; diff --git a/src/main/java/com/hfstudio/guidenh/guide/scene/LytGuidebookScene.java b/src/main/java/com/hfstudio/guidenh/guide/scene/LytGuidebookScene.java index e96da659..326f9c25 100644 --- a/src/main/java/com/hfstudio/guidenh/guide/scene/LytGuidebookScene.java +++ b/src/main/java/com/hfstudio/guidenh/guide/scene/LytGuidebookScene.java @@ -48,7 +48,9 @@ import com.hfstudio.guidenh.config.ModConfig; import com.hfstudio.guidenh.guide.color.ColorUtils; import com.hfstudio.guidenh.guide.color.ConstantColor; +import com.hfstudio.guidenh.guide.compiler.PageCompiler; import com.hfstudio.guidenh.guide.document.DefaultStyles; +import com.hfstudio.guidenh.guide.document.LytErrorSink; import com.hfstudio.guidenh.guide.document.LytRect; import com.hfstudio.guidenh.guide.document.LytSize; import com.hfstudio.guidenh.guide.document.block.LytBlock; @@ -66,7 +68,10 @@ import com.hfstudio.guidenh.guide.internal.util.DisplayScale; import com.hfstudio.guidenh.guide.internal.util.SmoothFloatState; import com.hfstudio.guidenh.guide.layout.LayoutContext; +import com.hfstudio.guidenh.guide.render.GuideText; +import com.hfstudio.guidenh.guide.render.PrimitiveCollector; import com.hfstudio.guidenh.guide.render.RenderContext; +import com.hfstudio.guidenh.guide.render.VanillaRenderContext; import com.hfstudio.guidenh.guide.scene.annotation.DiamondAnnotation; import com.hfstudio.guidenh.guide.scene.annotation.InWorldAnnotation; import com.hfstudio.guidenh.guide.scene.annotation.InWorldBlockFaceOverlayAnnotation; @@ -119,6 +124,7 @@ import com.hfstudio.guidenh.integration.structurelib.StructureLibPreviewSelection; import com.hfstudio.guidenh.integration.structurelib.StructureLibSceneMetadata; import com.hfstudio.guidenh.integration.structurelib.StructureLibTooltipContentBuilder; +import com.hfstudio.guidenh.libs.unist.UnistNode; import it.unimi.dsi.fastutil.ints.IntOpenHashSet; import it.unimi.dsi.fastutil.ints.IntSet; @@ -159,9 +165,27 @@ public boolean overlaps(int otherStartTick, int otherEndTickExclusive) { public static final int ORIGIN_Z_AXIS_COLOR = ColorUtils.Z_AXIS.getColor(); public static final ResolvedTextStyle VISIBLE_LAYER_SLIDER_TEXT_STYLE = DefaultStyles.BODY_TEXT .mergeWith(DefaultStyles.BASE_STYLE); - public static final ResolvedTextStyle STRUCTURELIB_TIER_SLIDER_TEXT_STYLE = DefaultStyles.BODY_TEXT + /** + * GuideText glyph-pipeline styles for the 3 bottom-control slider labels + * Dedicated constants - {@link #VISIBLE_LAYER_SLIDER_TEXT_STYLE} + * is still shared with the legacy loading-status text and must not change. + * fontScale 0.8 → line height round(17 × 0.8) = 14 = {@link #SCENE_SLIDER_AREA_HEIGHT}, + * so the emitted text fits its row exactly (cap height ≈ 9px, matching the + * legacy MC pixel font's footprint). + */ + public static final ResolvedTextStyle VISIBLE_LAYER_SLIDER_LABEL_TEXT_STYLE = DefaultStyles.BODY_TEXT.toBuilder() + .fontScale(0.8f) + .build() .mergeWith(DefaultStyles.BASE_STYLE); - public static final ResolvedTextStyle STRUCTURELIB_CHANNEL_SLIDER_TEXT_STYLE = DefaultStyles.BODY_TEXT + public static final ResolvedTextStyle STRUCTURELIB_TIER_SLIDER_LABEL_TEXT_STYLE = DefaultStyles.BODY_TEXT + .toBuilder() + .fontScale(0.8f) + .build() + .mergeWith(DefaultStyles.BASE_STYLE); + public static final ResolvedTextStyle STRUCTURELIB_CHANNEL_SLIDER_LABEL_TEXT_STYLE = DefaultStyles.BODY_TEXT + .toBuilder() + .fontScale(0.8f) + .build() .mergeWith(DefaultStyles.BASE_STYLE); public static final ResolvedTextStyle BLOCK_STATS_TEXT_STYLE = DefaultStyles.BODY_TEXT .mergeWith(DefaultStyles.BASE_STYLE); @@ -293,6 +317,14 @@ public boolean overlaps(int otherStartTick, int otherEndTickExclusive) { private float loadProgress; private String loadStatusText = ""; private int loadStatusColor = ColorUtils.WHITE.getColor(); + /** + * Scene-level error message surfaced from the block-build pipeline (structure format + * unsupported, zero placeable blocks, etc.). Unlike {@link #loadStatusText} this renders + * unconditionally (no loading-state debounce) so a failed build can never silently show a + * blank background. Cleared by {@link #clearBuildError()}. + */ + @Nullable + private String sceneBuildError; @Getter @Setter private boolean reserveBottomControlArea = true; @@ -455,6 +487,7 @@ public boolean overlaps(int otherStartTick, int otherEndTickExclusive) { private long structureLibControlMetadataRefreshGeneration = -1L; private final LinkedHashMap bindingFootprints = new LinkedHashMap<>(); private final List snbtPlacements = new ArrayList<>(); + private final List deferredBlockMutations = new ArrayList<>(); @Nullable private GuideSceneStructureSnapshot initialLevelSnapshot; private final List selectableStructureLibChannels = new ArrayList<>(); @@ -1322,7 +1355,7 @@ private void bindPrimaryStructureLibState(@Nullable StructureLibSceneBinding bin */ public boolean refreshStructureLibControlMetadata() { StructureLibDefinitionCache definitions = StructureLibDefinitionCache.getInstance(); - if (!definitions.isScansComplete()) { + if (!definitions.areScansComplete()) { return false; } long generation = definitions.getScanGeneration(); @@ -1404,10 +1437,16 @@ public StructureLibImportResult getStructureLibImportResult(@Nullable String nam return binding != null ? binding.getLastSuccessfulImportResult() : null; } + // SNBT placements registered by the ImportStructure element compiler. + public void addSnbtPlacement(SnbtPlacement placement) { snbtPlacements.add(placement); } + public void addDeferredBlockMutation(Runnable mutation) { + deferredBlockMutations.add(mutation); + } + public void setSnbtPlacements(List placements) { snbtPlacements.clear(); snbtPlacements.addAll(placements); @@ -1421,6 +1460,8 @@ public void clearSnbtPlacements() { snbtPlacements.clear(); } + // Unified build, clear and rebuild entry points. + /** * Build all blocks in the scene from registered SNBT placements and StructureLib bindings. * Called after element compilers have registered their configs, @@ -1430,6 +1471,18 @@ public void build() { GuidebookLevel sceneLevel = getLevel(); if (sceneLevel == null) return; + clearBuildError(); + LytErrorSink buildErrorSink = new LytErrorSink() { + + @Override + public void appendError(PageCompiler compiler, String text, UnistNode node) { + if (sceneBuildError == null) { + setBuildError(text); + } + GuideDebugLog.warnAlways("[GuideNH] [Scene] Structure build: {}", text); + } + }; + // Phase 1: SNBT static placements (ImportStructure) for (SnbtPlacement p : snbtPlacements) { // The compiler parsed this when it registered the placement, so a rebuild uses that rather than @@ -1450,7 +1503,7 @@ public void build() { p.getOffsetZ(), p.isFormed(), null, - null, + buildErrorSink, p.getSrc()); } @@ -1490,6 +1543,12 @@ public void build() { metadata.offsetBlockTooltips(offsetX, offsetY, offsetZ)); } } + + // Phase 3: deferred block mutations (ReplaceBlock / RemoveBlocks) + for (Runnable mutation : deferredBlockMutations) { + mutation.run(); + } + deferredBlockMutations.clear(); } /** @@ -1773,6 +1832,19 @@ public void clearSoundCues() { soundCues.clear(); } + /** + * Whether the scene carries any content mountable by {@code SceneScript}: a non-empty level, + * sound cues (PlaySound), static particles, static weather effects, or annotations. + * Aligned with the render ({@code LytGuidebookScene} render path) and export + * ({@code GameSceneExportRunner}) treatment of "empty" scenes. + */ + public boolean hasMountableSceneContent() { + return !level.isEmpty() || !soundCues.isEmpty() + || !staticSceneParticles.isEmpty() + || !staticWeatherEffects.isEmpty() + || !annotations.isEmpty(); + } + public boolean isLoading() { return isLoading && (System.nanoTime() - loadingRequestedAt) / 1_000_000L >= LOADING_DEBOUNCE_MS; } @@ -1824,6 +1896,24 @@ public void clearLoadState() { this.loadStatusColor = ColorUtils.WHITE.getColor(); } + /** + * Records a build-time error on the scene (structure format unsupported, zero placeable + * blocks, etc.). The scene renders it as visible red text instead of a silent background. + */ + public void setBuildError(@Nullable String message) { + this.sceneBuildError = message != null && !message.trim() + .isEmpty() ? message.trim() : null; + } + + @Nullable + public String getBuildError() { + return sceneBuildError; + } + + public void clearBuildError() { + this.sceneBuildError = null; + } + public void setBottomControlsVisible(boolean bottomControlsVisible) { int previousBottomControlGeometryHash = bottomControlGeometryHash(); this.bottomControlsVisible = bottomControlsVisible; @@ -2336,6 +2426,34 @@ public int getSceneButtonColumnReserveForExport() { return buttonColumnReserve(); } + public int getButtonsTotalHeightForExport() { + if (!interactive || !sceneButtonsVisible) { + return 0; + } + int buttonCount = cachedSceneButtonRoles().length; + return BTN_SIZE * buttonCount + BTN_GAP * Math.max(0, buttonCount - 1); + } + + /** Pre-clamping left dock size including BLOCK_STATS_DOCK_GAP, or 0. */ + public int getLeftDockForExport() { + return blockStatsDock == BlockStatsDock.LEFT ? blockStatsDockLengthForLayout(false) + BLOCK_STATS_DOCK_GAP : 0; + } + + /** Pre-clamping right dock size including BLOCK_STATS_DOCK_GAP, or 0. */ + public int getRightDockForExport() { + return blockStatsDock == BlockStatsDock.RIGHT ? blockStatsDockLengthForLayout(false) + BLOCK_STATS_DOCK_GAP : 0; + } + + /** Pre-clamping top dock size including BLOCK_STATS_DOCK_GAP, or 0. */ + public int getTopDockForExport() { + return blockStatsDock == BlockStatsDock.TOP ? blockStatsDockHeightForLayout() + BLOCK_STATS_DOCK_GAP : 0; + } + + /** Pre-clamping bottom dock size including BLOCK_STATS_DOCK_GAP, or 0. */ + public int getBottomDockForExport() { + return blockStatsDock == BlockStatsDock.BOTTOM ? blockStatsDockHeightForLayout() + BLOCK_STATS_DOCK_GAP : 0; + } + private int blockStatsDockLengthForLayout(boolean horizontal) { if (!blockStatsEnabled || !blockStatsVisible || blockStatsMode == BlockStatsMode.MANUAL @@ -2484,19 +2602,22 @@ protected void onLayoutMoved(int deltaX, int deltaY) {} @Override public void render(RenderContext context) { - int sceneW = layoutSceneWidth > 0 ? layoutSceneWidth - : getBounds().width() - buttonColumnReserve() - layoutSceneOffsetX; - if (sceneW < 16) sceneW = 16; - int sliderAreaHeight = getBottomControlAreaHeight(); - int sceneH = layoutSceneHeight > 0 ? layoutSceneHeight : Math.max(16, getBounds().height() - sliderAreaHeight); - int totalH = reserveBottomControlArea ? Math.max(sceneH + sliderAreaHeight, getBounds().height()) - : Math.max(sceneH, getBounds().height()); - LytRect outerRect = cachedOuterRect = updateCachedRect( - cachedOuterRect, - getBounds().x() + layoutSceneOffsetX, - getBounds().y() + layoutSceneOffsetY, - sceneW, - sceneH + (reserveBottomControlArea ? sliderAreaHeight : 0)); + // Hybrid mode: when this frame's collection claimed the slider labels / + // Block Stats name+count / load-state status text for the GuideText + // pipeline (computePrimitives emitted them after the HostDraw), suppress + // the legacy drawText for them. Consume immediately so a direct render() + // (scene editor / legacy subtree) never inherits a stale claim from an + // earlier pipeline frame. + boolean suppressLegacyText = this.suppressLegacyText; + this.suppressLegacyText = false; + boolean suppressBlockStatsText = this.suppressBlockStatsText; + this.suppressBlockStatsText = false; + boolean suppressLoadStateText = this.suppressLoadStateText; + this.suppressLoadStateText = false; + LytRect outerRect = resolveSceneOuterRect(); + // Scene content height = outerRect minus the bottom-control band (when reserved). + int sceneH = outerRect.height() - (reserveBottomControlArea ? getBottomControlAreaHeight() : 0); + int sceneW = outerRect.width(); LytRect sceneRect = cachedSceneRect = updateCachedRect( cachedSceneRect, outerRect.x(), @@ -2545,9 +2666,10 @@ public void render(RenderContext context) { this.lastOuterH = outerH; this.cachedScreenRect = updateCachedRect(cachedScreenRect, absX, absY, w, h); renderSceneBackground(context, sceneRect); - drawLoadProgressOverlay(context, sceneRect); - drawBottomControls(context, outerRect); - drawBlockStatsOverlay(context, sceneRect, outerRect); + drawLoadProgressOverlay(context, sceneRect, suppressLoadStateText); + drawSceneBuildError(context, sceneRect); + drawBottomControls(context, outerRect, suppressLegacyText); + drawBlockStatsOverlay(context, sceneRect, outerRect, suppressBlockStatsText); renderSceneBorder(context, sceneRect); if (interactive && sceneButtonsVisible) { drawSceneButtons(context, sceneRect, screenRect); @@ -2556,7 +2678,8 @@ public void render(RenderContext context) { } renderSceneBackground(context, sceneRect); - drawLoadProgressOverlay(context, sceneRect); + drawLoadProgressOverlay(context, sceneRect, suppressLoadStateText); + drawSceneBuildError(context, sceneRect); this.renderedContentClip = updateCachedRect(this.renderedContentClip, clipX, clipY, clipW, clipH); LytSize camOverride = cameraViewportOverride; @@ -2695,11 +2818,29 @@ else if (pa instanceof OverlayAnnotation ov) { resolveWeatherAnimationTick()); context.restoreExternalRenderState(); - drawBlockStatsOverlay(context, sceneRect, outerRect); + drawBlockStatsOverlay(context, sceneRect, outerRect, suppressBlockStatsText); context.restoreExternalRenderState(); if (!overlays.isEmpty()) { - LytRect viewport = cachedOverlayViewport = updateCachedRect(cachedOverlayViewport, absX, absY, w, h); + float zoom = 1.0f; + if (context instanceof VanillaRenderContext vrc) { + zoom = vrc.getZoom(); + } + LytRect viewport; + if (zoom != 1.0f) { + int docAbsX = Math.round(absX / zoom); + int docAbsY = Math.round(absY / zoom); + int docW = Math.max(1, Math.round(w / zoom)); + int docH = Math.max(1, Math.round(h / zoom)); + viewport = cachedOverlayViewport = updateCachedRect( + cachedOverlayViewport, + docAbsX, + docAbsY, + docW, + docH); + } else { + viewport = cachedOverlayViewport = updateCachedRect(cachedOverlayViewport, absX, absY, w, h); + } context.pushLocalScissor(sceneRect); try { for (var o : overlays) { @@ -2713,7 +2854,7 @@ else if (pa instanceof OverlayAnnotation ov) { applyCapturedCameraState(savedCameraState); } - drawBottomControls(context, outerRect); + drawBottomControls(context, outerRect, suppressLegacyText); // Draw border AFTER the 3D content so border pixels always sit on top. renderSceneBorder(context, sceneRect); @@ -2723,6 +2864,175 @@ else if (pa instanceof OverlayAnnotation ov) { } } + // Primitive pipeline opt-in (hybrid rendering mode). + + /** + * The scene is a leaf block (no {@link #getChildren()}), so there is no + * double-traversal risk from opting into {@code computePrimitives}. + */ + @Override + public boolean usePrimitives() { + return true; + } + + /** + * Hybrid mode: the entire legacy 3D scene rendering (background, + * border, buttons, slider tracks, Block Stats box, scrollbars) is kept as a + * single {@code HostDraw} emitted by {@link PrimitiveCollector#emitLegacy}; + * the 3 bottom-control slider labels, the Block Stats name/count + * runs and the load-state status line are emitted as + * GuideText glyph runs after it (painter order: text sits on + * top of the scene). + *

          + * Hard constraint: text must be emitted during the collection phase, + * because {@code render()} runs later, inside the HostDraw at execute + * time, where emitText would be dropped by the snapshot-then-execute pipeline + * (LytDocument takes {@code pc.result()} before {@code engine.execute}). + *

          + * {@code suppressLegacyText} / {@code suppressBlockStatsText} / + * {@code suppressLoadStateText} are claimed here and consumed at the top of + * {@link #render}, so the legacy path skips the migrated text while the + * direct render path (scene editor / legacy subtree) still draws it with the + * MC font. + */ + @Override + public void computePrimitives(PrimitiveCollector c) { + suppressLegacyText = true; + suppressBlockStatsText = true; + suppressLoadStateText = true; + c.emitLegacy(this); + + if (hasBottomControls() && !isPonderPlaying()) { + LytRect outerRect = resolveSceneOuterRect(); + if (hasStructureLibTierSlider()) { + emitSliderLabel( + c, + getStructureLibTierSliderLabel(), + STRUCTURELIB_TIER_SLIDER_LABEL_TEXT_STYLE, + outerRect, + resolveStructureLibTierRowIndex()); + } + if (hasVisibleLayerSlider()) { + emitSliderLabel( + c, + getVisibleLayerSliderLabel(), + VISIBLE_LAYER_SLIDER_LABEL_TEXT_STYLE, + outerRect, + resolveVisibleLayerRowIndex()); + } + for (StructureLibSceneMetadata.ChannelData channelData : getBottomControlStructureLibChannels()) { + emitSliderLabel( + c, + getStructureLibChannelSliderLabel(channelData), + STRUCTURELIB_CHANNEL_SLIDER_LABEL_TEXT_STYLE, + outerRect, + resolveStructureLibChannelRowIndex(channelData.getChannelId())); + } + } + emitBlockStatsText(c); + emitLoadStateText(c); + } + + /** + * Emit one slider label as a GuideText glyph run at its document position. + * Mirrors the legacy render gate: the label is only emitted when the slider + * track actually renders (non-empty layout-space track rect). + */ + private void emitSliderLabel(PrimitiveCollector c, String label, ResolvedTextStyle style, LytRect outerRect, + int rowIndex) { + if (label == null || label.isEmpty()) { + return; + } + LytRect renderTrackRect = resolveSliderTrackLayoutRect( + outerRect.x(), + outerRect.y(), + outerRect.width(), + outerRect.height(), + rowIndex); + if (renderTrackRect.isEmpty()) { + return; + } + SliderLabelGeometry g = computeSliderLabelGeometry(label, style, outerRect, rowIndex); + GuideText.emitText(c, label, g.textX(), g.textY(), style); + } + + /** + * Emit the Block Stats name/count runs as GuideText glyph runs at + * collect time (collection phase - see {@link #computePrimitives}). The + * overlay box, item icons and scrollbars stay in the HostDraw legacy + * render; only the text moves to the engine vector font. Geometry comes + * from the same context-free layout the render uses + * ({@link #layoutBlockStatsOverlay}), so the emitted text sits exactly + * where the legacy MC-font text would have been drawn. + */ + private void emitBlockStatsText(PrimitiveCollector c) { + if (!blockStatsEnabled || !blockStatsVisible) { + return; + } + LytRect outerRect = resolveSceneOuterRect(); + LytRect sceneRect = resolveSceneContentRect(outerRect); + if (sceneRect == null || sceneRect.isEmpty()) { + return; + } + BlockStatsOverlayLayout layout = layoutBlockStatsOverlay(sceneRect, outerRect); + if (layout == null) { + return; + } + // Mirror the legacy render's scissor (pushLocalScissor(viewport)) so + // partially-visible rows and long names are clipped to the box exactly + // as the MC-font text was. + LytRect viewport = layout.viewport(); + c.pushScissor(viewport.x(), viewport.y(), viewport.width(), viewport.height()); + try { + forEachBlockStatsRow( + layout, + (entry, itemX, rowY, textX, rowHeight, textOffsetY, itemOffsetY) -> forEachBlockStatsEntryText( + entry, + itemX, + rowY, + textX, + rowHeight, + textOffsetY, + itemOffsetY, + (text, x, y, style) -> GuideText.emitText(c, text, x, y, style))); + } finally { + c.popScissor(); + } + } + + /** + * Document-space rect for the scene plus its bottom-control band. Mirrors + * the computation previously inlined at the top of {@link #render}; extracted + * so {@link #computePrimitives} (collection phase) derives exactly the same + * outerRect the legacy render path uses. Frame-invariant: it only reads + * layout fields and {@link #getBounds()}, so the returned record is reused + * while the geometry is unchanged. + */ + private LytRect resolveSceneOuterRect() { + int sceneW = layoutSceneWidth > 0 ? layoutSceneWidth + : getBounds().width() - buttonColumnReserve() - layoutSceneOffsetX; + if (sceneW < 16) sceneW = 16; + int sliderAreaHeight = getBottomControlAreaHeight(); + int sceneH = layoutSceneHeight > 0 ? layoutSceneHeight : Math.max(16, getBounds().height() - sliderAreaHeight); + return cachedOuterRect = updateCachedRect( + cachedOuterRect, + getBounds().x() + layoutSceneOffsetX, + getBounds().y() + layoutSceneOffsetY, + sceneW, + sceneH + (reserveBottomControlArea ? sliderAreaHeight : 0)); + } + + /** + * Document-space rect for the scene content area (outerRect minus the + * bottom-control band), mirroring the computation at the top of + * {@link #render}. Frame-invariant (reads only layout fields), so the + * collect phase ({@link #computePrimitives}) and the legacy render agree. + */ + private LytRect resolveSceneContentRect(LytRect outerRect) { + int sceneH = outerRect.height() - (reserveBottomControlArea ? getBottomControlAreaHeight() : 0); + return new LytRect(outerRect.x(), outerRect.y(), outerRect.width(), sceneH); + } + private void renderSceneBackground(RenderContext context, LytRect sceneRect) { if (!showBackground) { return; @@ -2761,6 +3071,37 @@ private SceneFloorGridAnnotation getOrCreateFloorGridAnnotation(int[] bounds) { private int lastAbsX, lastAbsY, lastW, lastH; private float lastDocZoom = 1.0f; + /** + * Hybrid claim flag: set by {@link #computePrimitives} (collection + * phase) when the 3 bottom-control slider labels are emitted as GuideText + * primitives right after the legacy HostDraw. {@link #render} runs later, + * inside the HostDraw at execute time, and must then skip its own legacy + * drawText for those labels. The flag is read and consumed at the top of + * {@link #render} so a direct render() call (scene editor preview / legacy + * subtree fallback, where computePrimitives never ran) still draws the + * labels with the MC font as before. + */ + private boolean suppressLegacyText; + /** + * Block Stats hybrid claim flag, same lifecycle as {@link #suppressLegacyText}: + * set by {@link #computePrimitives} (collection phase) when the Block Stats + * name/count text is emitted as GuideText primitives right after the legacy + * HostDraw. {@link #render} runs later, inside the HostDraw at execute + * time, and must then skip its own legacy drawText for that text. The flag + * is read and consumed at the top of {@link #render} so a direct render() + * call (scene editor preview / legacy subtree fallback, where + * computePrimitives never ran) still draws the text with the MC font as + * before. + */ + private boolean suppressBlockStatsText; + /** + * Load-state hybrid claim flag, same lifecycle as {@link #suppressLegacyText}: + * set by {@link #computePrimitives} when the load-state status line is + * emitted as a GuideText primitive; consumed at the top of {@link #render} + * so the legacy HostDraw skips it while the direct render path still draws + * it with the MC font. + */ + private boolean suppressLoadStateText; private int lastOuterAbsX, lastOuterAbsY, lastOuterW, lastOuterH; /** Width reserved for the inner 3D scene (bounds.width minus the button column). */ private int layoutSceneWidth; @@ -2847,7 +3188,14 @@ private InWorldBoxFaceOverlayAnnotation getOrCreateBlockStatsHighlightAnnotation return blockStatsHighlightAnnotations.get(index); } - private List getCachedBlockStatsEntries(RenderContext context) { + /** + * Cached Block Stats entries plus their display-width caches. Context-free + * widths come from {@link GuideText#measureWidth} (engine + * vector font, single metric authority) so the collect phase + * ({@link #layoutBlockStatsOverlay}, no RenderContext) and the HostDraw + * render compute identical box/row geometry. + */ + private List getCachedBlockStatsEntries() { if (blockStatsDirty) { rebuildBlockStatsEntries(); } @@ -2857,11 +3205,11 @@ private List getCachedBlockStatsEntries(RenderContext cont for (SceneBlockStatsEntry entry : cachedBlockStatsEntries) { if (entry.getCount() > 1) { String countText = formatBlockStatsDisplayCount(entry.getCount()); - int countWidth = context.getStringWidth(countText, BLOCK_STATS_COUNT_TEXT_STYLE); + int countWidth = GuideText.measureWidth(countText, BLOCK_STATS_COUNT_TEXT_STYLE); cachedBlockStatsItemWidth = Math.max(cachedBlockStatsItemWidth, countWidth + 4); } String text = blockStatsEntryText(entry); - int width = context.getStringWidth(text, BLOCK_STATS_TEXT_STYLE); + int width = GuideText.measureWidth(text, BLOCK_STATS_TEXT_STYLE); entry.setCachedTextWidth(width); if (width > cachedBlockStatsContentWidth) { cachedBlockStatsContentWidth = width; @@ -3084,22 +3432,76 @@ private boolean shouldRenderOriginAxes() { return !forceHideOriginAxes && (forceOriginAxesVisible || ModConfig.debug.enableDebugMode); } - private void drawBlockStatsOverlay(RenderContext context, LytRect sceneRect, LytRect outerRect) { + /** + * Shared document-space geometry for the Block Stats overlay box and + * its visible rows, produced by {@link #layoutBlockStatsOverlay} + * (context-free, GuideText metrics) and consumed by both the HostDraw + * render and the collect-phase GuideText emission. + */ + private record BlockStatsOverlayLayout(List entries, LytRect box, LytRect viewport, + int rowHeight, int rowStride, int lineHeight, boolean wrapped, int entryWidth, int alongSlots, + boolean verticalDock) {} + + private void drawBlockStatsOverlay(RenderContext context, LytRect sceneRect, LytRect outerRect, + boolean suppressText) { + BlockStatsOverlayLayout layout = layoutBlockStatsOverlay(sceneRect, outerRect); + if (layout == null) { + return; + } + context.fillRect(layout.box(), BLOCK_STATS_BACKGROUND_COLOR); + context.drawBorder(layout.box(), BLOCK_STATS_BORDER_COLOR, 1); + context.pushLocalScissor(layout.viewport()); + try { + forEachBlockStatsRow( + layout, + (entry, itemX, rowY, textX, rowHeight, textOffsetY, itemOffsetY) -> drawBlockStatsEntry( + context, + entry, + layout.viewport(), + itemX, + rowY, + textX, + rowHeight, + textOffsetY, + itemOffsetY, + suppressText)); + } finally { + context.popScissor(); + } + drawBlockStatsScrollbars(context); + } + + /** + * Computes (and caches) the Block Stats overlay box/viewport/scroll + * geometry - the exact geometry the legacy render drew - without a + * RenderContext, so the collect phase ({@link #emitBlockStatsText}) and the + * HostDraw render agree. Returns {@code null} when the overlay must not + * render (the cached geometry is cleared, mirroring the legacy gates). + * Frame-invariant inputs only: layout fields, per-scene state and cached + * entries, all identical between collection and execution within a frame. + */ + @Nullable + private BlockStatsOverlayLayout layoutBlockStatsOverlay(LytRect sceneRect, LytRect outerRect) { cachedBlockStatsHitRegionCount = 0; if (!blockStatsEnabled || !blockStatsVisible || sceneRect == null || sceneRect.isEmpty()) { clearBlockStatsGeometry(); - return; + return null; } - List entries = getCachedBlockStatsEntries(context); + List entries = getCachedBlockStatsEntries(); if (entries.isEmpty()) { clearBlockStatsGeometry(); - return; + return null; } if (blockStatsDock.isOutside() && blockStatsMode != BlockStatsMode.MANUAL) { - drawDockedBlockStatsOverlay(context, sceneRect, outerRect, entries); - return; + return computeDockedBlockStatsLayout(entries, sceneRect, outerRect); } - int lineHeight = context.getLineHeight(BLOCK_STATS_TEXT_STYLE); + return computeInsideBlockStatsLayout(entries, sceneRect, outerRect); + } + + @Nullable + private BlockStatsOverlayLayout computeInsideBlockStatsLayout(List entries, LytRect sceneRect, + LytRect outerRect) { + int lineHeight = GuideText.lineHeight(BLOCK_STATS_TEXT_STYLE); int rowHeight = Math.max(BLOCK_STATS_ITEM_SIZE, lineHeight); int rowStride = rowHeight + BLOCK_STATS_ROW_GAP; int rowContentWidth = blockStatsEntryWidth(); @@ -3113,7 +3515,7 @@ private void drawBlockStatsOverlay(RenderContext context, LytRect sceneRect, Lyt || rowContentWidth <= 0 || blockStatsContentHeight <= 0) { clearBlockStatsGeometry(); - return; + return null; } boolean reserveVerticalScrollbar = blockStatsContentHeight + BLOCK_STATS_PADDING_Y * 2 > maxHeight; int preferredWidth = rowContentWidth + BLOCK_STATS_PADDING_X * 2 @@ -3161,37 +3563,23 @@ private void drawBlockStatsOverlay(RenderContext context, LytRect sceneRect, Lyt innerWidth, innerHeight); layoutBlockStatsScrollbars(box, viewport, horizontalNeeded, verticalNeeded); - - context.fillRect(box, BLOCK_STATS_BACKGROUND_COLOR); - context.drawBorder(box, BLOCK_STATS_BORDER_COLOR, 1); - context.pushLocalScissor(viewport); - try { - drawLinearBlockStatsRows(context, entries, viewport, rowHeight, rowStride, lineHeight); - } finally { - context.popScissor(); - } - drawBlockStatsScrollbars(context); + return new BlockStatsOverlayLayout( + entries, + box, + viewport, + rowHeight, + rowStride, + lineHeight, + false, + 0, + 0, + false); } - private int resolveInsideBlockStatsMaxWidth(LytRect sceneRect, int entryCount, int rowContentWidth) { - int maxWidth = blockStatsMaxWidth; - if (entryCount == 1) { - maxWidth = Math.max(maxWidth, rowContentWidth + BLOCK_STATS_PADDING_X * 2); - } - return Math.min(maxWidth, sceneRect.width()); - } - - private int resolveInsideBlockStatsMaxHeight(LytRect sceneRect, int entryCount, int rowHeight) { - int maxHeight = blockStatsMaxHeight; - if (entryCount == 1) { - maxHeight = Math.max(maxHeight, rowHeight + BLOCK_STATS_PADDING_Y * 2); - } - return Math.min(maxHeight, sceneRect.height()); - } - - private void drawDockedBlockStatsOverlay(RenderContext context, LytRect sceneRect, LytRect outerRect, - List entries) { - int lineHeight = context.getLineHeight(BLOCK_STATS_TEXT_STYLE); + @Nullable + private BlockStatsOverlayLayout computeDockedBlockStatsLayout(List entries, LytRect sceneRect, + LytRect outerRect) { + int lineHeight = GuideText.lineHeight(BLOCK_STATS_TEXT_STYLE); int rowHeight = Math.max(BLOCK_STATS_ITEM_SIZE, lineHeight); int rowStride = rowHeight + BLOCK_STATS_ROW_GAP; int entryWidth = blockStatsEntryWidth(); @@ -3211,7 +3599,7 @@ private void drawDockedBlockStatsOverlay(RenderContext context, LytRect sceneRec } if (maxWidth < BLOCK_STATS_MIN_WIDTH || maxHeight < BLOCK_STATS_MIN_HEIGHT) { clearBlockStatsGeometry(); - return; + return null; } int maxAlong = Math.max(rowStride, verticalDock ? sceneRect.height() : sceneRect.width()); int alongUnit = Math.max(1, verticalDock ? rowStride : entryWidth); @@ -3275,24 +3663,33 @@ private void drawDockedBlockStatsOverlay(RenderContext context, LytRect sceneRec innerWidth, innerHeight); layoutBlockStatsScrollbars(box, viewport, horizontalNeeded, verticalNeeded); - context.fillRect(box, BLOCK_STATS_BACKGROUND_COLOR); - context.drawBorder(box, BLOCK_STATS_BORDER_COLOR, 1); - context.pushLocalScissor(viewport); - try { - drawWrappedBlockStatsRows( - context, - entries, - viewport, - rowHeight, - rowStride, - lineHeight, - entryWidth, - alongSlots, - verticalDock); - } finally { - context.popScissor(); + return new BlockStatsOverlayLayout( + entries, + box, + viewport, + rowHeight, + rowStride, + lineHeight, + true, + entryWidth, + alongSlots, + verticalDock); + } + + private int resolveInsideBlockStatsMaxWidth(LytRect sceneRect, int entryCount, int rowContentWidth) { + int maxWidth = blockStatsMaxWidth; + if (entryCount == 1) { + maxWidth = Math.max(maxWidth, rowContentWidth + BLOCK_STATS_PADDING_X * 2); } - drawBlockStatsScrollbars(context); + return Math.min(maxWidth, sceneRect.width()); + } + + private int resolveInsideBlockStatsMaxHeight(LytRect sceneRect, int entryCount, int rowHeight) { + int maxHeight = blockStatsMaxHeight; + if (entryCount == 1) { + maxHeight = Math.max(maxHeight, rowHeight + BLOCK_STATS_PADDING_Y * 2); + } + return Math.min(maxHeight, sceneRect.height()); } private int blockStatsEntryWidth() { @@ -3306,60 +3703,114 @@ private int blockStatsItemWidth() { return Math.max(BLOCK_STATS_ITEM_SIZE, cachedBlockStatsItemWidth); } - private void drawLinearBlockStatsRows(RenderContext context, List entries, LytRect viewport, - int rowHeight, int rowStride, int lineHeight) { - int firstRow = Math.max(0, blockStatsScrollY / Math.max(1, rowStride)); - int rowY = viewport.y() - blockStatsScrollY + firstRow * rowStride; - int itemX = viewport.x() - blockStatsScrollX; - int textX = itemX + blockStatsItemWidth() + BLOCK_STATS_GAP; - int textOffsetY = Math.max(0, (rowHeight - lineHeight) / 2); - int itemOffsetY = Math.max(0, (rowHeight - BLOCK_STATS_ITEM_SIZE) / 2); - for (int i = firstRow; i < entries.size() && rowY < viewport.bottom(); i++) { - drawBlockStatsEntry( - context, - entries.get(i), - viewport, - itemX, - rowY, - textX, - rowHeight, - textOffsetY, - itemOffsetY); - rowY += rowStride; - } - } - - private void drawWrappedBlockStatsRows(RenderContext context, List entries, LytRect viewport, - int rowHeight, int rowStride, int lineHeight, int entryWidth, int alongSlots, boolean verticalDock) { - int textOffsetY = Math.max(0, (rowHeight - lineHeight) / 2); - int itemOffsetY = Math.max(0, (rowHeight - BLOCK_STATS_ITEM_SIZE) / 2); - int baseX = viewport.x() - blockStatsScrollX; - int baseY = viewport.y() - blockStatsScrollY; - for (int i = 0; i < entries.size(); i++) { - int along = i % alongSlots; - int cross = i / alongSlots; - int itemX = verticalDock ? baseX + cross * entryWidth : baseX + along * entryWidth; - int rowY = verticalDock ? baseY + along * rowStride : baseY + cross * rowStride; - if (rowY + rowHeight < viewport.y() || rowY >= viewport.bottom() - || itemX + entryWidth < viewport.x() - || itemX >= viewport.right()) { + /** + * Shared row iteration for the Block Stats overlay: yields the visible + * rows in document space with exactly the geometry of the former + * drawLinearBlockStatsRows / drawWrappedBlockStatsRows. Both the HostDraw + * render (draws icons + suppressed text) and the collect phase (emits + * GuideText runs) consume this - identical positions by construction. + */ + private void forEachBlockStatsRow(BlockStatsOverlayLayout layout, BlockStatsRowConsumer consumer) { + if (!layout.wrapped()) { + int firstRow = Math.max(0, blockStatsScrollY / Math.max(1, layout.rowStride())); + int rowY = layout.viewport() + .y() - blockStatsScrollY + + firstRow * layout.rowStride(); + int itemX = layout.viewport() + .x() - blockStatsScrollX; + int textX = itemX + blockStatsItemWidth() + BLOCK_STATS_GAP; + int textOffsetY = Math.max(0, (layout.rowHeight() - layout.lineHeight()) / 2); + int itemOffsetY = Math.max(0, (layout.rowHeight() - BLOCK_STATS_ITEM_SIZE) / 2); + for (int i = firstRow; i < layout.entries() + .size() && rowY + < layout.viewport() + .bottom(); i++) { + consumer.accept( + layout.entries() + .get(i), + itemX, + rowY, + textX, + layout.rowHeight(), + textOffsetY, + itemOffsetY); + rowY += layout.rowStride(); + } + return; + } + int textOffsetY = Math.max(0, (layout.rowHeight() - layout.lineHeight()) / 2); + int itemOffsetY = Math.max(0, (layout.rowHeight() - BLOCK_STATS_ITEM_SIZE) / 2); + int baseX = layout.viewport() + .x() - blockStatsScrollX; + int baseY = layout.viewport() + .y() - blockStatsScrollY; + for (int i = 0; i < layout.entries() + .size(); i++) { + int along = i % layout.alongSlots(); + int cross = i / layout.alongSlots(); + int itemX = layout.verticalDock() ? baseX + cross * layout.entryWidth() + : baseX + along * layout.entryWidth(); + int rowY = layout.verticalDock() ? baseY + along * layout.rowStride() : baseY + cross * layout.rowStride(); + if (rowY + layout.rowHeight() < layout.viewport() + .y() || rowY + >= layout.viewport() + .bottom() + || itemX + layout.entryWidth() < layout.viewport() + .x() + || itemX >= layout.viewport() + .right()) { continue; } - drawBlockStatsEntry( - context, - entries.get(i), - viewport, + consumer.accept( + layout.entries() + .get(i), itemX, rowY, itemX + blockStatsItemWidth() + BLOCK_STATS_GAP, - rowHeight, + layout.rowHeight(), textOffsetY, itemOffsetY); } } + @FunctionalInterface + private interface BlockStatsRowConsumer { + + void accept(SceneBlockStatsEntry entry, int itemX, int rowY, int textX, int rowHeight, int textOffsetY, + int itemOffsetY); + } + + /** + * The text runs (name + count) for one Block Stats row, in document + * space with line-top origin and GuideText metrics. Single source of truth + * shared by the collect-phase emission (GuideText.emitText) and the legacy + * render path (context.drawText, when not suppressed) - identical positions + * by construction. + */ + private void forEachBlockStatsEntryText(SceneBlockStatsEntry entry, int itemX, int rowY, int textX, int rowHeight, + int textOffsetY, int itemOffsetY, BlockStatsTextEmitter emitter) { + if (blockStatsShowNames) { + String text = blockStatsEntryText(entry); + emitter.text(text, textX, rowY + textOffsetY, BLOCK_STATS_TEXT_STYLE); + } + if (entry.getCount() > 1) { + String countText = formatBlockStatsDisplayCount(entry.getCount()); + int textWidth = GuideText.measureWidth(countText, BLOCK_STATS_COUNT_TEXT_STYLE); + int textHeight = GuideText.lineHeight(BLOCK_STATS_COUNT_TEXT_STYLE); + int countX = itemX + blockStatsItemWidth() - textWidth; + int countY = rowY + itemOffsetY + BLOCK_STATS_ITEM_SIZE - textHeight + 1; + emitter.text(countText, countX, countY, BLOCK_STATS_COUNT_TEXT_STYLE); + } + } + + @FunctionalInterface + private interface BlockStatsTextEmitter { + + void text(String text, int x, int y, ResolvedTextStyle style); + } + private void drawBlockStatsEntry(RenderContext context, SceneBlockStatsEntry entry, LytRect viewport, int itemX, - int rowY, int textX, int rowHeight, int textOffsetY, int itemOffsetY) { + int rowY, int textX, int rowHeight, int textOffsetY, int itemOffsetY, boolean suppressText) { boolean selected = selectedBlockStatsKey != null && selectedBlockStatsKey.equals(entry.getKey()); int rowWidth = blockStatsShowNames ? blockStatsEntryWidth() : blockStatsItemWidth(); if (selected) { @@ -3367,24 +3818,25 @@ private void drawBlockStatsEntry(RenderContext context, SceneBlockStatsEntry ent } int itemY = rowY + itemOffsetY; context.renderItemIcon(entry.getDisplayStack(), itemX, itemY); - drawBlockStatsItemCount(context, entry, itemX, itemY); addBlockStatsHitRegion(entry, itemX, rowY, rowWidth, rowHeight, viewport); - if (blockStatsShowNames) { - String text = blockStatsEntryText(entry); - context.drawText(text, textX, rowY + textOffsetY, BLOCK_STATS_TEXT_STYLE); - } - } - - private void drawBlockStatsItemCount(RenderContext context, SceneBlockStatsEntry entry, int itemX, int itemY) { - if (entry.getCount() <= 1) { - return; - } - String countText = formatBlockStatsDisplayCount(entry.getCount()); - int textWidth = context.getStringWidth(countText, BLOCK_STATS_COUNT_TEXT_STYLE); - int textHeight = context.getLineHeight(BLOCK_STATS_COUNT_TEXT_STYLE); - int countX = itemX + blockStatsItemWidth() - textWidth; - int countY = itemY + BLOCK_STATS_ITEM_SIZE - textHeight + 1; - context.drawText(countText, countX, countY, BLOCK_STATS_COUNT_TEXT_STYLE); + // In the primitive pipeline the name/count text is + // emitted as GuideText glyph runs at collect time (see + // emitBlockStatsText), so the legacy drawText here only runs on the + // direct render path (scene editor / legacy subtree fallback), where + // computePrimitives never claimed the text. + forEachBlockStatsEntryText( + entry, + itemX, + rowY, + textX, + rowHeight, + textOffsetY, + itemOffsetY, + (text, x, y, style) -> { + if (!suppressText) { + context.drawText(text, x, y, style); + } + }); } private void addBlockStatsHitRegion(SceneBlockStatsEntry entry, int x, int y, int width, int height, @@ -4593,11 +5045,14 @@ private float getLogicalCameraRotationZ() { } private float getLogicalCameraOffsetX() { - return ponderSceneData != null ? ponderCamOffX : camera.getOffsetX(); + // Ponder keyframe offsets and the ponder drag accumulate screen-space pixels + // (see applyCameraDrag's ponder branch); the camera view matrix applies them in world + // units, so convert with the projection scale s = 0.625 * 16 * zoom = 10 * zoom. + return ponderSceneData != null ? ponderCamOffX / (10f * ponderCamZoom) : camera.getOffsetX(); } private float getLogicalCameraOffsetY() { - return ponderSceneData != null ? ponderCamOffY : camera.getOffsetY(); + return ponderSceneData != null ? ponderCamOffY / (10f * ponderCamZoom) : camera.getOffsetY(); } private float[] captureCameraState() { @@ -4950,8 +5405,14 @@ public void attachPonderData(PonderSceneData data, List> a ponderCameraDefaults[1] = camera.getRotationX(); ponderCameraDefaults[2] = camera.getRotationY(); ponderCameraDefaults[3] = camera.getRotationZ(); - ponderCameraDefaults[4] = ponderBaseCamOffX; - ponderCameraDefaults[5] = ponderBaseCamOffY; + // Ponder keyframe offsets are screen-space pixels, while this scene's authored camera + // offset, and therefore ponderBaseCamOffX, is stored in world units. Store the base already + // scaled to the screen-space form the resolved keyframe states use; getLogicalCameraOffsetX/Y + // convert back at the camera application point. Both the scale and the base come from this + // snapshot because the live camera state already holds the previous timeline's values. + float baseZoom = ponderCameraDefaults[0]; + ponderCameraDefaults[4] = ponderBaseCamOffX * (10f * baseZoom); + ponderCameraDefaults[5] = ponderBaseCamOffY * (10f * baseZoom); this.ponderSceneData = data; rebuildPonderResolvedCameraStates(); rebuildPonderTimedEntityAnimations(); @@ -5446,6 +5907,13 @@ private void playPonderKeyframeSounds(int keyframeIndex) { } } + /** + * Resolves the effective camera state of every keyframe into reusable arrays. A keyframe that + * omits a camera field inherits the nearest authored value before it, which is what the running + * values below accumulate; {@link #ponderCameraDefaults} supplies the state before the first + * authored value. Ponder playback then costs one array copy per keyframe instead of a backward + * scan with fresh scratch arrays. + */ private void rebuildPonderResolvedCameraStates() { ponderResolvedCameraStates.clear(); if (ponderSceneData == null) { @@ -6900,7 +7368,7 @@ private void drawPonderKeyframeNodes(RenderContext context, int renderBarLeft, i } } - private void drawBottomControls(RenderContext context, LytRect outerRect) { + private void drawBottomControls(RenderContext context, LytRect outerRect, boolean suppressLegacyText) { clearCachedVisibleLayerSliderRects(); clearCachedTierSliderRects(); clearCachedChannelSliderRects(); @@ -6918,13 +7386,13 @@ private void drawBottomControls(RenderContext context, LytRect outerRect) { } if (!isPonderPlaying()) { if (hasStructureLibTierSlider()) { - drawStructureLibTierSlider(context, outerRect); + drawStructureLibTierSlider(context, outerRect, suppressLegacyText); } if (hasVisibleLayerSlider()) { - drawVisibleLayerSlider(context, outerRect); + drawVisibleLayerSlider(context, outerRect, suppressLegacyText); } for (StructureLibSceneMetadata.ChannelData channelData : getBottomControlStructureLibChannels()) { - drawStructureLibChannelSlider(context, outerRect, channelData); + drawStructureLibChannelSlider(context, outerRect, channelData, suppressLegacyText); } } } @@ -7053,7 +7521,64 @@ private LytRect resolveVisibleLayerSliderHitRect() { .hitRect(); } - private void drawLoadProgressOverlay(RenderContext context, LytRect sceneRect) { + /** + * Emit the load-state status line as a GuideText glyph run at + * collect time (collection phase - see {@link #computePrimitives}). The + * progress bar stays HostDraw; only the status text moves to the engine + * vector font. Geometry uses GuideText metrics in document space - no + * {@code /zoom} (the legacy getStringWidth→/z screen→doc division no longer + * applies). + */ + private void emitLoadStateText(PrimitiveCollector c) { + if (!isLoading()) { + return; + } + LytRect outerRect = resolveSceneOuterRect(); + LytRect sceneRect = resolveSceneContentRect(outerRect); + if (sceneRect.isEmpty()) { + return; + } + if (loadStatusText == null || loadStatusText.isEmpty()) { + return; + } + ResolvedTextStyle style = resolveLoadStatusTextStyle(); + int barWidth = Math.max(24, sceneRect.width() * 3 / 5); + int barX = sceneRect.x() + (sceneRect.width() - barWidth) / 2; + int barY = sceneRect.y() + sceneRect.height() / 2 - 2; + int textW = GuideText.measureWidth(loadStatusText, style); + int textH = GuideText.lineHeight(style); + int textX = barX + (barWidth - textW) / 2; + int textY = barY - textH - 4; + GuideText.emitText(c, loadStatusText, textX, textY, style); + } + + /** + * Status style for the load-state overlay: {@link #VISIBLE_LAYER_SLIDER_TEXT_STYLE} + * with the current {@link #loadStatusColor}. Shared by the collect-phase + * emission and the legacy render so both tint identically. + */ + private ResolvedTextStyle resolveLoadStatusTextStyle() { + var style = VISIBLE_LAYER_SLIDER_TEXT_STYLE; + return new ResolvedTextStyle( + style.fontScale(), + style.bold(), + style.italic(), + style.underlined(), + style.wavyUnderline(), + style.dottedUnderline(), + style.strikethrough(), + style.obfuscated(), + style.font(), + new ConstantColor(loadStatusColor), + style.whiteSpace(), + style.alignment(), + style.dropShadow(), + style.backgroundColor(), + style.inlineCode(), + style.baselineShift()); + } + + private void drawLoadProgressOverlay(RenderContext context, LytRect sceneRect, boolean suppressText) { if (!isLoading() || sceneRect.isEmpty()) return; int barWidth = Math.max(24, sceneRect.width() * 3 / 5); @@ -7074,33 +7599,59 @@ private void drawLoadProgressOverlay(RenderContext context, LytRect sceneRect) { } // Status text if (loadStatusText != null && !loadStatusText.isEmpty()) { - var style = VISIBLE_LAYER_SLIDER_TEXT_STYLE; - var coloredStyle = new ResolvedTextStyle( - style.fontScale(), - style.bold(), - style.italic(), - style.underlined(), - style.wavyUnderline(), - style.dottedUnderline(), - style.strikethrough(), - style.obfuscated(), - style.font(), - new ConstantColor(loadStatusColor), - style.whiteSpace(), - style.alignment(), - style.dropShadow(), - style.backgroundColor(), - style.inlineCode()); + var coloredStyle = resolveLoadStatusTextStyle(); float z = Math.max(0.0001f, lastDocZoom); int textW = Math.round(context.getStringWidth(loadStatusText, coloredStyle) / z); int textH = Math.round(context.getLineHeight(coloredStyle) / z); int textX = barX + (barWidth - textW) / 2; int textY = barY - textH - 4; - context.drawText(loadStatusText, textX, textY, coloredStyle); + // In the primitive pipeline the status text is emitted + // as a GuideText glyph run at collect time (see emitLoadStateText), + // so the legacy drawText here only runs on the direct render path + // (scene editor / legacy subtree fallback), where computePrimitives + // never claimed the text. + if (!suppressText) { + context.drawText(loadStatusText, textX, textY, coloredStyle); + } + } + } + + /** + * Draws the scene-level build error as red text centered in the scene. Renders whenever a + * build error was recorded (unconditional - unlike the loading overlay it is not gated on + * {@link #isLoading()}), so a failed structure build can never silently show a blank background. + */ + private void drawSceneBuildError(RenderContext context, LytRect sceneRect) { + if (sceneBuildError == null || sceneRect.isEmpty()) { + return; } + var style = VISIBLE_LAYER_SLIDER_TEXT_STYLE; + var coloredStyle = new ResolvedTextStyle( + style.fontScale(), + style.bold(), + style.italic(), + style.underlined(), + style.wavyUnderline(), + style.dottedUnderline(), + style.strikethrough(), + style.obfuscated(), + style.font(), + new ConstantColor(0xFFFF5555), + style.whiteSpace(), + style.alignment(), + true, + style.backgroundColor(), + style.inlineCode(), + style.baselineShift()); + float z = Math.max(0.0001f, lastDocZoom); + int textW = Math.round(context.getStringWidth(sceneBuildError, coloredStyle) / z); + int textH = Math.round(context.getLineHeight(coloredStyle) / z); + int textX = sceneRect.x() + (sceneRect.width() - textW) / 2; + int textY = sceneRect.y() + sceneRect.height() / 2 - textH; + context.drawText(sceneBuildError, textX, textY, coloredStyle); } - private void drawVisibleLayerSlider(RenderContext context, LytRect outerRect) { + private void drawVisibleLayerSlider(RenderContext context, LytRect outerRect, boolean suppressLegacyText) { int rowIndex = resolveVisibleLayerRowIndex(); LytRect screenTrackRect = resolveVisibleLayerSliderTrackRect(); LytRect renderTrackRect = resolveSliderTrackLayoutRect( @@ -7127,7 +7678,15 @@ private void drawVisibleLayerSlider(RenderContext context, LytRect outerRect) { } String label = getVisibleLayerSliderLabel(); logSliderGeometry("visible-layer", screenGeometry, rowIndex, label, outerRect); - drawSlider(context, renderGeometry, highlighted, outerRect, rowIndex, label, VISIBLE_LAYER_SLIDER_TEXT_STYLE); + drawSlider( + context, + renderGeometry, + highlighted, + outerRect, + rowIndex, + label, + VISIBLE_LAYER_SLIDER_LABEL_TEXT_STYLE, + suppressLegacyText); } private void applyVisibleLayerSliderAt(int mouseX) { @@ -7170,7 +7729,7 @@ private LytRect resolveStructureLibTierSliderHitRect() { .hitRect(); } - private void drawStructureLibTierSlider(RenderContext context, LytRect outerRect) { + private void drawStructureLibTierSlider(RenderContext context, LytRect outerRect, boolean suppressLegacyText) { int rowIndex = resolveStructureLibTierRowIndex(); LytRect screenTrackRect = resolveStructureLibTierSliderTrackRect(); LytRect renderTrackRect = resolveSliderTrackLayoutRect( @@ -7204,7 +7763,8 @@ private void drawStructureLibTierSlider(RenderContext context, LytRect outerRect outerRect, rowIndex, label, - STRUCTURELIB_TIER_SLIDER_TEXT_STYLE); + STRUCTURELIB_TIER_SLIDER_LABEL_TEXT_STYLE, + suppressLegacyText); } private void applyStructureLibTierSliderAt(int mouseX) { @@ -7276,7 +7836,7 @@ private LytRect resolveStructureLibChannelSliderTrackRect(String channelId) { } private void drawStructureLibChannelSlider(RenderContext context, LytRect outerRect, - StructureLibSceneMetadata.ChannelData channelData) { + StructureLibSceneMetadata.ChannelData channelData, boolean suppressLegacyText) { int rowIndex = resolveStructureLibChannelRowIndex(channelData.getChannelId()); LytRect screenTrackRect = resolveStructureLibChannelSliderTrackRect(channelData.getChannelId()); LytRect renderTrackRect = resolveSliderTrackLayoutRect( @@ -7319,7 +7879,8 @@ private void drawStructureLibChannelSlider(RenderContext context, LytRect outerR outerRect, rowIndex, label, - STRUCTURELIB_CHANNEL_SLIDER_TEXT_STYLE); + STRUCTURELIB_CHANNEL_SLIDER_LABEL_TEXT_STYLE, + suppressLegacyText); } private void applyStructureLibChannelSliderAt(String channelId, int mouseX) { @@ -7364,20 +7925,44 @@ private void nudgeStructureLibChannel(String channelId, int dwheel) { } private void drawSlider(RenderContext context, GuideSliderRenderer.SliderGeometry geometry, boolean highlighted, - LytRect outerRect, int rowIndex, String label, ResolvedTextStyle style) { + LytRect outerRect, int rowIndex, String label, ResolvedTextStyle style, boolean suppressLegacyText) { context.beginLocalView(); GuideSliderRenderer.render(Gui::drawRect, geometry, highlighted); context.endLocalView(); - float z = Math.max(0.0001f, lastDocZoom); - int textWidth = Math.round(context.getStringWidth(label, style) / z); - int textHeight = Math.round(context.getLineHeight(style) / z); + // In the primitive pipeline the label is emitted as a + // GuideText glyph run right after the legacy HostDraw (see + // computePrimitives), so the legacy drawText here only runs on the direct + // render path (scene editor / legacy subtree fallback), where + // computePrimitives never claimed the label. + if (!suppressLegacyText) { + SliderLabelGeometry labelGeometry = computeSliderLabelGeometry(label, style, outerRect, rowIndex); + context.drawText(label, labelGeometry.textX(), labelGeometry.textY(), style); + } + } + + /** + * Document-space position for a bottom-control slider label row: horizontally + * centered within {@code outerRect}, vertically centered inside the 14px + * {@link #SCENE_SLIDER_AREA_HEIGHT} row at {@code rowIndex}. Single source of + * truth for the label geometry shared by the legacy drawText path and the + * GuideText {@code emitText} path. Metrics are GuideText doc + * units - no {@code /zoom} conversion (the legacy getStringWidth→/z + * screen→doc division no longer applies). + */ + private SliderLabelGeometry computeSliderLabelGeometry(String label, ResolvedTextStyle style, LytRect outerRect, + int rowIndex) { + int textWidth = GuideText.measureWidth(label, style); + int textHeight = GuideText.lineHeight(style); int textX = outerRect.x() + (outerRect.width() - textWidth) / 2; int rowTop = bottomControlAreaTop(outerRect.bottom()) + rowIndex * SCENE_SLIDER_AREA_HEIGHT; int textY = rowTop + (SCENE_SLIDER_AREA_HEIGHT - textHeight) / 2; - context.drawText(label, textX, textY, style); + return new SliderLabelGeometry(textX, textY); } + /** Document-space (textX, textY) for a slider label (line-top origin). */ + private record SliderLabelGeometry(int textX, int textY) {} + private void logBottomControlState(String phase, LytRect outerRect, int bottomControlAreaHeight) { // Skip the per-frame string construction (key + describeRect + varargs boxing) entirely // when debug logging is disabled. logInfoOnce itself also re-checks, but only after we diff --git a/src/main/java/com/hfstudio/guidenh/guide/scene/SnbtPlacement.java b/src/main/java/com/hfstudio/guidenh/guide/scene/SnbtPlacement.java index e90fec29..d46ca2e3 100644 --- a/src/main/java/com/hfstudio/guidenh/guide/scene/SnbtPlacement.java +++ b/src/main/java/com/hfstudio/guidenh/guide/scene/SnbtPlacement.java @@ -7,7 +7,7 @@ /** * Records the placement configuration for a single {@code } element. - * Symmetric to {@link StructureLibSceneBinding} — the compiler registers placement configs, + * Symmetric to {@link StructureLibSceneBinding} - the compiler registers placement configs, * and {@link LytGuidebookScene#build()} uses them to place blocks. * *

          diff --git a/src/main/java/com/hfstudio/guidenh/guide/scene/StructureLibSceneBinding.java b/src/main/java/com/hfstudio/guidenh/guide/scene/StructureLibSceneBinding.java index be472c43..de189437 100644 --- a/src/main/java/com/hfstudio/guidenh/guide/scene/StructureLibSceneBinding.java +++ b/src/main/java/com/hfstudio/guidenh/guide/scene/StructureLibSceneBinding.java @@ -32,7 +32,7 @@ public class StructureLibSceneBinding { @Nullable private Consumer selectionChangeListener; - // Rebuild recipe — set once during compile + // Rebuild recipe - set once during compile private StructureLibBuildRequest rebuildRequestTemplate; @Getter private int rebuildOffsetX; @@ -116,6 +116,8 @@ public Map getChannelOverrides() { return channelOverrides; } + // Pending selection: UI state that survives page reloads. + @Nullable private StructureLibPreviewSelection pendingSelection; @@ -128,6 +130,8 @@ public void setPendingSelection(@Nullable StructureLibPreviewSelection pendingSe this.pendingSelection = pendingSelection; } + // Restores scene state from a stored selection. + public void applyPreviewSelection(StructureLibPreviewSelection selection) { if (selection == null) return; setCurrentTier(selection.getMasterTier()); @@ -138,6 +142,8 @@ public void applyPreviewSelection(StructureLibPreviewSelection selection) { } } + // Rebuild recipe captured at compile time. + public void setRebuildRecipe(StructureLibBuildRequest request, int offsetX, int offsetY, int offsetZ, boolean formed) { this.rebuildRequestTemplate = request; @@ -177,6 +183,8 @@ public StructureLibBuildRequest getRebuildRequestTemplate() { return hasRebuildRecipe ? rebuildRequestTemplate : null; } + // Selection change listener. + @Nullable public Consumer getSelectionChangeListener() { return selectionChangeListener; @@ -186,6 +194,8 @@ public void setSelectionChangeListener(@Nullable Consumer 0 && measure.boxHeight() > 0) { + LayoutContext layoutContext = getRichContentLayoutContext(); + richContent.layout(layoutContext, bx + PADDING_X, by + PADDING_Y, measure.availableWidth()); + GL11.glEnable(GL11.GL_TEXTURE_2D); + richContent.render(context); + } else { + GL11.glEnable(GL11.GL_TEXTURE_2D); + } ColorUtils.applyGlColor(ColorUtils.WHITE.getColor()); GL11.glDisable(GL11.GL_BLEND); return; diff --git a/src/main/java/com/hfstudio/guidenh/guide/scene/annotation/compiler/BoxAnnotationElementCompiler.java b/src/main/java/com/hfstudio/guidenh/guide/scene/annotation/compiler/BoxAnnotationElementCompiler.java index c81fced9..94522235 100644 --- a/src/main/java/com/hfstudio/guidenh/guide/scene/annotation/compiler/BoxAnnotationElementCompiler.java +++ b/src/main/java/com/hfstudio/guidenh/guide/scene/annotation/compiler/BoxAnnotationElementCompiler.java @@ -15,7 +15,7 @@ import com.hfstudio.guidenh.libs.mdast.mdx.model.MdxJsxElementFields; /** - * {@code }。 + * {@code }. */ public class BoxAnnotationElementCompiler extends AnnotationTagCompiler { diff --git a/src/main/java/com/hfstudio/guidenh/guide/scene/annotation/compiler/LineAnnotationElementCompiler.java b/src/main/java/com/hfstudio/guidenh/guide/scene/annotation/compiler/LineAnnotationElementCompiler.java index da608570..8929c896 100644 --- a/src/main/java/com/hfstudio/guidenh/guide/scene/annotation/compiler/LineAnnotationElementCompiler.java +++ b/src/main/java/com/hfstudio/guidenh/guide/scene/annotation/compiler/LineAnnotationElementCompiler.java @@ -19,7 +19,7 @@ import com.hfstudio.guidenh.libs.mdast.model.MdAstAnyContent; /** - * {@code }銆? + * {@code }. */ public class LineAnnotationElementCompiler extends AnnotationTagCompiler { diff --git a/src/main/java/com/hfstudio/guidenh/guide/scene/element/ImportStructureElementCompiler.java b/src/main/java/com/hfstudio/guidenh/guide/scene/element/ImportStructureElementCompiler.java index 10a331ac..4c815a46 100644 --- a/src/main/java/com/hfstudio/guidenh/guide/scene/element/ImportStructureElementCompiler.java +++ b/src/main/java/com/hfstudio/guidenh/guide/scene/element/ImportStructureElementCompiler.java @@ -4,8 +4,10 @@ import java.util.Set; import net.minecraft.block.Block; +import net.minecraft.nbt.NBTBase; import net.minecraft.nbt.NBTTagCompound; import net.minecraft.nbt.NBTTagList; +import net.minecraft.nbt.NBTTagString; import net.minecraft.util.ResourceLocation; import net.minecraft.world.World; @@ -138,43 +140,59 @@ public static void placeStructure(GuidebookLevel level, NBTTagCompound root, int if (errorSink == null) { errorSink = (c, text, node) -> {}; } - if (!root.hasKey("palette") || !root.hasKey("blocks")) { + if (!root.hasKey("palette") || (!root.hasKey("blocks") && !root.hasKey("data"))) { errorSink.appendError(compiler, "Unsupported structure format (missing palette/blocks)", null); return; } NBTTagList paletteTag = root.getTagList("palette", 10); - String[] palette = new String[paletteTag.tagCount()]; - for (int i = 0; i < paletteTag.tagCount(); i++) { - var entry = paletteTag.getCompoundTagAt(i); - palette[i] = entry.getString("Name"); + // Modern regional-structure dialect: palette is a list of STRING block names and the + // block entries live under `data` (or `blocks`) with a string `state` naming the palette + // entry directly (or an int index into the string palette). Older region-wand exports use + // a compound palette ({Name:...}) plus integer `state` indices - that path is preserved + // verbatim below and is the only dialect these strings ever carry. + boolean modernDialect = false; + if (paletteTag.tagCount() == 0 && root.getTag("palette") instanceof NBTTagList rawPalette) { + modernDialect = rawPalette.func_150303_d() == 8; } + if (modernDialect) { + int placed = placeStructureModernDialect(level, root, offsetX, offsetY, offsetZ, formed, errorSink, src); + if (placed == 0) { + errorSink.appendError(compiler, "Structure had no placeable blocks: " + src, null); + } + } else { + String[] palette = new String[paletteTag.tagCount()]; + for (int i = 0; i < paletteTag.tagCount(); i++) { + var entry = paletteTag.getCompoundTagAt(i); + palette[i] = entry.getString("Name"); + } - NBTTagList blocksTag = root.getTagList("blocks", 10); - int placed = 0; - for (int i = 0; i < blocksTag.tagCount(); i++) { - var b = blocksTag.getCompoundTagAt(i); - int state = b.getInteger("state"); - if (state < 0 || state >= palette.length) continue; - String name = palette[state]; - Block block = (Block) Block.blockRegistry.getObject(name); - if (block == null) continue; + NBTTagList blocksTag = root.getTagList("blocks", 10); + int placed = 0; + for (int i = 0; i < blocksTag.tagCount(); i++) { + var b = blocksTag.getCompoundTagAt(i); + int state = b.getInteger("state"); + if (state < 0 || state >= palette.length) continue; + String name = palette[state]; + Block block = (Block) Block.blockRegistry.getObject(name); + if (block == null) continue; - int[] pos = b.getIntArray("pos"); - if (pos.length < 3) continue; - int px = offsetX + pos[0]; - int py = level.clampBuildHeight(offsetY + pos[1]); - int pz = offsetZ + pos[2]; + int[] pos = b.getIntArray("pos"); + if (pos.length < 3) continue; + int px = offsetX + pos[0]; + int py = level.clampBuildHeight(offsetY + pos[1]); + int pz = offsetZ + pos[2]; - int meta = b.hasKey("meta") ? b.getInteger("meta") : 0; - NBTTagCompound tileTag = b.hasKey("nbt", 10) ? b.getCompoundTag("nbt") : null; - GuidebookPreviewBlockPlacer.place(level, px, py, pz, block, meta, tileTag, name, b); - ScenePreviewFormedState.updateAfterPlacement(level, px, py, pz, formed); - placed++; - } + int meta = b.hasKey("meta") ? b.getInteger("meta") : 0; + NBTTagCompound tileTag = b.hasKey("nbt", 10) ? b.getCompoundTag("nbt") : null; + GuidebookPreviewBlockPlacer.place(level, px, py, pz, block, meta, tileTag, name, b); + ScenePreviewFormedState.updateAfterPlacement(level, px, py, pz, formed); + placed++; + } - if (placed == 0) { - errorSink.appendError(compiler, "Structure had no placeable blocks: " + src, null); + if (placed == 0) { + errorSink.appendError(compiler, "Structure had no placeable blocks: " + src, null); + } } // Spawn entities stored by the region-wand exporter (snbt+entities mode). @@ -211,6 +229,56 @@ public static void placeStructure(GuidebookLevel level, NBTTagCompound root, int } } + /** + * Place blocks from a modern regional-structure SNBT dialect: {@code palette} is a list of + * string block names and the block entries live under {@code data} (or {@code blocks}) with a + * {@code state} that is either the palette string directly or an integer index into the string + * palette. Entries may carry the same optional {@code meta}/{@code nbt} fields as the legacy + * region-wand dialect. + * + * @return number of blocks successfully placed + */ + private static int placeStructureModernDialect(GuidebookLevel level, NBTTagCompound root, int offsetX, int offsetY, + int offsetZ, boolean formed, LytErrorSink errorSink, ResourceLocation src) { + NBTTagList paletteTag = root.getTagList("palette", 8); + String[] palette = new String[paletteTag.tagCount()]; + for (int i = 0; i < paletteTag.tagCount(); i++) { + palette[i] = paletteTag.getStringTagAt(i); + } + + NBTTagList blocksTag = root.hasKey("data", 9) ? root.getTagList("data", 10) : root.getTagList("blocks", 10); + int placed = 0; + for (int i = 0; i < blocksTag.tagCount(); i++) { + var b = blocksTag.getCompoundTagAt(i); + String name = null; + NBTBase stateTag = b.getTag("state"); + if (stateTag instanceof NBTTagString strTag) { + name = strTag.func_150285_a_(); + } else if (stateTag != null) { + int state = b.getInteger("state"); + if (state >= 0 && state < palette.length) { + name = palette[state]; + } + } + if (name == null || name.isEmpty()) continue; + Block block = (Block) Block.blockRegistry.getObject(name); + if (block == null) continue; + + int[] pos = b.getIntArray("pos"); + if (pos.length < 3) continue; + int px = offsetX + pos[0]; + int py = Math.clamp(offsetY + pos[1], 0, level.getHeight() - 1); + int pz = offsetZ + pos[2]; + + int meta = b.hasKey("meta") ? b.getInteger("meta") : 0; + NBTTagCompound tileTag = b.hasKey("nbt", 10) ? b.getCompoundTag("nbt") : null; + GuidebookPreviewBlockPlacer.place(level, px, py, pz, block, meta, tileTag, name, b); + ScenePreviewFormedState.updateAfterPlacement(level, px, py, pz, formed); + placed++; + } + return placed; + } + public static NBTTagCompound readStructureNbt(byte[] data) throws Exception { return GuideTextNbtCodec.readStructureNbt(data); } diff --git a/src/main/java/com/hfstudio/guidenh/guide/scene/element/ImportStructureLibElementCompiler.java b/src/main/java/com/hfstudio/guidenh/guide/scene/element/ImportStructureLibElementCompiler.java index 7124aad6..5239edd8 100644 --- a/src/main/java/com/hfstudio/guidenh/guide/scene/element/ImportStructureLibElementCompiler.java +++ b/src/main/java/com/hfstudio/guidenh/guide/scene/element/ImportStructureLibElementCompiler.java @@ -27,7 +27,7 @@ import blockrenderer6343.client.utils.ConstructableData; /** - * Compiler for <ImportStructureLib> MDX tags. Pure parse — no world operations. + * Compiler for <ImportStructureLib> MDX tags. Pure parse - no world operations. * Registers a binding and sets its rebuild recipe; actual block placement * happens later in {@link LytGuidebookScene#rebuildStructureLib()}. */ @@ -121,6 +121,8 @@ public void compile(GuidebookLevel level, CameraSettings camera, PageCompiler co } } + // Helpers for callers that need to repeat the attribute parsing. + @Nullable public static StructureLibBuildRequest buildDefaultPreviewRequest(MdxJsxElementFields el) { return buildDefaultPreviewRequest(null, NoopErrorSink.INSTANCE, el); diff --git a/src/main/java/com/hfstudio/guidenh/guide/scene/element/IsometricCameraElementCompiler.java b/src/main/java/com/hfstudio/guidenh/guide/scene/element/IsometricCameraElementCompiler.java index 49a0d19d..fdbfdded 100644 --- a/src/main/java/com/hfstudio/guidenh/guide/scene/element/IsometricCameraElementCompiler.java +++ b/src/main/java/com/hfstudio/guidenh/guide/scene/element/IsometricCameraElementCompiler.java @@ -11,7 +11,7 @@ import com.hfstudio.guidenh.libs.mdast.mdx.model.MdxJsxElementFields; /** - * {@code guideme.scene.element.IsometricCameraElementCompiler}。 + * {@code guideme.scene.element.IsometricCameraElementCompiler}. */ public class IsometricCameraElementCompiler implements SceneElementTagCompiler { @@ -23,9 +23,16 @@ public Set getTagNames() { @Override public void compile(GuidebookLevel level, CameraSettings camera, PageCompiler compiler, LytErrorSink errorSink, MdxJsxElementFields el) { - float yaw = MdxAttrs.getFloat(compiler, errorSink, el, "yaw", 0.0f); - float pitch = MdxAttrs.getFloat(compiler, errorSink, el, "pitch", 0.0f); - float roll = MdxAttrs.getFloat(compiler, errorSink, el, "roll", 0.0f); + boolean hasYaw = el.getAttribute("yaw") != null; + boolean hasPitch = el.getAttribute("pitch") != null; + boolean hasRoll = el.getAttribute("roll") != null; + if (!hasYaw && !hasPitch && !hasRoll) { + // No explicit attributes: keep whatever the camera already has (preset or previous config). + return; + } + float yaw = hasYaw ? MdxAttrs.getFloat(compiler, errorSink, el, "yaw", 0.0f) : camera.getRotationY(); + float pitch = hasPitch ? MdxAttrs.getFloat(compiler, errorSink, el, "pitch", 0.0f) : camera.getRotationX(); + float roll = hasRoll ? MdxAttrs.getFloat(compiler, errorSink, el, "roll", 0.0f) : camera.getRotationZ(); camera.setIsometricYawPitchRoll(yaw, pitch, roll); } } diff --git a/src/main/java/com/hfstudio/guidenh/guide/scene/element/RemoveBlocksElementCompiler.java b/src/main/java/com/hfstudio/guidenh/guide/scene/element/RemoveBlocksElementCompiler.java index c050c1fd..25ef96b7 100644 --- a/src/main/java/com/hfstudio/guidenh/guide/scene/element/RemoveBlocksElementCompiler.java +++ b/src/main/java/com/hfstudio/guidenh/guide/scene/element/RemoveBlocksElementCompiler.java @@ -7,6 +7,8 @@ import com.hfstudio.guidenh.guide.compiler.tags.MdxAttrs; import com.hfstudio.guidenh.guide.document.LytErrorSink; import com.hfstudio.guidenh.guide.scene.CameraSettings; +import com.hfstudio.guidenh.guide.scene.LytGuidebookScene; +import com.hfstudio.guidenh.guide.scene.annotation.compiler.AnnotationTagCompiler; import com.hfstudio.guidenh.guide.scene.cache.GuideSceneStructureCompileScope; import com.hfstudio.guidenh.guide.scene.level.GuidebookLevel; import com.hfstudio.guidenh.guide.scene.support.GuideBlockMatcher; @@ -41,6 +43,11 @@ public void compile(GuidebookLevel level, CameraSettings camera, PageCompiler co return; } - RemoveBlocksExecutor.execute(level, matcher); + LytGuidebookScene scene = AnnotationTagCompiler.CURRENT_SCENE.get(); + if (scene != null) { + scene.addDeferredBlockMutation(() -> RemoveBlocksExecutor.execute(level, matcher)); + } else { + RemoveBlocksExecutor.execute(level, matcher); + } } } diff --git a/src/main/java/com/hfstudio/guidenh/guide/scene/element/ReplaceBlockElementCompiler.java b/src/main/java/com/hfstudio/guidenh/guide/scene/element/ReplaceBlockElementCompiler.java index 40a3f3b8..b2dbac83 100644 --- a/src/main/java/com/hfstudio/guidenh/guide/scene/element/ReplaceBlockElementCompiler.java +++ b/src/main/java/com/hfstudio/guidenh/guide/scene/element/ReplaceBlockElementCompiler.java @@ -11,6 +11,8 @@ import com.hfstudio.guidenh.guide.document.LytErrorSink; import com.hfstudio.guidenh.guide.internal.structure.GuideTextNbtCodec; import com.hfstudio.guidenh.guide.scene.CameraSettings; +import com.hfstudio.guidenh.guide.scene.LytGuidebookScene; +import com.hfstudio.guidenh.guide.scene.annotation.compiler.AnnotationTagCompiler; import com.hfstudio.guidenh.guide.scene.cache.GuideSceneStructureCompileScope; import com.hfstudio.guidenh.guide.scene.level.GuidebookLevel; import com.hfstudio.guidenh.guide.scene.support.GuideBlockMatcher; @@ -108,22 +110,45 @@ public void compile(GuidebookLevel level, CameraSettings camera, PageCompiler co int bdz = boundedExtent(MdxAttrs.getInt(compiler, errorSink, el, "dz", 1)); boolean formed = SceneStructureOptions.isFormed(compiler, errorSink, el); - ReplaceBlockExecutor.execute( - level, - fromMatcher, - fromNbt, - toBlock, - toMeta, - toNbt, - toMatcher.getBlockId(), - hasBounds, - bx, - by, - bz, - bdx, - bdy, - bdz, - formed); + LytGuidebookScene scene = AnnotationTagCompiler.CURRENT_SCENE.get(); + if (scene != null) { + NBTTagCompound deferredFromNbt = fromNbt; + NBTTagCompound deferredToNbt = toNbt; + scene.addDeferredBlockMutation( + () -> ReplaceBlockExecutor.execute( + level, + fromMatcher, + deferredFromNbt, + toBlock, + toMeta, + deferredToNbt, + toMatcher.getBlockId(), + hasBounds, + bx, + by, + bz, + bdx, + bdy, + bdz, + formed)); + } else { + ReplaceBlockExecutor.execute( + level, + fromMatcher, + fromNbt, + toBlock, + toMeta, + toNbt, + toMatcher.getBlockId(), + hasBounds, + bx, + by, + bz, + bdx, + bdy, + bdz, + formed); + } } private static int boundedExtent(int declared) { diff --git a/src/main/java/com/hfstudio/guidenh/guide/scene/level/GuidebookLevel.java b/src/main/java/com/hfstudio/guidenh/guide/scene/level/GuidebookLevel.java index c438c2b0..0f623c28 100644 --- a/src/main/java/com/hfstudio/guidenh/guide/scene/level/GuidebookLevel.java +++ b/src/main/java/com/hfstudio/guidenh/guide/scene/level/GuidebookLevel.java @@ -198,7 +198,7 @@ public void prepareForPreview() { } previewStateDirty = false; rebindAllTileEntities(); - // Tick first so tile entities (e.g. BC pipes) call initialize() 鈫?computeConnections() + // Tick first so tile entities (e.g. BC pipes) call initialize() -> computeConnections() // before compat helpers read their state. tickPreviewWorld(); GuidePreviewStateSupport.prepare(this); diff --git a/src/main/java/com/hfstudio/guidenh/guide/scene/ponder/PonderKeyframe.java b/src/main/java/com/hfstudio/guidenh/guide/scene/ponder/PonderKeyframe.java index 1f9ec89d..91a49362 100644 --- a/src/main/java/com/hfstudio/guidenh/guide/scene/ponder/PonderKeyframe.java +++ b/src/main/java/com/hfstudio/guidenh/guide/scene/ponder/PonderKeyframe.java @@ -62,9 +62,9 @@ public class PonderKeyframe { * Maximum number of ticks over which the camera eases from the previous keyframe's position * to this keyframe's position. *

            - *
          • {@code null} (absent in JSON) — ease over the full duration of the segment (default behaviour).
          • - *
          • {@code 0} — instant snap to this keyframe's camera with no interpolation.
          • - *
          • {@code N > 0} — ease completes in {@code N} ticks; camera holds at the target for the + *
          • {@code null} (absent in JSON) - ease over the full duration of the segment (default behaviour).
          • + *
          • {@code 0} - instant snap to this keyframe's camera with no interpolation.
          • + *
          • {@code N > 0} - ease completes in {@code N} ticks; camera holds at the target for the * rest of the segment.
          • *
          */ diff --git a/src/main/java/com/hfstudio/guidenh/guide/scene/preview/StructureLibDefinitionCache.java b/src/main/java/com/hfstudio/guidenh/guide/scene/preview/StructureLibDefinitionCache.java index f4b386ae..3ae37a76 100644 --- a/src/main/java/com/hfstudio/guidenh/guide/scene/preview/StructureLibDefinitionCache.java +++ b/src/main/java/com/hfstudio/guidenh/guide/scene/preview/StructureLibDefinitionCache.java @@ -20,7 +20,6 @@ import blockrenderer6343.integration.gregtech.GTConstructableScan; import gregtech.api.GregTechAPI; import gregtech.api.interfaces.metatileentity.IMetaTileEntity; -import lombok.Getter; /** * Resolves the tier and channel ranges BlockRenderer6343 found for a controller. @@ -37,9 +36,7 @@ public class StructureLibDefinitionCache { private final Map resolvedControllers = new ConcurrentHashMap<>(); private final Map resolvedData = new ConcurrentHashMap<>(); private volatile boolean scanRequested; - @Getter private volatile boolean scansComplete; - @Getter private volatile long scanGeneration; private StructureLibDefinitionCache() {} @@ -75,6 +72,16 @@ public synchronized void startScans() { }, "GuideNH-StructureLibScan").start(); } + /** Returns true after both asynchronous definition scans have published their results. */ + public boolean areScansComplete() { + return scansComplete; + } + + /** Monotonically increases whenever a complete scan result becomes available. */ + public long getScanGeneration() { + return scanGeneration; + } + private void scanStructureLibContainersSafely() { new GuideStructureLibContainerScan(ignored -> {}, stacks -> { indexScannedControllers(stacks); @@ -108,6 +115,8 @@ private void indexScannedControllers(Map stacks) { } } + // Machine discovery. + /** * Finds the {@code IConstructable} a controller id refers to. * @@ -159,8 +168,12 @@ private static boolean isControllerMatch(IConstructable c, String blockId) { return false; } + // Tier and channel metadata. + /** - * The tier and channel ranges of a machine, or an empty default when it exposes none. + * Get tier/channel metadata for a machine. If the machine has no tiered elements, + * falls back to ConstructableData.getTierData() which returns an empty default (maxTotalTier=1). + * Only determines whether tier/channel sliders appear - does NOT affect rendering. */ public ConstructableData getConstructableData(IConstructable c) { ConstructableData merged = null; @@ -181,7 +194,8 @@ public ConstructableData getConstructableData(IConstructable c) { } } } catch (Throwable ignored) { - // The accessor is only available when BlockRenderer6343's compatibility mixin is applied. + // The published map may be unavailable while BlockRenderer6343's own scans are still running. + // Its single-entry lookup still answers for the controllers registered at this point. ConstructableData direct = ConstructableData.getTierData(c); merged = direct.hasData() ? direct : null; } @@ -194,9 +208,9 @@ private static ConstructableData mergeData(@Nullable ConstructableData current, if (candidate == null || !candidate.hasData()) return current; ConstructableData merged = current != null ? current : new ConstructableData(); merged.setMaxTier(candidate.getMaxTotalTier(), ""); - var channelMaxTierMap = candidate.getChannelMaxTierMap(); - if (channelMaxTierMap != null) { - for (var channel : channelMaxTierMap.object2IntEntrySet()) { + if (candidate.getChannelMaxTierMap() != null) { + for (var channel : candidate.getChannelMaxTierMap() + .object2IntEntrySet()) { if (channel.getKey() != null && !channel.getKey() .trim() .isEmpty()) { diff --git a/src/main/java/com/hfstudio/guidenh/guide/scene/support/BlockAnnotationTemplateExpander.java b/src/main/java/com/hfstudio/guidenh/guide/scene/support/BlockAnnotationTemplateExpander.java index 62e604fd..c50a802e 100644 --- a/src/main/java/com/hfstudio/guidenh/guide/scene/support/BlockAnnotationTemplateExpander.java +++ b/src/main/java/com/hfstudio/guidenh/guide/scene/support/BlockAnnotationTemplateExpander.java @@ -41,6 +41,7 @@ public static SceneAnnotation translate(SceneAnnotation templateAnnotation, int diamondAnnotation.getColor()); translated.setAlwaysOnTop(diamondAnnotation.isAlwaysOnTop()); translated.setTooltip(templateAnnotation.getTooltip()); + translated.setStructureLibCondition(templateAnnotation.getStructureLibCondition()); return translated; } diff --git a/src/main/java/com/hfstudio/guidenh/guide/scene/support/GuideDebugLog.java b/src/main/java/com/hfstudio/guidenh/guide/scene/support/GuideDebugLog.java index 7d1c2110..31738736 100644 --- a/src/main/java/com/hfstudio/guidenh/guide/scene/support/GuideDebugLog.java +++ b/src/main/java/com/hfstudio/guidenh/guide/scene/support/GuideDebugLog.java @@ -21,6 +21,14 @@ public class GuideDebugLog { protected GuideDebugLog() {} + /** Layout diagnostic overlay + tracer flag: -Dguidenh.layoutOverlay=true */ + private static final boolean LAYOUT_OVERLAY = Boolean.getBoolean("guidenh.layoutOverlay"); + + /** Whether the layout diagnostic overlay/tracer is enabled (JVM property or in-game debug option). */ + public static boolean isLayoutOverlayEnabled() { + return LAYOUT_OVERLAY || ModConfig.debug.layoutOverlay; + } + public static ContextScope pushContext(String language, String sourceLanguage, String sourcePath) { LogContext previous = CONTEXT.get(); CONTEXT.set(new LogContext(language, sourceLanguage, sourcePath, null, activeDiagnostics)); @@ -138,8 +146,11 @@ public static void warnAlways(@Nullable CharSequence message, Object... args) { return; } record(DiagnosticLevel.WARNING, message.toString(), args); - FMLLog.getLogger() - .warn(withContext(message.toString()), args); + var logger = FMLLog.getLogger(); + if (logger == null) { + return; // Test environment without FML initialized + } + logger.warn(withContext(message.toString()), args); } public static void info(boolean enabled, @Nullable CharSequence message, Object... args) { diff --git a/src/main/java/com/hfstudio/guidenh/guide/siteexport/site/GuideSiteGraphRenderer.java b/src/main/java/com/hfstudio/guidenh/guide/siteexport/site/GuideSiteGraphRenderer.java index 968d32e1..1b81cbab 100644 --- a/src/main/java/com/hfstudio/guidenh/guide/siteexport/site/GuideSiteGraphRenderer.java +++ b/src/main/java/com/hfstudio/guidenh/guide/siteexport/site/GuideSiteGraphRenderer.java @@ -41,6 +41,7 @@ import com.hfstudio.guidenh.guide.internal.mermaid.flowchart.FlowchartSubgraph; import com.hfstudio.guidenh.guide.internal.mermaid.mindmap.MindmapDocument; import com.hfstudio.guidenh.guide.internal.mermaid.mindmap.MindmapNode; +import com.hfstudio.guidenh.guide.render.GuideText; /** * Generates static HTML and SVG markup for chart, function-graph, file-tree, @@ -839,7 +840,7 @@ private static void renderFlowchartNodes(StringBuilder sb, FlowchartDocument doc int contentW = rectW - padX * 2; int contentH = rectH - padY * 2; LytRect contentArea = FlowchartShapes - .contentBounds(new LytRect(rectX, rectY, rectW, rectH), shape, contentW, contentH, padX, padY); + .contentBounds(new LytRect(rectX, rectY, rectW, rectH), shape, contentW, contentH, padX, padY, 1f); int textY = contentArea.y(); String textColor = isRoot ? "#F1F6FB" : "#D7DEE7"; @@ -2916,17 +2917,18 @@ private static void appendFunctionGraphLabel(StringBuilder svg, @Nullable String } List segments = new ArrayList<>(); int totalWidth = 0; + // Labels are exported at a 100px font size for PNG quality; bounds are normalized + // back to the 10px graph unit scale (typeset-at-target-size exporter, so a plain + // ratio, no reference-height calibration). + final float exportToGraphScale = 10f / 100f; for (MarkdownLatexShorthand.Segment segment : MarkdownLatexShorthand.split(text)) { GuideSiteLatexExporter.ExportedLatex exported = segment.isFormula() && latexExporter != null ? latexExporter.export(segment.getValue(), color, 100f) : null; String fallback = segment.isFormula() ? "$$" + segment.getValue() + "$$" : segment.getValue(); - int width = exported != null - ? Math.max(1, Math.round((float) exported.widthPx() * 10f / exported.referenceHeightPx())) + int width = exported != null ? Math.max(1, Math.round(exported.widthPx() * exportToGraphScale)) : estimateFunctionGraphLabelWidth(fallback); - int height = exported != null - ? Math.max(1, Math.round((float) exported.heightPx() * 10f / exported.referenceHeightPx())) - : 0; + int height = exported != null ? Math.max(1, Math.round(exported.heightPx() * exportToGraphScale)) : 0; segments.add(new FunctionGraphLabelSegment(fallback, exported, width, height)); totalWidth += width; } @@ -3335,7 +3337,7 @@ private static void renderFunctionGraphCornerLegend(StringBuilder svg, List") - .append(esc(ellipsize(plot.getLabel(), maxChars))) + .append(esc(GuideText.clipToChars(plot.getLabel(), maxChars, GuideText.ClipSuffix.DOTS3))) .append(""); rowY += CORNER_LEGEND_ROW_H; } @@ -3428,7 +3430,7 @@ private static void renderChartCornerLegend(StringBuilder svg, List .append("\" y=\"") .append(rowY) .append("\" font-size=\"9\" fill=\"#FFFFFF\" font-family=\"inherit\">") - .append(esc(ellipsize(item.name, maxChars))) + .append(esc(GuideText.clipToChars(item.name, maxChars, GuideText.ClipSuffix.DOTS3))) .append(""); rowY += CORNER_LEGEND_ROW_H; } @@ -3831,7 +3833,11 @@ private static void renderLegend(StringBuilder svg, List series, int .append(CHART_TEXT_SIZE) .append("\" fill=\"#D7DEE7\" font-family=\"inherit\">") .append( - esc(ellipsize(s.name, Math.max(1, (itemW - LEGEND_SWATCH - 2 * LEGEND_GAP) / CHART_CHAR_WIDTH)))) + esc( + GuideText.clipToChars( + s.name, + Math.max(1, (itemW - LEGEND_SWATCH - 2 * LEGEND_GAP) / CHART_CHAR_WIDTH), + GuideText.ClipSuffix.DOTS3))) .append(""); curX += itemW; col++; @@ -3886,19 +3892,6 @@ private static int cornerLegendMaxChars(int boxX, int boxWidth, int textX) { return Math.max(0, room / CHART_CHAR_WIDTH); } - private static String ellipsize(String text, int maxChars) { - if (text == null || text.isEmpty() || maxChars <= 0) { - return ""; - } - if (text.length() <= maxChars) { - return text; - } - if (maxChars <= 3) { - return text.substring(0, maxChars); - } - return text.substring(0, maxChars - 3) + "..."; - } - private static int clamp(int value, int min, int max) { return Math.clamp(value, min, max); } diff --git a/src/main/java/com/hfstudio/guidenh/guide/siteexport/site/GuideSiteHtmlCompiler.java b/src/main/java/com/hfstudio/guidenh/guide/siteexport/site/GuideSiteHtmlCompiler.java index 2b9f6b51..4926170f 100644 --- a/src/main/java/com/hfstudio/guidenh/guide/siteexport/site/GuideSiteHtmlCompiler.java +++ b/src/main/java/com/hfstudio/guidenh/guide/siteexport/site/GuideSiteHtmlCompiler.java @@ -5,7 +5,6 @@ import java.util.ArrayList; import java.util.List; import java.util.Locale; -import java.util.Map; import java.util.regex.Matcher; import java.util.regex.Pattern; @@ -32,8 +31,11 @@ import com.hfstudio.guidenh.guide.internal.markdown.MarkdownRuntimeBlocks.BlockquoteDirective; import com.hfstudio.guidenh.guide.internal.markdown.MarkdownRuntimeBlocks.QuoteIconSpec; import com.hfstudio.guidenh.guide.internal.mermaid.MermaidDiagramType; +import com.hfstudio.guidenh.guide.internal.mermaid.flowchart.FlowchartDocument; import com.hfstudio.guidenh.guide.internal.mermaid.flowchart.FlowchartParser; +import com.hfstudio.guidenh.guide.internal.mermaid.mindmap.MindmapDocument; import com.hfstudio.guidenh.guide.internal.mermaid.mindmap.MindmapParser; +import com.hfstudio.guidenh.guide.render.GuideText; import com.hfstudio.guidenh.guide.scene.support.GuideDebugLog; import com.hfstudio.guidenh.guide.sound.GuideSoundSpec; import com.hfstudio.guidenh.guide.sound.GuideSoundTrigger; @@ -592,7 +594,7 @@ private String compileParagraph(MdxJsxElementFields el, GuideSiteTemplateRegistr String defaultNamespace, @Nullable ResourceLocation currentPageId, SceneResolver sceneResolver) { String displayFormula = extractSoleDisplayLatexFromElement(el); if (displayFormula != null) { - return renderLatex(displayFormula, null, 1.0f, 100.0f, false, null, null, 0, 0, true, templates); + return renderLatex(displayFormula, null, 1.0f, false, null, null, 0, 0, true, templates); } return "

          " + compileChildren(el.children(), templates, defaultNamespace, currentPageId, sceneResolver) + "

          "; @@ -777,7 +779,24 @@ private String compileCodeBlockMdx(MdxJsxElementFields el, GuideSiteTemplateRegi return rendered != null ? rendered : GuideSiteGraphRenderer.renderFileTree(codeText); } if ("mermaid".equals(lang)) { - return renderMermaidCodeBlock(codeText, lang, width, height); + try { + MermaidDiagramType type = MermaidDiagramType.detect(codeText); + switch (type) { + case MINDMAP -> { + MindmapDocument doc = MindmapParser.parse(codeText); + return GuideSiteGraphRenderer.renderMermaidTree(doc); + } + case FLOWCHART -> { + FlowchartDocument doc = FlowchartParser.parse(codeText); + return GuideSiteGraphRenderer.renderFlowchart(doc); + } + case UNKNOWN -> { + return CODE_BLOCK_RENDERER.render("mermaid", codeText, width, height); + } + } + } catch (Exception ignored) { + return CODE_BLOCK_RENDERER.render("mermaid", codeText, width, height); + } } if ("funcgraph".equals(lang) || "functiongraph".equals(lang)) { try { @@ -791,19 +810,6 @@ private String compileCodeBlockMdx(MdxJsxElementFields el, GuideSiteTemplateRegi return CODE_BLOCK_RENDERER.render(lang, codeText, width, height); } - private String renderMermaidCodeBlock(String codeText, String language, @Nullable Integer width, - @Nullable Integer height) { - try { - return switch (MermaidDiagramType.detect(codeText)) { - case FLOWCHART -> GuideSiteGraphRenderer.renderFlowchart(FlowchartParser.parse(codeText), Map.of()); - case MINDMAP -> GuideSiteGraphRenderer.renderMermaidTree(MindmapParser.parse(codeText), Map.of()); - case UNKNOWN -> CODE_BLOCK_RENDERER.render(language, codeText, width, height); - }; - } catch (IllegalArgumentException ignored) { - return CODE_BLOCK_RENDERER.render(language, codeText, width, height); - } - } - @Nullable private static Integer parseMetaInt(String meta, String key) { if (meta == null || meta.isEmpty()) { @@ -1155,60 +1161,147 @@ private String compileFloatingImage(MdxJsxElementFields element, GuideSiteTempla if (heightValue != null && heightAlias != null) { return renderExportError("FloatingImage cannot use both height and h."); } - Integer cropX = parsePositiveOrZeroInt(element.getAttributeString("x", null)); - Integer cropY = parsePositiveOrZeroInt(element.getAttributeString("y", null)); - Integer cropWidth = parseAliasedPositiveInt(element, "width", "w"); - Integer cropHeight = parseAliasedPositiveInt(element, "height", "h"); + // Parity with FloatingImageCompiler.parseCropSpec: hasWidth / + // hasHeight are decided by raw attribute presence, so a present-but- + // malformed or non-positive dimension reports an error instead of + // silently falling back to the single-parameter path. + boolean hasWidth = hasText(widthValue) || hasText(widthAlias); + boolean hasHeight = hasText(heightValue) || hasText(heightAlias); String displayWidthValue = element.getAttributeString("displayWidth", null); String displayHeightValue = element.getAttributeString("displayHeight", null); Integer explicitDisplayWidth = parsePositiveInt(displayWidthValue); Integer explicitDisplayHeight = parsePositiveInt(displayHeightValue); - Double scaleX = parsePositiveDouble(element.getAttributeString("scaleX", null), 1.0d); - Double scaleY = parsePositiveDouble(element.getAttributeString("scaleY", null), 1.0d); - if (scaleX == null || scaleY == null) { - return renderExportError("FloatingImage scaleX and scaleY must be positive numbers."); - } if ((displayWidthValue != null && explicitDisplayWidth == null) || (displayHeightValue != null && explicitDisplayHeight == null)) { return renderExportError("FloatingImage displayWidth and displayHeight must be positive integers."); } boolean hasExplicitDisplaySize = explicitDisplayWidth != null || explicitDisplayHeight != null; + // A single explicit dimension (width-only or height-only) is a valid + // whole-image display size, and an explicit pixel display size can stand + // in for the whole width / height pair when every crop attribute is + // absent; only the "nothing to size the image with" case is an error. + if (!hasWidth && !hasHeight && !(hasExplicitDisplaySize && !hasCropAttributes(element))) { + return renderExportError("FloatingImage requires width or w, and height or h."); + } + // Error semantics mirror FloatingImageCompiler.parseAliasedIntAttr / + // parseCropSpec: a present non-integer reports "must be an integer", a + // present-but-non-positive value reports the non-positive error. + Integer width = null; + if (hasWidth) { + StringBuilder widthError = new StringBuilder(); + width = parsePresentDimension(widthValue != null ? widthValue : widthAlias, "width", widthError); + if (width == null) { + return renderExportError(widthError.toString()); + } + } + Integer height = null; + if (hasHeight) { + StringBuilder heightError = new StringBuilder(); + height = parsePresentDimension(heightValue != null ? heightValue : heightAlias, "height", heightError); + if (height == null) { + return renderExportError(heightError.toString()); + } + } + // x/y are crop offsets and default to 0 when absent (mirrors the + // runtime); a present-but-malformed or negative value is still an error. + Integer cropX = parseOptionalNonNegativeInt(element.getAttributeString("x", null), 0); + Integer cropY = parseOptionalNonNegativeInt(element.getAttributeString("y", null), 0); + if (cropX == null || cropY == null) { + return renderExportError("FloatingImage x and y must be non-negative integers."); + } + Double scaleX = parsePositiveDouble(element.getAttributeString("scaleX", null), 1.0d); + Double scaleY = parsePositiveDouble(element.getAttributeString("scaleY", null), 1.0d); + if (scaleX == null || scaleY == null) { + return renderExportError("FloatingImage scaleX and scaleY must be positive numbers."); + } if (hasExplicitDisplaySize && (element.getAttributeString("scaleX", null) != null || element.getAttributeString("scaleY", null) != null)) { return renderExportError("FloatingImage displayWidth/displayHeight cannot be used with scaleX/scaleY."); } + // displayWidth / displayHeight state the final pixel size directly, so a + // crop rectangle has to be complete for the single-dimension inference to + // have a defined source aspect ratio (mirrors FloatingImageCompiler). + if (hasExplicitDisplaySize && hasCropAttributes(element) && !(hasWidth && hasHeight)) { + return renderExportError( + "FloatingImage displayWidth/displayHeight require either no crop attributes or the full " + + "x, y, width or w, and height or h crop rectangle."); + } String src = resolveImageSource(rawSrc, currentPageId); + boolean inlineWrap = "inline".equals(element.getAttributeString("wrap", null)); String align = element.getAttributeString("align", "left"); if (!inlineWrap && !"left".equals(align) && !"right".equals(align)) { return renderExportError("FloatingImage align must be left or right unless wrap=\"inline\"."); } - StringBuilder wrapperStyle = new StringBuilder(); - if (inlineWrap) { - wrapperStyle.append("display:inline-block;vertical-align:middle;"); - } else if ("right".equals(align)) { - wrapperStyle.append("float:right;clear:both;margin:0 0 5px 5px;"); - } else { - wrapperStyle.append("float:left;clear:both;margin:0 5px 5px 0;"); + List annotations = collectImageAnnotations( + element, + templates, + defaultNamespace, + currentPageId, + sceneResolver, + hasWidth && hasHeight ? width : null, + hasWidth && hasHeight ? height : null); + + if (hasWidth && hasHeight) { + // displayWidth / displayHeight, when present, replace the crop and + // scale display size: the crop is unchanged and the is scaled + // by the ratio between the requested display size and the crop + // rectangle. + double displayWidth = explicitDisplayWidth != null ? explicitDisplayWidth + : explicitDisplayHeight != null ? explicitDisplayHeight * width / (double) height : width * scaleX; + double displayHeight = explicitDisplayHeight != null ? explicitDisplayHeight + : explicitDisplayWidth != null ? explicitDisplayWidth * height / (double) width : height * scaleY; + StringBuilder wrapperStyle = new StringBuilder(); + if (inlineWrap) { + wrapperStyle.append("display:inline-block;vertical-align:middle;"); + } else if ("right".equals(align)) { + wrapperStyle.append("float:right;clear:both;margin:0 0 5px 5px;"); + } else { + wrapperStyle.append("float:left;clear:both;margin:0 5px 5px 0;"); + } + // The crop box height comes from the stage span's aspect ratio, so the + // wrapper fixes the width only and the browser can size the crop + // before the image loads. + wrapperStyle.append("width:") + .append(toCssNumber(displayWidth)) + .append("px;"); + return buildFloatingImageHtml( + src, + alt, + title, + wrapperStyle.toString(), + null, + null, + true, + cropX, + cropY, + width, + height, + displayWidth / width, + displayHeight / height, + inlineWrap, + annotations); } - if (!hasCropAttributes(element)) { - if (!hasExplicitDisplaySize) { - return renderExportError( - "FloatingImage requires non-negative x and y, plus positive width or w and height or h."); + if (hasExplicitDisplaySize) { + // Whole-source-image display at an explicit pixel size: the wrapper + // fixes the width when one was requested, and buildFloatingImageHtml + // writes the display size onto the (aspect ratio for a full + // pair, data-display-height for a height-only request), so the + // browser derives the single missing axis from the intrinsic ratio. + // No crop data attributes are emitted. + StringBuilder wrapperStyle = new StringBuilder(); + if (inlineWrap) { + wrapperStyle.append("display:inline-block;vertical-align:middle;"); + } else if ("right".equals(align)) { + wrapperStyle.append("float:right;clear:both;margin:0 0 5px 5px;"); + } else { + wrapperStyle.append("float:left;clear:both;margin:0 5px 5px 0;"); } if (explicitDisplayWidth != null) { wrapperStyle.append("width:") - .append(explicitDisplayWidth) + .append(toCssNumber(explicitDisplayWidth)) .append("px;"); } - List annotations = collectImageAnnotations( - element, - templates, - defaultNamespace, - currentPageId, - sceneResolver, - null, - null); return buildFloatingImageHtml( src, alt, @@ -1226,49 +1319,91 @@ private String compileFloatingImage(MdxJsxElementFields element, GuideSiteTempla inlineWrap, annotations); } - if (cropX == null || cropY == null || cropWidth == null || cropHeight == null) { - return renderExportError( - "FloatingImage requires non-negative x and y, plus positive width or w and height or h."); - } - - double displayWidth = explicitDisplayWidth != null ? explicitDisplayWidth - : explicitDisplayHeight != null ? explicitDisplayHeight * cropWidth / (double) cropHeight - : cropWidth * scaleX; - double displayHeight = explicitDisplayHeight != null ? explicitDisplayHeight - : explicitDisplayWidth != null ? explicitDisplayWidth * cropHeight / (double) cropWidth - : cropHeight * scaleY; - double effectiveScaleX = displayWidth / cropWidth; - double effectiveScaleY = displayHeight / cropHeight; - wrapperStyle.append("width:") - .append(toCssNumber(displayWidth)) - .append("px;"); - - List annotations = collectImageAnnotations( - element, - templates, - defaultNamespace, - currentPageId, - sceneResolver, - cropWidth, - cropHeight); - return buildFloatingImageHtml( + return buildSingleParamFloatingImageHtml( src, alt, title, - wrapperStyle.toString(), - null, - null, - true, - cropX, - cropY, - cropWidth, - cropHeight, - effectiveScaleX, - effectiveScaleY, inlineWrap, + align, + hasWidth, + hasWidth ? width * scaleX : height * scaleY, annotations); } + /** + * Builds the HTML for a single-parameter {@code FloatingImage} + * (width-only or height-only): whole-image display at the given dimension × + * its scale, with the missing axis inferred from the image's natural aspect + * ratio. The export side has no runtime texture, so the natural ratio + * cannot be pre-computed here; instead the wrapper fixes only the given + * axis and the {@code } keeps the other axis at {@code auto}, letting + * the browser derive it from the intrinsic ratio at render time. + * + *

          + * The {@code guide-floating-image-wrap}/{@code guide-floating-image} + * classes are deliberately omitted: the site's {@code layoutCroppedFloatingImage()} + * recomputes wrapper/img sizes from crop {@code data-*} attributes with a + * {@code naturalWidth} fallback and would otherwise override the given + * dimension with the image's natural size. The inline style replicates the + * class-based wrapper behaviour instead. + */ + private String buildSingleParamFloatingImageHtml(String src, String alt, @Nullable String title, boolean inlineWrap, + String align, boolean hasWidth, double displayDimension, List annotations) { + StringBuilder wrapperStyle = new StringBuilder(); + if (inlineWrap) { + wrapperStyle.append("display:inline-block;vertical-align:middle;"); + } else if ("right".equals(align)) { + wrapperStyle.append("float:right;clear:both;margin:0 0 5px 5px;"); + } else { + wrapperStyle.append("float:left;clear:both;margin:0 5px 5px 0;"); + } + wrapperStyle + .append("position:relative;display:inline-block;max-width:100%;vertical-align:top;overflow:hidden;"); + if (hasWidth) { + wrapperStyle.append("width:") + .append(toCssNumber(displayDimension)) + .append("px;"); + } else { + wrapperStyle.append("height:") + .append(toCssNumber(displayDimension)) + .append("px;"); + } + StringBuilder html = new StringBuilder(); + html.append(""); + html.append("\"")"); + appendImageAnnotationSpans(html, annotations); + html.append(""); + return html.toString(); + } + private boolean hasCropAttributes(MdxJsxElementFields element) { return element.getAttributeString("x", null) != null || element.getAttributeString("y", null) != null || element.getAttributeString("width", null) != null @@ -1465,6 +1600,18 @@ private String buildFloatingImageHtml(String src, String alt, @Nullable String t .append("\""); } html.append(" loading=\"lazy\" decoding=\"async\">"); + appendImageAnnotationSpans(html, annotations); + html.append(""); + if (title != null && !title.isEmpty()) { + html.append("") + .append(escapeHtml(title)) + .append(""); + } + html.append(""); + return html.toString(); + } + + private void appendImageAnnotationSpans(StringBuilder html, List annotations) { for (ImageAnnotationExport annotation : annotations) { html.append(""); } - html.append(""); - if (title != null && !title.isEmpty()) { - html.append("") - .append(escapeHtml(title)) - .append(""); - } - html.append(""); - return html.toString(); } private String compileLatex(MdxJsxElementFields element, boolean display, GuideSiteTemplateRegistry templates, @@ -1511,7 +1650,9 @@ private String compileLatex(MdxJsxElementFields element, boolean display, GuideS } String color = element.getAttributeString("color", null); float scale = parseFloat(element.getAttributeString("scale", null), 1.0f); - float sourceScale = parseFloat(element.getAttributeString("sourceScale", null), 100.0f); + // NOTE: sourceScale is DEPRECATED (legacy attribute, parsed for backward + // compatibility and ignored): formulas are typeset at + // BASE_FONT_SIZE × scale directly; there is no separate render size. String tooltipHtml = compileLatexTooltip(element, templates, defaultNamespace, currentPageId, sceneResolver); boolean tooltipDisabled = readBoolean(element, "noTooltip", false) || (element.getAttribute("showTooltip") != null && !readBoolean(element, "showTooltip", true)); @@ -1524,7 +1665,6 @@ private String compileLatex(MdxJsxElementFields element, boolean display, GuideS formula, color, scale, - sourceScale, showTooltip, tooltipHtml, valign, @@ -1535,9 +1675,9 @@ private String compileLatex(MdxJsxElementFields element, boolean display, GuideS } } - private String renderLatex(String formula, @Nullable String color, float scale, float sourceScale, - boolean showTooltip, @Nullable String tooltipHtml, @Nullable String valign, int offsetX, int offsetY, - boolean display, GuideSiteTemplateRegistry templates) { + private String renderLatex(String formula, @Nullable String color, float scale, boolean showTooltip, + @Nullable String tooltipHtml, @Nullable String valign, int offsetX, int offsetY, boolean display, + GuideSiteTemplateRegistry templates) { String tag = display ? "div" : "span"; StringBuilder classes = new StringBuilder( display ? "guide-latex guide-latex-display" : "guide-latex guide-latex-inline"); @@ -1554,11 +1694,11 @@ private String renderLatex(String formula, @Nullable String color, float scale, float safeScale = Math.max(0.1f, scale); int fillColorArgb = parseLatexColorArgb(color); GuideSiteLatexExporter.ExportedLatex exported = latexExporter != null - ? latexExporter.export(formula, fillColorArgb, Math.max(16f, sourceScale)) + ? latexExporter.export(formula, fillColorArgb, GuideText.BASE_FONT_SIZE * safeScale) : null; if (exported != null) { - appendCssPx(style, "width", displayWidth(exported, safeScale)); - appendCssPx(style, "height", displayHeight(exported, safeScale)); + appendCssPx(style, "width", displayWidth(exported)); + appendCssPx(style, "height", displayHeight(exported)); if (!display && isLatexBaselineAlign(valign)) { style.append("vertical-align:bottom;"); } @@ -1572,7 +1712,7 @@ private String renderLatex(String formula, @Nullable String color, float scale, } int visualOffsetY = offsetY; if (exported != null && !display && isLatexBaselineAlign(valign)) { - visualOffsetY += displayDepth(exported, safeScale); + visualOffsetY += displayDepth(exported); } if (offsetX != 0 || visualOffsetY != 0) { style.append("transform:translate(") @@ -1647,17 +1787,20 @@ private String compileLatexTooltip(MdxJsxElementFields element, GuideSiteTemplat : null; } - private int displayWidth(GuideSiteLatexExporter.ExportedLatex exported, float scale) { - return Math.max(1, (int) Math.ceil((double) exported.widthPx() * 20.0d * scale / exported.referenceHeightPx())); + /** + * The exporter typesets at {@code BASE_FONT_SIZE × scale}, so the exported pixel dimensions + * ARE the target display pixels, used verbatim, with no reference-height scaling conversion. + */ + private int displayWidth(GuideSiteLatexExporter.ExportedLatex exported) { + return Math.max(1, exported.widthPx()); } - private int displayHeight(GuideSiteLatexExporter.ExportedLatex exported, float scale) { - return Math - .max(1, (int) Math.ceil((double) exported.heightPx() * 20.0d * scale / exported.referenceHeightPx())); + private int displayHeight(GuideSiteLatexExporter.ExportedLatex exported) { + return Math.max(1, exported.heightPx()); } - private int displayDepth(GuideSiteLatexExporter.ExportedLatex exported, float scale) { - return Math.max(0, (int) Math.ceil((double) exported.depthPx() * 20.0d * scale / exported.referenceHeightPx())); + private int displayDepth(GuideSiteLatexExporter.ExportedLatex exported) { + return Math.max(0, exported.depthPx()); } private boolean isLatexBaselineAlign(@Nullable String valign) { @@ -1700,8 +1843,7 @@ private String compileLatexText(String text, GuideSiteTemplateRegistry templates StringBuilder html = new StringBuilder(); for (MarkdownLatexShorthand.Segment segment : MarkdownLatexShorthand.split(text)) { if (segment.isFormula()) { - html.append( - renderLatex(segment.getValue(), null, 1.0f, 100.0f, false, null, null, 0, 0, false, templates)); + html.append(renderLatex(segment.getValue(), null, 1.0f, false, null, null, 0, 0, false, templates)); } else { html.append(escapeHtml(segment.getValue())); } @@ -1946,6 +2088,27 @@ private Integer parsePositiveOrZeroInt(@Nullable String raw) { } } + /** + * Parses an optional non-negative integer with a fallback for the absent + * case (mirrors {@code FloatingImageCompiler.parseOptionalIntAttr}): absent + * attributes yield the fallback (e.g. 0 for x/y), while a present-but- + * malformed or negative value yields {@code null} so the caller can report + * it. Used for single-parameter FloatingImage x/y crop offsets. + */ + @Nullable + private Integer parseOptionalNonNegativeInt(@Nullable String raw, int fallback) { + if (raw == null || raw.trim() + .isEmpty()) { + return fallback; + } + try { + int value = Integer.parseInt(raw.trim()); + return value >= 0 ? value : null; + } catch (NumberFormatException ignored) { + return null; + } + } + private int readInt(MdxJsxElementFields element, String name, int fallback) { String raw = element.getAttributeString(name, null); if (raw == null || raw.trim() @@ -1959,14 +2122,30 @@ private int readInt(MdxJsxElementFields element, String name, int fallback) { } } + /** + * Parses a FloatingImage width/height dimension that is known to be present + * in the raw attributes (non-blank). Mirrors {@code FloatingImageCompiler}: + * a malformed value appends "{name} must be an integer." to + * {@code errorMessage}, while a present-but-non-positive value appends the + * non-positive error. Returns the parsed positive value, or {@code null} + * with {@code errorMessage} populated on failure. + */ @Nullable - private Integer parseAliasedPositiveInt(MdxJsxElementFields element, String primaryName, String aliasName) { - String primaryValue = element.getAttributeString(primaryName, null); - String aliasValue = element.getAttributeString(aliasName, null); - if (primaryValue != null && aliasValue != null) { + private Integer parsePresentDimension(String raw, String name, StringBuilder errorMessage) { + try { + int value = Integer.parseInt(raw.trim()); + if (value <= 0) { + errorMessage + .append("FloatingImage crop values must be non-negative and width/height must be positive."); + return null; + } + return value; + } catch (NumberFormatException ex) { + errorMessage.append("FloatingImage ") + .append(name) + .append(" must be an integer."); return null; } - return parsePositiveInt(primaryValue != null ? primaryValue : aliasValue); } @Nullable diff --git a/src/main/java/com/hfstudio/guidenh/guide/siteexport/site/GuideSiteLatexExporter.java b/src/main/java/com/hfstudio/guidenh/guide/siteexport/site/GuideSiteLatexExporter.java index 9880bb7d..ca8e80c9 100644 --- a/src/main/java/com/hfstudio/guidenh/guide/siteexport/site/GuideSiteLatexExporter.java +++ b/src/main/java/com/hfstudio/guidenh/guide/siteexport/site/GuideSiteLatexExporter.java @@ -16,40 +16,46 @@ import com.hfstudio.guidenh.guide.color.ColorUtils; import com.hfstudio.guidenh.guide.scene.support.GuideDebugLog; +/** + * Exports LaTeX formulas as PNG assets for the static site. + * + *

          + * Typeset-at-target-size. Formulas are typeset directly at + * {@code fontSize = GuideText.BASE_FONT_SIZE × userScale} pixels + * ({@code setSize(fontSize)}), so the exported {@code widthPx}/{@code heightPx}/ + * {@code depthPx} ARE the target display pixel dimensions; the site HTML uses + * them verbatim. No reference-string calibration, no scaling conversion. + */ public class GuideSiteLatexExporter { - private static final String CALIBRATION_FORMULA = "x"; - private final GuideSiteAssetRegistry assets; private final Map exports = new HashMap<>(); - private final Map referenceHeights = new HashMap<>(); public GuideSiteLatexExporter(GuideSiteAssetRegistry assets) { this.assets = assets; } - public ExportedLatex export(String formula, int fillColorArgb, float sourceScale) { + public ExportedLatex export(String formula, int fillColorArgb, float fontSize) { if (formula == null || formula.trim() .isEmpty()) { return null; } - float safeSourceScale = Math.max(16f, sourceScale); - String key = fillColorArgb + ":" + safeSourceScale + ":" + formula; + float safeFontSize = Math.max(1f, fontSize); + String key = fillColorArgb + ":" + safeFontSize + ":" + formula; ExportedLatex cached = exports.get(key); if (cached != null) { return cached; } try { - TeXIcon icon = createIcon(formula, fillColorArgb, safeSourceScale); + TeXIcon icon = createIcon(formula, fillColorArgb, safeFontSize); BufferedImage image = renderImage(icon); String src = GuideSitePageAssetExporter.ROOT_PREFIX + assets.writePngAsync("latex", image); ExportedLatex exported = new ExportedLatex( src, icon.getIconWidth(), icon.getIconHeight(), - Math.max(0, (int) Math.ceil(icon.getTrueIconDepth())), - referenceHeight(safeSourceScale)); + Math.max(0, (int) Math.ceil(icon.getTrueIconDepth()))); exports.put(key, exported); return exported; } catch (ParseException e) { @@ -68,6 +74,7 @@ public ExportedLatex export(String formula, int fillColorArgb, float sourceScale } } + /** Escapes control characters so one export warning stays on one log line. */ private String formatFormulaForLog(String formula) { return formula.replace("\\", "\\\\") .replace("\r", "\\r") @@ -75,24 +82,17 @@ private String formatFormulaForLog(String formula) { .replace("\t", "\\t"); } - private int referenceHeight(float sourceScale) throws ParseException { - Integer cached = referenceHeights.get(sourceScale); - if (cached != null) { - return cached; - } - TeXIcon icon = createIcon(CALIBRATION_FORMULA, ColorUtils.WHITE.getColor(), sourceScale); - int height = Math.max(1, icon.getIconHeight()); - referenceHeights.put(sourceScale, height); - return height; - } - - private TeXIcon createIcon(String formula, int fillColorArgb, float sourceScale) throws ParseException { + private TeXIcon createIcon(String formula, int fillColorArgb, float fontSize) throws ParseException { TeXFormula texFormula = new TeXFormula(formula); TeXIcon icon = texFormula.new TeXIconBuilder().setStyle(TeXConstants.STYLE_DISPLAY) - .setSize(sourceScale) + .setSize(fontSize) .setFGColor(new Color(fillColorArgb, true)) .build(); - icon.setInsets(new Insets(2, 2, 2, 2)); + // Two-arg form (trueValues): keep the intended 2px/side insets. The + // single-arg setInsets(Insets) delegates to setInsets(insets, false) + // and silently inflates every side by (int)(0.18f*size). Same + // pitfall/fix as GuideLatexRenderer. + icon.setInsets(new Insets(2, 2, 2, 2), true); icon.setForeground(new Color(fillColorArgb, true)); return icon; } @@ -125,14 +125,12 @@ public static class ExportedLatex { private final int widthPx; private final int heightPx; private final int depthPx; - private final int referenceHeightPx; - public ExportedLatex(String src, int widthPx, int heightPx, int depthPx, int referenceHeightPx) { + public ExportedLatex(String src, int widthPx, int heightPx, int depthPx) { this.src = src; this.widthPx = widthPx; this.heightPx = heightPx; this.depthPx = depthPx; - this.referenceHeightPx = referenceHeightPx; } public String src() { @@ -150,9 +148,5 @@ public int heightPx() { public int depthPx() { return depthPx; } - - public int referenceHeightPx() { - return referenceHeightPx; - } } } diff --git a/src/main/java/com/hfstudio/guidenh/guide/siteexport/site/GuideSiteMdxTagRenderer.java b/src/main/java/com/hfstudio/guidenh/guide/siteexport/site/GuideSiteMdxTagRenderer.java index b1095a97..7da6986e 100644 --- a/src/main/java/com/hfstudio/guidenh/guide/siteexport/site/GuideSiteMdxTagRenderer.java +++ b/src/main/java/com/hfstudio/guidenh/guide/siteexport/site/GuideSiteMdxTagRenderer.java @@ -1040,7 +1040,7 @@ private String renderItemLink(MdxJsxElementFields element, String defaultNamespa ? GuideSiteItemSupport.export(item.registryId(), item.stack(), itemIconResolver, itemId) : GuideSiteItemSupport.unresolved(itemId); - // showTooltip — default true for ItemLink + // showTooltip: default true for ItemLink MdxJsxAttribute noTooltipAttribute = element.getAttribute("noTooltip"); MdxJsxAttribute showTooltipAttribute = element.getAttribute("showTooltip"); boolean noTooltip = readBoolean(noTooltipAttribute, "noTooltip", false); @@ -1048,7 +1048,7 @@ private String renderItemLink(MdxJsxElementFields element, String defaultNamespa : !noTooltip; boolean showText = readBoolean(element, "showText", true); - // showIcon — null/falsy = no icon; "left", "right", or any truthy = icon at that side + // showIcon: null/falsy = no icon; "left", "right", or any truthy = icon at that side String iconPosition = ItemImageCompiler.resolveLabelPosition(readOptional(element.getAttribute("showIcon"))); String templateId = null; diff --git a/src/main/java/com/hfstudio/guidenh/guide/siteexport/site/GuideSiteNeiPhase1BackgroundExporter.java b/src/main/java/com/hfstudio/guidenh/guide/siteexport/site/GuideSiteNeiPhase1BackgroundExporter.java index b6e24bbd..b2331935 100644 --- a/src/main/java/com/hfstudio/guidenh/guide/siteexport/site/GuideSiteNeiPhase1BackgroundExporter.java +++ b/src/main/java/com/hfstudio/guidenh/guide/siteexport/site/GuideSiteNeiPhase1BackgroundExporter.java @@ -31,12 +31,12 @@ public class GuideSiteNeiPhase1BackgroundExporter implements AutoCloseable { /** - * Extra transparent border around the NEI body rectangle. GregTech (and similar) ModularUI / nine-patch chrome - * often draws a few pixels outside + * Extra transparent border around the NEI body rectangle. GregTech (and similar) + * ModularUI / nine-patch chrome often draws a few pixels outside * {@link NeiRecipeLookup#lookupHandlerWidth}/{@link NeiRecipeLookup#lookupHandlerHeight}; - * a flush viewport clips top/right bezel lines and truncates footer text unless we pad here. Site overlays use the - * same - * inset; see {@link GuideSiteRecipeExporter#renderNeiPositionedSlots}. + * a flush viewport clips top/right bezel lines and truncates footer text unless we pad + * here. Site overlays use the same inset; see + * {@link GuideSiteRecipeExporter#renderNeiPositionedSlots}. */ public static final int VIEWPORT_MARGIN_PX = 6; diff --git a/src/main/java/com/hfstudio/guidenh/guide/siteexport/site/GuideSiteRecipeExporter.java b/src/main/java/com/hfstudio/guidenh/guide/siteexport/site/GuideSiteRecipeExporter.java index 12490b41..bcdd8698 100644 --- a/src/main/java/com/hfstudio/guidenh/guide/siteexport/site/GuideSiteRecipeExporter.java +++ b/src/main/java/com/hfstudio/guidenh/guide/siteexport/site/GuideSiteRecipeExporter.java @@ -18,7 +18,9 @@ public class GuideSiteRecipeExporter { - /** NEI slot chrome is commonly {@code 18脳18} pixels; vanilla/GT handlers draw {@code 16脳16} items inset by 1px. */ + /** + * NEI slot chrome is commonly {@code 18 x 18} pixels; vanilla/GT handlers draw {@code 16 x 16} items inset by 1px. + */ public static final int NEI_SLOT_GUI_PIXELS = 18; @Nullable @@ -195,10 +197,11 @@ public String renderNeiPositionedSlots(List slots, GuideSi double topPct; if (usePhase1Canvas) { /* - * PNG viewport is HandlerInfo WxH plus symmetric VIEWPORT_MARGIN_PX 鈥?same inset as Phase1 translate(m, - * yShift+m). - * GT/ModularUI nine-patch bezel can extend a few px past the nominal body; relying on HandlerInfo alone - * clips edges. + * The PNG viewport is the HandlerInfo width x height plus a symmetric + * VIEWPORT_MARGIN_PX, the same inset the phase 1 canvas applies via + * translate(m, yShift + m). + * GT/ModularUI nine-patch bezel can extend a few px past the nominal body; + * relying on HandlerInfo alone clips edges. */ int phase1YShift = phase1BodyYShiftPx != null ? phase1BodyYShiftPx : 0; int m = GuideSiteNeiPhase1BackgroundExporter.VIEWPORT_MARGIN_PX; diff --git a/src/main/java/com/hfstudio/guidenh/guide/siteexport/site/GuideSiteSceneAnnotationSerializer.java b/src/main/java/com/hfstudio/guidenh/guide/siteexport/site/GuideSiteSceneAnnotationSerializer.java index 072c32fd..53746916 100644 --- a/src/main/java/com/hfstudio/guidenh/guide/siteexport/site/GuideSiteSceneAnnotationSerializer.java +++ b/src/main/java/com/hfstudio/guidenh/guide/siteexport/site/GuideSiteSceneAnnotationSerializer.java @@ -1757,8 +1757,10 @@ private static void appendImageBlock(StringBuilder html, LytImageBlock image, } boolean cropped = image.getCropWidth() > 0 && image.getCropHeight() > 0; - int displayWidth = image.getDisplayWidth(); - int displayHeight = image.getDisplayHeight(); + // The displayWidth/displayHeight tag attributes map onto the explicit-size + // mechanism (single-dimension = display-size; see the image measure chain). + int displayWidth = image.getExplicitWidth(); + int displayHeight = image.getExplicitHeight(); if (cropped) { if (displayWidth <= 0 && displayHeight > 0) { displayWidth = Math.max( diff --git a/src/main/java/com/hfstudio/guidenh/guide/siteexport/site/layout/DefaultSiteRecipeLayoutStrategy.java b/src/main/java/com/hfstudio/guidenh/guide/siteexport/site/layout/DefaultSiteRecipeLayoutStrategy.java index 497499c7..ec9797a4 100644 --- a/src/main/java/com/hfstudio/guidenh/guide/siteexport/site/layout/DefaultSiteRecipeLayoutStrategy.java +++ b/src/main/java/com/hfstudio/guidenh/guide/siteexport/site/layout/DefaultSiteRecipeLayoutStrategy.java @@ -9,7 +9,7 @@ import com.hfstudio.guidenh.integration.nei.NeiRecipeLookup; /** - * Preserves the historical site layout: 3脳3 flow grid + optional supporting column + result slot. + * Preserves the historical site layout: 3 x 3 flow grid + optional supporting column + result slot. */ public class DefaultSiteRecipeLayoutStrategy implements SiteRecipeLayoutStrategy { diff --git a/src/main/java/com/hfstudio/guidenh/guide/siteexport/site/layout/PositionedNeiSiteRecipeLayoutStrategy.java b/src/main/java/com/hfstudio/guidenh/guide/siteexport/site/layout/PositionedNeiSiteRecipeLayoutStrategy.java index 6439e5a4..ac0a307f 100644 --- a/src/main/java/com/hfstudio/guidenh/guide/siteexport/site/layout/PositionedNeiSiteRecipeLayoutStrategy.java +++ b/src/main/java/com/hfstudio/guidenh/guide/siteexport/site/layout/PositionedNeiSiteRecipeLayoutStrategy.java @@ -8,7 +8,7 @@ /** * Lays out NEI-style recipes using each {@link NeiRecipeLookup.Slot}'s {@code relx}/{@code rely} * inside a percentage-based canvas so handlers with irregular grids (e.g. loot tables) match NEI - * spacing more closely than the legacy 3脳3 + vertical {@code others} column. + * spacing more closely than the legacy 3 x 3 + vertical {@code others} column. */ public class PositionedNeiSiteRecipeLayoutStrategy implements SiteRecipeLayoutStrategy { diff --git a/src/main/java/com/hfstudio/guidenh/guide/style/ResolvedTextStyle.java b/src/main/java/com/hfstudio/guidenh/guide/style/ResolvedTextStyle.java index 332f86f4..96a7f5de 100644 --- a/src/main/java/com/hfstudio/guidenh/guide/style/ResolvedTextStyle.java +++ b/src/main/java/com/hfstudio/guidenh/guide/style/ResolvedTextStyle.java @@ -8,4 +8,4 @@ public record ResolvedTextStyle(float fontScale, boolean bold, boolean italic, boolean underlined, boolean wavyUnderline, boolean dottedUnderline, boolean strikethrough, boolean obfuscated, String font, ColorValue color, WhiteSpaceMode whiteSpace, TextAlignment alignment, boolean dropShadow, - ColorValue backgroundColor, boolean inlineCode) {} + ColorValue backgroundColor, boolean inlineCode, float baselineShift) {} diff --git a/src/main/java/com/hfstudio/guidenh/guide/style/TextStyle.java b/src/main/java/com/hfstudio/guidenh/guide/style/TextStyle.java index f00969cf..4cc7c5d5 100644 --- a/src/main/java/com/hfstudio/guidenh/guide/style/TextStyle.java +++ b/src/main/java/com/hfstudio/guidenh/guide/style/TextStyle.java @@ -12,7 +12,7 @@ public record TextStyle(@Nullable Float fontScale, @Nullable Boolean bold, @Null @Nullable Boolean underlined, @Nullable Boolean wavyUnderline, @Nullable Boolean dottedUnderline, @Nullable Boolean strikethrough, @Nullable Boolean obfuscated, @Nullable String font, @Nullable ColorValue color, @Nullable WhiteSpaceMode whiteSpace, @Nullable TextAlignment alignment, @Nullable Boolean dropShadow, - @Nullable ColorValue backgroundColor, @Nullable Boolean inlineCode) { + @Nullable ColorValue backgroundColor, @Nullable Boolean inlineCode, @Nullable Float baselineShift) { public static final TextStyle EMPTY = new TextStyle( null, @@ -29,6 +29,7 @@ public record TextStyle(@Nullable Float fontScale, @Nullable Boolean bold, @Null null, null, null, + null, null); public ResolvedTextStyle mergeWith(ResolvedTextStyle base) { @@ -48,6 +49,7 @@ public ResolvedTextStyle mergeWith(ResolvedTextStyle base) { var dropShadow = this.dropShadow != null ? this.dropShadow : base.dropShadow(); var backgroundColor = this.backgroundColor != null ? this.backgroundColor : base.backgroundColor(); var inlineCode = this.inlineCode != null ? this.inlineCode : base.inlineCode(); + var baselineShift = this.baselineShift != null ? this.baselineShift : base.baselineShift(); return new ResolvedTextStyle( fontScale, bold, @@ -63,7 +65,8 @@ public ResolvedTextStyle mergeWith(ResolvedTextStyle base) { alignment, dropShadow, backgroundColor, - inlineCode); + inlineCode, + baselineShift); } public Builder toBuilder() { @@ -83,6 +86,7 @@ public Builder toBuilder() { builder.dropShadow = dropShadow; builder.backgroundColor = backgroundColor; builder.inlineCode = inlineCode; + builder.baselineShift = baselineShift; return builder; } @@ -107,6 +111,7 @@ public static class Builder { private Boolean dropShadow; private ColorValue backgroundColor; private Boolean inlineCode; + private Float baselineShift; public Builder apply(TextStyle style) { if (style.fontScale() != null) { @@ -154,6 +159,9 @@ public Builder apply(TextStyle style) { if (style.inlineCode() != null) { inlineCode = style.inlineCode(); } + if (style.baselineShift() != null) { + baselineShift = style.baselineShift(); + } return this; } @@ -232,6 +240,11 @@ public Builder inlineCode(Boolean inlineCode) { return this; } + public Builder baselineShift(Float baselineShift) { + this.baselineShift = baselineShift; + return this; + } + public TextStyle build() { return new TextStyle( fontScale, @@ -248,7 +261,8 @@ public TextStyle build() { alignment, dropShadow, backgroundColor, - inlineCode); + inlineCode, + baselineShift); } } } diff --git a/src/main/java/com/hfstudio/guidenh/guide/style/token/ColorValue.java b/src/main/java/com/hfstudio/guidenh/guide/style/token/ColorValue.java new file mode 100644 index 00000000..9ead54fe --- /dev/null +++ b/src/main/java/com/hfstudio/guidenh/guide/style/token/ColorValue.java @@ -0,0 +1,53 @@ +package com.hfstudio.guidenh.guide.style.token; + +/** ARGB color token value. Semantic constants are pre-defined statics. */ +public record ColorValue(int argb) implements ResolvedValue { + + public static final ColorValue WHITE = new ColorValue(0xFFFFFFFF); + public static final ColorValue BLACK = new ColorValue(0xFF000000); + public static final ColorValue TRANSPARENT = new ColorValue(0x00000000); + public static final ColorValue RED = new ColorValue(0xFFFF0000); + public static final ColorValue GREEN = new ColorValue(0xFF00FF00); + public static final ColorValue BLUE = new ColorValue(0xFF0000FF); + + /** Parse from hex string: "0xFF373737", "#373737", "255,55,55" */ + public static ColorValue parse(String s) { + if (s == null || s.isBlank()) return TRANSPARENT; + s = s.strip(); + try { + if (s.startsWith("0x") || s.startsWith("0X")) { + return new ColorValue((int) Long.parseLong(s.substring(2), 16)); + } + if (s.startsWith("#")) { + String hex = s.substring(1); + if (hex.length() == 6) hex = "FF" + hex; + return new ColorValue((int) Long.parseLong(hex, 16)); + } + String[] parts = s.split(","); + if (parts.length >= 3) { + int r = Integer.parseInt(parts[0].strip()); + int g = Integer.parseInt(parts[1].strip()); + int b = Integer.parseInt(parts[2].strip()); + int a = parts.length >= 4 ? Integer.parseInt(parts[3].strip()) : 255; + return new ColorValue((a << 24) | (r << 16) | (g << 8) | b); + } + } catch (NumberFormatException ignored) {} + throw new IllegalArgumentException("Invalid color value: " + s); + } + + public int red() { + return (argb >>> 16) & 0xFF; + } + + public int green() { + return (argb >>> 8) & 0xFF; + } + + public int blue() { + return argb & 0xFF; + } + + public int alpha() { + return (argb >>> 24) & 0xFF; + } +} diff --git a/src/main/java/com/hfstudio/guidenh/guide/style/token/DimensionValue.java b/src/main/java/com/hfstudio/guidenh/guide/style/token/DimensionValue.java new file mode 100644 index 00000000..f6c4922d --- /dev/null +++ b/src/main/java/com/hfstudio/guidenh/guide/style/token/DimensionValue.java @@ -0,0 +1,49 @@ +package com.hfstudio.guidenh.guide.style.token; + +public record DimensionValue(float value, DimUnit unit) implements ResolvedValue { + + public static final DimensionValue ZERO = new DimensionValue(0, DimUnit.PX); + + public static DimensionValue px(float px) { + return new DimensionValue(px, DimUnit.PX); + } + + public static DimensionValue pct(float pct) { + return new DimensionValue(pct, DimUnit.PCT); + } + + /** Parse: "6px", "100%", "1.5em", "0" (defaults to px) */ + public static DimensionValue parse(String s) { + if (s == null || s.isBlank()) return ZERO; + s = s.strip() + .toLowerCase(); + try { + if (s.endsWith("px")) return px( + Float.parseFloat( + s.substring(0, s.length() - 2) + .strip())); + if (s.endsWith("%")) return pct( + Float.parseFloat( + s.substring(0, s.length() - 1) + .strip())); + if (s.endsWith("em")) return new DimensionValue( + Float.parseFloat( + s.substring(0, s.length() - 2) + .strip()), + DimUnit.EM); + return px(Float.parseFloat(s)); + } catch (NumberFormatException e) { + throw new IllegalArgumentException("Invalid dimension value: " + s); + } + } + + public int pxInt() { + return Math.round(value); + } + + public enum DimUnit { + PX, + PCT, + EM + } +} diff --git a/src/main/java/com/hfstudio/guidenh/guide/style/token/FloatValue.java b/src/main/java/com/hfstudio/guidenh/guide/style/token/FloatValue.java new file mode 100644 index 00000000..ba2e2d62 --- /dev/null +++ b/src/main/java/com/hfstudio/guidenh/guide/style/token/FloatValue.java @@ -0,0 +1,15 @@ +package com.hfstudio.guidenh.guide.style.token; + +public record FloatValue(float value) implements ResolvedValue { + + public static final FloatValue ZERO = new FloatValue(0f); + + public static FloatValue parse(String s) { + if (s == null || s.isBlank()) return ZERO; + try { + return new FloatValue(Float.parseFloat(s.strip())); + } catch (NumberFormatException e) { + throw new IllegalArgumentException("Invalid float value: " + s); + } + } +} diff --git a/src/main/java/com/hfstudio/guidenh/guide/style/token/GuideThemeManager.java b/src/main/java/com/hfstudio/guidenh/guide/style/token/GuideThemeManager.java new file mode 100644 index 00000000..354b2998 --- /dev/null +++ b/src/main/java/com/hfstudio/guidenh/guide/style/token/GuideThemeManager.java @@ -0,0 +1,135 @@ +package com.hfstudio.guidenh.guide.style.token; + +import java.io.BufferedReader; +import java.io.FileInputStream; +import java.io.IOException; +import java.io.InputStreamReader; +import java.nio.charset.StandardCharsets; +import java.nio.file.Path; +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.concurrent.atomic.AtomicReference; + +import net.minecraft.client.Minecraft; + +import com.hfstudio.guidenh.guide.scene.support.GuideDebugLog; + +/** + * Global singleton. Holds the active Theme. + *

          + * Usage: + * + *

          + * 
          + * TokenKey<ColorValue> BREAK_COLOR = TokenKey
          + *     .define("--thematic-break-color", TokenType.COLOR, new ColorValue(0xFF373737));
          + * // in collectPrimitives:
          + * int argb = GuideThemeManager.instance()
          + *     .active()
          + *     .color(BREAK_COLOR)
          + *     .argb();
          + * 
          + *

          + * Theme files live under {@code config/GuideNH/themes/}. + * Default theme: {@code config/GuideNH/themes/default.cfg}. + * Reload via {@code /guidenh theme reload }. + */ +public final class GuideThemeManager { + + private static final GuideThemeManager INSTANCE = new GuideThemeManager(); + + private final AtomicReference active; + + private GuideThemeManager() { + // Build a bootstrap theme from all registered TokenKey defaults. + List> keys = ThemeRegistry.snapshot(); + Theme bootstrap = Theme.build("bootstrap", keys, Collections.emptyMap()); + this.active = new AtomicReference<>(bootstrap); + } + + public static GuideThemeManager instance() { + return INSTANCE; + } + + public Theme active() { + return active.get(); + } + + /** + * Load a theme from the config directory. + * + * @param themeName theme file name without extension (e.g. "default") + */ + public void reload(String themeName) { + Path themesDir = Minecraft.getMinecraft().mcDataDir.toPath() + .resolve("config") + .resolve("GuideNH") + .resolve("themes"); + Path file = themesDir.resolve(themeName + ".cfg"); + + if (!file.toFile() + .exists()) { + GuideDebugLog.warnAlways("Theme file not found: " + file + ", using defaults"); + return; + } + + Map overrides = parseCfg(file); + List> keys = ThemeRegistry.snapshot(); + Theme newTheme = Theme.build(themeName, keys, overrides); + active.set(newTheme); + GuideDebugLog.warnAlways("Theme loaded: " + themeName + " (" + overrides.size() + " overrides)"); + } + + /** + * Load the default theme on startup. + * Called from Guide.init() or GuideScreen first open. + */ + public void ensureLoaded() { + if (active().name() + .equals("bootstrap")) { + reload("default"); + } + } + + /** Parse a simple .cfg file into key=value pairs. */ + private static Map parseCfg(Path file) { + Map map = new LinkedHashMap<>(); + try (BufferedReader r = new BufferedReader( + new InputStreamReader(new FileInputStream(file.toFile()), StandardCharsets.UTF_8))) { + String line; + int lineNo = 0; + while ((line = r.readLine()) != null) { + lineNo++; + line = line.strip(); + if (line.isEmpty() || line.startsWith("#") || line.startsWith("//")) continue; + + int eq = line.indexOf('='); + if (eq < 0) { + GuideDebugLog.warnAlways( + "Theme " + file.getFileName() + ":" + lineNo + " - no '=' separator, skipping: " + line); + continue; + } + String key = line.substring(0, eq) + .strip(); + String value = line.substring(eq + 1) + .strip(); + if (key.isEmpty()) continue; + + // Strip inline comments after value (unless value starts with #) + int comment = value.indexOf(" #"); + if (comment > 0) value = value.substring(0, comment) + .strip(); + comment = value.indexOf(" //"); + if (comment > 0) value = value.substring(0, comment) + .strip(); + + map.put(key, value); + } + } catch (IOException e) { + GuideDebugLog.warnAlways("Failed to read theme file: " + file + " - " + e.getMessage()); + } + return map; + } +} diff --git a/src/main/java/com/hfstudio/guidenh/guide/style/token/IntValue.java b/src/main/java/com/hfstudio/guidenh/guide/style/token/IntValue.java new file mode 100644 index 00000000..1abc2216 --- /dev/null +++ b/src/main/java/com/hfstudio/guidenh/guide/style/token/IntValue.java @@ -0,0 +1,15 @@ +package com.hfstudio.guidenh.guide.style.token; + +public record IntValue(int value) implements ResolvedValue { + + public static final IntValue ZERO = new IntValue(0); + + public static IntValue parse(String s) { + if (s == null || s.isBlank()) return ZERO; + try { + return new IntValue(Integer.parseInt(s.strip())); + } catch (NumberFormatException e) { + throw new IllegalArgumentException("Invalid int value: " + s); + } + } +} diff --git a/src/main/java/com/hfstudio/guidenh/guide/style/token/ResolvedValue.java b/src/main/java/com/hfstudio/guidenh/guide/style/token/ResolvedValue.java new file mode 100644 index 00000000..23533947 --- /dev/null +++ b/src/main/java/com/hfstudio/guidenh/guide/style/token/ResolvedValue.java @@ -0,0 +1,5 @@ +package com.hfstudio.guidenh.guide.style.token; + +/** All token value types implement this sealed interface. */ +public sealed interface ResolvedValue permits ColorValue,DimensionValue,FloatValue,IntValue { +} diff --git a/src/main/java/com/hfstudio/guidenh/guide/style/token/Theme.java b/src/main/java/com/hfstudio/guidenh/guide/style/token/Theme.java new file mode 100644 index 00000000..e80dd7f8 --- /dev/null +++ b/src/main/java/com/hfstudio/guidenh/guide/style/token/Theme.java @@ -0,0 +1,104 @@ +package com.hfstudio.guidenh.guide.style.token; + +import java.util.List; +import java.util.Map; + +import javax.annotation.Nullable; + +import com.hfstudio.guidenh.guide.scene.support.GuideDebugLog; + +import it.unimi.dsi.fastutil.objects.Object2IntArrayMap; +import it.unimi.dsi.fastutil.objects.Object2IntMap; + +/** + * One theme: a resolved name→id→value mapping. + * Immutable after construction. Created by GuideThemeManager.reload(). + */ +public final class Theme { + + private final String name; + private final Object2IntMap nameToId; + private final ResolvedValue[] values; + + Theme(String name, Object2IntMap nameToId, ResolvedValue[] values) { + this.name = name; + this.nameToId = nameToId; + this.values = values; + } + + public String name() { + return name; + } + + /** Fast lookup by TokenKey, the preferred path. */ + @SuppressWarnings("unchecked") + public T get(TokenKey key) { + int id = key.id(); + if (id < values.length) { + ResolvedValue v = values[id]; + if (v != null) return (T) v; + } + return key.defaultValue(); + } + + /** Slow lookup by name, for flexibility only. */ + @SuppressWarnings("unchecked") + @Nullable + public T get(String name, Class type) { + int id = nameToId.getOrDefault(name, -1); + if (id >= 0 && id < values.length) { + ResolvedValue v = values[id]; + if (v != null && type.isInstance(v)) return (T) v; + } + return null; + } + + public ColorValue color(TokenKey key) { + return get(key); + } + + public DimensionValue dim(TokenKey key) { + return get(key); + } + + public FloatValue flt(TokenKey key) { + return get(key); + } + + public IntValue int_(TokenKey key) { + return get(key); + } + + /** Builder for GuideThemeManager. */ + + static Theme build(String themeName, List> keys, Map overrides) { + Object2IntMap nameToId = new Object2IntArrayMap<>(keys.size()); + ResolvedValue[] values = new ResolvedValue[keys.size()]; + + for (TokenKey key : keys) { + int id = key.id(); + nameToId.put(key.name(), id); + + String raw = overrides.get(key.name()); + if (raw != null) { + values[id] = parseWithFallback(key, raw); + } else { + values[id] = key.defaultValue(); + } + } + + return new Theme(themeName, nameToId, values); + } + + private static ResolvedValue parseWithFallback(TokenKey key, String raw) { + try { + return key.type() + .parse(raw); + } catch (Exception e) { + GuideDebugLog.warnAlways( + "Theme: failed to parse '" + key + .name() + "'='" + raw + "', falling back to default (" + key.defaultValue() + ")"); + return key.defaultValue(); + } + } +} diff --git a/src/main/java/com/hfstudio/guidenh/guide/style/token/ThemeRegistry.java b/src/main/java/com/hfstudio/guidenh/guide/style/token/ThemeRegistry.java new file mode 100644 index 00000000..bf8db76e --- /dev/null +++ b/src/main/java/com/hfstudio/guidenh/guide/style/token/ThemeRegistry.java @@ -0,0 +1,27 @@ +package com.hfstudio.guidenh.guide.style.token; + +import java.util.ArrayList; +import java.util.List; + +/** + * Internal registry: the growing list of all TokenKeys ever defined. + * Package-private; GuideThemeManager is the only consumer. + */ +final class ThemeRegistry { + + private static final List> keys = new ArrayList<>(); + + static void register(TokenKey key) { + keys.add(key); + } + + static List> snapshot() { + return new ArrayList<>(keys); + } + + static int count() { + return keys.size(); + } + + private ThemeRegistry() {} +} diff --git a/src/main/java/com/hfstudio/guidenh/guide/style/token/TokenKey.java b/src/main/java/com/hfstudio/guidenh/guide/style/token/TokenKey.java new file mode 100644 index 00000000..d9b96003 --- /dev/null +++ b/src/main/java/com/hfstudio/guidenh/guide/style/token/TokenKey.java @@ -0,0 +1,53 @@ +package com.hfstudio.guidenh.guide.style.token; + +import java.util.concurrent.atomic.AtomicInteger; + +/** + * Each node declares static final TokenKey fields at class init time. + * The id is assigned sequentially and is stable within one JVM lifetime. + */ +public final class TokenKey { + + private static final AtomicInteger NEXT_ID = new AtomicInteger(0); + + private final int id; + private final String name; + private final TokenType type; + private final T defaultValue; + + private TokenKey(int id, String name, TokenType type, T defaultValue) { + this.id = id; + this.name = name; + this.type = type; + this.defaultValue = defaultValue; + } + + /** Register a new token key. Called from node static initializers. */ + public static TokenKey define(String name, TokenType type, T defaultValue) { + int id = NEXT_ID.getAndIncrement(); + TokenKey key = new TokenKey<>(id, name, type, defaultValue); + ThemeRegistry.register(key); + return key; + } + + public int id() { + return id; + } + + public String name() { + return name; + } + + public TokenType type() { + return type; + } + + public T defaultValue() { + return defaultValue; + } + + @Override + public String toString() { + return name + "[" + id + "]"; + } +} diff --git a/src/main/java/com/hfstudio/guidenh/guide/style/token/TokenType.java b/src/main/java/com/hfstudio/guidenh/guide/style/token/TokenType.java new file mode 100644 index 00000000..058d9d13 --- /dev/null +++ b/src/main/java/com/hfstudio/guidenh/guide/style/token/TokenType.java @@ -0,0 +1,36 @@ +package com.hfstudio.guidenh.guide.style.token; + +/** Token value type tag. Each type knows how to parse its string form. */ +public enum TokenType { + + COLOR { + + @Override + public ColorValue parse(String s) { + return ColorValue.parse(s); + } + }, + DIMENSION { + + @Override + public DimensionValue parse(String s) { + return DimensionValue.parse(s); + } + }, + FLOAT { + + @Override + public FloatValue parse(String s) { + return FloatValue.parse(s); + } + }, + INT { + + @Override + public IntValue parse(String s) { + return IntValue.parse(s); + } + }; + + public abstract ResolvedValue parse(String s); +} diff --git a/src/main/java/com/hfstudio/guidenh/integration/ae2/Ae2BaseTileNetworkStreamPreview.java b/src/main/java/com/hfstudio/guidenh/integration/ae2/Ae2BaseTileNetworkStreamPreview.java index 0a141f92..0727c795 100644 --- a/src/main/java/com/hfstudio/guidenh/integration/ae2/Ae2BaseTileNetworkStreamPreview.java +++ b/src/main/java/com/hfstudio/guidenh/integration/ae2/Ae2BaseTileNetworkStreamPreview.java @@ -22,7 +22,8 @@ * {@code X}). The payload is whatever each subclass concatenates via {@code @TileEvent(NETWORK_WRITE)} handlers. * Non-exhaustive examples in AE2: {@code TileDrive}, {@code TileChest}, {@code TileSkyChest}, {@code TileInterface}, * spatial / quantum / assembler family tiles, crafting monitor; {@link TileCableBus} shares the same base class but - * uses multipart streams 鈥?guide export uses cable-bus supplement ({@link Ae2ServerPreviewRegistration#SUPPLEMENT_ID}) + * uses multipart streams; the guide export uses cable-bus supplement + * ({@link Ae2ServerPreviewRegistration#SUPPLEMENT_ID}) * instead of this blob. */ public class Ae2BaseTileNetworkStreamPreview { diff --git a/src/main/java/com/hfstudio/guidenh/integration/ae2/Ae2BaseTileNetworkStructureSupport.java b/src/main/java/com/hfstudio/guidenh/integration/ae2/Ae2BaseTileNetworkStructureSupport.java index d81064c5..122fb7d8 100644 --- a/src/main/java/com/hfstudio/guidenh/integration/ae2/Ae2BaseTileNetworkStructureSupport.java +++ b/src/main/java/com/hfstudio/guidenh/integration/ae2/Ae2BaseTileNetworkStructureSupport.java @@ -42,7 +42,7 @@ public interface ExportTileLookup { TileEntity getTile(int x, int y, int z); } - /** Per-export: dim:x:y:z 鈫?raw {@code X} description bytes. */ + /** Per-export: dim:x:y:z -> raw {@code X} description bytes. */ public static class Ae2BaseTileNetworkMpSnapshot { private final Map xpByKey; diff --git a/src/main/java/com/hfstudio/guidenh/integration/ae2/Ae2CableBusPartStreamCodec.java b/src/main/java/com/hfstudio/guidenh/integration/ae2/Ae2CableBusPartStreamCodec.java index b07d7e7d..779103a5 100644 --- a/src/main/java/com/hfstudio/guidenh/integration/ae2/Ae2CableBusPartStreamCodec.java +++ b/src/main/java/com/hfstudio/guidenh/integration/ae2/Ae2CableBusPartStreamCodec.java @@ -14,7 +14,8 @@ import io.netty.buffer.Unpooled; /** - * Packs AE2 cable-bus {@link IPart#writeToStream} bytes per facing ({@code 0}鈥搟@code 5}) for preview wire and MP batch. + * Packs AE2 cable-bus {@link IPart#writeToStream} bytes per facing ({@code 0} to {@code 5}) for preview wire and MP + * batch. */ public class Ae2CableBusPartStreamCodec { diff --git a/src/main/java/com/hfstudio/guidenh/integration/ae2/Ae2CableBusSideStreams.java b/src/main/java/com/hfstudio/guidenh/integration/ae2/Ae2CableBusSideStreams.java index 852ef527..234dd90d 100644 --- a/src/main/java/com/hfstudio/guidenh/integration/ae2/Ae2CableBusSideStreams.java +++ b/src/main/java/com/hfstudio/guidenh/integration/ae2/Ae2CableBusSideStreams.java @@ -5,7 +5,7 @@ import org.jetbrains.annotations.Nullable; /** - * Per-facing {@link appeng.api.parts.IPart#writeToStream} payloads for a cable bus (ordinals 0鈥?). + * Per-facing {@link appeng.api.parts.IPart#writeToStream} payloads for a cable bus (ordinals 0-5). */ public class Ae2CableBusSideStreams { diff --git a/src/main/java/com/hfstudio/guidenh/integration/ae2/Ae2CableStructureSupport.java b/src/main/java/com/hfstudio/guidenh/integration/ae2/Ae2CableStructureSupport.java index ed70e0ed..76666831 100644 --- a/src/main/java/com/hfstudio/guidenh/integration/ae2/Ae2CableStructureSupport.java +++ b/src/main/java/com/hfstudio/guidenh/integration/ae2/Ae2CableStructureSupport.java @@ -48,7 +48,7 @@ public interface ExportTileLookup { TileEntity getTile(int x, int y, int z); } - /** Per-export batch: dim:x:y:z 鈫?unified {@link Ae2CablePreviewWireCodec} bytes. */ + /** Per-export batch: dim:x:y:z -> unified {@link Ae2CablePreviewWireCodec} bytes. */ public static class Ae2CableMpSnapshot { private final Map wireByKey; diff --git a/src/main/java/com/hfstudio/guidenh/integration/ae2/network/GuideNhAe2CableBatchRequestMessage.java b/src/main/java/com/hfstudio/guidenh/integration/ae2/network/GuideNhAe2CableBatchRequestMessage.java index fc3cbee3..796f7788 100644 --- a/src/main/java/com/hfstudio/guidenh/integration/ae2/network/GuideNhAe2CableBatchRequestMessage.java +++ b/src/main/java/com/hfstudio/guidenh/integration/ae2/network/GuideNhAe2CableBatchRequestMessage.java @@ -12,7 +12,7 @@ public class GuideNhAe2CableBatchRequestMessage implements IMessage { private long corrId; private int dim; - /** Flat xyzxyz… length divisible by 3. */ + /** Flat xyzxyz... length divisible by 3. */ private int[] xyz; public GuideNhAe2CableBatchRequestMessage() { diff --git a/src/main/java/com/hfstudio/guidenh/integration/nei/NeiDirectCalls.java b/src/main/java/com/hfstudio/guidenh/integration/nei/NeiDirectCalls.java index 1b083ffe..e3addee4 100644 --- a/src/main/java/com/hfstudio/guidenh/integration/nei/NeiDirectCalls.java +++ b/src/main/java/com/hfstudio/guidenh/integration/nei/NeiDirectCalls.java @@ -143,7 +143,7 @@ public static boolean otherStacksThrows(Object handler, int idx) { } public static void handleItemTooltip(Object handler, ItemStack stack, List tooltip, int idx) { - // Passing null for GuiRecipe is intentional 鈥?we are not inside a live recipe GUI. + // Passing null for GuiRecipe is intentional; we are not inside a live recipe GUI. List result = h(handler).handleItemTooltip(null, stack, tooltip, idx); if (result != null && result != tooltip) { tooltip.clear(); diff --git a/src/main/java/com/hfstudio/guidenh/integration/neicustomdiagram/NeiCustomDiagramBridge.java b/src/main/java/com/hfstudio/guidenh/integration/neicustomdiagram/NeiCustomDiagramBridge.java index 90217746..deaaaf33 100644 --- a/src/main/java/com/hfstudio/guidenh/integration/neicustomdiagram/NeiCustomDiagramBridge.java +++ b/src/main/java/com/hfstudio/guidenh/integration/neicustomdiagram/NeiCustomDiagramBridge.java @@ -57,7 +57,7 @@ private static boolean isDiagramGroupHandlerImpl(Object handler) { *

          * {@code renderX}/{@code renderY} remain parent-local GUI coordinates (matching {@code Gui}); * {@code guiScissorAbs*} are absolute GUI coords (viewport space), same convention as {@code VanillaRenderContext} - * scissor 闁?required because {@code GL_SCISSOR} ignores {@code GL_MODELVIEW}. + * scissor is required because {@code GL_SCISSOR} ignores {@code GL_MODELVIEW}. * *

          * For wide diagrams, {@code guiScissorAbsW} may be smaller than intrinsic layout (NEI {@code HandlerInfo} diff --git a/src/main/java/com/hfstudio/guidenh/integration/structurelib/StructureLibBuildService.java b/src/main/java/com/hfstudio/guidenh/integration/structurelib/StructureLibBuildService.java index 820f3872..33fddfe0 100644 --- a/src/main/java/com/hfstudio/guidenh/integration/structurelib/StructureLibBuildService.java +++ b/src/main/java/com/hfstudio/guidenh/integration/structurelib/StructureLibBuildService.java @@ -56,6 +56,8 @@ public class StructureLibBuildService { public static final int SURVIVAL_BUDGET = Integer.MAX_VALUE; public static final int SURVIVAL_MAX_ROUNDS = 256; + // Identity of a resolved controller block. + public record ResolvedController(String blockId, Block block, int meta) {} /** @@ -118,6 +120,8 @@ public static StructureLibSceneMetadata readControlMetadata(@Nullable StructureL return StructureLibPreviewTooltipMetadataBuilder.createControlMetadata(request); } + // Build pipeline entry points. + public StructureLibBuildResult build(StructureLibBuildRequest request) { try { return doBuild(request); @@ -242,6 +246,8 @@ private static Object resolveMetadataContext(TileEntity controllerTile, IConstru return constructable; } + // Resolves a controller block id into a registry block. + public static ResolvedController resolveController(String controllerId) { GuideBlockMatcher matcher = GuideBlockMatcher.parse(controllerId); Block block = (Block) Block.blockRegistry.getObject(matcher.getBlockId()); @@ -251,6 +257,8 @@ public static ResolvedController resolveController(String controllerId) { return new ResolvedController(matcher.getBlockId(), block, matcher.getMeta() != null ? matcher.getMeta() : 0); } + // Places the controller tile so StructureLib can construct into the level. + @Nullable public static TileEntity placeController(GuidebookLevel level, World world, ResolvedController controller) { for (StructureLibControllerPlacementIntegration integration : StructureLibControllerIntegrationRegistry.global() @@ -279,6 +287,8 @@ public static TileEntity placeController(GuidebookLevel level, World world, Reso return placed; } + // Resolves the constructable behind a controller tile. + @Nullable public static IConstructable resolveConstructable(TileEntity controllerTile) { if (controllerTile instanceof IConstructableProvider provider) { @@ -299,6 +309,8 @@ public static IConstructable resolveConstructable(TileEntity controllerTile) { return null; } + // Builds the trigger stack carrying tier and channel overrides. + public static ItemStack createTrigger(StructureLibBuildRequest request) { ItemStack stack = new ItemStack(StructureLibAPI.getDefaultHologramItem(), Math.max(MIN_TIER, request.tier())); for (Map.Entry entry : request.channels() @@ -315,6 +327,8 @@ public static ItemStack createTrigger(StructureLibBuildRequest request) { return stack; } + // Drives the constructable, preferring the survival path when available. + public static void buildStructure(IConstructable constructable, ItemStack trigger, PreviewFakePlayer fakePlayer, StructureLibBuildRequest request, TileEntity controllerTile) { StructureLibMinimumHatchPlacement.beginPreviewBuild(); @@ -473,6 +487,8 @@ public boolean takeAll(ItemStack stack, boolean simulate) { } } + // Pushes built state to the registered preview synchronizers. + public static void syncPreviewState(TileEntity controllerTile, ItemStack trigger, StructureLibBuildRequest request) { for (StructureLibPreviewStateSynchronizer synchronizer : StructureLibControllerIntegrationRegistry.global() @@ -481,6 +497,8 @@ public static void syncPreviewState(TileEntity controllerTile, ItemStack trigger } } + // Captures filled blocks as sorted, origin-relative placements. + public static List snapshotBlocks(GuidebookLevel level) { return snapshotBlocksAndOrigin(level).blocks(); } @@ -540,6 +558,8 @@ private static BlockSnapshot snapshotBlocksAndOrigin(GuidebookLevel level) { private record BlockSnapshot(List blocks, int originX, int originY, int originZ) {} + // Serialization helpers. + @Nullable public static NBTTagCompound serializeTile(@Nullable TileEntity tile) { if (tile == null) return null; diff --git a/src/main/java/com/hfstudio/guidenh/integration/structurelib/StructureLibPreviewTooltipMetadataBuilder.java b/src/main/java/com/hfstudio/guidenh/integration/structurelib/StructureLibPreviewTooltipMetadataBuilder.java index f87852ff..00a3165a 100644 --- a/src/main/java/com/hfstudio/guidenh/integration/structurelib/StructureLibPreviewTooltipMetadataBuilder.java +++ b/src/main/java/com/hfstudio/guidenh/integration/structurelib/StructureLibPreviewTooltipMetadataBuilder.java @@ -120,12 +120,11 @@ private static StructureLibSceneMetadata createControlMetadata(String controller } int maxTier = Math.max(1, data.getMaxTotalTier()); List channels = new ArrayList<>(); - var channelMaxTierMap = data.getChannelMaxTierMap(); - if (channelMaxTierMap == null) { + if (data.getChannelMaxTierMap() == null) { return metadata.withTierAndChannelData(1, maxTier, request.tier(), request.tier(), channels); } - - for (var entry : channelMaxTierMap.object2IntEntrySet()) { + for (var entry : data.getChannelMaxTierMap() + .object2IntEntrySet()) { String channel = StructureLibPreviewSelection.normalizeChannelId(entry.getKey()); if (channel != null) { channels.add( diff --git a/src/main/java/com/hfstudio/guidenh/integration/structurelib/StructureLibSceneMetadata.java b/src/main/java/com/hfstudio/guidenh/integration/structurelib/StructureLibSceneMetadata.java index dbae54cf..f0cd47dd 100644 --- a/src/main/java/com/hfstudio/guidenh/integration/structurelib/StructureLibSceneMetadata.java +++ b/src/main/java/com/hfstudio/guidenh/integration/structurelib/StructureLibSceneMetadata.java @@ -14,8 +14,8 @@ import lombok.Getter; /** - * Metadata about a StructureLib preview, including controller identity, tier/channel ranges, and per-block tooltip - * data. + * Metadata about a StructureLib preview - controller identity, tier/channel ranges, per-block tooltip data. + * Tooltip data (hatch info, block candidates) is no longer produced; fields remain for backward compat. */ public class StructureLibSceneMetadata { @@ -93,6 +93,8 @@ public StructureLibSceneMetadata offsetBlockTooltips(int offsetX, int offsetY, i return withBlockTooltips(shifted); } + // Fluent factories for programmatic construction. + public StructureLibSceneMetadata withTierData(int minValue, int maxValue, int defaultValue, int currentValue) { return new StructureLibSceneMetadata( controller, @@ -137,6 +139,8 @@ public StructureLibSceneMetadata withChannelData(String channelId, String label, blockTooltipDataByPos); } + // Tooltip data. Deprecated: always empty. + @Nullable public BlockTooltipData getBlockTooltipData(int x, int y, int z) { return blockTooltipDataByPos.get(packBlockPos(x, y, z)); @@ -146,6 +150,8 @@ public boolean hasHatchTooltipData() { return !hatchTooltipEntries.isEmpty(); } + // Accessors for the immutable metadata fields. + @Nullable public TierData getTierData() { return tierData; @@ -184,6 +190,8 @@ public String getFlip() { return flip; } + // Position packing into a single long. + public static long packBlockPos(int x, int y, int z) { return (((long) x & 0x3FFFFFFL) << 38) | (((long) z & 0x3FFFFFFL) << 12) | ((long) y & 0xFFFL); } @@ -200,6 +208,8 @@ public static int unpackBlockPosZ(long packedPos) { return (int) (packedPos << 26 >> 38); } + // Validation and collection helpers. + public static String requireController(@Nullable String controller) { if (controller == null) throw new IllegalArgumentException("StructureLib metadata controller cannot be null"); String trimmed = controller.trim(); @@ -280,6 +290,8 @@ public static int clamp(int value, int minValue, int maxValue) { return Math.min(value, maxValue); } + // Nested value types. + @Getter public static class BlockTooltipEntry { diff --git a/src/main/resources/assets/guidenh/lang/en_US.lang b/src/main/resources/assets/guidenh/lang/en_US.lang index 6206de78..426eacf5 100644 --- a/src/main/resources/assets/guidenh/lang/en_US.lang +++ b/src/main/resources/assets/guidenh/lang/en_US.lang @@ -523,6 +523,7 @@ guideme.guidebook.SceneVisibleLayerLabel=Layer guideme.guidebook.SceneLoading=Loading guideme.guidebook.Search=Search guideme.guidebook.HomePage=Home Page +guideme.guidebook.Settings=Settings guideme.guidebook.HomePageSpecialPages=Special Pages guideme.guidebook.NavBarSpecialPages=Special Pages guideme.guidebook.NavBarExpandAll=Expand All @@ -684,6 +685,7 @@ guidenh.debug.menu.display_options=Display Options guidenh.debug.menu.display_options.show_fps=Show FPS guidenh.debug.menu.display_options.show_memory=Show Memory guidenh.debug.menu.display_options.show_mouse=Show Mouse Position +guidenh.debug.menu.display_options.layout_overlay=Layout Overlay (Java/Rust rects) guidenh.debug.menu.recompile_page=Recompile Current Page guidenh.debug.menu.export_debug_data=Export Debug Data diff --git a/src/main/resources/assets/guidenh/lang/zh_CN.lang b/src/main/resources/assets/guidenh/lang/zh_CN.lang index ff6d1ef8..bb7c636a 100644 --- a/src/main/resources/assets/guidenh/lang/zh_CN.lang +++ b/src/main/resources/assets/guidenh/lang/zh_CN.lang @@ -523,6 +523,7 @@ guideme.guidebook.SceneVisibleLayerLabel=层数 guideme.guidebook.SceneLoading=加载中 guideme.guidebook.Search=搜索 guideme.guidebook.HomePage=首页 +guideme.guidebook.Settings=设置 guideme.guidebook.HomePageSpecialPages=特殊页面 guideme.guidebook.NavBarSpecialPages=特殊页面 guideme.guidebook.NavBarExpandAll=全部展开 @@ -681,6 +682,7 @@ guidenh.debug.menu.display_options=显示选项 guidenh.debug.menu.display_options.show_fps=显示 FPS guidenh.debug.menu.display_options.show_memory=显示内存 guidenh.debug.menu.display_options.show_mouse=显示鼠标位置 +guidenh.debug.menu.display_options.layout_overlay=布局诊断浮层 (Java/Rust 矩形) guidenh.debug.menu.recompile_page=重新编译当前页面 guidenh.debug.menu.export_debug_data=导出调试数据 diff --git a/src/main/resources/assets/guidenh/textures/guide/buttons.png b/src/main/resources/assets/guidenh/textures/guide/buttons.png index 2876889894feff76b66ac4e5dcdfb3a2b7519eaa..3f53577b11747aa268cf32ab7dbcb19e9c699d7c 100644 GIT binary patch delta 3587 zcmV+e4*c=OFoYqHBYzM}Nkl%aYtm5r$_7v$WrAeAqjCCFEi@Md5-xr~n9!XhMQdRxRx=D|h%jE$8a`Nid2{^*lTzMk+#X{BTKI`&BS7UxX3=Sll}LtZZeP~qLXKYxGz`{Bb+Uw@bXXU?6i z{Ix<`3oBB2W!f!1eE7+&ERpNjtFpa)`^U@k^ZUnJ*q;aTZ$saQ63(7tRPKNIivU#E z3PhEu1em>T!EQY)`-PzmB8lT#v}PB?DVw=>j<_8rQ;V zPDS@c=n>+x;R)3|ADMT(<1C>?OU|a?L&f zd4Fndxb!T%FlPibQh;mcL@TA2HhE0OqkmiH%0lg>O@Df4*0E>d#p$rVh`M{vxBazq zJezAY0ldn;@#-2bo=!+qsdZY4+Y&*x$w!E|bR{i4=SGkjL!49ZQpnRqYA#tzEG4@(o%Sx23=_)PvFt? zj=Jqu8h@?d^8cs@FnS%g@Bnn)m)|A7t$jLPuYAmU2hk^Y@c^teN1F@9rie|=KAJ}9 zQCfC~>;$yh!o5ATBW*;B*$zBuUkX{IJ!oFMTk`XmB@HT z;*@1#W<=sk`TCsKt?U0r0Cd6h3bp4#MJqweb$_!mI$C(W%GI);b-N?>(aOI9o#}fv z?N+lQt=F#ns~Pd+td8i`^?xG(!tm?=J&E?tm+iNnVJ6*2$G< zjel%gSX-FUbM^TpJ)>!A*_UJ97EN=_-uo-r`C>f%|mH$Tz z&mLkX(`pH@S8%PkJppf}LFbY6zjgo0{(q4&a%!Zv74Ar-Z3)0g9_oCG+>0d@{NA@~ z34ko5mH_l_y=iF`h*~9zRIpk5Q8ABRUoHQh4587&JDUH|{F8CD9s(nIU~T3^plsZF z7|S*z1w>*TLIr5tO8#X1OQ+kWW_qHP#kh1(7iSZMuve_&7QQvy2bF_E#kJ~_voEH zDY4cjD7J8F*WEgPbr68XGXn$b>6h57Si!cM+4UEaTAxWPevd6(UiaphI_t@3!M{KNkdw*B3t;SouHyV~zA#2w%GePLwVe6!0q!JxN1gn^0zjQK4Rm=+j}f}e(xNBk#TVujyHr=o1oSjKd*&->acBG zV}yqzZLS9}+orp9n^2}9+J0`i{YUlyW}b|-C!{gI+`ONc56k3ag9KcqMC%3BvL7p$m#&BYQMR(mwkF}25}9KEi-W^btcP{&p| zzsGLGX2Z*u*5Vc8JkS1{Wq+=rMJ7fXdzV+s@)z|edRMTnvb|e8C6tP29UFbxMB1~p zEfW5S{T?2GHTAdU0n}`2*W=A!kKDK31u(V!-u;W@zjbe{^y2|s_UQxA!yJ}s*VI_r zd+$4IsrkxTdGZO;viI77^5rDA?gJ2Blw3OM&L-3^R%yM7N4Bkdynh0Htd@paI;?iT z^aN_74A<%dc>e!u#q>42j6MKw31@UQ-mLw~E$TJYo&rbm0BTRbT7E=5sMcyS?+MyU zS9CFpjNe)qv)5DnH0J@dp7Mu#>W4-FW_xv4^a@{l6rkk+6mC5V(7UL{n!w^+LkZi8 zqZO~`9rcU|wcfs4^M5|dy|P}1CXaa@#W`qeyg%@6=K;Lvi9qrti?fq|W||Sw&J%zh zn-Q$$-D**;2|#5MO*5^LG)INhO2>J?)<{dS-S%1G$o+??{4ZVrdT4n7BXjYuy%wNF zv>#?R{F(<7y_c=klUCf(cDxjq*G3;p%eL|cVLj!yaL8%E8-EDEqT$D$Wu+s_fLB!5 z9*<}y{Lv=V3{fGCbW(aWJb=4g4Tu0N8hwD<_6PN>>G$TSM5v4c+$9JAKw_@)anP5} ziq&tc>_@^{5&wuCE=S@^P!oJ=@;tfe8Vy3eUZX z!IoaR_1KNX=YP#3FTKaDaJ7BZPTQBZsT6{qaBhW8U#}px>|15w9JHTJ|4970EJyB5 z`1-9GRo+afo*8J-`QN=W+@j}Ls}ZAiJ=fW~H*#iMkA1|>v$uX*v-*1TrzcNZVYX&B zdg}s4!qep;lHQYJ-^%CO|D*O+d0ut>WwZSBXH)NPCqlL0|-e{UFd#gg$3Q;uv+VyyI?;{?Rmv$>%wf)}zJ?k)9 zdFLHF1%Cx(<_SJyUn@T|_HIF}E%$5{BP*6?m0+_qx!Rf&E?u9I=lq=vpoSUL^M*0G54AfxSv+ z9kXt5Z-Hsu*V^@4=SH7cUd7Vlr_bwM1!&!`R)4x$`P@@>B5AsG*!Py*p8SiP6Y0Yk zsXt2vz*8!@FVY#N&x^_y>?39GsbU}L1oZ6ne$NAmc3RiO&C9#)PQY3w*9x=MR}iWEt@9#%5Z;aN zDSrg53g{hgRlLY`dv>*B+P>Co(f6uVv3dkR_fqSvRft>vN77JBd!+1L0&zw6?`0po zH_?ZgTgjqIZf(&vvR9Cz;r5&pc}i>X(3MA4u9mPw+yC0$veWTfPt)1>y?bf>j@tLc zxwin!q~{a}Yfk|fxi>=sAPdfDGMmAtG+vk+}J11KKd$=7406=1@%YgylWq-4) z0Q4@!EjBCD@T(vG@HM>0$H!l;3>)CZH33$<98Wrst!X+b~o3WPq$#%ZAuYrMBq{!gDiJ^b#sfB9O`X$}B5XqUip->v=D);q^K8b)-wuI2W7%>g#H3!C2r zD6V$-3QFWlyOxXYeGIb}#zOH%z&r|>2g zU#1ZM_2J>+Z}NA|zKD!me6;=MDGqQ40wBEv?{^LPw6|Uk^8Eb1D;?I_1ZzJ)MwSbJ zd<~fQyM}DXm&!Ubk*F+5w)QhY39_?$6TtetI!*w1Ax!`PxZ`;Mqkp3SJp$pKJMtQG zfXp1K2@rYbU!;P2?S24g2!L3+8`{6R3E<5^h}Z(aqyTt5&z<-?yfUdN&@8@eBV=h0yfxiv7G5cXpD-8MmzcL$8e^#tFEda1k z09v2->V0z8z8I{(CumI%Zhfc}045d#$x0WgY^&$;>+tkC11dkj0yYmoZl}}fvTaXK zKYsqZZTg(rChcVywXpPg+y62=`9B*Q+hy7IO%uR#y?ff)`dX!%x@q8rCFk>7?+&27 z53n9n0E){$@^#-)0kFccItHWZ0Dw*bSn)aURXz7s#~=a#aPZZDBkTDs5CDM0%YpTK z{TTKGNF)GW#~=VC5dZ+#m%Df$008U*vr!h_0h2%$tQa2h@?Ul={?QL7O5Xqg002ov JPDHLkV1g7UPMH7z literal 6213 zcmeHLcT`jPwmyLY3u8eCY$!2U!H|$pGlV7(kUE7ob&CyzrFXj&+nX^ z*|!(BZ0Y)?001nrx3hKz00_8*0E?BupF=^nso>9&06ULh08m-I@Pzg!x$K2^!1>h zKQJ&}5SfZ`wzmBo4LriaXdF%e27w3-4b=@r>#~D<5qc&jCZBzP2zYQ9i$mbTS;1Og zJXlkLNkQ}g4xP<{E_fsm*&!S(4CMML31+}ox2)jLg#t^4;1U85db-Gkq&@=4q_4Pu zkRbn$!O0{9#h=2YusFdWR_`k|fX3#qgK6x4c}S3sbRl|f@ri0 zh|t%=piTY-3W|nI;1K>Jm`uV@*+EPKm@u75@TDLESiUgm*D_*uv;EmYU|^6P`XdWR zM~poym_uNZDE8J^7?`szolXWBpiQVIL=!lbLN$h?(fayuqMkklZfsA?vo3KEVo(nrHh z(BP1fk%mSjW2&JE5sflMEDQr^^l8q1XZ=@_{GIhjKc8qI{+#befn)6G3;e@A$2~v^ z{K^3OQPUU#X<=5eFw%lZDP-8^)AT>+=U<`v#Xgiq0iFJdct1rCW>Yz#gdmEgFWCG4 zI2?$7M?RQvf(fTi2`&v_rSA?2qk~qf~q{oMlrzcYNY7QmR*NYR-}nE*LGs)U0ea z=TTqaplTonZ2rRy*NtzO&UX zJnujkTK^b1rV4W`UX{41NR_M{cB1@*?D)E{@S$_-(Rz}5ui`ixL~F#D@gYAe82*UY zI^Aw_CnxV)$?vmDfKHLf4-%_Ar9ES{-`U>bb%+Kh@AB-To7R&S+f<&Z8=c~=Zalm-wP((=7iO(3-$ORmB0NRup|wT} zBd$qCMwSa^qPGIYKH(?Y8CEtrmRbE&3a8+GczhPcykqEmD^5qglyghcR*0{SxnCCA z<6Ud#@O=GgmRPO>S$Ps6msz(5BWBpx#vh6`HLpExsLebrVtL8T;962FoX_ydvAkn< zIS&DU6nEr+t#)}tkm0Q0vFJpv{MjykS?`m}RYGRR5wp}T4Vf&pF0--U|L_Kry_t>E z^Yg(^${YJR%_9yGw%tv6h?Y{V^}R_VnOnPT>Sb|aU)Zp1?-gAY19Q%yj7WWKNT-9$ z%WO|_N*=Y1M=5{D4)h)=3gPzOPmjfQA3Fc@DJ|72lMdUQ-_B;2qh8C@`4>00drwCf zydT<3McqvR9E=3HSO~kyviHJdd3;pQtlc_#RZ)+`E`zzG!fw+*M9j^qi%X*ht9eWK zyKW``6PrYVbhXo-TVq^uJ!bmSs!YS93up6>+p0)t^SE)RlP|*;$G$xpa#`hQUSL1n zg3+FTqNV!U6*FyVO39P(X^$CI+eOQ8Zfbrro=GjO41V7BYpQ&~wWAlTy7G!%*+uhI z>ILUnl3)ClN`*^jQ=kBF=E2hw7KzjX2PA4)f#2lKo|dh*B)Y0XwKAM!z`jPRuI^%E z324~5DJx4G5%=n2hkWq0L-Q*3%x5>%%Bn}F`lN9;ghjbgHx?BG%HjK+EbmrJ?q#*b zwYtBSj`-lu74g_jh%Bq-5eoI_zBs1NCZp=1OV126>*=LWT=p^{rzUpq)rl`J44Y8h zfOyr(*e???0NZy}8RnhexiXJ>GUiL)g~koX@JeqzVnUut`*p?JF9n{Krs8Yo87#;% z({&m6x#RlHiOKzUa1A)yClvSQsF?N?-UF?=u@YfRdG!(LrkwLh7x4}zT1$1Ag_@E( zED; zjloK26&4M+Hx(Kn3#-#LL^)+t_U&0Ab50Ro6&WW!T`QbUcK1wn`!+OXE{l0L&2gKp zMv;KcE8G=bpUIRgZ$2nAI-7aiSyjNmI`KCd3Cg&Ya@85EC(zu`<*IU>dZ0v;Jr}-3 z9Bx$6^qkkf=GSN)X7*I0f_rG`dc_xw8VP?H4V6fw!w34ajN|ESL$eMUR0>Mir}EUe07!1Euh* z^l_veJZ@*lG2imsfC0Ze{G%-b~FH*rn#l4ibBMMI_csS{a!=kMOMUrMu&8>p$mb70PUT0x`8zQ5B zgTGH(qws6uF5xSoy8IbO>*lrX0M<~;U0m`q{m^QDij96}Oo`{!8&-Z*q)KUmx6!ug z7YZ+SXDK1`)VoMIA^@J=jm)2S;HYd%N)U*Da&4`TD*bK^BQ}HLpz@m{>ifC-%u!~B zRtMkBr8PypK30A)MYrkO{hQBy>n!xALn17wzM2)i*vH4hr4u}cOS({#t`5Go+C3|Y z(3rT9Pn30N�EW9ycfAri)F>?;O^sZH;Ye(7t$Hu=UygnFgnLq2xl`!i_wIB$=}K z_5hImFqlDnKRX+dfQu8`aXT?%sPI=~ZXLMcg2?{lJ)8SRd!75HCYKUx$_}chOY=uH zmv|@k*tH**bb0RY5gZ@zfT$goZ}hM?eDk>d+}gsy!~F*xHay4^RGSfd+wkRrgOQ@? z#-Fys&GA_qM3E4!`Tnt$XL5YIp7yrOZ%AFkyh7wu%LWJTWTD;B7piOT4T^NxuBmY@ zLn(XKENOgt`F3PZ>j1n29-S>4z!NncgL5<{Te))F@ds2*`wrJB!9;9l-EF}7sYR35 zty3PqWXGvkhbDMj(Nv2|Dxsz2EftoXx?t`9V8gk53j5#_&&@F>y+M5rN8VKGsS?My z4VTKI>&#+ZJHuMi6;R{fq~6PkPhH0=f;J0z&9M4ae9>@7I^*@v6#kJ+wBxOFiNB2H z9&b|VScOMK=;RBmexAF+%&@-OzswQim2%x$`@Qz)2-7O=s!aR0(~;!eEkTg?3J;VZ zxkLD+LkBT~N%0jTrCafL3ulU6*`~UCRW-cH6i<#Q6f|V1<+z&3E?u*`8a~9UVL8p) zyt00bv>m8(hP=PDC*|z7C+VT}a6wN})EF));Dcw@8?Hj*0UAS^ARo)|l~f(Lb*?vB z(AI^Ynyeo%TxFWN%&zV1 zaW!mtVx&GZeFeJcqD}lOOnM|a2Wm4n9Jo4c4`cL2JkIrQLaz0UyuCB8@ipG5vF*L6 zpdbo2%zO9>4^ZE%9%VQ|*4ig;hU{2}JusbN-YmIYo1L6;pBG=1e^2{*{LcEBW+}G6 zRC~60x^ufT`?5eGJ8qMlIxHF?*Ue3&$R5hxApIn+r|r-qNpYR`>t1==yNEB+=0w|g zk}^-0XG^-F!RWb5RoLMS&Ca=4Gt=-#-po2yh0-(D$zNG35)~ot6&dAD(SyV_c8+y8v{UBlV;cZ?_Ou5(&6-i> z_8f;-(SzwsyFN3L&yLsi21U%g?_INDI{DbKNA8P|jY=;x-CsD3KfxLLwP+!7E;-Gc z&(0$dj?u-60NbNoX~&aW^3ka&*0?BROg>hF=t!NPYDtXQ8wyld8ZF6wR}#{R32$-7 z&F#Q39^8F7Br8_b(9nq2wV?YY=3KWfD_|JZi{c2)v$Qh_zKGO`u88s4*Bs1T=nUrN z&Jso7eRT0-z_V+2zO_|!ft7blQ$oD3>{qF{s4G=IDTyvMaq3x=8>R&mE^QsC8T`c@ zo0^lkh<7I9C&#luyk>=Oq6jGT;P)I|hkUbQC8?#>f){;$J@C%Q2x>uWUAFkgwch|x zJvTT|KU~=|--G8fstzwTwJLzw7JYYixUv=t6z+WZ!)ZDAsMOUXNs3fm_h7n@8}WAM@?6Cl zB@5xI(k_Fyjh*2pPZ}E&?{eLFtFy;}bsoCFn1N5HWPP5dxAx#OtMt6BZJ0WRtxukh z90mqu14xKZY}TLxP%(2aVq-<$E{iw|E(_l-$Wu>OTAf!L1uV*g!wz6Ged#z99iZi|ovj{d#zY zm^+_xz0majWSTQT>t-omjMsH;bIs{#neUHOc8TIEfv-w#)%_ey>fX%pxw+opX@R(D zIUO-~dpbib?us^ZkiV6K8X7$B20r)qiyK>-5#px9igkzwDP7^Az+5DM^0LCdq**O* z*+w;>Mw8)#T)zUKjqd?mBcAhKJ`oupPXj Date: Sun, 4 Oct 2026 04:55:31 +0800 Subject: [PATCH 5/6] docs: minimal English README, zh-CN localization, AGENTS.md --- AGENTS.md | 115 ++++++++++ README.md | 211 +++++++----------- README_zh-CN.md | 126 +++++++++++ README_zh.md | 191 ---------------- wiki/Guide-Page-Format-zh-CN.md | 1 + wiki/Guide-Page-Format.md | 2 + wiki/Images-And-Assets-zh-CN.md | 26 +-- wiki/Images-And-Assets.md | 26 +-- wiki/Tags-Reference-zh-CN.md | 6 +- wiki/Tags-Reference.md | 6 +- .../guidenh/guidenh/_en_us/scene-camera.md | 11 +- .../guidenh/guidenh/_zh_cn/scene-camera.md | 9 +- 12 files changed, 369 insertions(+), 361 deletions(-) create mode 100644 AGENTS.md create mode 100644 README_zh-CN.md delete mode 100644 README_zh.md diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 00000000..d1478b68 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,115 @@ +# GuideNH Agent Guide + +GuideNH is an in-game guide framework for Minecraft 1.7.10 / Forge 10.13.4.1614. The Java side +provides the guide engine, page parser, screens, and mod integration; the Rust side provides the +layout engine that the Java side loads as a native library. This file describes the repository +layout, the commands that build and verify it, and the conventions that all code and documentation +in the public tree must follow. + +## Repository Layout + +| Path | Contents | +|---|---| +| `src/main/java/` | Java sources: guide engine, layout bridge, page parser, screens, settings, item index. | +| `src/main/resources/` | Mod resources: assets, mixin configuration, `mcmod.info`. `natives/` holds the compiled layout engine library and is generated, not tracked. | +| `src/rust/layout-engine/` | Rust layout engine crate: layout, text measurement, JNI bridge, FlatBuffers schema. | +| `src/test/java/` | JUnit tests for the Java side. | +| `test/visual/` | Visual regression system: fixture resource pack, assertion ratchet, Python tools. | +| `test/visual/resourcepack/` | Fixture guide pack rendered during visual verification. Fixture pages live under `assets/guidenh/guidenh/_en_us/visualtest/`. | +| `test/visual/ratchet/` | Bounds assertions that lock accepted layout geometry in place. | +| `test/visual/tools/` | Python helpers for rendering, bounds assertions, screenshot freshness, log scanning, and screening. | +| `tools/` | Runtime bridge scripts and the FlatBuffers code regeneration helper. | +| `wiki/` | Published user documentation, maintained as English and Chinese pairs. | +| `gradle/` | Gradle wrapper and the daemon JDK toolchain configuration. | + +## Build, Test, And Run + +```powershell +./gradlew build +./gradlew compileJava compileTestJava test +./gradlew runClient +./gradlew runServer +./gradlew spotlessApply +``` + +Rust side, from the crate directory: + +```powershell +cd src/rust/layout-engine +cargo test +cargo build --release +``` + +The Gradle wrapper provisions its own JDK 25 toolchain through +`gradle/gradle-daemon-jvm.properties`; the supported runtime matrix is JDK 8, 17, and 21. A Rust +toolchain installed with rustup is required only for the native layout engine. + +## Native Library + +The layout engine library is a build artifact, not a tracked file. `./gradlew buildRustNative` +compiles `src/rust/layout-engine`, and the Gradle build copies the resulting +`guide_layout_engine.dll` into `src/main/resources/natives/`. The copy is skipped when the library +has not been built, so a checkout without a Rust toolchain still builds. Let the Gradle task perform +the copy instead of copying the file by hand, because the cargo target directory can be relocated. + +The cargo target directory is resolved from the first of these that is set: + +1. the Gradle property `guidenh.rustTargetDir`; +2. the environment variable `GUIDENH_RUST_TARGET_DIR`; +3. the `target-dir` entry in `src/rust/layout-engine/.cargo/config.toml`, which is a local, + untracked redirection, with relative values resolved against the crate directory; +4. the in-tree default `src/rust/layout-engine/target`. + +## Visual Verification + +Render the fixture corpus headlessly. Page ids are listed one per line, each as +`guidenh:visualtest/.md`. Keep each batch to at most 40 pages, and always wrap a batch in the +render watchdog: + +```powershell +py -3 test/visual/tools/render_watchdog.py --timeout 2400 --log -- cmd /c "gradlew.bat runClient25 -Dguidenh.guide.sources=/test/visual/resourcepack -Dguidenh.headlessRender=true -Dguidenh.renderpage.guide=guidenh:guidenh -Dguidenh.renderpage.list= -Dguidenh.renderpage.out= -Dguidenh.renderpage.width=900 -Dguidenh.renderpage.scale=2 -Dguidenh.renderpage.bounds=true" +``` + +Run the layout harness as a gate, which must report a total issue count of 0: + +```powershell +./gradlew compileJava compileTestJava test runLayoutDump +``` + +Then check the rendered bounds against the ratchet, which must exit 0: + +```powershell +py -3 test/visual/tools/assert_bounds.py --shots run/client_new/ --assertions test/visual/ratchet/assertions.json +``` + +The ratchet is append-only: add assertions for newly verified geometry, and adjust an existing +assertion only when the geometry change is intentional and reviewed. Assertions are keyed by page id +such as `visualtest_text_decorations.md` and assert class counts, node existence, or bounds. + +## Logs And Paths + +- Render and client logs are written under `run/client_new/logs/`. +- Render output, screenshots, bounds JSON, and logs stay under the untracked `run/` tree. +- Every path in code, comments, documentation, scripts, and configuration must be repository-relative. + Never commit a machine-local absolute path. + +## Comment And Documentation Conventions + +Public code and documentation are written in English. This applies to comments, identifiers, string +messages, and section titles. + +- Java doc comments use the standard block form with `@param`, `@return`, and `@throws` tags. Rust + items use rustdoc comments with `# Arguments` and `# Returns` sections where they add value. +- Write comments that carry information the code does not already express. Do not restate the next + line, and do not add banner or separator comment lines built from repeated `=`, `-`, `#`, or `*` + characters. +- Do not use Markdown bold markers, meaning a doubled asterisk pair, inside comments. +- Do not use CJK dashes or CJK punctuation in code, comments, identifiers, or documentation. +- Do not reference internal research or working documents that are not distributed with the + repository, and do not cite section numbers from them. +- Editing comments must not change executable behavior or rendering results. + +Localized documents are the only exception to the English rule: `README_zh-CN.md` and the +`wiki/*-zh-CN.md` pages are maintained in Chinese, and fixture pages under +`test/visual/resourcepack/` may carry non-English text as test payload. Everything else in the +public tree is English. diff --git a/README.md b/README.md index cfd5e2fa..185e69a4 100644 --- a/README.md +++ b/README.md @@ -10,183 +10,126 @@

          English | - 简体中文 + 简体中文

          -## **Introduction** +## Introduction -* GuideNH is an in-game guide framework for Minecraft **1.7.10** -* It ports and extends GuideME-style Markdown documentation for GTNH-era modpacks. -* It is designed for authoring rich guide books directly from resource-pack style Markdown files. -* The [GuideNH online editor](https://www.gtnewhorizons.com/GuideNH) can import, edit, and preview guide folders or ZIP archives in a browser. See the [GTNH contribution guidelines](https://github.com/GTNewHorizons/.github/blob/main/CONTRIBUTING.md) for authoring guidance. +GuideNH is an in-game guide framework for Minecraft 1.7.10 / Forge 10.13.4.1614. It ports and +extends the GuideME-style Markdown documentation system, so that mods and modpacks can ship rich +guide books as resource packs: navigation and search, an item index, Mermaid and LaTeX rendering, +interactive 3D scenes, and live in-game editing of guide pages. -## Required Dependencies +The [GuideNH online editor](https://www.gtnewhorizons.com/GuideNH) can import, edit, and preview +guide folders or ZIP archives in a browser. See the +[GTNH contribution guidelines](https://github.com/GTNewHorizons/.github/blob/main/CONTRIBUTING.md) +for authoring guidance. -* [GTNHLib (>= 0.11.16)](https://github.com/GTNewHorizons/GTNHLib) +## Requirements -## **Features** +- Required dependency: [GTNHLib (>= 0.11.16)](https://github.com/GTNewHorizons/GTNHLib). -* Markdown pages with YAML frontmatter, navigation metadata, categories, anchors, tables, footnotes, Mermaid, LaTeX, charts, and highlighted text. -* MDX-style runtime tags such as ``, ``, ``, ``, ``, ``, ``, ``, and ``. -* Interactive 3D GameScene previews with block/entity placement, StructureLib import, Ponder playback, layer sliders, grid controls, annotations, and block statistics. -* Live guide editing mode with split editor/preview, toolbar actions, debounced saving, external-change handling, and resource-pack page creation. -* Multi-language guide folders with fallback, item index navigation, search, server integration, and resource reload support. +- JDK 8, 17, or 21 for the supported runtime matrix. The Gradle build itself runs on a JDK 25 + toolchain, which the wrapper provisions from the settings in `gradle/gradle-daemon-jvm.properties`. +- A Rust toolchain installed with [rustup](https://rustup.rs), needed only to build the layout + engine native library. +- No separate Gradle installation: use the bundled wrapper (`gradlew`, `gradlew.bat`). +- Python 3, invoked as `py -3` on Windows, for the visual test tools. -## **Authors** - -- Programmer: `HFstudio` -- Upstream inspiration: [GuideME](https://github.com/AppliedEnergistics/GuideME) - -## **License** - -- Code: [LGPL-3.0](LICENSE.txt) -- Bundled third-party libraries keep their own licenses. - -## **Wiki** - -* [English Wiki](wiki/Home-en-US.md) -* [中文 Wiki](wiki/Home-zh-CN.md) -* [Structure Export](wiki/Structure-Export.md) -* [Runtime example resource pack](wiki/resourcepack) - -## **Quick Start** +## Build ```powershell -.\gradlew.bat spotlessApply -.\gradlew.bat build -.\gradlew.bat runClient +./gradlew build ``` -In game: - -* Press `G` to open the guide home page. -* Hold `G` while hovering an indexed item to jump to its guide entry. -* Press `F3+T` to reload edited guide resources. - -## **DefaultGuide** +The guide engine renders through a native library. Build it once before running the game: -GuideNH automatically creates `config/guidenh/DefaultGuide/` on the client. -It also supports `config/guidenh/DefaultGuide.zip` as a standard full resource pack, and the zip is preferred when both exist. +```powershell +./gradlew buildRustNative +``` -This directory uses a native namespace-root layout: +Build the library from the crate directory instead: -```text -config/guidenh/DefaultGuide/ -`-- / - `-- guidenh/ - |-- assets/ - | `-- shared_structure.snbt - |-- _en_us/ - | `-- index.md - `-- _zh_cn/ - `-- index.md +```powershell +cd src/rust/layout-engine +cargo build --release ``` -The directory starts empty. Add your own pages and assets there when you want a client-side default guide source. +`buildRustNative` compiles `src/rust/layout-engine` and the Gradle build copies the resulting +`guide_layout_engine.dll` into `src/main/resources/natives/`. That directory is a generated build +output and is not tracked, so the copy is what puts the library where the game loads it. When the +cargo target directory is redirected, the copy follows the resolved directory; let the Gradle task +do it rather than copying the file by hand. -If you use `DefaultGuide.zip`, use the normal full resource-pack layout: +## Run -```text -config/guidenh/DefaultGuide.zip -`-- assets/ - `-- / - `-- guidenh/ - |-- assets/ - |-- _en_us/ - `-- _zh_cn/ +```powershell +./gradlew runClient +./gradlew runServer ``` -`wiki/resourcepack/` remains the repository example resource pack. It keeps the outer `assets/` directory because it is -used for documentation and bundled examples. +In game: -## **Authoring Example** +- Press `G` to open the guide home page. +- Hold `G` while hovering an indexed item to jump to its guide entry. +- Press `F3+T` to reload edited guide resources. -```md ---- -navigation: - title: Machines - parent: index.md -author: GuideNH -date: 2026-05-10 ---- -# Machines +## Test -Press to interact. +```powershell +./gradlew compileJava compileTestJava test +``` - - - - +```powershell +cd src/rust/layout-engine +cargo test ``` -## **Develop** +## Visual Verification -### **Guide Folder** +Render the visual fixture pack headlessly. Each batch is limited to 40 pages and must be wrapped in +the render watchdog: -```text -assets//guidenh/ -|-- assets/ -| `-- shared_structure.snbt -|-- _en_us/ -| |-- index.md -| `-- machines.md -`-- _zh_cn/ - |-- index.md - `-- machines.md +```powershell +py -3 test/visual/tools/render_watchdog.py --timeout 2400 --log -- cmd /c "gradlew.bat runClient25 -Dguidenh.guide.sources=/test/visual/resourcepack -Dguidenh.headlessRender=true -Dguidenh.renderpage.guide=guidenh:guidenh -Dguidenh.renderpage.list= -Dguidenh.renderpage.out= -Dguidenh.renderpage.width=900 -Dguidenh.renderpage.scale=2 -Dguidenh.renderpage.bounds=true" ``` -### **DefaultGuide Folder** - -```text -config/guidenh/DefaultGuide/ -`-- / - `-- guidenh/ - |-- assets/ - | `-- shared_structure.snbt - |-- _en_us/ - | `-- index.md - `-- _zh_cn/ - `-- index.md -``` +Check the rendered bounds against the assertion ratchet: -### **DefaultGuide Zip** - -```text -config/guidenh/DefaultGuide.zip -`-- assets/ - `-- / - `-- guidenh/ - |-- assets/ - | `-- shared_structure.snbt - |-- _en_us/ - | `-- index.md - `-- _zh_cn/ - `-- index.md +```powershell +py -3 test/visual/tools/assert_bounds.py --shots run/client_new/ --assertions test/visual/ratchet/assertions.json ``` -### **Register A Guide** +Render output and client logs are written under `run/client_new/`. See [test/visual/README.md](test/visual/README.md) +for the fixture corpus, the ratchet, and the available tools. -```java -Guide.builder(new ResourceLocation("yourmod", "guidenh")).build(); -``` +## Wiki -### **Verification** +- [English wiki](wiki/Home-en-US.md) +- [Chinese wiki](wiki/Home-zh-CN.md) +- [Getting started](wiki/Getting-Started.md) +- [Guide page format](wiki/Guide-Page-Format.md) +- [Structure export](wiki/Structure-Export.md) +- [Example resource pack](wiki/resourcepack) -```powershell -.\gradlew.bat spotlessApply -.\gradlew.bat build --rerun-tasks -``` +## License -## **Credits** +- Code: [LGPL-3.0](LICENSE.txt) +- Bundled third-party libraries keep their own licenses. -Thanks to persephone for providing the icon textures! +## Credits -Thanks to the following developers for their contributions to GuideNH: +Thanks to persephone for the icon textures. GuideNH is based on ideas from +[GuideME](https://github.com/AppliedEnergistics/GuideME), distributed under LGPL-3.0, and uses +open-source libraries including SnakeYAML, Apache Lucene, Apache Commons Lang, FlatBuffers Java, and +JLaTeXMath. contributors -GuideNH is based on ideas from [GuideME](https://github.com/AppliedEnergistics/GuideME), distributed under LGPL-3.0. -It also uses open-source libraries including SnakeYAML, Apache Lucene, Apache Commons Lang, FlatBuffers Java, and JLaTeXMath. +## Coding Agents + +See [AGENTS.md](AGENTS.md) for the repository layout, the full command reference, and the comment and +documentation conventions. diff --git a/README_zh-CN.md b/README_zh-CN.md new file mode 100644 index 00000000..95a9e052 --- /dev/null +++ b/README_zh-CN.md @@ -0,0 +1,126 @@ +

          + GuideNH +

          +
          +

          + 支持的 Minecraft 版本 + 支持的 Forge 版本 + 许可证 +

          + +

          + English | + Chinese +

          + +## 简介 + +GuideNH 是面向 Minecraft 1.7.10 / Forge 10.13.4.1614 的游戏内指南框架。它移植并扩展了 GuideME 风格的 +Markdown 文档系统,让模组与整合包可以以资源包形式发布内容丰富的指南书:导航与搜索、物品索引、 +Mermaid 与 LaTeX 渲染、可交互的 3D 场景,以及在游戏内直接编辑指南页面。 + +可以使用 [GuideNH 在线编辑器](https://www.gtnewhorizons.com/GuideNH) 在浏览器中导入、编辑和预览指南文件夹或 +ZIP 压缩包。投稿说明见 [GTNH 贡献指南](https://github.com/GTNewHorizons/.github/blob/main/CONTRIBUTING.md)。 + +## 环境要求 + +- 必需依赖:[GTNHLib (>= 0.11.16)](https://github.com/GTNewHorizons/GTNHLib)。 + +- 受支持的运行时矩阵使用 JDK 8、17 或 21。Gradle 构建本身运行在 JDK 25 工具链上,由 + `gradle/gradle-daemon-jvm.properties` 中的设置在 wrapper 内自动准备。 +- 使用 [rustup](https://rustup.rs) 安装的 Rust 工具链,仅在构建布局引擎原生库时需要。 +- 无需单独安装 Gradle,直接使用仓库自带的 wrapper(`gradlew`、`gradlew.bat`)。 +- Python 3,在 Windows 上以 `py -3` 调用,用于视觉测试工具。 + +## 构建 + +```powershell +./gradlew build +``` + +指南引擎通过原生库完成排版渲染,运行游戏前请先构建一次: + +```powershell +./gradlew buildRustNative +``` + +也可以直接在该 crate 目录下构建: + +```powershell +cd src/rust/layout-engine +cargo build --release +``` + +`buildRustNative` 会编译 `src/rust/layout-engine`,随后由 Gradle 构建把生成的 +`guide_layout_engine.dll` 复制到 `src/main/resources/natives/`。该目录是构建产物,不入库, +因此这一步复制才是把原生库放到游戏加载位置的环节。若 cargo 目标目录被重定向,复制会跟随解析后的 +目录;请交给 Gradle 任务完成复制,不要手工拷贝文件。 + +## 运行 + +```powershell +./gradlew runClient +./gradlew runServer +``` + +进入游戏后: + +- 按 `G` 打开指南主页。 +- 鼠标悬停在已建立索引的物品上并按住 `G`,可以跳转到对应指南页面。 +- 按 `F3+T` 可以重新加载已编辑的指南资源。 + +## 测试 + +```powershell +./gradlew compileJava compileTestJava test +``` + +```powershell +cd src/rust/layout-engine +cargo test +``` + +## 视觉验证 + +以 headless 方式渲染视觉 fixture 资源包。每批不超过 40 页,并且必须由渲染看门狗包裹执行: + +```powershell +py -3 test/visual/tools/render_watchdog.py --timeout 2400 --log -- cmd /c "gradlew.bat runClient25 -Dguidenh.guide.sources=/test/visual/resourcepack -Dguidenh.headlessRender=true -Dguidenh.renderpage.guide=guidenh:guidenh -Dguidenh.renderpage.list= -Dguidenh.renderpage.out= -Dguidenh.renderpage.width=900 -Dguidenh.renderpage.scale=2 -Dguidenh.renderpage.bounds=true" +``` + +用断言棘轮核对渲染出的 bounds: + +```powershell +py -3 test/visual/tools/assert_bounds.py --shots run/client_new/ --assertions test/visual/ratchet/assertions.json +``` + +渲染输出与客户端日志写在 `run/client_new/` 下。fixture 语料、棘轮与可用工具的说明见 +[test/visual/README.md](test/visual/README.md)。 + +## Wiki + +- [英文 Wiki](wiki/Home-en-US.md) +- [中文 Wiki](wiki/Home-zh-CN.md) +- [快速上手](wiki/Getting-Started-zh-CN.md) +- [指南页面格式](wiki/Guide-Page-Format-zh-CN.md) +- [结构导出](wiki/Structure-Export-zh-CN.md) +- [示例资源包](wiki/resourcepack) + +## 许可证 + +- 代码:[LGPL-3.0](LICENSE.txt) +- 内置第三方库遵循各自的许可证。 + +## 致谢 + +感谢 persephone 提供图标纹理。GuideNH 借鉴了 [GuideME](https://github.com/AppliedEnergistics/GuideME) +的设计思路,GuideME 以 LGPL-3.0 分发;本项目还使用了 SnakeYAML、Apache Lucene、Apache Commons Lang、 +FlatBuffers Java、JLaTeXMath 等开源库。 + + + contributors + + +## 面向编码代理 + +仓库布局、完整命令参考与注释和文档规范见 [AGENTS.md](AGENTS.md)(英文)。 diff --git a/README_zh.md b/README_zh.md deleted file mode 100644 index 08b7a389..00000000 --- a/README_zh.md +++ /dev/null @@ -1,191 +0,0 @@ -

          - GuideNH -

          -
          -

          - 支持的 Minecraft 版本 - 支持的 Forge 版本 - 许可证 -

          - -

          - English | - 简体中文 -

          - -## **简介** - -* GuideNH 是面向 Minecraft **1.7.10** 的游戏内指南框架。 -* 它移植并扩展了 GuideME 风格的 Markdown 指南系统,适合 GTNH 时代的整合包与大型模组。 -* 它允许作者直接用资源包结构中的 Markdown 文件编写复杂指南书。 -* 可以使用 [GuideNH 在线编辑器](https://www.gtnewhorizons.com/GuideNH) 在浏览器中导入、编辑和预览指南文件夹或 ZIP 压缩包。投稿说明见 [GTNH 贡献指南](https://github.com/GTNewHorizons/.github/blob/main/CONTRIBUTING.md)。 - -## 依赖需求 - -* [GTNHLib (>= 0.11.16)](https://github.com/GTNewHorizons/GTNHLib) - -## **功能** - -* 支持带 YAML 表头的 Markdown 页面、导航元数据、分类、锚点、表格、脚注、Mermaid、LaTeX、图表与文本高亮。 -* 支持 ``、``、``、``、``、``、``、``、`` 等 MDX 风格运行时标签。 -* 支持交互式 3D GameScene 预览,包括方块/实体放置、StructureLib 导入、Ponder 播放、层滑条、网格按钮、注解与方块统计表。 -* 支持游戏内指南编辑模式,包括编辑/预览分屏、工具栏操作、短延迟自动保存、外部变更处理与资源包页面创建。 -* 支持多语言指南目录与回退、物品索引跳转、搜索、服务端集成和资源重载。 - -## **作者** - -- 程序:`HFstudio` -- 上游灵感:[GuideME](https://github.com/AppliedEnergistics/GuideME) - -## **许可证** - -- 代码:[LGPL-3.0](LICENSE.txt) -- 内置第三方库遵循各自的许可证。 - -## **Wiki** - -* [English Wiki](wiki/Home-en-US.md) -* [中文 Wiki](wiki/Home-zh-CN.md) -* [结构导出](wiki/Structure-Export-zh-CN.md) -* [运行时示例资源包](wiki/resourcepack) - -## **快速开始** - -```powershell -.\gradlew.bat spotlessApply -.\gradlew.bat build -.\gradlew.bat runClient -``` - -进入游戏后: - -* 按 `G` 打开指南主页。 -* 鼠标悬停在已建立索引的物品上并按住 `G`,可以跳转到对应指南页面。 -* 按 `F3+T` 可以重新加载已编辑的指南资源。 - -## **DefaultGuide** - -GuideNH 会在客户端自动创建 `config/guidenh/DefaultGuide/`。 -它还支持把 `config/guidenh/DefaultGuide.zip` 作为标准完整资源包加载,并且当 zip 与目录同时存在时优先使用 zip。 - -这个目录使用原生命名空间根布局: - -```text -config/guidenh/DefaultGuide/ -`-- / - `-- guidenh/ - |-- assets/ - | `-- shared_structure.snbt - |-- _en_us/ - | `-- index.md - `-- _zh_cn/ - `-- index.md -``` - -该目录默认为空。需要客户端默认指南源时,请把你自己的页面和资源放在这里。 - -如果你使用 `DefaultGuide.zip`,请采用标准完整资源包布局: - -```text -config/guidenh/DefaultGuide.zip -`-- assets/ - `-- / - `-- guidenh/ - |-- assets/ - |-- _en_us/ - `-- _zh_cn/ -``` - -`wiki/resourcepack/` 仍然是仓库内的示例资源包。它保留外层 `assets/` 目录,用于文档说明和内置示例。 - -## **编写示例** - -```md ---- -navigation: - title: 机器 - parent: index.md -author: GuideNH -date: 2026-05-10 ---- - -# 机器 - -按 进行交互。 - - - - - -``` - -## **开发** - -### **指南目录** - -```text -assets//guidenh/ -|-- assets/ -| `-- shared_structure.snbt -|-- _en_us/ -| |-- index.md -| `-- machines.md -`-- _zh_cn/ - |-- index.md - `-- machines.md -``` - -### **DefaultGuide 目录** - -```text -config/guidenh/DefaultGuide/ -`-- / - `-- guidenh/ - |-- assets/ - | `-- shared_structure.snbt - |-- _en_us/ - | `-- index.md - `-- _zh_cn/ - `-- index.md -``` - -### **DefaultGuide Zip** - -```text -config/guidenh/DefaultGuide.zip -`-- assets/ - `-- / - `-- guidenh/ - |-- assets/ - | `-- shared_structure.snbt - |-- _en_us/ - | `-- index.md - `-- _zh_cn/ - `-- index.md -``` - -### **注册指南** - -```java -Guide.builder(new ResourceLocation("yourmod", "guidenh")).build(); -``` - -### **验证** - -```powershell -.\gradlew.bat spotlessApply -.\gradlew.bat build --rerun-tasks -``` - -## **致谢** - -感谢Persephone提供了图标纹理! - -感谢以下开发者对 GuideNH 的贡献: - - - contributors - - -GuideNH 借鉴了 [GuideME](https://github.com/AppliedEnergistics/GuideME) 的设计思路,GuideME 使用 LGPL-3.0 分发。 -本项目还使用了 SnakeYAML、Apache Lucene、Apache Commons Lang、FlatBuffers Java、JLaTeXMath 等开源库。 diff --git a/wiki/Guide-Page-Format-zh-CN.md b/wiki/Guide-Page-Format-zh-CN.md index 72dae95a..cbe2e13c 100644 --- a/wiki/Guide-Page-Format-zh-CN.md +++ b/wiki/Guide-Page-Format-zh-CN.md @@ -305,6 +305,7 @@ GuideNH 会读取第一个 YAML frontmatter 块,并解析这些已知键: ```yaml item_id: minecraft:potion 16384-16462,!16386 item_ids: + - minecraft:book - ae2:white_paint_ball:* - "" navigation: diff --git a/wiki/Guide-Page-Format.md b/wiki/Guide-Page-Format.md index e8c1c991..0c1cae11 100644 --- a/wiki/Guide-Page-Format.md +++ b/wiki/Guide-Page-Format.md @@ -316,8 +316,10 @@ GuideNH reads the first YAML frontmatter block and parses these known keys: ```yaml item_id: minecraft:potion 16384-16462,!16386 item_ids: + - minecraft:book - ae2:white_paint_ball:* - "" + navigation: title: Root parent: index.md diff --git a/wiki/Images-And-Assets-zh-CN.md b/wiki/Images-And-Assets-zh-CN.md index a67d1ec2..9bfc10f9 100644 --- a/wiki/Images-And-Assets-zh-CN.md +++ b/wiki/Images-And-Assets-zh-CN.md @@ -33,14 +33,14 @@ GuideNH 会解析路径,并从指南内容根目录加载对应的二进制资 | 属性 | 必需 | 含义 | | --- | --- | --- | | `src` | 是 | 图片路径 | -| `x` | 是 | 原图裁剪起始 X,单位为源图像像素 | -| `y` | 是 | 原图裁剪起始 Y,单位为源图像像素 | -| `width` / `w` | 是 | 原图裁剪宽度,单位为源图像像素;两种写法只能二选一 | -| `height` / `h` | 是 | 原图裁剪高度,单位为源图像像素;两种写法只能二选一 | +| `x` | 否 | 原图裁剪起始 X,单位为源图像像素;缺省 `0` | +| `y` | 否 | 原图裁剪起始 Y,单位为源图像像素;缺省 `0` | +| `width` / `w` | `width` / `height` 至少给一维 | 源图像像素宽度(与 `height` 同时给出时为裁剪宽度,只给一维时为整图显示宽度);两种写法只能二选一 | +| `height` / `h` | `width` / `height` 至少给一维 | 源图像像素高度(与 `width` 同时给出时为裁剪高度,只给一维时为整图显示高度);两种写法只能二选一 | | `scaleX` | 否 | 水平显示缩放倍率,默认 `1.0` | | `scaleY` | 否 | 垂直显示缩放倍率,默认 `1.0` | -| `displayWidth` | 否 | 最终显示宽度,单位为像素;单独使用时按裁剪区域比例计算高度 | -| `displayHeight` | 否 | 最终显示高度,单位为像素;单独使用时按裁剪区域比例计算宽度 | +| `displayWidth` | 否 | 直接以像素指定最终显示宽度,替代 `scaleX`;另一维按裁剪区域比例推断,未裁剪时按图片自然比例推断 | +| `displayHeight` | 否 | 直接以像素指定最终显示高度,替代 `scaleY`;另一维按裁剪区域比例推断,未裁剪时按图片自然比例推断 | | `wrap` | 否 | `inline` 表示真正行内放置,其他值使用常规环绕模式 | | `align` | 否 | 浮动时使用 `left` 或 `right`;`wrap="inline"` 时会被忽略 | | `title` | 否 | tooltip/title 文本 | @@ -50,13 +50,13 @@ GuideNH 会解析路径,并从指南内容根目录加载对应的二进制资 ### 说明 -- 裁剪时必须同时提供 `x`、`y`、`width` / `w` 与 `height` / `h` -- 全部省略裁剪属性时,可使用 `displayWidth` 或 `displayHeight` 显示整张源图 -- `width` 和 `height` 现在表示裁剪区域,不再表示最终显示尺寸 -- `scaleX` 与 `scaleY` 会将最终显示尺寸计算为 `cropWidth * scaleX` 与 `cropHeight * scaleY` -- `displayWidth` 或 `displayHeight` 以像素指定最终显示尺寸;只提供其中一个时,另一个尺寸按裁剪区域宽高比自动计算 -- 同时提供 `displayWidth` 与 `displayHeight` 时,可按指定尺寸进行非等比拉伸 -- `displayWidth` / `displayHeight` 不能与 `scaleX` / `scaleY` 同时使用 +- `x` 和 `y` 为可选裁剪偏移,缺省为 `0` +- `width` / `w` 与 `height` / `h` 至少要提供一维,除非改用 `displayWidth` / `displayHeight` 指定尺寸;两者都不提供会渲染可见错误 +- 同时给出两维时,`width` 和 `height` 描述裁剪区域,不再表示最终显示尺寸;最终显示尺寸为 `cropWidth * scaleX` 与 `cropHeight * scaleY` +- 只给出其中一维时,图片按该尺寸显示,另一维根据图片自然宽高比推断——运行时与站点导出行为一致(导出端由浏览器按 intrinsic ratio 处理) +- `displayWidth` / `displayHeight` 直接以像素指定最终显示尺寸,不再对裁剪区域做缩放,因此不能与 `scaleX` / `scaleY` 同时使用 +- `displayWidth` / `displayHeight` 需要完全不写裁剪属性(此时显示整张源图),或写全裁剪矩形;只给一个显示尺寸时,另一维按裁剪区域推断,未裁剪时按图片自然比例推断 +- 同时给出 `displayWidth` 与 `displayHeight` 时,可按指定尺寸进行非等比拉伸 - 支持单轴拉伸,只修改一个缩放值即可 - 同时写 `width` 和 `w`,或同时写 `height` 和 `h`,都会渲染可见错误 - 旧版把 `width` / `height` 当作显示尺寸的 `` 内容会发生破坏性变更,需要手动迁移 diff --git a/wiki/Images-And-Assets.md b/wiki/Images-And-Assets.md index d7546510..d9fb129b 100644 --- a/wiki/Images-And-Assets.md +++ b/wiki/Images-And-Assets.md @@ -33,14 +33,14 @@ other mods directly. | Attribute | Required | Meaning | | --- | --- | --- | | `src` | yes | image path | -| `x` | yes | crop start X in source-image pixels | -| `y` | yes | crop start Y in source-image pixels | -| `width` / `w` | yes | crop width in source-image pixels; exactly one spelling must be used | -| `height` / `h` | yes | crop height in source-image pixels; exactly one spelling must be used | +| `x` | no | crop start X in source-image pixels; default `0` | +| `y` | no | crop start Y in source-image pixels; default `0` | +| `width` / `w` | at least one of `width` / `height` | width in source-image pixels (crop width when paired with `height`, otherwise a whole-image display width); exactly one spelling must be used | +| `height` / `h` | at least one of `width` / `height` | height in source-image pixels (crop height when paired with `width`, otherwise a whole-image display height); exactly one spelling must be used | | `scaleX` | no | horizontal display multiplier, default `1.0` | | `scaleY` | no | vertical display multiplier, default `1.0` | -| `displayWidth` | no | final display width in pixels; preserves the crop aspect ratio when used alone | -| `displayHeight` | no | final display height in pixels; preserves the crop aspect ratio when used alone | +| `displayWidth` | no | final display width in pixels instead of `scaleX`; the other axis follows the crop rectangle, or the natural image ratio when nothing is cropped | +| `displayHeight` | no | final display height in pixels instead of `scaleY`; the other axis follows the crop rectangle, or the natural image ratio when nothing is cropped | | `wrap` | no | `inline` for true inline placement, otherwise use the normal wrapping modes | | `align` | no | `left` or `right` for floating placement; ignored when `wrap="inline"` | | `title` | no | tooltip/title text | @@ -50,13 +50,13 @@ other mods directly. ### Notes -- `x`, `y`, `width` / `w`, and `height` / `h` are all required together when cropping -- when the crop attributes are all omitted, `displayWidth` or `displayHeight` displays the full source image -- `width` and `height` now describe the crop rectangle, not the final display size -- `scaleX` and `scaleY` calculate the final display size as `cropWidth * scaleX` by `cropHeight * scaleY` -- `displayWidth` or `displayHeight` sets the final display size in pixels; when only one is present, the other dimension is calculated from the crop aspect ratio -- providing both `displayWidth` and `displayHeight` allows intentional non-proportional stretching -- `displayWidth` / `displayHeight` cannot be combined with `scaleX` / `scaleY` +- `x` and `y` are optional crop offsets and default to `0` +- at least one of `width` / `w` or `height` / `h` is required, unless `displayWidth` / `displayHeight` sizes the image instead; providing neither renders a visible error +- when both dimensions are given, `width` and `height` describe the crop rectangle, not the final display size; the final display size is `cropWidth * scaleX` by `cropHeight * scaleY` +- when only one dimension is given, the image is displayed at that size and the missing axis is inferred from the image's natural aspect ratio (in-game and in the site export the behavior matches; the export side lets the browser derive the other axis from the intrinsic ratio) +- `displayWidth` / `displayHeight` set the final display size in pixels instead of scaling the crop rectangle, so they cannot be combined with `scaleX` / `scaleY` +- `displayWidth` / `displayHeight` need either no crop attribute at all, which displays the whole source image, or the full crop rectangle; with a single display dimension the other axis is inferred from the crop rectangle, and from the natural image ratio when nothing is cropped +- giving both `displayWidth` and `displayHeight` allows intentional non-proportional stretching - single-axis stretching is supported by setting only one scale differently - `width` with `w`, or `height` with `h`, is invalid and renders a visible error - old `FloatingImage width/height as display size` content is intentionally breaking and must be migrated manually diff --git a/wiki/Tags-Reference-zh-CN.md b/wiki/Tags-Reference-zh-CN.md index 1e161589..477715d6 100644 --- a/wiki/Tags-Reference-zh-CN.md +++ b/wiki/Tags-Reference-zh-CN.md @@ -488,10 +488,10 @@ GuideNH 会在运行时 Markdown 脚注展开后内部使用这个块标签。 快速规则: - `src` 支持相对路径、根路径,以及显式 `modid:path` 纹理 id -- `x`、`y`、`width` / `w`、`height` / `h` 用于描述原图上的裁剪区域,且必须全部提供 +- `x` 和 `y` 为可选裁剪偏移(缺省 `0`);`width` / `w` 与 `height` / `h` 至少提供一维(除非改用 `displayWidth` / `displayHeight` 指定尺寸),只给一维时另一维按图片自然宽高比推断 - `scaleX` 与 `scaleY` 用于缩放裁剪结果,并支持横向 / 纵向独立拉伸 -- `displayWidth` 与 `displayHeight` 以像素指定最终尺寸;只提供一个值时保持裁剪区域比例,两个值都提供时可拉伸 -- `displayWidth` / `displayHeight` 不能与 `scaleX` / `scaleY` 同时使用 +- `displayWidth` 与 `displayHeight` 以像素指定最终尺寸,不能与 `scaleX` / `scaleY` 同时使用 +- `displayWidth` / `displayHeight` 需要完全不写裁剪属性(此时显示整张源图)或写全裁剪矩形;只给一个值时按裁剪区域比例推断,未裁剪时按图片自然比例推断,两个值都提供时可拉伸 - `wrap="inline"` 会让图片真正作为行内内容插入文本流;此时 `align` 会被忽略 - 旧内容如果把 `width` / `height` 当成最终显示尺寸,需要手动迁移 diff --git a/wiki/Tags-Reference.md b/wiki/Tags-Reference.md index e3abdf30..9e448ebb 100644 --- a/wiki/Tags-Reference.md +++ b/wiki/Tags-Reference.md @@ -517,10 +517,10 @@ See [Images And Assets](Images-And-Assets) for the full behavior. Quick rules: - `src` supports relative paths, rooted paths, and explicit `modid:path` texture ids -- `x`, `y`, `width` / `w`, and `height` / `h` define the crop rectangle on the original image and must all be present +- `x` and `y` are optional crop offsets (default `0`); at least one of `width` / `w` or `height` / `h` is required, unless `displayWidth` / `displayHeight` sizes the image instead, and a single dimension infers the other axis from the image's natural aspect ratio - `scaleX` and `scaleY` resize the cropped result and support independent horizontal / vertical stretching -- `displayWidth` and `displayHeight` set final dimensions in pixels; one value preserves the crop aspect ratio, while two values allow stretching -- `displayWidth` / `displayHeight` cannot be combined with `scaleX` / `scaleY` +- `displayWidth` and `displayHeight` set the final dimensions in pixels and cannot be combined with `scaleX` / `scaleY` +- `displayWidth` / `displayHeight` need either no crop attribute at all, which displays the whole source image, or the full crop rectangle; one value keeps the crop or natural aspect ratio, while two values allow stretching - `wrap="inline"` places the image truly inline inside text flow; in that mode `align` is ignored - old content that used `width` / `height` as final display size must be migrated manually diff --git a/wiki/resourcepack/assets/guidenh/guidenh/_en_us/scene-camera.md b/wiki/resourcepack/assets/guidenh/guidenh/_en_us/scene-camera.md index 2a2e6879..595f653a 100644 --- a/wiki/resourcepack/assets/guidenh/guidenh/_en_us/scene-camera.md +++ b/wiki/resourcepack/assets/guidenh/guidenh/_en_us/scene-camera.md @@ -46,9 +46,16 @@ Viewport size variants, camera presets, and pan/offset tests. * `perspective="isometric_north_east"` / `"isometric_north_west"` / `"up"` — pick a yaw/pitch/roll preset; * `rotateX` / `rotateY` / `rotateZ` — explicit per-axis overrides applied on top of the preset; -* `offsetX` / `offsetY` — screen-space pan (units: blocks); +* `offsetX` / `offsetY` — screen-space camera pan, in **pixels** (see [Pan Offset](#pan-offset)); * `centerX` / `centerY` / `centerZ` — explicit world-space rotation center (overrides auto-center). +> **Migration note (2026-08)**: Older versions of this page described `offsetX` / `offsetY` as +> "units: blocks". That wording is outdated — offsets are **screen-space pixels** and are +> independent of the scene zoom (the camera converts them internally by dividing by the +> projection scale `10 × zoom`). If you previously used offset values written for the old +> blocks semantics, re-tune them to the pixel displacement you want on screen; a given pixel +> offset now produces the same visual shift at every zoom level. + NE vs NW preset: @@ -75,7 +82,7 @@ Top-down view (`up`): ## Pan Offset -`offsetX` / `offsetY` pan — right scene offset by +2 / +1 blocks: +`offsetX` / `offsetY` pan — right scene offset by +2 / +1 pixels (screen-space): diff --git a/wiki/resourcepack/assets/guidenh/guidenh/_zh_cn/scene-camera.md b/wiki/resourcepack/assets/guidenh/guidenh/_zh_cn/scene-camera.md index d111c88f..1256a503 100644 --- a/wiki/resourcepack/assets/guidenh/guidenh/_zh_cn/scene-camera.md +++ b/wiki/resourcepack/assets/guidenh/guidenh/_zh_cn/scene-camera.md @@ -46,9 +46,14 @@ categories: * `perspective="isometric_north_east"` / `"isometric_north_west"` / `"up"` — 选择预设视角(yaw/pitch/roll); * `rotateX` / `rotateY` / `rotateZ` — 在预设之上显式覆盖单个轴的旋转; -* `offsetX` / `offsetY` — 屏幕空间平移(单位:方块); +* `offsetX` / `offsetY` — 屏幕空间相机平移,单位为**像素**(见[屏幕空间平移](#屏幕空间平移)); * `centerX` / `centerY` / `centerZ` — 显式指定旋转中心(覆盖自动居中)。 +> **迁移说明(2026-08)**:旧版本文档将 `offsetX` / `offsetY` 描述为「单位:方块」, +> 该表述已过时——偏移是**屏幕空间像素**,与场景缩放无关(相机内部按投影比例 +> `10 × zoom` 换算)。若你此前按旧的方块语义书写偏移值,请按目标屏幕位移重新调整; +> 现在相同的像素偏移在任意缩放级别下都产生相同的视觉位移。 + NE 与 NW 预设对比: @@ -75,7 +80,7 @@ NE 与 NW 预设对比: ## 屏幕空间平移 -`offsetX` / `offsetY` 平移——右侧场景向右下偏移 2/1 个方块: +`offsetX` / `offsetY` 平移——右侧场景向右下偏移 2/1 像素(屏幕空间): From 88e449d0589c1aa75196c47d7c4ac7da52371016 Mon Sep 17 00:00:00 2001 From: Cheng Ming <8456918+Windorain@users.noreply.github.com> Date: Sun, 4 Oct 2026 04:56:00 +0800 Subject: [PATCH 6/6] chore: align .gitignore with upstream Follow upstream on src/test tracking, profileconfig.json and web-editor/node_modules; keep the rust crate, visual tooling, and build-time natives entries. --- .gitignore | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/.gitignore b/.gitignore index c89a22de..228767dc 100644 --- a/.gitignore +++ b/.gitignore @@ -32,6 +32,7 @@ src/main/resources/mixins.*([!.]).json *.bat *.DS_Store !gradlew.bat +!tools/regen_java_flatc.bat .factorypath addon.local.gradle addon.local.gradle.kts @@ -63,3 +64,19 @@ layout.json CLAUDE.md .profileconfig.json /web-editor/node_modules + +### Rust layout-engine ### +/src/rust/layout-engine/target/ +/src/rust/layout-engine/src/guidenh_layout_generated.rs +/src/rust/layout-engine/.cargo/ +# Native library is a build-time artifact (copied by the copyRustNative Gradle task) +/src/main/resources/natives/ + +### Visual inspection tooling (API keys & python artifacts) ### +.env +.env.local +test/visual/tools/.env +__pycache__/ +*.pyc +.venv/ +test/visual/local/