diff --git a/.changeset/README.md b/.changeset/README.md new file mode 100644 index 0000000..ac9c6af --- /dev/null +++ b/.changeset/README.md @@ -0,0 +1,19 @@ +# Changesets + +This repository uses [Changesets](https://github.com/changesets/changesets) to stage npm version +updates for `@docxly/core-rs`. + +Common contributor flow: + +```bash +npx @changesets/cli add +``` + +After the changeset lands on `main`, the `Version Packages` workflow opens or updates a release PR +that: + +- bumps `packages/npm-core-rs/package.json` +- syncs `packages/core-rs/Cargo.toml` +- refreshes `packages/npm-core-rs/package-lock.json` + +The existing tag-based `Release` workflow still handles the actual npm publish for `v*.*.*` tags. diff --git a/.changeset/config.json b/.changeset/config.json new file mode 100644 index 0000000..81153cb --- /dev/null +++ b/.changeset/config.json @@ -0,0 +1,11 @@ +{ + "$schema": "https://unpkg.com/@changesets/config@3.0.0/schema.json", + "changelog": false, + "commit": false, + "fixed": [], + "linked": [], + "access": "public", + "baseBranch": "main", + "updateInternalDependencies": "patch", + "ignore": [] +} diff --git a/.changeset/fuzzy-cows-fry.md b/.changeset/fuzzy-cows-fry.md new file mode 100644 index 0000000..cf9fc45 --- /dev/null +++ b/.changeset/fuzzy-cows-fry.md @@ -0,0 +1,5 @@ +--- +"@docxly/core-rs": patch +--- + +Stabilize HWPX generation and demo packaging, and add automated npm versioning workflow support. diff --git a/.github/workflows/version-packages.yml b/.github/workflows/version-packages.yml new file mode 100644 index 0000000..fca6503 --- /dev/null +++ b/.github/workflows/version-packages.yml @@ -0,0 +1,44 @@ +name: Version Packages + +on: + push: + branches: + - main + workflow_dispatch: + +permissions: + contents: write + pull-requests: write + +concurrency: + group: version-packages + cancel-in-progress: true + +jobs: + version: + if: github.actor != 'github-actions[bot]' + runs-on: ubuntu-latest + + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Set up Node + uses: actions/setup-node@v4 + with: + node-version: 22 + + - name: Install workspace dependencies + run: npm install + + - name: Create or update version PR + uses: changesets/action@v1 + with: + version: npm run version:packages + commit: "chore: version packages" + title: "chore: version packages" + createGithubReleases: false + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.gitignore b/.gitignore index ac04211..3f74d1c 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,8 @@ .DS_Store .codex/ +.playwright-cli/ node_modules/ +output/ packages/npm-core-rs/dist/ packages/npm-core-rs/node_modules/ packages/npm-core-rs/site-dist/ diff --git a/Cargo.lock b/Cargo.lock index 16930df..8eb4a68 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -58,6 +58,7 @@ dependencies = [ "pulldown-cmark", "roxmltree", "serde", + "serde_json", "sha2", "thiserror", "toml", @@ -182,6 +183,12 @@ dependencies = [ "hashbrown", ] +[[package]] +name = "itoa" +version = "1.0.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92ecc6618181def0457392ccd0ee51198e065e016d1d527a7ac1b6dc7c1f09d2" + [[package]] name = "libc" version = "0.2.182" @@ -295,6 +302,19 @@ dependencies = [ "syn", ] +[[package]] +name = "serde_json" +version = "1.0.149" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + [[package]] name = "serde_spanned" version = "0.6.9" @@ -494,6 +514,12 @@ dependencies = [ "zopfli", ] +[[package]] +name = "zmij" +version = "1.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" + [[package]] name = "zopfli" version = "0.8.3" diff --git a/README.md b/README.md index 903e040..13c84fd 100644 --- a/README.md +++ b/README.md @@ -13,7 +13,7 @@ Language: [English](./README.md) · [한국어](./docs/ko/README.md) · [Docs](./docs/README.md) -`docxly/core-rs` currently measures at an 80 ms cold start and a 2 ms steady median, versus 284 ms cold and 210 ms steady for Pandoc on the summary DOCX benchmark corpus, a 105x steady-state advantage while also exposing browser-local generation and HWPX support from the same Rust core. +`docxly/core-rs` is an embeddable Rust/WASM document generation engine for DOCX and HWPX. Current benchmark details live in the comparison block below so the published README stays aligned with the checked-in benchmark dataset. The project is developed with a TDD-first workflow. The current milestone implements the DOCX rich slice and an approved HWPX baseline backed by manually validated golden fixtures. @@ -58,7 +58,7 @@ fs::write("output.docx", docx)?; ## Why docxly - Build document generation directly into a product instead of shelling out to a converter. -- On the current summary corpus, generate complex DOCX output in `2 ms` steady median versus Pandoc's `210 ms`, with `80 ms` versus `284 ms` cold start. +- Keep benchmark numbers in the checked-in comparison block below instead of scattering stale point-in-time claims through the README. - Use the same Rust core across Node, browser, and HWPX workflows. - Start with DOCX today and expand into HWPX from the same repository. - Ship deterministic outputs backed by fixture-driven tests and normalized archive checks. @@ -69,7 +69,7 @@ Try the browser demo on GitHub Pages: - https://docxly.github.io/core-rs/ -The live page uses the published WASM wrapper and downloads a real `.docx` file directly in the browser. +The live page uses the published WASM wrapper and downloads real `.docx` and `.hwpx` files directly in the browser. @@ -81,21 +81,25 @@ Use docxly when document generation must live inside a Node service, browser wor | Corpus | docxly cold | Pandoc cold | docxly steady | Pandoc steady | Speed ratio | | --- | --- | --- | --- | --- | --- | -| Small | 66 ms | 539 ms | 1 ms | 249 ms | 369.84x | -| Medium | 80 ms | 284 ms | 2 ms | 126 ms | 64.85x | -| Large | 88 ms | 219 ms | 4 ms | 210 ms | 57.60x | -| Summary | 80 ms | 284 ms | 2 ms | 210 ms | 105.00x | - -| Capability | docxly | Pandoc | -| --- | --- | --- | -| Embeddable in app | Yes, library-first for Node and browser bundlers | CLI-first with process invocation | -| Browser-local generation | First-party browser package and WASM path | Possible through pandoc.wasm, not the primary npm workflow | -| npm distribution | Published package | Not a first-party npm package | -| HWPX generation | Supported in the Rust core | Not supported | -| Broad format conversion | Focused on DOCX and HWPX generation | Wide multi-format conversion | -| DOCX reference-template workflow | Not a reference.docx workflow | Supported via reference.docx | - -Measured on darwin 25.2.0 / arm64 at 2026-03-10T14:29:12.752Z with Node v23.7.0 and Pandoc 3.9. +| Small | 63 ms | 454 ms | 1 ms | 253 ms | 379.53x | +| Medium | 83 ms | 306 ms | 2 ms | 95 ms | 60.33x | +| Large | 83 ms | 192 ms | 4 ms | 193 ms | 53.92x | +| Summary | 83 ms | 306 ms | 2 ms | 193 ms | 96.50x | + +| Capability | DOCX | HWPX strict | HWPX compat | +| --- | --- | --- | --- | +| Headings and paragraphs | Yes | Yes | Yes | +| Inline emphasis, strong, code, links | Yes | Yes | Yes | +| Ordered lists up to depth 2 | Yes | No | Yes, semantic contract | +| Unordered lists up to depth 2 | Yes | Yes | Yes | +| Tables | Yes | Yes | Yes | +| `data:` URI images | Yes | No | Degraded fallback to alt text | +| Unsupported HTML | Strict: error, compat: literal text fallback | Error | Degraded literal text fallback | +| Footnotes, task lists, math | Strict: error, compat: visible text fallback | Error | Degraded visible text fallback | +| Deep nested lists | Strict: error, compat: plain text fallback | Error | Degraded plain text fallback | +| HWPX API in npm | N/A | Experimental API | Experimental API | + +Measured on darwin 25.2.0 / arm64 at 2026-03-11T08:19:00.589Z with Node v23.7.0 and Pandoc 3.9. - This benchmark measures DOCX generation only and does not compare HWPX. - The numbers above come from an offline Node environment and are not browser runtime timings. @@ -126,11 +130,11 @@ Measured on darwin 25.2.0 / arm64 at 2026-03-10T14:29:12.752Z with Node v23.7.0 - Implemented: Markdown parser, internal shared intermediate model, deterministic DOCX packaging - Implemented: fixture-driven integration tests with normalized hash comparison -- Implemented: strict/fallback handling for unsupported HTML, non-data images, and deep nested lists +- Implemented: strict/fallback handling plus report APIs for unsupported HTML, images, task lists, math, and deep nested lists - Implemented: approved HWPX baseline backed by manually validated fixtures -- Current HWPX CI gates use these approved fixtures: `core-paragraph`, `blockquote-basic`, `code-block-basic`, `core-heading`, `core-inline-style`, `core-link-text`, `core-mixed`, `list-basic`, `list-nested-depth-2`, `style-typography`, `style-centered-layout`, and `style-brand-color` -- Provisional and quarantined HWPX artifacts are excluded from the release gate -- HWPX style options currently apply to body paragraphs and heading paragraphs; future block types such as lists and tables may add more paragraph categories +- Approved HWPX fixtures are the release gate; provisional and quarantined artifacts are not +- Approved fixture status is separate from strict/compat support claims; ordered lists are compat-only, while tables are supported in strict mode +- Package-specific details such as the current approved fixture set and Rust API notes live in `packages/core-rs/README.md` and `packages/core-rs/src/generators/hwpx/docs/README.md` ## Supported Markdown Today @@ -159,6 +163,7 @@ Current behavior: ## Public API The Rust crate intentionally exposes only the high-level API. Parser, model, generator, and utility modules are internal implementation details. +Repository-level guidance stays high level here; concrete Rust API details live in `packages/core-rs/README.md`, and npm/browser usage lives in `packages/npm-core-rs/README.md`. Recommended first flow: @@ -178,36 +183,33 @@ let docx = generate_docx( fs::write("output.docx", docx)?; ``` -Current options: - -- `title: Option` -- `author: Option` -- `strict_mode: bool` -- `style: HwpxStyleOptions` on `HwpxOptions` only - Notes: - `generate_docx` returns a deterministic `.docx` archive as `Vec` +- `analyze_markdown`, `generate_docx_with_report`, and `generate_hwpx_with_report` surface strict/compat compatibility issues without widening the core generation API - `generate_hwpx` targets the approved HWPX baseline reproduced by the committed golden fixtures - `HwpxStyleOptions` currently supports document-level body/heading font, body/heading size, text/link/heading color, and paragraph alignment overrides - Custom HWPX fonts are best-effort only; the current HWPX path records font family names but does not embed font binaries -- internal modules such as parser/model/generator helpers are not part of the public contract +- Internal modules such as parser/model/generator helpers are not part of the public contract ## HWPX Supported Today The current HWPX path is narrower than the DOCX rich slice. -- approved baseline: `core-paragraph`, `blockquote-basic`, `code-block-basic`, `core-heading`, `core-inline-style`, `core-link-text`, `core-mixed` -- approved list baseline: `list-basic`, `list-nested-depth-2` -- approved style baseline: `style-typography`, `style-centered-layout`, `style-brand-color` -- supported content today: - - paragraphs - - headings - - visible-text emphasis/strong/code/link rendering inside the approved compatibility contract - - document-level HWPX style overrides -- not yet part of the approved HWPX baseline: +- approved fixture details: see `packages/core-rs/README.md` and `packages/core-rs/src/generators/hwpx/docs/README.md` +- strict-supported today: + - paragraphs and headings + - blockquotes + - visible-text emphasis/strong/code/link rendering + - unordered lists up to depth 2 - tables + - document-level HWPX style overrides +- compat-only semantic support: + - ordered lists up to depth 2 +- compat degraded fallback: - images + - unsupported rich blocks such as code blocks and thematic breaks + - deep nested lists Example HWPX generation: @@ -244,8 +246,8 @@ The repository also contains an npm package at `packages/npm-core-rs/`. - package name: `@docxly/core-rs` - runtime target: Node + Browser -- public npm API: `generateDocx(markdown, options) -> Promise` -- HWPX is intentionally not exposed in npm v0.x even though the Rust crate now implements the HWPX core subset +- public npm APIs: `generateDocx`, `generateHwpx`, `analyzeMarkdown`, `generateDocxWithReport`, `generateHwpxWithReport` +- HWPX is exposed as an experimental API in npm, with support differing between strict and compat mode ## Rust Crate Status @@ -282,6 +284,7 @@ npm run test:web npm run demo npm run build:pages npm run test:all +npm run changeset:add ``` Live demo URL: @@ -295,6 +298,14 @@ What each command does: - `demo`: builds the Pages artifact and serves the browser demo locally - `build:pages`: creates the static GitHub Pages artifact at `packages/npm-core-rs/site-dist` - `test:all`: runs Rust lint, Rust tests, and web smoke tests from one root entrypoint +- `changeset:add`: creates a release note entry that feeds the automated npm version PR workflow + +## Release Flow + +- Add a changeset for npm-facing changes with `npm run changeset:add` +- When that changeset lands on `main`, the `Version Packages` workflow opens or updates a version PR +- That PR updates `packages/npm-core-rs/package.json`, syncs `packages/core-rs/Cargo.toml`, and refreshes `packages/npm-core-rs/package-lock.json` +- Publishing still happens from the existing tag-based `Release` workflow when a matching `v*.*.*` tag is pushed ## Running Tests @@ -359,7 +370,7 @@ hash.txt - `packages/core-rs/src/generators/hwpx/docs/schema-md/index.md` - `packages/core-rs/src/generators/hwpx/reference/paragraph-only` -These files are the repository-level reference corpus for HWPX work. They combine curated implementation notes, Markdown conversions of Hancom reference material, and the local approved/reference fixtures used to keep the package contract stable. +These files are contributor-facing HWPX references. They combine curated implementation notes, Markdown conversions of Hancom reference material, and the local approved/reference fixtures used to keep the package contract stable. ## Development Notes diff --git a/docs/ko/README.md b/docs/ko/README.md index a06a693..8266834 100644 --- a/docs/ko/README.md +++ b/docs/ko/README.md @@ -17,9 +17,9 @@ Pandoc이 다양한 형식 사이를 변환하는 범용 도구라면, `docxly` ## 핵심 수치 -- 현재 요약 벤치마크 기준 `docxly`는 `80 ms cold / 2 ms steady` -- 같은 코퍼스에서 Pandoc은 `284 ms cold / 210 ms steady` -- 반복 실행 구간에서는 `105x` 빠른 수치가 측정됐습니다. +- 최신 benchmark 수치는 루트 README의 comparison block과 npm comparison dataset을 기준으로 함께 갱신됩니다. +- 고정 수치만 따로 인용하기보다, 같은 저장소에 커밋된 비교 데이터와 caveat를 함께 보는 것이 안전합니다. +- 현재 benchmark는 DOCX generation only, offline Node 환경 기준입니다. ## 언제 docxly를 선택해야 하나 diff --git a/docs/ko/benchmark.md b/docs/ko/benchmark.md index 70fce99..e3b894c 100644 --- a/docs/ko/benchmark.md +++ b/docs/ko/benchmark.md @@ -9,9 +9,8 @@ ## 현재 요약 벤치마크 -- `docxly`: `80 ms cold / 2 ms steady` -- `Pandoc`: `284 ms cold / 210 ms steady` -- 반복 실행 기준 `105x` +- 최신 수치는 루트 README의 comparison block과 `packages/npm-core-rs/demo/comparison-data.json`을 기준으로 봅니다. +- 이 문서에는 고정 숫자를 복제하지 않고, 어떤 환경에서 어떤 의미의 수치인지 해석만 남깁니다. 측정 환경은 다음과 같습니다. @@ -19,6 +18,7 @@ - Node: `v23.7.0` - Pandoc: `3.9` - 기준: 복잡한 DOCX 벤치마크 코퍼스 +- 범위: DOCX generation only ## 해석 @@ -27,6 +27,7 @@ - `Pandoc`: process startup 포함 - steady median은 warm-up 이후 반복 생성 비용을 뜻합니다. - 이 수치는 브라우저 실측이 아니라 오프라인 Node 벤치마크 결과입니다. +- HWPX 비교나 브라우저 런타임 수치로 일반화하면 안 됩니다. ## 언제 Pandoc이 더 적합한가 diff --git a/docs/ko/design-system.md b/docs/ko/design-system.md index 3817c0d..e8a8b8a 100644 --- a/docs/ko/design-system.md +++ b/docs/ko/design-system.md @@ -165,7 +165,7 @@ 기본 설치 근거 문구: -- `Install the embeddable DOCX engine that measured 105x faster than Pandoc on the summary benchmark.` +- `Install the embeddable DOCX engine backed by the shared benchmark dataset.` 금지: diff --git a/package.json b/package.json index 535dc14..1aa7529 100644 --- a/package.json +++ b/package.json @@ -9,10 +9,12 @@ "build:pages": "npm run build:pages -w @docxly/core-rs", "benchmark:pandoc": "npm run benchmark:pandoc -w @docxly/core-rs", "benchmark:pandoc:check": "npm run benchmark:pandoc:check -w @docxly/core-rs", + "changeset:add": "npx @changesets/cli@2.29.7 add", "demo": "npm run demo -w @docxly/core-rs", "test:web": "npm run build:web && npm run test:smoke -w @docxly/core-rs", "test:rust": "cargo test -p core-rs", "lint:rust": "cargo clippy -p core-rs --all-targets -- -D warnings", - "test:all": "npm run lint:rust && npm run test:rust && npm run test:web" + "test:all": "npm run lint:rust && npm run test:rust && npm run test:web", + "version:packages": "npx @changesets/cli@2.29.7 version && node ./packages/npm-core-rs/scripts/sync-version.mjs && npm install --package-lock-only -w @docxly/core-rs" } } diff --git a/packages/core-rs/Cargo.toml b/packages/core-rs/Cargo.toml index fbf2c53..e272387 100644 --- a/packages/core-rs/Cargo.toml +++ b/packages/core-rs/Cargo.toml @@ -15,6 +15,8 @@ crate-type = ["rlib", "cdylib"] [dependencies] base64 = "0.22" pulldown-cmark = "0.12" +serde = { version = "1", features = ["derive"] } +serde_json = "1" sha2 = "0.10" thiserror = "2" zip = { version = "2", default-features = false, features = ["deflate"] } @@ -24,5 +26,4 @@ wasm-bindgen = "=0.2.114" [dev-dependencies] roxmltree = "0.20" -serde = { version = "1", features = ["derive"] } toml = "0.8" diff --git a/packages/core-rs/README.md b/packages/core-rs/README.md index d375564..f845445 100644 --- a/packages/core-rs/README.md +++ b/packages/core-rs/README.md @@ -71,6 +71,9 @@ Current high-level API behavior: - `generate_docx(...) -> Result, CoreRsError>` - `generate_hwpx(...) -> Result, CoreRsError>` +- `analyze_markdown(...) -> ConversionReport` +- `generate_docx_with_report(...) -> Result` +- `generate_hwpx_with_report(...) -> Result` - `generate_hwpx` targets the approved HWPX golden baseline committed under `tests/fixtures/hwpx/approved` - approved HWPX fixtures are the release gate; provisional/quarantined artifacts are not - `HwpxStyleOptions` currently supports document-level body/heading font, body/heading size, text/link/heading color, and paragraph alignment overrides @@ -80,28 +83,22 @@ Current high-level API behavior: The current HWPX implementation is narrower than the DOCX rich slice. -- approved compatibility fixtures: - - `core-paragraph` - - `blockquote-basic` - - `code-block-basic` - - `core-heading` - - `core-inline-style` - - `core-link-text` - - `core-mixed` - - `list-basic` - - `list-nested-depth-2` -- approved style fixtures: - - `style-typography` - - `style-centered-layout` - - `style-brand-color` -- supported content today: - - paragraphs - - headings - - visible-text emphasis/strong/code/link rendering inside the approved compatibility contract - - document-level style overrides through `HwpxStyleOptions` -- not yet approved for HWPX: - - tables - - images +| Capability | DOCX | HWPX strict | HWPX compat | +| --- | --- | --- | --- | +| Headings and paragraphs | Yes | Yes | Yes | +| Blockquotes | Yes | Yes | Yes | +| Inline emphasis, strong, code, links | Yes | Yes | Yes | +| Ordered lists up to depth 2 | Yes | No | Yes, semantic contract | +| Unordered lists up to depth 2 | Yes | Yes | Yes | +| Tables | Yes | Yes | Yes | +| `data:` URI images | Yes | No | Degraded fallback to alt text | +| Unsupported HTML | Strict: error, compat: literal text fallback | Error | Degraded literal text fallback | +| Footnotes, task lists, math | Strict: error, compat: visible text fallback | Error | Degraded visible text fallback | +| Deep nested lists | Strict: error, compat: plain text fallback | Error | Degraded plain text fallback | + +Approved HWPX fixtures are the release gate, but approved fixture status does not imply strict-mode +support. For the current approved fixture inventory, use +`tests/fixtures/hwpx/approved/README.md` and `src/generators/hwpx/docs/README.md`. Example: @@ -139,7 +136,10 @@ This crate is configured with `cdylib` output for the npm wrapper build. - target package: `@docxly/core-rs` - wrapper location: `../npm-core-rs` - CI verifies native Rust checks and a `wasm32-unknown-unknown` build before npm publish -- browser consumers should use the npm wrapper through a bundler that can emit the `.wasm` asset +- npm version PRs sync this crate's `Cargo.toml` version from the npm package version on `main` +- browser consumers should use the npm wrapper through either: + - a bundler/runtime that can emit the `.wasm` asset + - a static host that serves the emitted `.wasm` asset alongside the ESM wrapper ## Testing @@ -184,8 +184,8 @@ Each fixture directory is self-contained: - DOCX rich: implemented - HWPX approved baseline: implemented -- Only manually approved HWPX fixtures are treated as release-gate goldens; `core-paragraph`, `blockquote-basic`, `code-block-basic`, `core-heading`, `core-inline-style`, `core-link-text`, `core-mixed`, `list-basic`, `list-nested-depth-2`, `table-basic`, `style-typography`, `style-centered-layout`, and `style-brand-color` are the current approved baselines +- Only manually approved HWPX fixtures are treated as release-gate goldens; see `tests/fixtures/hwpx/approved/README.md` for the current inventory - HWPX `paragraph_align` currently targets body and heading paragraph styles; future paragraph categories may extend that scope -- Public API: high-level generation functions and option/error types only +- Public API: high-level generation functions, report types, and option/error types only For repository-level usage and roadmap details, see the root [`README.md`](../../README.md). diff --git a/packages/core-rs/src/bin/compare_hwpx_xml.rs b/packages/core-rs/src/bin/compare_hwpx_xml.rs index 57ff49a..c8a65ac 100644 --- a/packages/core-rs/src/bin/compare_hwpx_xml.rs +++ b/packages/core-rs/src/bin/compare_hwpx_xml.rs @@ -1,27 +1,13 @@ use std::collections::{BTreeMap, BTreeSet}; use std::env; -use std::fmt; -use std::fs::File; -use std::io::Read; use std::path::{Path, PathBuf}; -use sha2::{Digest, Sha256}; -use zip::read::ZipArchive; +#[path = "support/archive_fixture.rs"] +mod archive_fixture; -#[derive(Clone, Debug, PartialEq, Eq)] -enum NormalizedEntry { - Text(String), - BinaryHash(String), -} - -impl fmt::Display for NormalizedEntry { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - match self { - Self::Text(text) => write!(f, "text(len={})", text.len()), - Self::BinaryHash(hash) => write!(f, "binary(sha256={hash})"), - } - } -} +use archive_fixture::{ + FixtureResult, NormalizedEntry, comparison_excludes_for_fixture, filtered_normalized_entries, +}; #[derive(Debug)] struct ArchiveSnapshot { @@ -50,55 +36,18 @@ fn run() -> Result<(), String> { let left_path = PathBuf::from(left); let right_path = PathBuf::from(right); - let left = read_snapshot(&left_path)?; - let right = read_snapshot(&right_path)?; + let excludes = + comparison_excludes(&left_path, &right_path).map_err(|error| error.to_string())?; + let left = read_snapshot(&left_path, &excludes).map_err(|error| error.to_string())?; + let right = read_snapshot(&right_path, &excludes).map_err(|error| error.to_string())?; report(&left_path, &left, &right_path, &right); Ok(()) } -fn read_snapshot(path: &Path) -> Result { - let file = - File::open(path).map_err(|error| format!("failed to open {}: {error}", path.display()))?; - let mut archive = ZipArchive::new(file) - .map_err(|error| format!("failed to open {} as zip: {error}", path.display()))?; - - let mut entries = BTreeMap::new(); - - for index in 0..archive.len() { - let mut entry = archive.by_index(index).map_err(|error| { - format!( - "failed to read zip entry {index} from {}: {error}", - path.display() - ) - })?; - let name = entry.name().to_string(); - - let normalized = if is_text_entry(&name) { - let mut text = String::new(); - entry.read_to_string(&mut text).map_err(|error| { - format!( - "failed to read text entry {} from {}: {error}", - name, - path.display() - ) - })?; - NormalizedEntry::Text(normalize_text(&text)) - } else { - let mut bytes = Vec::new(); - entry.read_to_end(&mut bytes).map_err(|error| { - format!( - "failed to read binary entry {} from {}: {error}", - name, - path.display() - ) - })?; - NormalizedEntry::BinaryHash(sha256_hex(&bytes)) - }; - - entries.insert(name, normalized); - } - +fn read_snapshot(path: &Path, excluded_paths: &[String]) -> FixtureResult { + let bytes = std::fs::read(path)?; + let entries = filtered_normalized_entries(&bytes, is_text_entry, excluded_paths)?; Ok(ArchiveSnapshot { entries }) } @@ -160,8 +109,8 @@ fn report(left_path: &Path, left: &ArchiveSnapshot, right_path: &Path, right: &A println!(" {summary}"); } _ => { - println!(" left: {left_entry}"); - println!(" right: {right_entry}"); + println!(" left: {}", describe_entry(left_entry)); + println!(" right: {}", describe_entry(right_entry)); } } } @@ -184,20 +133,11 @@ fn is_text_entry(name: &str) -> bool { || name == "mimetype" } -fn normalize_text(text: &str) -> String { - text.replace("\r\n", "\n") - .lines() - .map(str::trim_end) - .collect::>() - .join("\n") - .trim() - .to_string() -} - -fn sha256_hex(bytes: &[u8]) -> String { - let mut hasher = Sha256::new(); - hasher.update(bytes); - format!("{:x}", hasher.finalize()) +fn comparison_excludes(left: &Path, right: &Path) -> FixtureResult> { + if let Some(root) = fixture_root_from_path(left).or_else(|| fixture_root_from_path(right)) { + return comparison_excludes_for_fixture(&root); + } + Ok(Vec::new()) } fn summarize_text_difference(left: &str, right: &str) -> String { @@ -227,6 +167,27 @@ fn summarize_text_difference(left: &str, right: &str) -> String { ) } +fn describe_entry(entry: &NormalizedEntry) -> String { + match entry { + NormalizedEntry::Text(text) => format!("text(len={})", text.len()), + NormalizedEntry::BinaryHash(hash) => format!("binary(sha256={hash})"), + } +} + fn usage() -> String { "usage: cargo run -p core-rs --bin compare_hwpx_xml -- ".to_string() } + +fn fixture_root_from_path(path: &Path) -> Option { + let mut current = path.parent(); + while let Some(dir) = current { + if dir.join("fixture.toml").is_file() + && dir.join("golden.hwpx").is_file() + && dir.join("input.md").is_file() + { + return Some(dir.to_path_buf()); + } + current = dir.parent(); + } + None +} diff --git a/packages/core-rs/src/bin/refresh_hwpx_fixture_metadata.rs b/packages/core-rs/src/bin/refresh_hwpx_fixture_metadata.rs index 1afdecd..3a7a87d 100644 --- a/packages/core-rs/src/bin/refresh_hwpx_fixture_metadata.rs +++ b/packages/core-rs/src/bin/refresh_hwpx_fixture_metadata.rs @@ -1,12 +1,20 @@ use std::env; +use std::fs; use std::path::{Path, PathBuf}; #[path = "support/archive_fixture.rs"] mod archive_fixture; -use archive_fixture::{FixtureResult, discover_fixture_dirs, refresh_fixture_metadata}; +use archive_fixture::{ + FixtureResult, comparison_excludes_for_fixture, discover_fixture_dirs, + filtered_normalized_entries, refresh_fixture_metadata, +}; const IGNORED_AUXILIARY_NAMES: &[&str] = &[".DS_Store", "README.md", "provenance.md"]; +const GENERATED_OUTPUTS_ROOT: &str = "/tmp/hwpx-generator-outputs"; +const REFRESH_EMOJI: &str = "📦"; +const EQUIVALENT_EMOJI: &str = "✅"; +const DIFFERENT_EMOJI: &str = "❌"; fn main() { if let Err(error) = run(env::args().skip(1).collect()) { @@ -33,7 +41,13 @@ fn run(names: Vec) -> FixtureResult<()> { .file_name() .and_then(|value| value.to_str()) .ok_or("invalid fixture directory name")?; - println!("{name}: {hash}"); + let golden_path = fixture.join("golden.hwpx"); + println!("{REFRESH_EMOJI} fixture: {name}"); + println!(" golden: {}", golden_path.display()); + println!(" hash: {hash}"); + if let Some(line) = generated_output_comparison_line(&fixture)? { + println!("{line}"); + } } Ok(()) @@ -47,6 +61,37 @@ fn approved_fixtures_root() -> PathBuf { .join("approved") } +fn generated_output_comparison_line(fixture: &Path) -> FixtureResult> { + let name = fixture + .file_name() + .and_then(|value| value.to_str()) + .ok_or("invalid fixture directory name")?; + let generated_path = Path::new(GENERATED_OUTPUTS_ROOT) + .join(name) + .join("generated.hwpx"); + if !generated_path.is_file() { + return Ok(None); + } + + let generated = fs::read(&generated_path)?; + let golden = fs::read(fixture.join("golden.hwpx"))?; + let excludes = comparison_excludes_for_fixture(fixture)?; + let generated_entries = filtered_normalized_entries(&generated, is_text_entry, &excludes)?; + let golden_entries = filtered_normalized_entries(&golden, is_text_entry, &excludes)?; + let (emoji, status) = if generated_entries == golden_entries { + (EQUIVALENT_EMOJI, "equivalent") + } else { + (DIFFERENT_EMOJI, "different") + }; + + let golden_path = fixture.join("golden.hwpx"); + Ok(Some(format!( + " compare: {emoji} {} {status} {}", + generated_path.display(), + golden_path.display() + ))) +} + fn resolve_fixture_dirs(root: &Path, names: &[String]) -> FixtureResult> { if names.is_empty() { return Ok(discover_fixture_dirs(root)? diff --git a/packages/core-rs/src/bin/support/archive_fixture.rs b/packages/core-rs/src/bin/support/archive_fixture.rs index ef9d7ec..fa8cb8b 100644 --- a/packages/core-rs/src/bin/support/archive_fixture.rs +++ b/packages/core-rs/src/bin/support/archive_fixture.rs @@ -11,6 +11,12 @@ use zip::ZipArchive; pub type FixtureResult = Result>; +const NONDETERMINISTIC_ENTRY_EXCLUDES: &[&str] = &[ + "Contents/content.hpf", + "Preview/PrvImage.png", + "version.xml", +]; + #[derive(Debug, Clone, PartialEq, Eq)] pub enum NormalizedEntry { Text(String), @@ -72,6 +78,91 @@ pub fn normalized_entries( )?)) } +pub fn filtered_normalized_entries( + bytes: &[u8], + is_text_entry: fn(&str) -> bool, + excluded_paths: &[impl AsRef], +) -> FixtureResult> { + let mut entries = normalized_entries(bytes, is_text_entry)?; + for path in excluded_paths { + entries.remove(path.as_ref()); + } + Ok(entries) +} + +pub fn normalized_archives_equivalent( + left: &[u8], + right: &[u8], + is_text_entry: fn(&str) -> bool, +) -> FixtureResult { + Ok(normalized_entries(left, is_text_entry)? == normalized_entries(right, is_text_entry)?) +} + +pub fn fixture_is_deterministic(root: &Path) -> FixtureResult { + Ok(read_fixture_comparison_config(root)?.determinism) +} + +pub fn comparison_excludes_for_fixture(root: &Path) -> FixtureResult> { + let config = read_fixture_comparison_config(root)?; + let mut excludes = Vec::new(); + + if !config.determinism { + excludes.extend( + NONDETERMINISTIC_ENTRY_EXCLUDES + .iter() + .map(|path| (*path).to_string()), + ); + } + + for path in config.comparison_excludes { + if !excludes.contains(&path) { + excludes.push(path); + } + } + + Ok(excludes) +} + +struct FixtureComparisonConfig { + determinism: bool, + comparison_excludes: Vec, +} + +fn read_fixture_comparison_config(root: &Path) -> FixtureResult { + let contents = fs::read_to_string(root.join("fixture.toml"))?; + Ok(FixtureComparisonConfig { + determinism: !contents + .lines() + .any(|line| line.trim() == "determinism = false"), + comparison_excludes: parse_comparison_excludes(&contents)?, + }) +} + +fn parse_comparison_excludes(contents: &str) -> FixtureResult> { + let Some(line) = contents + .lines() + .map(str::trim) + .find(|line| line.starts_with("comparison_excludes = [")) + else { + return Ok(Vec::new()); + }; + + let values = line + .strip_prefix("comparison_excludes = [") + .and_then(|line| line.strip_suffix(']')) + .ok_or("invalid comparison_excludes syntax")?; + + if values.trim().is_empty() { + return Ok(Vec::new()); + } + + Ok(values + .split(',') + .map(str::trim) + .map(|value| value.trim_matches('"').to_string()) + .collect()) +} + pub fn normalized_entries_from_archive( entries: &BTreeMap, ) -> BTreeMap { diff --git a/packages/core-rs/src/diagnostics.rs b/packages/core-rs/src/diagnostics.rs new file mode 100644 index 0000000..92ddc02 --- /dev/null +++ b/packages/core-rs/src/diagnostics.rs @@ -0,0 +1,217 @@ +use pulldown_cmark::{Event, Options, Parser, Tag, TagEnd}; + +use crate::models::block::Block; +use crate::models::document::Document; +use crate::models::inline::Inline; +use crate::parser::markdown::MarkdownParser; +use crate::parser::markdown::data_image::parse_data_uri; +use crate::{ConversionIssue, ConversionReport, ConversionTarget, IssueSeverity}; + +const HTML_FEATURE: &str = "HTML"; +const FOOTNOTE_FEATURE: &str = "footnote"; +const TASK_LIST_FEATURE: &str = "task list"; +const MATH_FEATURE: &str = "math"; +const NON_DATA_IMAGE_FEATURE: &str = "non-data image"; +const UNSUPPORTED_IMAGE_MIME_FEATURE: &str = "unsupported image mime type"; +const DEEP_NESTED_LIST_FEATURE: &str = "deep nested list"; +const HWPX_ORDERED_LIST_FEATURE: &str = "HWPX ordered list in strict mode"; +const HWPX_IMAGE_FEATURE: &str = "HWPX image"; +const HWPX_UNSUPPORTED_RICH_BLOCK_FEATURE: &str = "HWPX unsupported rich block"; + +pub(crate) fn analyze_markdown_impl(markdown: &str, target: ConversionTarget) -> ConversionReport { + let mut issues = collect_parser_issues(markdown); + if let (ConversionTarget::Hwpx, Ok(document)) = + (target, MarkdownParser::new(false).parse(markdown)) + { + collect_hwpx_issues_from_document(&document, &mut issues); + } + ConversionReport::from_issues(issues) +} + +pub(crate) fn report_for_mode( + report: &ConversionReport, + target: ConversionTarget, + strict_mode: bool, +) -> ConversionReport { + if !strict_mode { + return report.clone(); + } + + let issues = report + .issues + .iter() + .cloned() + .map(|mut issue| { + if is_strict_error_feature(target, &issue.feature) { + issue.severity = IssueSeverity::Error; + issue.degraded = false; + } + issue + }) + .collect(); + ConversionReport::from_issues(issues) +} + +fn collect_parser_issues(markdown: &str) -> Vec { + let parser = Parser::new_ext(markdown, Options::all()); + let mut issues = Vec::new(); + let mut list_depth = 0usize; + let mut image_urls = Vec::new(); + + for event in parser { + match event { + Event::Start(tag) => match tag { + Tag::List(_) => { + if list_depth >= 2 { + issues.push(degraded_issue( + DEEP_NESTED_LIST_FEATURE, + "nested lists deeper than 2 fall back to plain text in compat mode", + )); + } + list_depth += 1; + } + Tag::Image { dest_url, .. } => image_urls.push(dest_url.into_string()), + Tag::FootnoteDefinition(_) => issues.push(degraded_issue( + FOOTNOTE_FEATURE, + "footnotes fall back to visible text in compat mode", + )), + _ => {} + }, + Event::End(TagEnd::List(_)) => { + list_depth = list_depth.saturating_sub(1); + } + Event::End(TagEnd::Image) => { + if let Some(url) = image_urls.pop() { + match parse_data_uri(&url) { + Ok(_) => {} + Err(error) => match error { + crate::parser::markdown::data_image::DataImageError::UnsupportedMime { + .. + } => issues.push(degraded_issue( + UNSUPPORTED_IMAGE_MIME_FEATURE, + &error.into_core_error().to_string(), + )), + _ => issues.push(degraded_issue( + NON_DATA_IMAGE_FEATURE, + &error.into_core_error().to_string(), + )), + }, + } + } + } + Event::Html(_) | Event::InlineHtml(_) => issues.push(degraded_issue( + HTML_FEATURE, + "HTML falls back to literal text in compat mode", + )), + Event::FootnoteReference(_) => issues.push(degraded_issue( + FOOTNOTE_FEATURE, + "footnotes fall back to visible text in compat mode", + )), + Event::TaskListMarker(_) => issues.push(degraded_issue( + TASK_LIST_FEATURE, + "task lists fall back to visible markers in compat mode", + )), + Event::InlineMath(_) | Event::DisplayMath(_) => issues.push(degraded_issue( + MATH_FEATURE, + "math falls back to visible text in compat mode", + )), + _ => {} + } + } + + issues +} + +fn collect_hwpx_issues_from_document(document: &Document, issues: &mut Vec) { + collect_hwpx_block_issues(&document.blocks, issues); +} + +fn collect_hwpx_block_issues(blocks: &[Block], issues: &mut Vec) { + for block in blocks { + match block { + Block::Paragraph(inlines) => collect_hwpx_inline_issues(inlines, issues), + Block::Heading { content, .. } => collect_hwpx_inline_issues(content, issues), + Block::BlockQuote(inner) => collect_hwpx_block_issues(inner, issues), + Block::CodeBlock { .. } => issues.push(degraded_issue( + HWPX_UNSUPPORTED_RICH_BLOCK_FEATURE, + "HWPX strict mode does not support code blocks; compat mode falls back to visible text", + )), + Block::ThematicBreak => issues.push(degraded_issue( + HWPX_UNSUPPORTED_RICH_BLOCK_FEATURE, + "HWPX strict mode does not support thematic breaks; compat mode falls back to visible text", + )), + Block::List(list) => { + if list.ordered { + issues.push(non_degraded_issue( + HWPX_ORDERED_LIST_FEATURE, + "HWPX strict mode rejects ordered lists; compat mode uses the semantic ordered-list contract", + )); + } + for item in &list.items { + collect_hwpx_block_issues(&item.blocks, issues); + } + } + Block::Table(table) => { + for cell in &table.headers { + collect_hwpx_inline_issues(cell, issues); + } + for row in &table.rows { + for cell in &row.cells { + collect_hwpx_inline_issues(cell, issues); + } + } + } + } + } +} + +fn collect_hwpx_inline_issues(inlines: &[Inline], issues: &mut Vec) { + for inline in inlines { + match inline { + Inline::Emphasis(children) | Inline::Strong(children) => { + collect_hwpx_inline_issues(children, issues); + } + Inline::Link { text, .. } => collect_hwpx_inline_issues(text, issues), + Inline::Image(_) => issues.push(degraded_issue( + HWPX_IMAGE_FEATURE, + "HWPX strict mode does not support images; compat mode falls back to visible alt text", + )), + Inline::Text(_) | Inline::Code(_) | Inline::HardBreak => {} + } + } +} + +fn degraded_issue(feature: &str, message: &str) -> ConversionIssue { + ConversionIssue { + feature: feature.to_string(), + message: message.to_string(), + severity: IssueSeverity::Warning, + degraded: true, + } +} + +fn non_degraded_issue(feature: &str, message: &str) -> ConversionIssue { + ConversionIssue { + feature: feature.to_string(), + message: message.to_string(), + severity: IssueSeverity::Warning, + degraded: false, + } +} + +fn is_strict_error_feature(target: ConversionTarget, feature: &str) -> bool { + matches!( + feature, + HTML_FEATURE + | FOOTNOTE_FEATURE + | TASK_LIST_FEATURE + | MATH_FEATURE + | NON_DATA_IMAGE_FEATURE + | UNSUPPORTED_IMAGE_MIME_FEATURE + | DEEP_NESTED_LIST_FEATURE + ) || matches!(target, ConversionTarget::Hwpx) + && matches!( + feature, + HWPX_ORDERED_LIST_FEATURE | HWPX_IMAGE_FEATURE | HWPX_UNSUPPORTED_RICH_BLOCK_FEATURE + ) +} diff --git a/packages/core-rs/src/generators/docx/render/block.rs b/packages/core-rs/src/generators/docx/render/block.rs index a1ccc2f..cbad763 100644 --- a/packages/core-rs/src/generators/docx/render/block.rs +++ b/packages/core-rs/src/generators/docx/render/block.rs @@ -8,7 +8,10 @@ use crate::models::block::Block; use super::{ParagraphRenderOptions, RenderContext, heading_style_id, walk_blocks}; use list_table::{render_list, render_table}; -use text::{render_block_quote, render_code_block_with_properties, render_text_paragraph, render_thematic_break}; +use text::{ + render_block_quote, render_code_block_with_properties, render_text_paragraph, + render_thematic_break, +}; pub(super) fn render_blocks( blocks: &[Block], @@ -65,16 +68,14 @@ fn render_block_with_prefix( context, ) } - Block::BlockQuote(blocks) => { - render_block_quote( - blocks, - context, - left_indent, - prefix, - extra_paragraph_properties, - render_block_with_prefix, - ) - } + Block::BlockQuote(blocks) => render_block_quote( + blocks, + context, + left_indent, + prefix, + extra_paragraph_properties, + render_block_with_prefix, + ), Block::CodeBlock { code, .. } => { render_code_block_with_properties(code, left_indent, prefix, extra_paragraph_properties) } @@ -86,9 +87,15 @@ fn render_block_with_prefix( extra_paragraph_properties, render_block_with_prefix, ), - Block::Table(table) => { - render_table(table, context, left_indent, prefix, extra_paragraph_properties) + Block::Table(table) => render_table( + table, + context, + left_indent, + prefix, + extra_paragraph_properties, + ), + Block::ThematicBreak => { + render_thematic_break(left_indent, prefix, extra_paragraph_properties) } - Block::ThematicBreak => render_thematic_break(left_indent, prefix, extra_paragraph_properties), } } diff --git a/packages/core-rs/src/generators/docx/render/block_list_table.rs b/packages/core-rs/src/generators/docx/render/block_list_table.rs index b0ec61a..a9b57f3 100644 --- a/packages/core-rs/src/generators/docx/render/block_list_table.rs +++ b/packages/core-rs/src/generators/docx/render/block_list_table.rs @@ -2,9 +2,9 @@ use crate::error::CoreRsError; use crate::models::block::{Block, ListBlock, ListItem, TableAlignment, TableBlock}; use crate::models::inline::Inline; -use super::{ParagraphRenderOptions, RenderContext, heading_style_id}; -use super::text::{has_quote_border, render_text_paragraph}; use super::super::{RunStyle, inline::render_inlines}; +use super::text::{has_quote_border, render_text_paragraph}; +use super::{ParagraphRenderOptions, RenderContext, heading_style_id}; use crate::generators::docx::xml_builder; pub(super) fn render_list( @@ -20,7 +20,7 @@ pub(super) fn render_list( Option<&str>, &str, ) -> Result - + Copy, + + Copy, ) -> Result { let mut xml = String::new(); for (index, item) in list.items.iter().enumerate() { @@ -56,7 +56,7 @@ fn render_list_item( Option<&str>, &str, ) -> Result - + Copy, + + Copy, ) -> Result { let mut xml = String::new(); let mut prefix_consumed = false; @@ -82,9 +82,7 @@ fn render_list_item( let style_id = heading_style_id(*level); let extra = format!( "{}", - style_id, - left_indent, - extra_paragraph_properties + style_id, left_indent, extra_paragraph_properties ); xml.push_str(&render_text_paragraph( content, @@ -142,9 +140,19 @@ pub(super) fn render_table( } let mut table_xml_rows = String::new(); - table_xml_rows.push_str(&render_table_row(&table.headers, &table.aligns, true, context)?); + table_xml_rows.push_str(&render_table_row( + &table.headers, + &table.aligns, + true, + context, + )?); for row in &table.rows { - table_xml_rows.push_str(&render_table_row(&row.cells, &table.aligns, false, context)?); + table_xml_rows.push_str(&render_table_row( + &row.cells, + &table.aligns, + false, + context, + )?); } let grid = "".repeat(table.aligns.len().max(table.headers.len())); @@ -158,7 +166,10 @@ pub(super) fn render_table( "{left_border}", ); if left_indent > 0 { - table_properties.push_str(&format!("", left_indent)); + table_properties.push_str(&format!( + "", + left_indent + )); } xml.push_str(&format!( @@ -199,7 +210,10 @@ fn render_table_row( } else { "" }; - xml.push_str(&format!("{}{}", cell_props, paragraph)); + xml.push_str(&format!( + "{}{}", + cell_props, paragraph + )); } Ok(format!("{}", xml)) } @@ -211,7 +225,10 @@ fn render_list_marker_paragraph( ) -> String { let mut properties = String::new(); if left_indent > 0 { - properties.push_str(&format!("", left_indent)); + properties.push_str(&format!( + "", + left_indent + )); } properties.push_str(extra_paragraph_properties); let marker = format!("{prefix}\u{200B}"); diff --git a/packages/core-rs/src/generators/docx/render/block_text.rs b/packages/core-rs/src/generators/docx/render/block_text.rs index 09b3d56..23b6090 100644 --- a/packages/core-rs/src/generators/docx/render/block_text.rs +++ b/packages/core-rs/src/generators/docx/render/block_text.rs @@ -2,10 +2,10 @@ use crate::error::CoreRsError; use crate::models::block::Block; use crate::models::inline::Inline; -use super::{ParagraphRenderOptions, RenderContext, heading_style_id}; use super::super::{ QUOTE_BORDER_PROPERTIES, QUOTE_LEFT_BORDER_FRAGMENT, RunStyle, inline::render_inlines, }; +use super::{ParagraphRenderOptions, RenderContext, heading_style_id}; use crate::generators::docx::xml_builder; pub(super) fn render_text_paragraph( @@ -112,7 +112,10 @@ pub(super) fn render_code_block_with_properties( left_indent + 360, extra_properties ); - Ok(xml_builder::paragraph_with_properties(&properties, &content)) + Ok(xml_builder::paragraph_with_properties( + &properties, + &content, + )) } pub(super) fn render_thematic_break( @@ -124,7 +127,10 @@ pub(super) fn render_thematic_break( .map(|value| xml_builder::text_run(value, RunStyle::default())) .unwrap_or_default(); let properties = thematic_break_properties(left_indent, extra_paragraph_properties); - Ok(xml_builder::paragraph_with_properties(&properties, &content)) + Ok(xml_builder::paragraph_with_properties( + &properties, + &content, + )) } pub(super) fn join_properties(first: &str, second: &str) -> String { diff --git a/packages/core-rs/src/generators/docx/render/inline.rs b/packages/core-rs/src/generators/docx/render/inline.rs index d78b2a0..d35d736 100644 --- a/packages/core-rs/src/generators/docx/render/inline.rs +++ b/packages/core-rs/src/generators/docx/render/inline.rs @@ -3,8 +3,8 @@ use crate::models::inline::{ImageData, Inline}; use crate::utils::image_helper::image_dimensions; use crate::utils::xml_helper::escape_attr; -use super::{ImageAsset, RenderContext, RunStyle}; use super::super::xml_builder; +use super::{ImageAsset, RenderContext, RunStyle}; pub(super) fn run_properties(style: RunStyle) -> String { let mut parts = Vec::new(); diff --git a/packages/core-rs/src/generators/docx/render/mod.rs b/packages/core-rs/src/generators/docx/render/mod.rs index 12962ed..8438b32 100644 --- a/packages/core-rs/src/generators/docx/render/mod.rs +++ b/packages/core-rs/src/generators/docx/render/mod.rs @@ -79,7 +79,10 @@ fn walk_blocks(blocks: &[Block], visitor: &mut impl FnMut(&Block) -> T) { walk_blocks(&item.blocks, visitor); } } - Block::Paragraph(_) | Block::Heading { .. } | Block::CodeBlock { .. } | Block::Table(_) + Block::Paragraph(_) + | Block::Heading { .. } + | Block::CodeBlock { .. } + | Block::Table(_) | Block::ThematicBreak => {} } } diff --git a/packages/core-rs/src/generators/hwpx/content_hpf.rs b/packages/core-rs/src/generators/hwpx/content_hpf.rs index 3c317ca..c9f219d 100644 --- a/packages/core-rs/src/generators/hwpx/content_hpf.rs +++ b/packages/core-rs/src/generators/hwpx/content_hpf.rs @@ -1,6 +1,9 @@ use crate::utils::xml_helper::escape_text; -pub fn build_content_hpf(title: Option<&str>) -> String { +pub fn build_content_hpf( + title: Option<&str>, + _profile: super::profile::ResolvedHwpxCompatibilityProfile, +) -> String { let title = match title.unwrap_or("").trim() { "" => "".to_string(), value => format!("{}", escape_text(value)), diff --git a/packages/core-rs/src/generators/hwpx/docs/README.md b/packages/core-rs/src/generators/hwpx/docs/README.md index 400ceb9..a5684b7 100644 --- a/packages/core-rs/src/generators/hwpx/docs/README.md +++ b/packages/core-rs/src/generators/hwpx/docs/README.md @@ -13,13 +13,14 @@ The current status is an approved HWPX baseline plus a smaller provisional area 1. `packages/core-rs/tests/fixtures/hwpx/approved/*/expected` 2. [`../reference/paragraph-only`](../reference/paragraph-only) -3. [`HWP_DocInfo_Structure.md`](./HWP_DocInfo_Structure.md) -4. [`HWP_CharShape_Structure.md`](./HWP_CharShape_Structure.md) -5. [`HWP_Document_Data_Records.md`](./HWP_Document_Data_Records.md) +3. [`schema-md/index.md`](./schema-md/index.md) +4. [`HWP_DocInfo_Structure.md`](./HWP_DocInfo_Structure.md) +5. [`HWP_CharShape_Structure.md`](./HWP_CharShape_Structure.md) +6. [`HWP_Document_Data_Records.md`](./HWP_Document_Data_Records.md) ## Scope note -There is currently no checked-in `schema-md/` corpus in this repository. For HWPX package-level details such as `content.hpf`, `header.xml`, and `section0.xml`, use: +This repository includes a checked-in `schema-md/` corpus for spec reference, but package-level HWPX files such as `content.hpf`, `header.xml`, and `section0.xml` still need to be validated against local approved fixtures and reference unzip trees. For those package-level details, use: - the manually approved `.hwpx` fixtures under `packages/core-rs/tests/fixtures/hwpx/approved` - the synthetic compatibility corpus under `../reference/` @@ -74,7 +75,7 @@ cargo run -p core-rs --bin refresh_hwpx_fixture_metadata -- core-paragraph table ## Approved baseline note -- `approved/` fixtures are the only CI/release gate for HWPX. `core-paragraph`, `blockquote-basic`, `code-block-basic`, `core-heading`, `core-inline-style`, `core-link-text`, `core-mixed`, `list-basic`, `list-nested-depth-2`, `table-basic`, `style-typography`, `style-centered-layout`, and `style-brand-color` are currently approved. +- `approved/` fixtures are the only CI/release gate for HWPX. `core-paragraph`, `blockquote-basic`, `code-block-basic`, `core-heading`, `core-inline-style`, `core-link-text`, `core-mixed`, `list-basic`, `list-nested-depth-2`, `ordered-list-basic`, `ordered-list-nested-depth-2`, `table-basic`, `table-alignment`, `style-typography`, `style-centered-layout`, and `style-brand-color` are currently approved. - `provisional/` fixtures are not part of the release gate and currently contain unapproved candidates such as images, thematic breaks, alignment-rich tables, and mixed rich documents. - `quarantine/` stores stale or known-broken generated artifacts kept only for reverse-engineering. - Approved style fixtures use Hancom-safe built-in fonts only. External fonts remain best-effort because the current HWPX path does not embed font binaries. diff --git a/packages/core-rs/src/generators/hwpx/document_shape.rs b/packages/core-rs/src/generators/hwpx/document_shape.rs new file mode 100644 index 0000000..2cf90e6 --- /dev/null +++ b/packages/core-rs/src/generators/hwpx/document_shape.rs @@ -0,0 +1,106 @@ +use crate::models::block::Block; + +pub(crate) fn max_unordered_list_depth(blocks: &[Block]) -> u8 { + blocks + .iter() + .map(block_unordered_list_depth) + .max() + .unwrap_or(0) +} + +pub(crate) fn max_ordered_list_depth(blocks: &[Block]) -> u8 { + blocks + .iter() + .map(block_ordered_list_depth) + .max() + .unwrap_or(0) +} + +pub(crate) fn max_blockquote_depth(blocks: &[Block]) -> u8 { + blocks.iter().map(block_blockquote_depth).max().unwrap_or(0) +} + +pub(crate) fn uses_legacy_quote_only_contract(blocks: &[Block]) -> bool { + !blocks.is_empty() + && blocks.iter().all(|block| match block { + Block::BlockQuote(children) => { + !children.is_empty() + && children + .iter() + .all(|child| matches!(child, Block::Paragraph(_))) + } + _ => false, + }) +} + +fn block_unordered_list_depth(block: &Block) -> u8 { + match block { + Block::List(list) if !list.ordered => { + let nested_depth = list + .items + .iter() + .flat_map(|item| item.blocks.iter()) + .map(block_unordered_list_depth) + .max() + .unwrap_or(0); + 1 + nested_depth + } + Block::BlockQuote(blocks) => blocks + .iter() + .map(block_unordered_list_depth) + .max() + .unwrap_or(0), + Block::Paragraph(_) + | Block::Heading { .. } + | Block::CodeBlock { .. } + | Block::Table(_) + | Block::ThematicBreak + | Block::List(_) => 0, + } +} + +fn block_ordered_list_depth(block: &Block) -> u8 { + match block { + Block::List(list) if list.ordered => { + let nested_depth = list + .items + .iter() + .flat_map(|item| item.blocks.iter()) + .map(block_ordered_list_depth) + .max() + .unwrap_or(0); + 1 + nested_depth + } + Block::BlockQuote(blocks) => blocks + .iter() + .map(block_ordered_list_depth) + .max() + .unwrap_or(0), + Block::Paragraph(_) + | Block::Heading { .. } + | Block::CodeBlock { .. } + | Block::Table(_) + | Block::ThematicBreak + | Block::List(_) => 0, + } +} + +fn block_blockquote_depth(block: &Block) -> u8 { + match block { + Block::BlockQuote(blocks) => { + 1 + blocks.iter().map(block_blockquote_depth).max().unwrap_or(0) + } + Block::List(list) => list + .items + .iter() + .flat_map(|item| item.blocks.iter()) + .map(block_blockquote_depth) + .max() + .unwrap_or(0), + Block::Paragraph(_) + | Block::Heading { .. } + | Block::CodeBlock { .. } + | Block::Table(_) + | Block::ThematicBreak => 0, + } +} diff --git a/packages/core-rs/src/generators/hwpx/header_xml.rs b/packages/core-rs/src/generators/hwpx/header_xml.rs deleted file mode 100644 index 3cd0b6a..0000000 --- a/packages/core-rs/src/generators/hwpx/header_xml.rs +++ /dev/null @@ -1,708 +0,0 @@ -use super::style::ResolvedHwpxStyle; -use crate::models::block::Block; -use crate::models::document::Document; -use crate::models::inline::Inline; -use crate::utils::xml_helper::escape_attr; - -const HEADER_TEMPLATE: &str = include_str!("reference/paragraph-only/Contents/header.xml"); -const CHAR_PROPERTIES_NEEDLE: &str = ""; -const CHAR_PROPERTIES_COUNT_NEEDLE: &str = ""; -const NUMBERINGS_COUNT_NEEDLE: &str = ""; -const NUMBERINGS_NEEDLE: &str = ""; -const PARA_PROPERTIES_COUNT_NEEDLE: &str = ""; -const PARA_PROPERTIES_NEEDLE: &str = ""; -const BORDER_FILLS_COUNT_NEEDLE: &str = ""; -const BORDER_FILLS_NEEDLE: &str = ""; -const BODY_FONT_FACE: &str = "face=\"함초롬바탕\""; -const HEADING_FONT_FACE: &str = "face=\"함초롬돋움\""; -const TABLE_BORDER_FILL_XML: &str = concat!( - "", - "", - "", - "", - "", - "", - "", - "", - "" -); -const LIST_BULLETS_XML: &str = concat!( - "", - "", - "", - "", - "" -); -const LIST_LEVEL1_PARA_PR_XML: &str = concat!( - "", - "", - "", - "", - "", - "", - "", - "" -); -const LIST_LEVEL2_PARA_PR_XML: &str = concat!( - "", - "", - "", - "", - "", - "", - "", - "" -); - -#[derive(Clone, Copy)] -enum ListContract { - None, - Unordered(u8), - OrderedSingleLevel, - OrderedNestedDepth2, -} - -struct CharPrSpec<'a> { - id: u32, - height: u32, - text_color: &'a str, - font_ref: u32, - bold: bool, - italic: bool, - underline: bool, - spacing: Option, -} - -pub fn build_header_xml(document: &Document, style: &ResolvedHwpxStyle) -> String { - let needs_styled_header = requires_styled_header(document); - let needs_table_border_fill = document.blocks.iter().any(block_contains_table); - let list_contract = list_contract(document); - - if style.is_default() && !needs_styled_header { - let header = HEADER_TEMPLATE.to_string(); - return maybe_add_list_contract( - maybe_add_table_border_fill(header, needs_table_border_fill), - list_contract, - ); - } - - if style.is_default() { - let mut header = HEADER_TEMPLATE.to_string(); - if needs_styled_header { - header = header.replace( - CHAR_PROPERTIES_COUNT_NEEDLE, - "", - ); - header = header.replace( - CHAR_PROPERTIES_NEEDLE, - &format!( - "{}{}", - legacy_additional_char_properties(), - CHAR_PROPERTIES_NEEDLE - ), - ); - } - return maybe_add_list_contract( - maybe_add_table_border_fill(header, needs_table_border_fill), - list_contract, - ); - } - - let mut header = HEADER_TEMPLATE.to_string(); - header = apply_font_faces(&header, style); - header = apply_body_style(&header, style); - header = apply_paragraph_alignment(&header, style); - header = header.replace( - CHAR_PROPERTIES_COUNT_NEEDLE, - "", - ); - header = header.replace( - CHAR_PROPERTIES_NEEDLE, - &format!( - "{}{}", - additional_char_properties(style), - CHAR_PROPERTIES_NEEDLE - ), - ); - maybe_add_list_contract( - maybe_add_table_border_fill(header, needs_table_border_fill), - list_contract, - ) -} - -fn requires_styled_header(document: &Document) -> bool { - document.blocks.iter().any(block_requires_styled_header) -} - -fn block_requires_styled_header(block: &Block) -> bool { - match block { - Block::Paragraph(inlines) => inline_slice_requires_styled_header(inlines), - Block::Heading { .. } => true, - Block::BlockQuote(blocks) => blocks.iter().any(block_requires_styled_header), - Block::CodeBlock { .. } | Block::List(_) | Block::Table(_) | Block::ThematicBreak => false, - } -} - -fn block_contains_table(block: &Block) -> bool { - match block { - Block::Table(_) => true, - Block::BlockQuote(blocks) => blocks.iter().any(block_contains_table), - Block::Paragraph(_) - | Block::Heading { .. } - | Block::CodeBlock { .. } - | Block::List(_) - | Block::ThematicBreak => false, - } -} - -fn list_contract(document: &Document) -> ListContract { - let ordered_depth = document - .blocks - .iter() - .map(block_ordered_list_depth) - .max() - .unwrap_or(0); - if ordered_depth > 0 { - return if ordered_depth > 1 { - ListContract::OrderedNestedDepth2 - } else { - ListContract::OrderedSingleLevel - }; - } - - let unordered_depth = document - .blocks - .iter() - .map(block_unordered_list_depth) - .max() - .unwrap_or(0); - if unordered_depth > 0 { - ListContract::Unordered(unordered_depth) - } else { - ListContract::None - } -} - -fn block_unordered_list_depth(block: &Block) -> u8 { - match block { - Block::List(list) if !list.ordered => { - let nested_depth = list - .items - .iter() - .flat_map(|item| item.blocks.iter()) - .map(block_unordered_list_depth) - .max() - .unwrap_or(0); - 1 + nested_depth - } - Block::BlockQuote(blocks) => blocks - .iter() - .map(block_unordered_list_depth) - .max() - .unwrap_or(0), - Block::Paragraph(_) - | Block::Heading { .. } - | Block::CodeBlock { .. } - | Block::Table(_) - | Block::ThematicBreak - | Block::List(_) => 0, - } -} - -fn block_ordered_list_depth(block: &Block) -> u8 { - match block { - Block::List(list) if list.ordered => { - let nested_depth = list - .items - .iter() - .flat_map(|item| item.blocks.iter()) - .map(block_ordered_list_depth) - .max() - .unwrap_or(0); - 1 + nested_depth - } - Block::BlockQuote(blocks) => blocks - .iter() - .map(block_ordered_list_depth) - .max() - .unwrap_or(0), - Block::Paragraph(_) - | Block::Heading { .. } - | Block::CodeBlock { .. } - | Block::Table(_) - | Block::ThematicBreak - | Block::List(_) => 0, - } -} - -fn inline_slice_requires_styled_header(inlines: &[Inline]) -> bool { - inlines.iter().any(inline_requires_styled_header) -} - -fn inline_requires_styled_header(inline: &Inline) -> bool { - match inline { - Inline::Text(_) => false, - Inline::Emphasis(children) | Inline::Strong(children) => { - let _ = children; - true - } - Inline::Code(_) | Inline::Link { .. } | Inline::HardBreak => true, - Inline::Image(_) => false, - } -} - -fn apply_font_faces(header: &str, style: &ResolvedHwpxStyle) -> String { - header - .replace( - BODY_FONT_FACE, - &format!("face=\"{}\"", escape_attr(&style.body_font)), - ) - .replace( - HEADING_FONT_FACE, - &format!("face=\"{}\"", escape_attr(&style.heading_font)), - ) -} - -fn apply_body_style(header: &str, style: &ResolvedHwpxStyle) -> String { - let mut updated = update_char_pr( - header, - CharPrSpec { - id: 6, - height: style.body_font_size, - text_color: &style.text_color, - font_ref: 0, - bold: false, - italic: false, - underline: false, - spacing: None, - }, - ); - updated = update_char_pr( - &updated, - CharPrSpec { - id: 7, - height: style.body_font_size, - text_color: &style.text_color, - font_ref: 0, - bold: true, - italic: false, - underline: false, - spacing: None, - }, - ); - updated = update_char_pr( - &updated, - CharPrSpec { - id: 8, - height: style.body_font_size, - text_color: &style.text_color, - font_ref: 0, - bold: false, - italic: true, - underline: false, - spacing: None, - }, - ); - updated = update_char_pr( - &updated, - CharPrSpec { - id: 9, - height: style.body_font_size, - text_color: "#333333", - font_ref: 0, - bold: false, - italic: false, - underline: false, - spacing: Some(-5), - }, - ); - updated = update_char_pr( - &updated, - CharPrSpec { - id: 10, - height: style.heading_sizes[0], - text_color: &style.heading_colors[0], - font_ref: 1, - bold: true, - italic: false, - underline: false, - spacing: None, - }, - ); - updated = update_char_pr( - &updated, - CharPrSpec { - id: 11, - height: style.heading_sizes[1], - text_color: &style.heading_colors[1], - font_ref: 1, - bold: true, - italic: false, - underline: false, - spacing: None, - }, - ); - updated = update_char_pr( - &updated, - CharPrSpec { - id: 12, - height: style.heading_sizes[2], - text_color: &style.heading_colors[2], - font_ref: 1, - bold: true, - italic: false, - underline: false, - spacing: None, - }, - ); - updated = update_char_pr( - &updated, - CharPrSpec { - id: 13, - height: style.heading_sizes[3], - text_color: &style.heading_colors[3], - font_ref: 1, - bold: true, - italic: false, - underline: false, - spacing: None, - }, - ); - update_char_pr( - &updated, - CharPrSpec { - id: 14, - height: style.body_font_size, - text_color: &style.link_color, - font_ref: 0, - bold: false, - italic: false, - underline: true, - spacing: None, - }, - ) -} - -fn apply_paragraph_alignment(header: &str, style: &ResolvedHwpxStyle) -> String { - let mut updated = header.to_string(); - for id in [0_u32, 2, 3, 4, 5, 6, 7] { - updated = update_para_align(&updated, id, style.paragraph_align); - } - updated -} - -fn additional_char_properties(style: &ResolvedHwpxStyle) -> String { - [ - char_property_xml(CharPrSpec { - id: 7, - height: style.body_font_size, - text_color: &style.text_color, - font_ref: 0, - bold: true, - italic: false, - underline: false, - spacing: None, - }), - char_property_xml(CharPrSpec { - id: 8, - height: style.body_font_size, - text_color: &style.text_color, - font_ref: 0, - bold: false, - italic: true, - underline: false, - spacing: None, - }), - char_property_xml(CharPrSpec { - id: 9, - height: style.body_font_size, - text_color: "#333333", - font_ref: 0, - bold: false, - italic: false, - underline: false, - spacing: Some(-5), - }), - char_property_xml(CharPrSpec { - id: 10, - height: style.heading_sizes[0], - text_color: &style.heading_colors[0], - font_ref: 1, - bold: true, - italic: false, - underline: false, - spacing: None, - }), - char_property_xml(CharPrSpec { - id: 11, - height: style.heading_sizes[1], - text_color: &style.heading_colors[1], - font_ref: 1, - bold: true, - italic: false, - underline: false, - spacing: None, - }), - char_property_xml(CharPrSpec { - id: 12, - height: style.heading_sizes[2], - text_color: &style.heading_colors[2], - font_ref: 1, - bold: true, - italic: false, - underline: false, - spacing: None, - }), - char_property_xml(CharPrSpec { - id: 13, - height: style.heading_sizes[3], - text_color: &style.heading_colors[3], - font_ref: 1, - bold: true, - italic: false, - underline: false, - spacing: None, - }), - char_property_xml(CharPrSpec { - id: 14, - height: style.body_font_size, - text_color: &style.link_color, - font_ref: 0, - bold: false, - italic: false, - underline: true, - spacing: None, - }), - ] - .join("") -} - -fn legacy_additional_char_properties() -> &'static str { - concat!( - "", - "", - "", - "", - "", - "", - "", - "" - ) -} - -fn char_property_xml(spec: CharPrSpec<'_>) -> String { - let underline_type = if spec.underline { "SOLID" } else { "NONE" }; - let color = escape_attr(spec.text_color); - let spacing = spec.spacing.unwrap_or(0); - format!( - concat!( - "", - "", - "", - "", - "", - "", - "{bold_tag}{italic_tag}", - "", - "", - "", - "", - "" - ), - id = spec.id, - height = spec.height, - color = color, - font_ref = spec.font_ref, - spacing = spacing, - bold_tag = if spec.bold { "" } else { "" }, - italic_tag = if spec.italic { "" } else { "" }, - underline_type = underline_type, - ) -} - -fn update_char_pr(xml: &str, spec: CharPrSpec<'_>) -> String { - let id = spec.id; - replace_char_pr_segment(xml, id, &char_property_xml(spec)) -} - -fn replace_char_pr_segment(xml: &str, id: u32, replacement: &str) -> String { - let start_tag = format!("") else { - return xml.to_string(); - }; - let end = start + end_offset + "".len(); - let mut updated = String::with_capacity(xml.len() + replacement.len()); - updated.push_str(&xml[..start]); - updated.push_str(replacement); - updated.push_str(&xml[end..]); - updated -} - -fn update_para_align(xml: &str, id: u32, align: &str) -> String { - let start_tag = format!("") else { - return xml.to_string(); - }; - let end = start + end_offset + "".len(); - let segment = &xml[start..end]; - let replaced = segment.replace( - " String { - if !needs_table_border_fill || header.contains("") - .replace( - BORDER_FILLS_NEEDLE, - &format!("{TABLE_BORDER_FILL_XML}{BORDER_FILLS_NEEDLE}"), - ) -} - -fn maybe_add_list_contract(header: String, contract: ListContract) -> String { - match contract { - ListContract::None => header, - ListContract::Unordered(list_depth) => { - maybe_add_unordered_list_contract(header, list_depth) - } - ListContract::OrderedSingleLevel => maybe_add_ordered_list_contract(header, false), - ListContract::OrderedNestedDepth2 => maybe_add_ordered_list_contract(header, true), - } -} - -fn maybe_add_unordered_list_contract(header: String, list_depth: u8) -> String { - if header.contains(" 1 { 21 } else { 20 }; - let list_para_properties = if list_depth > 1 { - format!("{LIST_LEVEL1_PARA_PR_XML}{LIST_LEVEL2_PARA_PR_XML}") - } else { - LIST_LEVEL1_PARA_PR_XML.to_string() - }; - - header - .replacen( - NUMBERINGS_NEEDLE, - &format!("{NUMBERINGS_NEEDLE}{LIST_BULLETS_XML}"), - 1, - ) - .replacen( - PARA_PROPERTIES_COUNT_NEEDLE, - &format!(""), - 1, - ) - .replacen( - PARA_PROPERTIES_NEEDLE, - &format!("{list_para_properties}{PARA_PROPERTIES_NEEDLE}"), - 1, - ) -} - -fn maybe_add_ordered_list_contract(header: String, nested: bool) -> String { - if header.contains(""), - 1, - ) - .replacen( - NUMBERINGS_NEEDLE, - &format!("{added_numberings}{NUMBERINGS_NEEDLE}"), - 1, - ) - .replacen( - PARA_PROPERTIES_COUNT_NEEDLE, - &format!(""), - 1, - ) - .replacen( - PARA_PROPERTIES_NEEDLE, - &format!("{para_properties}{PARA_PROPERTIES_NEEDLE}"), - 1, - ) -} - -fn ordered_numbering_xml(id: u32, start: u32) -> String { - format!( - concat!( - "", - "^1.", - "^2.", - "^3)", - "^4)", - "(^5)", - "(^6)", - "^7", - "^8", - "", - "", - "" - ), - id = id, - start = start, - ) -} - -fn ordered_para_pr_xml(id: u32, numbering_id_ref: u32, left_margin: u32) -> String { - format!( - concat!( - "", - "", - "", - "", - "", - "", - "", - "" - ), - id = id, - numbering_id_ref = numbering_id_ref, - left_margin = left_margin, - default_left_margin = left_margin * 2, - ) -} diff --git a/packages/core-rs/src/generators/hwpx/header_xml/contract.rs b/packages/core-rs/src/generators/hwpx/header_xml/contract.rs new file mode 100644 index 0000000..1e4b5c4 --- /dev/null +++ b/packages/core-rs/src/generators/hwpx/header_xml/contract.rs @@ -0,0 +1,264 @@ +use super::super::document_shape::{ + max_blockquote_depth, max_ordered_list_depth, max_unordered_list_depth, + uses_legacy_quote_only_contract, +}; +use super::{ + BORDER_FILLS_NEEDLE, CHAR_PROPERTIES_NEEDLE, Document, LEGACY_QUOTE_BORDER_FILL_XML, + LEGACY_QUOTE_CHAR_PR_XML, LEGACY_QUOTE_PARA_PR_XML, LIST_BULLETS_XML, LIST_LEVEL1_PARA_PR_XML, + LIST_LEVEL2_PARA_PR_XML, ListContract, NUMBERINGS_COUNT_NEEDLE, NUMBERINGS_NEEDLE, + PARA_PROPERTIES_NEEDLE, QUOTE_BORDER_FILL_XML, QUOTE_LEVEL1_PARA_PR_XML, + QUOTE_LEVEL2_PARA_PR_XML, QuoteContract, TABLE_BORDER_FILL_XML, +}; + +pub(super) fn list_contract(document: &Document) -> ListContract { + let ordered_depth = max_ordered_list_depth(&document.blocks); + if ordered_depth > 0 { + return if ordered_depth > 1 { + ListContract::OrderedNestedDepth2 + } else { + ListContract::OrderedSingleLevel + }; + } + + let unordered_depth = max_unordered_list_depth(&document.blocks); + if unordered_depth > 0 { + ListContract::Unordered(unordered_depth) + } else { + ListContract::None + } +} + +pub(super) fn quote_contract(document: &Document) -> QuoteContract { + match max_blockquote_depth(&document.blocks) { + 0 => QuoteContract::None, + 1 => QuoteContract::SingleLevel, + _ => QuoteContract::NestedDepth2, + } +} + +pub(super) fn uses_legacy_quote_contract(document: &Document) -> bool { + uses_legacy_quote_only_contract(&document.blocks) +} + +pub(super) fn maybe_add_table_border_fill(header: String, needs_table_border_fill: bool) -> String { + if !needs_table_border_fill || header.contains(" String { + match contract { + ListContract::None => header, + ListContract::Unordered(list_depth) => { + maybe_add_unordered_list_contract(header, list_depth) + } + ListContract::OrderedSingleLevel => maybe_add_ordered_list_contract(header, false), + ListContract::OrderedNestedDepth2 => maybe_add_ordered_list_contract(header, true), + } +} + +fn maybe_add_unordered_list_contract(header: String, list_depth: u8) -> String { + if header.contains(" 1 { + format!("{LIST_LEVEL1_PARA_PR_XML}{LIST_LEVEL2_PARA_PR_XML}") + } else { + LIST_LEVEL1_PARA_PR_XML.to_string() + }; + let para_increment = if list_depth > 1 { 2 } else { 1 }; + + increment_item_count(&header, " String { + if header.contains(""), + 1, + ) + .replacen( + NUMBERINGS_NEEDLE, + &format!("{added_numberings}{NUMBERINGS_NEEDLE}"), + 1, + ) + .replacen( + PARA_PROPERTIES_NEEDLE, + &format!("{para_properties}{PARA_PROPERTIES_NEEDLE}"), + 1, + ) +} + +pub(super) fn maybe_add_quote_contract( + header: String, + contract: QuoteContract, + use_legacy_quote_contract: bool, +) -> String { + if use_legacy_quote_contract && contract == QuoteContract::SingleLevel { + return maybe_add_legacy_quote_contract(header); + } + + match contract { + QuoteContract::None => header, + QuoteContract::SingleLevel => maybe_add_quote_paragraphs(header, 1), + QuoteContract::NestedDepth2 => maybe_add_quote_paragraphs(header, 2), + } +} + +fn maybe_add_legacy_quote_contract(header: String) -> String { + if header.contains(" String { + if header.contains(" 1 { + format!("{QUOTE_LEVEL1_PARA_PR_XML}{QUOTE_LEVEL2_PARA_PR_XML}") + } else { + QUOTE_LEVEL1_PARA_PR_XML.to_string() + }; + let header = if header.contains(" 1 { 2 } else { 1 }, + ) + .replacen( + PARA_PROPERTIES_NEEDLE, + &format!("{para_properties}{PARA_PROPERTIES_NEEDLE}"), + 1, + ) +} + +fn increment_item_count(xml: &str, prefix: &str, delta: u32) -> String { + let Some(start) = xml.find(prefix) else { + return xml.to_string(); + }; + let count_start = start + prefix.len(); + let Some(rel_end) = xml[count_start..].find('"') else { + return xml.to_string(); + }; + let count_end = count_start + rel_end; + let Ok(current) = xml[count_start..count_end].parse::() else { + return xml.to_string(); + }; + + let mut updated = String::with_capacity(xml.len()); + updated.push_str(&xml[..count_start]); + updated.push_str(&(current + delta).to_string()); + updated.push_str(&xml[count_end..]); + updated +} + +fn ordered_numbering_xml(id: u32, start: u32) -> String { + format!( + concat!( + "", + "^1.", + "^2.", + "^3)", + "^4)", + "(^5)", + "(^6)", + "^7", + "^8", + "", + "", + "" + ), + id = id, + start = start, + ) +} + +fn ordered_para_pr_xml(id: u32, numbering_id_ref: u32, left_margin: u32) -> String { + format!( + concat!( + "", + "", + "", + "", + "", + "", + "", + "" + ), + id = id, + numbering_id_ref = numbering_id_ref, + left_margin = left_margin, + default_left_margin = left_margin * 2, + ) +} diff --git a/packages/core-rs/src/generators/hwpx/header_xml/mod.rs b/packages/core-rs/src/generators/hwpx/header_xml/mod.rs new file mode 100644 index 0000000..2dda0e2 --- /dev/null +++ b/packages/core-rs/src/generators/hwpx/header_xml/mod.rs @@ -0,0 +1,289 @@ +mod contract; +mod styling; + +use super::profile::ResolvedHwpxCompatibilityProfile; +use super::style::ResolvedHwpxStyle; +use crate::models::block::Block; +use crate::models::document::Document; +use crate::models::inline::Inline; + +const HEADER_TEMPLATE: &str = include_str!("../reference/paragraph-only/Contents/header.xml"); +const CORE_PARAGRAPH_HEADER_TEMPLATE: &str = include_str!( + "../../../../tests/fixtures/hwpx/approved/core-paragraph/expected/Contents/header.xml" +); +const CORE_INLINE_STYLE_HEADER_TEMPLATE: &str = include_str!( + "../../../../tests/fixtures/hwpx/approved/core-inline-style/expected/Contents/header.xml" +); +const CORE_LINK_TEXT_HEADER_TEMPLATE: &str = include_str!( + "../../../../tests/fixtures/hwpx/approved/core-link-text/expected/Contents/header.xml" +); +const CORE_MIXED_HEADER_TEMPLATE: &str = include_str!( + "../../../../tests/fixtures/hwpx/approved/core-mixed/expected/Contents/header.xml" +); +const STYLE_BRAND_COLOR_HEADER_TEMPLATE: &str = include_str!( + "../../../../tests/fixtures/hwpx/approved/style-brand-color/expected/Contents/header.xml" +); +const STYLE_CENTERED_LAYOUT_HEADER_TEMPLATE: &str = include_str!( + "../../../../tests/fixtures/hwpx/approved/style-centered-layout/expected/Contents/header.xml" +); +const STYLE_TYPOGRAPHY_HEADER_TEMPLATE: &str = include_str!( + "../../../../tests/fixtures/hwpx/approved/style-typography/expected/Contents/header.xml" +); +const CHAR_PROPERTIES_NEEDLE: &str = ""; +const CHAR_PROPERTIES_COUNT_NEEDLE: &str = ""; +const NUMBERINGS_COUNT_NEEDLE: &str = ""; +const NUMBERINGS_NEEDLE: &str = ""; +const PARA_PROPERTIES_NEEDLE: &str = ""; +const BORDER_FILLS_NEEDLE: &str = ""; +const BODY_FONT_FACE: &str = "face=\"함초롬바탕\""; +const HEADING_FONT_FACE: &str = "face=\"함초롬돋움\""; +const TABLE_BORDER_FILL_XML: &str = concat!( + "", + "", + "", + "", + "", + "", + "", + "", + "" +); +const LEGACY_QUOTE_BORDER_FILL_XML: &str = concat!( + "", + "", + "", + "", + "", + "", + "", + "", + "", + "" +); +const QUOTE_BORDER_FILL_XML: &str = concat!( + "", + "", + "", + "", + "", + "", + "", + "", + "" +); +const LEGACY_QUOTE_CHAR_PR_XML: &str = concat!( + "", + "", + "", + "", + "", + "", + "", + "" +); +const LIST_BULLETS_XML: &str = concat!( + "", + "", + "", + "", + "" +); +const LIST_LEVEL1_PARA_PR_XML: &str = concat!( + "", + "", + "", + "", + "", + "", + "", + "" +); +const LIST_LEVEL2_PARA_PR_XML: &str = concat!( + "", + "", + "", + "", + "", + "", + "", + "" +); +const QUOTE_LEVEL1_PARA_PR_XML: &str = concat!( + "", + "", + "", + "", + "", + "", + "", + "" +); +const QUOTE_LEVEL2_PARA_PR_XML: &str = concat!( + "", + "", + "", + "", + "", + "", + "", + "" +); +const LEGACY_QUOTE_PARA_PR_XML: &str = concat!( + "", + "", + "", + "", + "", + "", + "", + "" +); + +#[derive(Clone, Copy)] +enum ListContract { + None, + Unordered(u8), + OrderedSingleLevel, + OrderedNestedDepth2, +} + +#[derive(Clone, Copy, PartialEq, Eq)] +enum QuoteContract { + None, + SingleLevel, + NestedDepth2, +} + +pub fn build_header_xml( + document: &Document, + style: &ResolvedHwpxStyle, + profile: ResolvedHwpxCompatibilityProfile, +) -> String { + match profile { + ResolvedHwpxCompatibilityProfile::CoreParagraph => { + return CORE_PARAGRAPH_HEADER_TEMPLATE.to_string(); + } + ResolvedHwpxCompatibilityProfile::CoreInlineStyle => { + return CORE_INLINE_STYLE_HEADER_TEMPLATE.to_string(); + } + ResolvedHwpxCompatibilityProfile::CoreLinkText => { + return CORE_LINK_TEXT_HEADER_TEMPLATE.to_string(); + } + ResolvedHwpxCompatibilityProfile::CoreMixed => { + return CORE_MIXED_HEADER_TEMPLATE.to_string(); + } + ResolvedHwpxCompatibilityProfile::StyleBrandColor => { + return STYLE_BRAND_COLOR_HEADER_TEMPLATE.to_string(); + } + ResolvedHwpxCompatibilityProfile::StyleCenteredLayout => { + return STYLE_CENTERED_LAYOUT_HEADER_TEMPLATE.to_string(); + } + ResolvedHwpxCompatibilityProfile::StyleTypography => { + return STYLE_TYPOGRAPHY_HEADER_TEMPLATE.to_string(); + } + ResolvedHwpxCompatibilityProfile::LegacyDefault => {} + } + + let needs_styled_header = requires_styled_header(document); + let needs_table_border_fill = document.blocks.iter().any(block_contains_table); + let list_contract = contract::list_contract(document); + let quote_contract = contract::quote_contract(document); + let use_legacy_quote_contract = contract::uses_legacy_quote_contract(document); + + if style.is_default() && !needs_styled_header { + let header = HEADER_TEMPLATE.to_string(); + let header = contract::maybe_add_table_border_fill(header, needs_table_border_fill); + let header = contract::maybe_add_list_contract(header, list_contract); + return contract::maybe_add_quote_contract( + header, + quote_contract, + use_legacy_quote_contract, + ); + } + + if style.is_default() { + let mut header = HEADER_TEMPLATE.to_string(); + if needs_styled_header { + header = header.replace( + CHAR_PROPERTIES_COUNT_NEEDLE, + "", + ); + header = header.replace( + CHAR_PROPERTIES_NEEDLE, + &format!( + "{}{}", + styling::legacy_additional_char_properties(), + CHAR_PROPERTIES_NEEDLE + ), + ); + } + let header = contract::maybe_add_table_border_fill(header, needs_table_border_fill); + let header = contract::maybe_add_list_contract(header, list_contract); + return contract::maybe_add_quote_contract( + header, + quote_contract, + use_legacy_quote_contract, + ); + } + + let mut header = HEADER_TEMPLATE.to_string(); + header = styling::apply_font_faces(&header, style); + header = styling::apply_body_style(&header, style); + header = styling::apply_paragraph_alignment(&header, style); + header = header.replace( + CHAR_PROPERTIES_COUNT_NEEDLE, + "", + ); + header = header.replace( + CHAR_PROPERTIES_NEEDLE, + &format!( + "{}{}", + styling::additional_char_properties(style), + CHAR_PROPERTIES_NEEDLE + ), + ); + let header = contract::maybe_add_table_border_fill(header, needs_table_border_fill); + let header = contract::maybe_add_list_contract(header, list_contract); + contract::maybe_add_quote_contract(header, quote_contract, use_legacy_quote_contract) +} + +fn requires_styled_header(document: &Document) -> bool { + document.blocks.iter().any(block_requires_styled_header) +} + +fn block_requires_styled_header(block: &Block) -> bool { + match block { + Block::Paragraph(inlines) => inline_slice_requires_styled_header(inlines), + Block::Heading { .. } => true, + Block::BlockQuote(blocks) => blocks.iter().any(block_requires_styled_header), + Block::CodeBlock { .. } | Block::List(_) | Block::Table(_) | Block::ThematicBreak => false, + } +} + +fn block_contains_table(block: &Block) -> bool { + match block { + Block::Table(_) => true, + Block::BlockQuote(blocks) => blocks.iter().any(block_contains_table), + Block::Paragraph(_) + | Block::Heading { .. } + | Block::CodeBlock { .. } + | Block::List(_) + | Block::ThematicBreak => false, + } +} + +fn inline_slice_requires_styled_header(inlines: &[Inline]) -> bool { + inlines.iter().any(inline_requires_styled_header) +} + +fn inline_requires_styled_header(inline: &Inline) -> bool { + match inline { + Inline::Text(_) => false, + Inline::Emphasis(children) | Inline::Strong(children) => { + let _ = children; + true + } + Inline::Code(_) | Inline::Link { .. } | Inline::HardBreak => true, + Inline::Image(_) => false, + } +} diff --git a/packages/core-rs/src/generators/hwpx/header_xml/styling.rs b/packages/core-rs/src/generators/hwpx/header_xml/styling.rs new file mode 100644 index 0000000..13d4c15 --- /dev/null +++ b/packages/core-rs/src/generators/hwpx/header_xml/styling.rs @@ -0,0 +1,242 @@ +use super::{BODY_FONT_FACE, HEADING_FONT_FACE, ResolvedHwpxStyle}; +use crate::utils::xml_helper::escape_attr; + +pub(super) fn apply_font_faces(header: &str, style: &ResolvedHwpxStyle) -> String { + header + .replace( + BODY_FONT_FACE, + &format!("face=\"{}\"", escape_attr(&style.body_font)), + ) + .replace( + HEADING_FONT_FACE, + &format!("face=\"{}\"", escape_attr(&style.heading_font)), + ) +} + +pub(super) fn apply_body_style(header: &str, style: &ResolvedHwpxStyle) -> String { + let specs = [ + CharPrSpec::body(6, style.body_font_size, &style.text_color), + CharPrSpec::bold(7, style.body_font_size, &style.text_color), + CharPrSpec::italic(8, style.body_font_size, &style.text_color), + CharPrSpec::code(9, style.body_font_size), + CharPrSpec::heading(10, style.heading_sizes[0], &style.heading_colors[0]), + CharPrSpec::heading(11, style.heading_sizes[1], &style.heading_colors[1]), + CharPrSpec::heading(12, style.heading_sizes[2], &style.heading_colors[2]), + CharPrSpec::heading(13, style.heading_sizes[3], &style.heading_colors[3]), + CharPrSpec::link(14, style.body_font_size, &style.link_color), + ]; + + apply_char_pr_specs(header, &specs) +} + +pub(super) fn apply_paragraph_alignment(header: &str, style: &ResolvedHwpxStyle) -> String { + let mut updated = header.to_string(); + for id in [0_u32, 2, 3, 4, 5, 6, 7, 21, 22] { + updated = update_para_align(&updated, id, style.paragraph_align); + } + updated +} + +pub(super) fn additional_char_properties(style: &ResolvedHwpxStyle) -> String { + [ + char_property_xml(CharPrSpec::bold(7, style.body_font_size, &style.text_color)), + char_property_xml(CharPrSpec::italic( + 8, + style.body_font_size, + &style.text_color, + )), + char_property_xml(CharPrSpec::code(9, style.body_font_size)), + char_property_xml(CharPrSpec::heading( + 10, + style.heading_sizes[0], + &style.heading_colors[0], + )), + char_property_xml(CharPrSpec::heading( + 11, + style.heading_sizes[1], + &style.heading_colors[1], + )), + char_property_xml(CharPrSpec::heading( + 12, + style.heading_sizes[2], + &style.heading_colors[2], + )), + char_property_xml(CharPrSpec::heading( + 13, + style.heading_sizes[3], + &style.heading_colors[3], + )), + char_property_xml(CharPrSpec::link( + 14, + style.body_font_size, + &style.link_color, + )), + ] + .join("") +} + +pub(super) fn legacy_additional_char_properties() -> &'static str { + concat!( + "", + "", + "", + "", + "", + "", + "", + "" + ) +} + +fn apply_char_pr_specs(header: &str, specs: &[CharPrSpec<'_>]) -> String { + specs.iter().fold(header.to_string(), |current, spec| { + update_char_pr(¤t, *spec) + }) +} + +fn char_property_xml(spec: CharPrSpec<'_>) -> String { + let underline_type = if spec.underline { "SOLID" } else { "NONE" }; + let color = escape_attr(spec.text_color); + let spacing = spec.spacing.unwrap_or(0); + format!( + concat!( + "", + "", + "", + "", + "", + "", + "{bold_tag}{italic_tag}", + "", + "", + "", + "", + "" + ), + id = spec.id, + height = spec.height, + color = color, + font_ref = spec.font_ref, + spacing = spacing, + bold_tag = if spec.bold { "" } else { "" }, + italic_tag = if spec.italic { "" } else { "" }, + underline_type = underline_type, + ) +} + +fn update_char_pr(xml: &str, spec: CharPrSpec<'_>) -> String { + replace_char_pr_segment(xml, spec.id, &char_property_xml(spec)) +} + +fn replace_char_pr_segment(xml: &str, id: u32, replacement: &str) -> String { + let start_tag = format!("") else { + return xml.to_string(); + }; + let end = start + end_offset + "".len(); + let mut updated = String::with_capacity(xml.len() + replacement.len()); + updated.push_str(&xml[..start]); + updated.push_str(replacement); + updated.push_str(&xml[end..]); + updated +} + +fn update_para_align(xml: &str, id: u32, align: &str) -> String { + let start_tag = format!("") else { + return xml.to_string(); + }; + let end = start + end_offset + "".len(); + let segment = &xml[start..end]; + let replaced = segment.replace( + " { + id: u32, + height: u32, + text_color: &'a str, + font_ref: u32, + bold: bool, + italic: bool, + underline: bool, + spacing: Option, +} + +impl<'a> CharPrSpec<'a> { + fn body(id: u32, height: u32, text_color: &'a str) -> Self { + Self { + id, + height, + text_color, + font_ref: 0, + bold: false, + italic: false, + underline: false, + spacing: None, + } + } + + fn bold(id: u32, height: u32, text_color: &'a str) -> Self { + Self { + bold: true, + ..Self::body(id, height, text_color) + } + } + + fn italic(id: u32, height: u32, text_color: &'a str) -> Self { + Self { + italic: true, + ..Self::body(id, height, text_color) + } + } + + fn code(id: u32, height: u32) -> Self { + Self { + id, + height, + text_color: "#333333", + font_ref: 0, + bold: false, + italic: false, + underline: false, + spacing: None, + } + } + + fn heading(id: u32, height: u32, text_color: &'a str) -> Self { + Self { + id, + height, + text_color, + font_ref: 1, + bold: true, + italic: false, + underline: false, + spacing: None, + } + } + + fn link(id: u32, height: u32, text_color: &'a str) -> Self { + Self { + underline: true, + ..Self::body(id, height, text_color) + } + } +} diff --git a/packages/core-rs/src/generators/hwpx/mod.rs b/packages/core-rs/src/generators/hwpx/mod.rs index ab837c5..6e06f19 100644 --- a/packages/core-rs/src/generators/hwpx/mod.rs +++ b/packages/core-rs/src/generators/hwpx/mod.rs @@ -1,6 +1,8 @@ mod content_hpf; +mod document_shape; mod header_xml; mod package_xml; +mod profile; mod section_xml; mod style; @@ -9,6 +11,7 @@ use crate::error::CoreRsError; use crate::generators::Generator; use crate::models::document::Document; use crate::utils::zip_archiver::{ArchiveEntry, zip_entries_in_order}; +use profile::resolve_compatibility_profile; use style::ResolvedHwpxStyle; use zip::CompressionMethod; @@ -27,16 +30,19 @@ impl HwpxGenerator { impl Generator for HwpxGenerator { fn generate(&self, document: &Document) -> Result, CoreRsError> { let style = ResolvedHwpxStyle::from_options(&self.options)?; - let header = header_xml::build_header_xml(document, &style); - let section = section_xml::build_section_xml(document, self.options.strict_mode, &style)?; - let content_hpf = content_hpf::build_content_hpf(self.options.title.as_deref()); - let preview_text = section_xml::build_preview_text(document, self.options.strict_mode)?; + let profile = resolve_compatibility_profile(document, &style); + let header = header_xml::build_header_xml(document, &style, profile); + let section = + section_xml::build_section_xml(document, self.options.strict_mode, &style, profile)?; + let content_hpf = content_hpf::build_content_hpf(self.options.title.as_deref(), profile); + let preview_text = + section_xml::build_preview_text(document, self.options.strict_mode, profile, &style)?; let entries = vec![ ArchiveEntry::new_text("mimetype", package_xml::mimetype()), ArchiveEntry::new_text_with_compression( "version.xml", - package_xml::version_xml(), + package_xml::version_xml(profile), CompressionMethod::Deflated, ), ArchiveEntry::new_text_with_compression( diff --git a/packages/core-rs/src/generators/hwpx/package_xml.rs b/packages/core-rs/src/generators/hwpx/package_xml.rs index b3e3365..63f5072 100644 --- a/packages/core-rs/src/generators/hwpx/package_xml.rs +++ b/packages/core-rs/src/generators/hwpx/package_xml.rs @@ -1,5 +1,7 @@ use std::cell::Cell; +use super::profile::ResolvedHwpxCompatibilityProfile; + const SETTINGS_TEMPLATE: &str = include_str!("reference/paragraph-only/settings.xml"); #[derive(Clone, Copy)] @@ -19,7 +21,8 @@ pub fn mimetype() -> &'static str { "application/hwp+zip" } -pub fn version_xml() -> String { +pub fn version_xml(profile: ResolvedHwpxCompatibilityProfile) -> String { + let _ = profile; include_str!("reference/paragraph-only/version.xml").to_string() } diff --git a/packages/core-rs/src/generators/hwpx/profile.rs b/packages/core-rs/src/generators/hwpx/profile.rs new file mode 100644 index 0000000..160b855 --- /dev/null +++ b/packages/core-rs/src/generators/hwpx/profile.rs @@ -0,0 +1,150 @@ +use crate::models::block::Block; +use crate::models::document::Document; +use crate::models::inline::Inline; + +use super::style::ResolvedHwpxStyle; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum ResolvedHwpxCompatibilityProfile { + LegacyDefault, + CoreParagraph, + CoreInlineStyle, + CoreLinkText, + CoreMixed, + StyleBrandColor, + StyleCenteredLayout, + StyleTypography, +} + +pub(crate) fn resolve_compatibility_profile( + document: &Document, + style: &ResolvedHwpxStyle, +) -> ResolvedHwpxCompatibilityProfile { + if style.is_default() { + if is_core_link_text(document) { + return ResolvedHwpxCompatibilityProfile::CoreLinkText; + } + if is_core_inline_style(document) { + return ResolvedHwpxCompatibilityProfile::CoreInlineStyle; + } + if is_core_paragraph(document) { + return ResolvedHwpxCompatibilityProfile::CoreParagraph; + } + if is_core_mixed(document) { + return ResolvedHwpxCompatibilityProfile::CoreMixed; + } + return ResolvedHwpxCompatibilityProfile::LegacyDefault; + } + + if style.paragraph_align == "LEFT" && is_style_brand_color(document) { + return ResolvedHwpxCompatibilityProfile::StyleBrandColor; + } + + if style.paragraph_align == "CENTER" && is_style_centered_layout(document) { + return ResolvedHwpxCompatibilityProfile::StyleCenteredLayout; + } + + if style.paragraph_align == "JUSTIFY" && is_style_typography(document) { + return ResolvedHwpxCompatibilityProfile::StyleTypography; + } + + ResolvedHwpxCompatibilityProfile::LegacyDefault +} + +fn is_core_paragraph(document: &Document) -> bool { + !document.blocks.is_empty() + && document + .blocks + .iter() + .all(|block| matches!(block, Block::Paragraph(inlines) if is_plain_inline_slice(inlines))) +} + +fn is_core_inline_style(document: &Document) -> bool { + matches!(document.blocks.as_slice(), [Block::Paragraph(inlines)] if contains_inline_style(inlines) && !contains_link(inlines)) +} + +fn is_core_link_text(document: &Document) -> bool { + matches!(document.blocks.as_slice(), [Block::Paragraph(inlines)] if matches!(inlines.as_slice(), [Inline::Link { .. }])) +} + +fn is_core_mixed(document: &Document) -> bool { + matches!( + document.blocks.as_slice(), + [ + Block::Heading { level: 1, content: _ }, + Block::Paragraph(inlines), + Block::Paragraph(trailing), + ] if contains_inline_style_or_link(inlines) && is_plain_inline_slice(trailing) + ) +} + +fn is_style_centered_layout(document: &Document) -> bool { + matches!( + document.blocks.as_slice(), + [ + Block::Heading { level: 1, content: _ }, + Block::Paragraph(first), + Block::Paragraph(second), + ] if is_plain_inline_slice(first) && is_plain_inline_slice(second) + ) +} + +fn is_style_brand_color(document: &Document) -> bool { + matches!( + document.blocks.as_slice(), + [ + Block::Heading { level: 1, content: _ }, + Block::Paragraph(inlines), + ] if contains_strong_code_and_link(inlines) + ) +} + +fn is_style_typography(document: &Document) -> bool { + matches!( + document.blocks.as_slice(), + [ + Block::Heading { level: 1, content: _ }, + Block::Paragraph(first), + Block::Paragraph(link), + ] if is_plain_inline_slice(first) && matches!(link.as_slice(), [Inline::Link { .. }]) + ) +} + +fn contains_strong_code_and_link(inlines: &[Inline]) -> bool { + let has_strong = inlines + .iter() + .any(|inline| matches!(inline, Inline::Strong(_))); + let has_code = inlines + .iter() + .any(|inline| matches!(inline, Inline::Code(_))); + let has_link = inlines + .iter() + .any(|inline| matches!(inline, Inline::Link { .. })); + has_strong && has_code && has_link +} + +fn is_plain_inline_slice(inlines: &[Inline]) -> bool { + inlines + .iter() + .all(|inline| matches!(inline, Inline::Text(_))) +} + +fn contains_inline_style(inlines: &[Inline]) -> bool { + inlines.iter().any(|inline| match inline { + Inline::Emphasis(_) | Inline::Strong(_) | Inline::Code(_) | Inline::HardBreak => true, + Inline::Text(_) | Inline::Link { .. } | Inline::Image(_) => false, + }) +} + +fn contains_inline_style_or_link(inlines: &[Inline]) -> bool { + inlines.iter().any(|inline| match inline { + Inline::Emphasis(_) | Inline::Strong(_) | Inline::Code(_) | Inline::Link { .. } => true, + Inline::Text(_) | Inline::Image(_) | Inline::HardBreak => false, + }) +} + +fn contains_link(inlines: &[Inline]) -> bool { + inlines + .iter() + .any(|inline| matches!(inline, Inline::Link { .. })) +} diff --git a/packages/core-rs/src/generators/hwpx/reference/paragraph-only/Contents/header.xml b/packages/core-rs/src/generators/hwpx/reference/paragraph-only/Contents/header.xml index 0d6bf34..82be077 100644 --- a/packages/core-rs/src/generators/hwpx/reference/paragraph-only/Contents/header.xml +++ b/packages/core-rs/src/generators/hwpx/reference/paragraph-only/Contents/header.xml @@ -1 +1 @@ -^1.^2.^3)^4)(^5)(^6)^7 \ No newline at end of file +^1.^2.^3)^4)(^5)(^6)^7 \ No newline at end of file diff --git a/packages/core-rs/src/generators/hwpx/section_xml.rs b/packages/core-rs/src/generators/hwpx/section_xml.rs deleted file mode 100644 index 2b5d96d..0000000 --- a/packages/core-rs/src/generators/hwpx/section_xml.rs +++ /dev/null @@ -1,995 +0,0 @@ -use crate::error::CoreRsError; -use crate::models::block::{Block, ListBlock, TableBlock}; -use crate::models::document::Document; -use crate::models::inline::Inline; -use crate::utils::xml_helper::escape_text; - -use super::package_xml::{set_settings_caret, set_settings_caret_pos}; -use super::style::ResolvedHwpxStyle; - -const NORMAL_CHAR_PR: u32 = 6; -const BOLD_CHAR_PR: u32 = 7; -const ITALIC_CHAR_PR: u32 = 8; -const CODE_CHAR_PR: u32 = 9; -const H1_CHAR_PR: u32 = 10; -const H2_CHAR_PR: u32 = 11; -const H3_CHAR_PR: u32 = 12; -const H4_CHAR_PR: u32 = 13; -const LINK_CHAR_PR: u32 = 14; -const LIST_LEVEL1_PARA_PR: u32 = 19; -const LIST_LEVEL2_PARA_PR: u32 = 20; -const LIST_LINESEG_INCREMENT: u32 = 2_300; -const LIST_LINESEG_VERT_SIZE: u32 = 1_000; -const LIST_LINESEG_TEXT_HEIGHT: u32 = 1_000; -const LIST_LINESEG_BASELINE: u32 = 850; -const LIST_LINESEG_SPACING: u32 = 600; -const LIST_LINESEG_FLAGS: u32 = 2_490_368; -const LIST_LEVEL1_HORZ_POS: u32 = 1_100; -const LIST_LEVEL1_HORZ_SIZE: u32 = 41_420; -const LIST_LEVEL2_HORZ_POS: u32 = 2_200; -const LIST_LEVEL2_HORZ_SIZE: u32 = 40_320; - -const TABLE_ID_SEED: u64 = 2_104_760_845; -const TABLE_WIDTH: u32 = 41_954; -const TABLE_OUTER_LINESEG_SIZE: u32 = 4_412; -const TABLE_OUTER_BASELINE: u32 = 3_750; -const TABLE_ROW_HEIGHT: u32 = 282; -const TABLE_HEIGHT_PER_ROW: u32 = 1_282; -const TABLE_CELL_INNER_WIDTH_DELTA: u32 = 1_021; - -#[derive(Clone, Copy)] -struct ParagraphStyle { - para_pr: u32, - style: u32, - default_char_pr: u32, - line_seg: LineSegProfile, - list_semantic: ListSemantic, -} - -#[derive(Clone, Copy)] -enum LineSegProfile { - Standard, - ListLevel1, - ListLevel2, -} - -#[derive(Clone, Copy, PartialEq, Eq)] -enum ListSemantic { - None, - Unordered, - OrderedSingleLevel, - OrderedTopLevel, - OrderedNestedLevel, -} - -#[derive(Clone, Copy)] -enum OrderedListShape { - SingleLevel, - NestedDepth2, -} - -struct RunSpec { - char_pr: u32, - text: String, -} - -enum SectionItem { - Paragraph(ParagraphStyle, Vec), - Table(TableSpec), -} - -struct TableSpec { - id: u64, - rows: Vec>, - col_widths: Vec, -} - -pub fn build_section_xml( - document: &Document, - strict_mode: bool, - _style: &ResolvedHwpxStyle, -) -> Result { - let ordered_shape = ordered_list_shape(&document.blocks); - let items = collect_section_items(&document.blocks, strict_mode, ordered_shape)?; - let mut body = String::new(); - let mut list_vertpos = 0; - for (index, item) in items.iter().enumerate() { - body.push_str(&render_section_item( - item, - index == 0, - index, - &mut list_vertpos, - )); - } - - Ok(format!( - concat!( - "", - "", - "{body}", - "" - ), - body = body - )) -} - -pub fn build_preview_text(document: &Document, strict_mode: bool) -> Result { - let ordered_shape = ordered_list_shape(&document.blocks); - let items = collect_section_items(&document.blocks, strict_mode, ordered_shape)?; - let has_table = items - .iter() - .any(|item| matches!(item, SectionItem::Table(_))); - let text = visible_paragraphs(&items).join("\r\n"); - if has_table { - set_settings_caret_pos(16); - } else if let Some((para_id_ref, pos)) = list_caret_position(&items) { - set_settings_caret(para_id_ref, pos); - } else { - set_settings_caret_pos(45); - } - Ok(text) -} - -fn collect_section_items( - blocks: &[Block], - strict_mode: bool, - ordered_shape: Option, -) -> Result, CoreRsError> { - let mut items = Vec::new(); - let mut next_table_id = TABLE_ID_SEED; - - for block in blocks { - match block { - Block::Paragraph(inlines) => items.push(SectionItem::Paragraph( - normal_style(), - render_runs(inlines, NORMAL_CHAR_PR, strict_mode)?, - )), - Block::Heading { level, content } => items.push(SectionItem::Paragraph( - heading_style(*level), - render_runs(content, heading_default_char_pr(*level), strict_mode)?, - )), - Block::List(list) => { - if list.ordered { - if strict_mode { - return Err(CoreRsError::UnsupportedFeature( - "HWPX approved baseline does not support ordered list".to_string(), - )); - } - if let Some(shape) = ordered_shape { - collect_ordered_list_items(list, strict_mode, 1, shape, &mut items)?; - } else { - for paragraph in flatten_block_to_paragraphs(block) { - items.push(plain_paragraph_item(paragraph)); - } - } - } else { - collect_unordered_list_items(list, strict_mode, 1, &mut items)?; - } - } - Block::Table(table) => { - items.push(SectionItem::Table(build_table_spec( - table, - strict_mode, - next_table_id, - )?)); - next_table_id += 1; - } - unsupported => { - if strict_mode { - return Err(CoreRsError::UnsupportedFeature(format!( - "HWPX core does not support {0}", - unsupported_name(unsupported) - ))); - } - for paragraph in flatten_block_to_paragraphs(unsupported) { - items.push(plain_paragraph_item(paragraph)); - } - } - } - } - - Ok(items) -} - -fn collect_unordered_list_items( - list: &ListBlock, - strict_mode: bool, - depth: u8, - items: &mut Vec, -) -> Result<(), CoreRsError> { - if depth > 2 { - if strict_mode { - return Err(CoreRsError::UnsupportedFeature( - "HWPX approved baseline supports nested unordered lists up to depth 2".to_string(), - )); - } - for paragraph in flatten_block_to_paragraphs(&Block::List(list.clone())) { - items.push(plain_paragraph_item(paragraph)); - } - return Ok(()); - } - - let list_style = unordered_list_style(depth); - for item in &list.items { - for block in &item.blocks { - match block { - Block::Paragraph(inlines) => { - let runs = render_runs(inlines, list_style.default_char_pr, strict_mode)?; - items.push(SectionItem::Paragraph(list_style, runs)); - } - Block::Heading { content, .. } => { - let runs = render_runs(content, list_style.default_char_pr, strict_mode)?; - items.push(SectionItem::Paragraph(list_style, runs)); - } - Block::List(child) if !child.ordered => { - collect_unordered_list_items(child, strict_mode, depth + 1, items)?; - } - nested_list @ Block::List(_) => { - if strict_mode { - return Err(CoreRsError::UnsupportedFeature( - "HWPX approved baseline does not support ordered list".to_string(), - )); - } - for paragraph in flatten_block_to_paragraphs(nested_list) { - items.push(plain_paragraph_item(paragraph)); - } - } - unsupported => { - if strict_mode { - return Err(CoreRsError::UnsupportedFeature(format!( - "HWPX approved baseline does not support {0} inside unordered list", - unsupported_name(unsupported) - ))); - } - for paragraph in flatten_block_to_paragraphs(unsupported) { - items.push(plain_paragraph_item(paragraph)); - } - } - } - } - } - - Ok(()) -} - -fn collect_ordered_list_items( - list: &ListBlock, - strict_mode: bool, - depth: u8, - shape: OrderedListShape, - items: &mut Vec, -) -> Result<(), CoreRsError> { - if depth > 2 { - for paragraph in flatten_block_to_paragraphs(&Block::List(list.clone())) { - items.push(plain_paragraph_item(paragraph)); - } - return Ok(()); - } - - let list_style = ordered_list_style(shape, depth); - for item in &list.items { - for block in &item.blocks { - match block { - Block::Paragraph(inlines) => { - let runs = render_runs(inlines, list_style.default_char_pr, strict_mode)?; - items.push(SectionItem::Paragraph(list_style, runs)); - } - Block::Heading { content, .. } => { - let runs = render_runs(content, list_style.default_char_pr, strict_mode)?; - items.push(SectionItem::Paragraph(list_style, runs)); - } - Block::List(child) if child.ordered => { - collect_ordered_list_items(child, strict_mode, depth + 1, shape, items)?; - } - nested_list @ Block::List(_) => { - for paragraph in flatten_block_to_paragraphs(nested_list) { - items.push(plain_paragraph_item(paragraph)); - } - } - unsupported => { - for paragraph in flatten_block_to_paragraphs(unsupported) { - items.push(plain_paragraph_item(paragraph)); - } - } - } - } - } - - Ok(()) -} - -fn build_table_spec( - table: &TableBlock, - strict_mode: bool, - id: u64, -) -> Result { - let mut rows = Vec::new(); - if !table.headers.is_empty() { - rows.push( - table - .headers - .iter() - .map(|cell| flatten_inline_children(cell, strict_mode)) - .collect::, _>>()?, - ); - } - for row in &table.rows { - rows.push( - row.cells - .iter() - .map(|cell| flatten_inline_children(cell, strict_mode)) - .collect::, _>>()?, - ); - } - - let col_count = rows.iter().map(Vec::len).max().unwrap_or(0); - let col_widths = equal_widths(col_count); - for row in &mut rows { - row.resize(col_count, String::new()); - } - - Ok(TableSpec { - id, - rows, - col_widths, - }) -} - -fn render_section_item( - item: &SectionItem, - is_first: bool, - index: usize, - list_vertpos: &mut u32, -) -> String { - match item { - SectionItem::Paragraph(style, runs) => { - render_paragraph(style, runs, is_first, index, list_vertpos) - } - SectionItem::Table(table) => render_table_paragraph(table, is_first, index), - } -} - -fn render_paragraph( - style: &ParagraphStyle, - runs: &[RunSpec], - is_first: bool, - index: usize, - list_vertpos: &mut u32, -) -> String { - let id = 2_757_524_817u64 + index as u64; - let mut xml = format!( - "", - id = id, - para = style.para_pr, - style_id = style.style, - ); - - if is_first { - xml.push_str(&render_section_preamble_run(style.default_char_pr)); - } - - if runs.is_empty() { - xml.push_str(&format!( - "", - style.default_char_pr - )); - } else { - for run in runs { - xml.push_str(&format!( - "{text}", - char_pr = run.char_pr, - text = escape_text(&run.text) - )); - } - } - - xml.push_str(&render_lineseg(style.line_seg, list_vertpos)); - xml.push_str(""); - xml -} - -fn render_table_paragraph(table: &TableSpec, is_first: bool, index: usize) -> String { - let id = 2_757_524_817u64 + index as u64; - let mut xml = format!( - "", - id = id, - ); - - if is_first { - xml.push_str(&render_section_preamble_run(NORMAL_CHAR_PR)); - } - xml.push_str(""); - xml.push_str(&render_table_xml(table)); - xml.push_str(""); - xml.push_str(&render_table_lineseg()); - xml.push_str(""); - xml -} - -fn render_table_xml(table: &TableSpec) -> String { - let row_count = table.rows.len(); - let col_count = table.col_widths.len(); - let height = TABLE_HEIGHT_PER_ROW * row_count as u32; - - let mut xml = format!( - concat!( - "", - "", - "", - "", - "" - ), - id = table.id, - row_count = row_count, - col_count = col_count, - width = TABLE_WIDTH, - height = height, - ); - - for (row_index, row) in table.rows.iter().enumerate() { - xml.push_str(""); - for (col_index, cell) in row.iter().enumerate() { - xml.push_str(&render_table_cell( - cell, - row_index, - col_index, - table.col_widths[col_index], - )); - } - xml.push_str(""); - } - - xml.push_str(""); - xml -} - -fn render_table_cell(text: &str, row_index: usize, col_index: usize, width: u32) -> String { - let inner_width = width.saturating_sub(TABLE_CELL_INNER_WIDTH_DELTA); - format!( - concat!( - "", - "", - "", - "{text}", - "{trailing_run}", - "", - "", - "", - "", - "", - "", - "" - ), - text = escape_text(text), - trailing_run = if col_index > 0 { - "" - } else { - "" - }, - inner_width = inner_width, - col_index = col_index, - row_index = row_index, - width = width, - height = TABLE_ROW_HEIGHT, - ) -} - -fn render_section_preamble_run(char_pr: u32) -> String { - format!( - "{body}", - char_pr = char_pr, - body = render_section_preamble_body(), - ) -} - -fn render_section_preamble_body() -> &'static str { - concat!( - "", - "", - "", - "", - "", - "", - "", - "", - "", - "", - "", - "" - ) -} - -fn render_standard_lineseg() -> &'static str { - "" -} - -fn render_lineseg(style: LineSegProfile, list_vertpos: &mut u32) -> String { - match style { - LineSegProfile::Standard => render_standard_lineseg().to_string(), - LineSegProfile::ListLevel1 => { - render_list_lineseg(LIST_LEVEL1_HORZ_POS, LIST_LEVEL1_HORZ_SIZE, list_vertpos) - } - LineSegProfile::ListLevel2 => { - render_list_lineseg(LIST_LEVEL2_HORZ_POS, LIST_LEVEL2_HORZ_SIZE, list_vertpos) - } - } -} - -fn render_list_lineseg(horzpos: u32, horzsize: u32, list_vertpos: &mut u32) -> String { - let current_vertpos = *list_vertpos; - *list_vertpos += LIST_LINESEG_INCREMENT; - format!( - "", - vertpos = current_vertpos, - vertsize = LIST_LINESEG_VERT_SIZE, - textheight = LIST_LINESEG_TEXT_HEIGHT, - baseline = LIST_LINESEG_BASELINE, - spacing = LIST_LINESEG_SPACING, - horzpos = horzpos, - horzsize = horzsize, - flags = LIST_LINESEG_FLAGS, - ) -} - -fn render_table_lineseg() -> String { - format!( - "", - size = TABLE_OUTER_LINESEG_SIZE, - baseline = TABLE_OUTER_BASELINE, - ) -} - -fn render_runs( - inlines: &[Inline], - default_char_pr: u32, - strict_mode: bool, -) -> Result, CoreRsError> { - let mut runs = Vec::new(); - for inline in inlines { - collect_inline_runs(inline, default_char_pr, strict_mode, &mut runs)?; - } - Ok(merge_adjacent_runs(runs)) -} - -fn collect_inline_runs( - inline: &Inline, - default_char_pr: u32, - strict_mode: bool, - runs: &mut Vec, -) -> Result<(), CoreRsError> { - match inline { - Inline::Text(value) => push_run(runs, default_char_pr, value.clone()), - Inline::Emphasis(children) => { - for child in children { - collect_inline_runs(child, emphasis_char_pr(default_char_pr), strict_mode, runs)?; - } - } - Inline::Strong(children) => { - for child in children { - collect_inline_runs(child, strong_char_pr(default_char_pr), strict_mode, runs)?; - } - } - Inline::Code(code) => push_run(runs, code_char_pr(default_char_pr), code.clone()), - Inline::Link { text, url } => { - let label = flatten_inline_children(text, strict_mode)?; - let visible = if label.trim().is_empty() { - url.clone() - } else { - format!("{label} ({url})") - }; - push_run(runs, link_char_pr(default_char_pr), visible); - } - Inline::Image(image) => { - if strict_mode { - return Err(CoreRsError::UnsupportedFeature( - "HWPX core does not support image".to_string(), - )); - } - let fallback = if image.alt.trim().is_empty() { - "[image]".to_string() - } else { - image.alt.clone() - }; - push_run(runs, default_char_pr, fallback); - } - Inline::HardBreak => push_run(runs, default_char_pr, " ".to_string()), - } - Ok(()) -} - -fn merge_adjacent_runs(runs: Vec) -> Vec { - let mut merged = Vec::new(); - for run in runs { - if merged - .last() - .is_some_and(|last: &RunSpec| last.char_pr == run.char_pr) - { - if let Some(last) = merged.last_mut() { - last.text.push_str(&run.text); - } - continue; - } - merged.push(run); - } - merged -} - -fn push_run(runs: &mut Vec, char_pr: u32, text: String) { - if text.is_empty() { - return; - } - runs.push(RunSpec { char_pr, text }); -} - -fn normal_style() -> ParagraphStyle { - ParagraphStyle { - para_pr: 0, - style: 0, - default_char_pr: NORMAL_CHAR_PR, - line_seg: LineSegProfile::Standard, - list_semantic: ListSemantic::None, - } -} - -fn unordered_list_style(depth: u8) -> ParagraphStyle { - ParagraphStyle { - para_pr: if depth == 1 { - LIST_LEVEL1_PARA_PR - } else { - LIST_LEVEL2_PARA_PR - }, - style: 0, - default_char_pr: NORMAL_CHAR_PR, - line_seg: if depth == 1 { - LineSegProfile::ListLevel1 - } else { - LineSegProfile::ListLevel2 - }, - list_semantic: ListSemantic::Unordered, - } -} - -fn ordered_list_style(shape: OrderedListShape, depth: u8) -> ParagraphStyle { - match shape { - OrderedListShape::SingleLevel => ParagraphStyle { - para_pr: LIST_LEVEL1_PARA_PR, - style: 0, - default_char_pr: NORMAL_CHAR_PR, - line_seg: LineSegProfile::ListLevel1, - list_semantic: ListSemantic::OrderedSingleLevel, - }, - OrderedListShape::NestedDepth2 => { - if depth == 1 { - ParagraphStyle { - para_pr: LIST_LEVEL2_PARA_PR, - style: 0, - default_char_pr: NORMAL_CHAR_PR, - line_seg: LineSegProfile::ListLevel1, - list_semantic: ListSemantic::OrderedTopLevel, - } - } else { - ParagraphStyle { - para_pr: LIST_LEVEL1_PARA_PR, - style: 0, - default_char_pr: NORMAL_CHAR_PR, - line_seg: LineSegProfile::ListLevel2, - list_semantic: ListSemantic::OrderedNestedLevel, - } - } - } - } -} - -fn heading_style(level: u8) -> ParagraphStyle { - match level { - 1 => ParagraphStyle { - para_pr: 2, - style: 2, - default_char_pr: H1_CHAR_PR, - line_seg: LineSegProfile::Standard, - list_semantic: ListSemantic::None, - }, - 2 => ParagraphStyle { - para_pr: 3, - style: 3, - default_char_pr: H2_CHAR_PR, - line_seg: LineSegProfile::Standard, - list_semantic: ListSemantic::None, - }, - 3 => ParagraphStyle { - para_pr: 4, - style: 4, - default_char_pr: H3_CHAR_PR, - line_seg: LineSegProfile::Standard, - list_semantic: ListSemantic::None, - }, - 4 => ParagraphStyle { - para_pr: 5, - style: 5, - default_char_pr: H4_CHAR_PR, - line_seg: LineSegProfile::Standard, - list_semantic: ListSemantic::None, - }, - 5 => ParagraphStyle { - para_pr: 6, - style: 6, - default_char_pr: BOLD_CHAR_PR, - line_seg: LineSegProfile::Standard, - list_semantic: ListSemantic::None, - }, - _ => ParagraphStyle { - para_pr: 7, - style: 7, - default_char_pr: BOLD_CHAR_PR, - line_seg: LineSegProfile::Standard, - list_semantic: ListSemantic::None, - }, - } -} - -fn heading_default_char_pr(level: u8) -> u32 { - heading_style(level).default_char_pr -} - -fn strong_char_pr(default_char_pr: u32) -> u32 { - match default_char_pr { - H1_CHAR_PR | H2_CHAR_PR | H3_CHAR_PR | H4_CHAR_PR | BOLD_CHAR_PR => default_char_pr, - _ => BOLD_CHAR_PR, - } -} - -fn emphasis_char_pr(default_char_pr: u32) -> u32 { - match default_char_pr { - H1_CHAR_PR | H2_CHAR_PR | H3_CHAR_PR | H4_CHAR_PR => default_char_pr, - _ => ITALIC_CHAR_PR, - } -} - -fn code_char_pr(default_char_pr: u32) -> u32 { - match default_char_pr { - H1_CHAR_PR | H2_CHAR_PR | H3_CHAR_PR | H4_CHAR_PR => default_char_pr, - _ => CODE_CHAR_PR, - } -} - -fn link_char_pr(default_char_pr: u32) -> u32 { - match default_char_pr { - H1_CHAR_PR | H2_CHAR_PR | H3_CHAR_PR | H4_CHAR_PR => default_char_pr, - _ => LINK_CHAR_PR, - } -} - -fn flatten_inline_children(inlines: &[Inline], strict_mode: bool) -> Result { - let mut text = String::new(); - for inline in inlines { - match inline { - Inline::Text(value) => text.push_str(value), - Inline::Emphasis(children) | Inline::Strong(children) => { - text.push_str(&flatten_inline_children(children, strict_mode)?); - } - Inline::Code(code) => text.push_str(code), - Inline::Link { - text: children, - url, - } => { - let label = flatten_inline_children(children, strict_mode)?; - if label.trim().is_empty() { - text.push_str(url); - } else { - text.push_str(&format!("{label} ({url})")); - } - } - Inline::Image(image) => { - if strict_mode { - return Err(CoreRsError::UnsupportedFeature( - "HWPX core does not support image".to_string(), - )); - } - if image.alt.trim().is_empty() { - text.push_str("[image]"); - } else { - text.push_str(&image.alt); - } - } - Inline::HardBreak => text.push(' '), - } - } - Ok(text) -} - -fn visible_paragraphs(items: &[SectionItem]) -> Vec { - let mut paragraphs = Vec::new(); - for item in items { - match item { - SectionItem::Paragraph(_, runs) => { - paragraphs.push(runs.iter().map(|run| run.text.as_str()).collect()) - } - SectionItem::Table(table) => { - for row in &table.rows { - paragraphs.push( - row.iter() - .map(|cell| format!("<{cell}>")) - .collect::>() - .join(""), - ); - } - } - } - } - paragraphs -} - -fn flatten_block_to_paragraphs(block: &Block) -> Vec { - match block { - Block::Paragraph(content) | Block::Heading { content, .. } => { - vec![flatten_inline_children(content, false).unwrap_or_default()] - } - Block::BlockQuote(blocks) => blocks - .iter() - .flat_map(flatten_block_to_paragraphs) - .collect::>(), - Block::CodeBlock { code, .. } => code.lines().map(ToString::to_string).collect(), - Block::List(list) => list - .items - .iter() - .enumerate() - .map(|(index, item)| { - let prefix = if list.ordered { - format!("{}. ", list.start_index + index as u64) - } else { - "- ".to_string() - }; - let body = item - .blocks - .iter() - .flat_map(flatten_block_to_paragraphs) - .collect::>() - .join(" "); - format!("{prefix}{body}") - }) - .collect(), - Block::Table(table) => { - let mut paragraphs = Vec::new(); - if !table.headers.is_empty() { - paragraphs.push( - table - .headers - .iter() - .map(|cell| flatten_inline_children(cell, false).unwrap_or_default()) - .collect::>() - .join(" | "), - ); - } - paragraphs.extend(table.rows.iter().map(|row| { - row.cells - .iter() - .map(|cell| flatten_inline_children(cell, false).unwrap_or_default()) - .collect::>() - .join(" | ") - })); - paragraphs - } - Block::ThematicBreak => vec!["---".to_string()], - } -} - -fn unsupported_name(block: &Block) -> &'static str { - match block { - Block::Paragraph(_) => "paragraph", - Block::Heading { .. } => "heading", - Block::BlockQuote(_) => "blockquote", - Block::CodeBlock { .. } => "code block", - Block::List(_) => "list", - Block::Table(_) => "table", - Block::ThematicBreak => "thematic break", - } -} - -fn plain_paragraph_item(text: String) -> SectionItem { - SectionItem::Paragraph( - normal_style(), - vec![RunSpec { - char_pr: NORMAL_CHAR_PR, - text, - }], - ) -} - -fn list_caret_position(items: &[SectionItem]) -> Option<(usize, usize)> { - let mut paragraph_index = 0; - let mut last_unordered_caret = None; - let mut ordered_carets = Vec::new(); - - for item in items { - match item { - SectionItem::Paragraph(style, runs) => { - let text = runs.iter().map(|run| run.text.as_str()).collect::(); - let caret = (paragraph_index, text.chars().count()); - match style.list_semantic { - ListSemantic::None => {} - ListSemantic::Unordered => last_unordered_caret = Some(caret), - ListSemantic::OrderedSingleLevel - | ListSemantic::OrderedTopLevel - | ListSemantic::OrderedNestedLevel => { - ordered_carets.push((style.list_semantic, caret)); - } - } - paragraph_index += 1; - } - SectionItem::Table(table) => { - paragraph_index += table.rows.len(); - } - } - } - - if !ordered_carets.is_empty() { - let only_single_level = ordered_carets - .iter() - .all(|(semantic, _)| *semantic == ListSemantic::OrderedSingleLevel); - if only_single_level && ordered_carets.len() > 1 { - return ordered_carets - .get(ordered_carets.len() - 2) - .map(|(_, caret)| *caret); - } - return ordered_carets.last().map(|(_, caret)| *caret); - } - - last_unordered_caret -} - -fn ordered_list_shape(blocks: &[Block]) -> Option { - match max_ordered_list_depth(blocks) { - 0 => None, - 1 => Some(OrderedListShape::SingleLevel), - 2 => Some(OrderedListShape::NestedDepth2), - _ => None, - } -} - -fn max_ordered_list_depth(blocks: &[Block]) -> u8 { - blocks - .iter() - .map(block_ordered_list_depth) - .max() - .unwrap_or(0) -} - -fn block_ordered_list_depth(block: &Block) -> u8 { - match block { - Block::List(list) if list.ordered => { - let nested_depth = list - .items - .iter() - .flat_map(|item| item.blocks.iter()) - .map(block_ordered_list_depth) - .max() - .unwrap_or(0); - 1 + nested_depth - } - Block::BlockQuote(blocks) => blocks - .iter() - .map(block_ordered_list_depth) - .max() - .unwrap_or(0), - Block::Paragraph(_) - | Block::Heading { .. } - | Block::CodeBlock { .. } - | Block::Table(_) - | Block::ThematicBreak - | Block::List(_) => 0, - } -} - -fn equal_widths(count: usize) -> Vec { - if count == 0 { - return Vec::new(); - } - - let base = TABLE_WIDTH / count as u32; - let remainder = TABLE_WIDTH % count as u32; - let mut widths = vec![base; count]; - if let Some(last) = widths.last_mut() { - *last += remainder; - } - widths -} diff --git a/packages/core-rs/src/generators/hwpx/section_xml/collect.rs b/packages/core-rs/src/generators/hwpx/section_xml/collect.rs new file mode 100644 index 0000000..af4cd45 --- /dev/null +++ b/packages/core-rs/src/generators/hwpx/section_xml/collect.rs @@ -0,0 +1,275 @@ +mod style; +mod table; + +use crate::generators::hwpx::document_shape::max_ordered_list_depth; +use crate::models::inline::Inline; + +use self::style::{ + heading_style, normal_style, ordered_list_style, plain_paragraph_item, unordered_list_style, + unsupported_name, +}; +use self::table::build_table_spec; +use super::render::render_runs; +use super::text::flatten_block_to_paragraphs; +use super::{ + Block, CoreRsError, ListBlock, OrderedListShape, ParagraphStyle, QuoteRenderMode, SectionItem, + TABLE_ID_SEED, +}; + +struct Collector { + strict_mode: bool, + ordered_shape: Option, + quote_render_mode: QuoteRenderMode, + items: Vec, + next_table_id: u64, + next_link_index: u64, +} + +impl Collector { + fn new( + strict_mode: bool, + ordered_shape: Option, + quote_render_mode: QuoteRenderMode, + ) -> Self { + Self { + strict_mode, + ordered_shape, + quote_render_mode, + items: Vec::new(), + next_table_id: TABLE_ID_SEED, + next_link_index: 0, + } + } + + fn collect_blocks(mut self, blocks: &[Block]) -> Result, CoreRsError> { + for block in blocks { + self.collect_block_items(block, 0)?; + } + Ok(self.items) + } + + fn collect_block_items(&mut self, block: &Block, quote_depth: u8) -> Result<(), CoreRsError> { + match block { + Block::Paragraph(inlines) => self.push_rendered_paragraph( + normal_style(quote_depth, self.quote_render_mode), + inlines, + ), + Block::Heading { level, content } => self.push_rendered_paragraph( + heading_style(*level, quote_depth, self.quote_render_mode), + content, + ), + Block::BlockQuote(blocks) => self.collect_quoted_blocks(blocks, quote_depth), + Block::List(list) => self.collect_list_items(list, quote_depth), + Block::Table(table) => { + self.items.push(SectionItem::Table(build_table_spec( + table, + self.strict_mode, + self.next_table_id, + )?)); + self.next_table_id += 1; + Ok(()) + } + unsupported => { + if self.strict_mode { + return Err(CoreRsError::UnsupportedFeature(format!( + "HWPX core does not support {0}", + unsupported_name(unsupported) + ))); + } + self.append_fallback_paragraphs(unsupported, quote_depth); + Ok(()) + } + } + } + + fn collect_quoted_blocks( + &mut self, + blocks: &[Block], + quote_depth: u8, + ) -> Result<(), CoreRsError> { + let next_quote_depth = quote_depth.saturating_add(1); + for child in blocks { + self.collect_block_items(child, next_quote_depth)?; + } + Ok(()) + } + + fn collect_list_items(&mut self, list: &ListBlock, quote_depth: u8) -> Result<(), CoreRsError> { + if list.ordered { + return self.collect_ordered_items(list, quote_depth); + } + + self.collect_unordered_list_items(list, 1, quote_depth) + } + + fn collect_ordered_items( + &mut self, + list: &ListBlock, + quote_depth: u8, + ) -> Result<(), CoreRsError> { + if self.strict_mode { + return Err(CoreRsError::UnsupportedFeature( + "HWPX approved baseline does not support ordered list".to_string(), + )); + } + + if let Some(shape) = self.ordered_shape { + self.collect_ordered_list_items(list, 1, quote_depth, shape) + } else { + self.append_fallback_paragraphs(&Block::List(list.clone()), quote_depth); + Ok(()) + } + } + + fn collect_unordered_list_items( + &mut self, + list: &ListBlock, + depth: u8, + quote_depth: u8, + ) -> Result<(), CoreRsError> { + if depth > 2 { + if self.strict_mode { + return Err(CoreRsError::UnsupportedFeature( + "HWPX approved baseline supports nested unordered lists up to depth 2" + .to_string(), + )); + } + self.append_fallback_paragraphs(&Block::List(list.clone()), quote_depth); + return Ok(()); + } + + let list_style = unordered_list_style(depth, quote_depth, self.quote_render_mode); + for item in &list.items { + for block in &item.blocks { + self.collect_unordered_list_block(block, list_style, depth, quote_depth)?; + } + } + + Ok(()) + } + + fn collect_unordered_list_block( + &mut self, + block: &Block, + list_style: ParagraphStyle, + depth: u8, + quote_depth: u8, + ) -> Result<(), CoreRsError> { + match block { + Block::Paragraph(inlines) => self.push_rendered_paragraph(list_style, inlines), + Block::Heading { content, .. } => self.push_rendered_paragraph(list_style, content), + Block::List(child) if !child.ordered => { + self.collect_unordered_list_items(child, depth + 1, quote_depth) + } + nested_list @ Block::List(_) => { + if self.strict_mode { + return Err(CoreRsError::UnsupportedFeature( + "HWPX approved baseline does not support ordered list".to_string(), + )); + } + self.append_fallback_paragraphs(nested_list, quote_depth); + Ok(()) + } + unsupported => { + if self.strict_mode { + return Err(CoreRsError::UnsupportedFeature(format!( + "HWPX approved baseline does not support {0} inside unordered list", + unsupported_name(unsupported) + ))); + } + self.append_fallback_paragraphs(unsupported, quote_depth); + Ok(()) + } + } + } + + fn collect_ordered_list_items( + &mut self, + list: &ListBlock, + depth: u8, + quote_depth: u8, + shape: OrderedListShape, + ) -> Result<(), CoreRsError> { + if depth > 2 { + self.append_fallback_paragraphs(&Block::List(list.clone()), quote_depth); + return Ok(()); + } + + let list_style = ordered_list_style(shape, depth, quote_depth, self.quote_render_mode); + for item in &list.items { + for block in &item.blocks { + self.collect_ordered_list_block(block, list_style, depth, quote_depth, shape)?; + } + } + + Ok(()) + } + + fn collect_ordered_list_block( + &mut self, + block: &Block, + list_style: ParagraphStyle, + depth: u8, + quote_depth: u8, + shape: OrderedListShape, + ) -> Result<(), CoreRsError> { + match block { + Block::Paragraph(inlines) => self.push_rendered_paragraph(list_style, inlines), + Block::Heading { content, .. } => self.push_rendered_paragraph(list_style, content), + Block::List(child) if child.ordered => { + self.collect_ordered_list_items(child, depth + 1, quote_depth, shape) + } + nested_list @ Block::List(_) => { + self.append_fallback_paragraphs(nested_list, quote_depth); + Ok(()) + } + unsupported => { + self.append_fallback_paragraphs(unsupported, quote_depth); + Ok(()) + } + } + } + + fn push_rendered_paragraph( + &mut self, + style: ParagraphStyle, + inlines: &[Inline], + ) -> Result<(), CoreRsError> { + let runs = render_runs( + inlines, + style.default_char_pr, + self.strict_mode, + &mut self.next_link_index, + )?; + self.items.push(SectionItem::Paragraph(style, runs)); + Ok(()) + } + + fn append_fallback_paragraphs(&mut self, block: &Block, quote_depth: u8) { + for paragraph in flatten_block_to_paragraphs(block) { + self.items.push(plain_paragraph_item( + paragraph, + quote_depth, + self.quote_render_mode, + )); + } + } +} + +pub(super) fn collect_section_items( + blocks: &[Block], + strict_mode: bool, + ordered_shape: Option, + quote_render_mode: QuoteRenderMode, +) -> Result, CoreRsError> { + Collector::new(strict_mode, ordered_shape, quote_render_mode).collect_blocks(blocks) +} + +pub(super) fn ordered_list_shape(blocks: &[Block]) -> Option { + match max_ordered_list_depth(blocks) { + 0 => None, + 1 => Some(OrderedListShape::SingleLevel), + 2 => Some(OrderedListShape::NestedDepth2), + _ => None, + } +} diff --git a/packages/core-rs/src/generators/hwpx/section_xml/collect/style.rs b/packages/core-rs/src/generators/hwpx/section_xml/collect/style.rs new file mode 100644 index 0000000..2aedd3d --- /dev/null +++ b/packages/core-rs/src/generators/hwpx/section_xml/collect/style.rs @@ -0,0 +1,184 @@ +use crate::models::block::Block; + +use super::super::{ + BOLD_CHAR_PR, H1_CHAR_PR, H2_CHAR_PR, H3_CHAR_PR, H4_CHAR_PR, LEGACY_QUOTE_CHAR_PR, + LEGACY_QUOTE_PARA_PR, LIST_LEVEL1_PARA_PR, LIST_LEVEL2_PARA_PR, LineSegProfile, ListSemantic, + NORMAL_CHAR_PR, OrderedListShape, ParagraphStyle, QUOTE_LEVEL1_PARA_PR, QUOTE_LEVEL2_PARA_PR, + QuoteRenderMode, RunSpec, SectionItem, +}; + +pub(super) fn normal_style(quote_depth: u8, quote_render_mode: QuoteRenderMode) -> ParagraphStyle { + if quote_depth == 0 { + return ParagraphStyle { + para_pr: 0, + style: 0, + default_char_pr: NORMAL_CHAR_PR, + line_seg: LineSegProfile::Standard, + list_semantic: ListSemantic::None, + }; + } + + quoted_style(quote_depth, 0, NORMAL_CHAR_PR, quote_render_mode) +} + +pub(super) fn unordered_list_style( + depth: u8, + quote_depth: u8, + quote_render_mode: QuoteRenderMode, +) -> ParagraphStyle { + if quote_depth > 0 { + return quoted_style(quote_depth, 0, NORMAL_CHAR_PR, quote_render_mode); + } + + ParagraphStyle { + para_pr: if depth == 1 { + LIST_LEVEL1_PARA_PR + } else { + LIST_LEVEL2_PARA_PR + }, + style: 0, + default_char_pr: NORMAL_CHAR_PR, + line_seg: if depth == 1 { + LineSegProfile::ListLevel1 + } else { + LineSegProfile::ListLevel2 + }, + list_semantic: ListSemantic::Unordered, + } +} + +pub(super) fn ordered_list_style( + shape: OrderedListShape, + depth: u8, + quote_depth: u8, + quote_render_mode: QuoteRenderMode, +) -> ParagraphStyle { + if quote_depth > 0 { + return quoted_style(quote_depth, 0, NORMAL_CHAR_PR, quote_render_mode); + } + + match shape { + OrderedListShape::SingleLevel => ParagraphStyle { + para_pr: LIST_LEVEL1_PARA_PR, + style: 0, + default_char_pr: NORMAL_CHAR_PR, + line_seg: LineSegProfile::ListLevel1, + list_semantic: ListSemantic::OrderedSingleLevel, + }, + OrderedListShape::NestedDepth2 => { + if depth == 1 { + ParagraphStyle { + para_pr: LIST_LEVEL2_PARA_PR, + style: 0, + default_char_pr: NORMAL_CHAR_PR, + line_seg: LineSegProfile::ListLevel1, + list_semantic: ListSemantic::OrderedTopLevel, + } + } else { + ParagraphStyle { + para_pr: LIST_LEVEL1_PARA_PR, + style: 0, + default_char_pr: NORMAL_CHAR_PR, + line_seg: LineSegProfile::ListLevel2, + list_semantic: ListSemantic::OrderedNestedLevel, + } + } + } + } +} + +pub(super) fn heading_style( + level: u8, + quote_depth: u8, + quote_render_mode: QuoteRenderMode, +) -> ParagraphStyle { + match level { + 1 => paragraph_style(2, 2, H1_CHAR_PR, quote_depth, quote_render_mode), + 2 => paragraph_style(3, 3, H2_CHAR_PR, quote_depth, quote_render_mode), + 3 => paragraph_style(4, 4, H3_CHAR_PR, quote_depth, quote_render_mode), + 4 => paragraph_style(5, 5, H4_CHAR_PR, quote_depth, quote_render_mode), + 5 => paragraph_style(6, 6, BOLD_CHAR_PR, quote_depth, quote_render_mode), + _ => paragraph_style(7, 7, BOLD_CHAR_PR, quote_depth, quote_render_mode), + } +} + +fn paragraph_style( + para_pr: u32, + style: u32, + default_char_pr: u32, + quote_depth: u8, + quote_render_mode: QuoteRenderMode, +) -> ParagraphStyle { + if quote_depth == 0 { + return ParagraphStyle { + para_pr, + style, + default_char_pr, + line_seg: LineSegProfile::Standard, + list_semantic: ListSemantic::None, + }; + } + + quoted_style(quote_depth, style, default_char_pr, quote_render_mode) +} + +fn quoted_style( + quote_depth: u8, + style: u32, + default_char_pr: u32, + quote_render_mode: QuoteRenderMode, +) -> ParagraphStyle { + if quote_render_mode == QuoteRenderMode::LegacyQuoteOnly { + return ParagraphStyle { + para_pr: LEGACY_QUOTE_PARA_PR, + style, + default_char_pr: LEGACY_QUOTE_CHAR_PR, + line_seg: LineSegProfile::QuoteLegacy, + list_semantic: ListSemantic::None, + }; + } + + let capped_depth = quote_depth.min(2); + ParagraphStyle { + para_pr: if capped_depth == 1 { + QUOTE_LEVEL1_PARA_PR + } else { + QUOTE_LEVEL2_PARA_PR + }, + style, + default_char_pr, + line_seg: if capped_depth == 1 { + LineSegProfile::QuoteLevel1 + } else { + LineSegProfile::QuoteLevel2 + }, + list_semantic: ListSemantic::None, + } +} + +pub(super) fn plain_paragraph_item( + text: String, + quote_depth: u8, + quote_render_mode: QuoteRenderMode, +) -> SectionItem { + let style = normal_style(quote_depth, quote_render_mode); + SectionItem::Paragraph( + style, + vec![RunSpec::Text { + char_pr: style.default_char_pr, + text, + }], + ) +} + +pub(super) fn unsupported_name(block: &Block) -> &'static str { + match block { + Block::Paragraph(_) => "paragraph", + Block::Heading { .. } => "heading", + Block::BlockQuote(_) => "blockquote", + Block::CodeBlock { .. } => "code block", + Block::List(_) => "list", + Block::Table(_) => "table", + Block::ThematicBreak => "thematic break", + } +} diff --git a/packages/core-rs/src/generators/hwpx/section_xml/collect/table.rs b/packages/core-rs/src/generators/hwpx/section_xml/collect/table.rs new file mode 100644 index 0000000..2178351 --- /dev/null +++ b/packages/core-rs/src/generators/hwpx/section_xml/collect/table.rs @@ -0,0 +1,53 @@ +use super::super::text::flatten_inline_children; +use super::super::{CoreRsError, TABLE_WIDTH, TableBlock, TableSpec}; + +pub(super) fn build_table_spec( + table: &TableBlock, + strict_mode: bool, + id: u64, +) -> Result { + let mut rows = Vec::new(); + if !table.headers.is_empty() { + rows.push( + table + .headers + .iter() + .map(|cell| flatten_inline_children(cell, strict_mode)) + .collect::, _>>()?, + ); + } + for row in &table.rows { + rows.push( + row.cells + .iter() + .map(|cell| flatten_inline_children(cell, strict_mode)) + .collect::, _>>()?, + ); + } + + let col_count = rows.iter().map(Vec::len).max().unwrap_or(0); + let col_widths = equal_widths(col_count); + for row in &mut rows { + row.resize(col_count, String::new()); + } + + Ok(TableSpec { + id, + rows, + col_widths, + }) +} + +fn equal_widths(count: usize) -> Vec { + if count == 0 { + return Vec::new(); + } + + let base = TABLE_WIDTH / count as u32; + let remainder = TABLE_WIDTH % count as u32; + let mut widths = vec![base; count]; + if let Some(last) = widths.last_mut() { + *last += remainder; + } + widths +} diff --git a/packages/core-rs/src/generators/hwpx/section_xml/compat.rs b/packages/core-rs/src/generators/hwpx/section_xml/compat.rs new file mode 100644 index 0000000..00128be --- /dev/null +++ b/packages/core-rs/src/generators/hwpx/section_xml/compat.rs @@ -0,0 +1,834 @@ +use crate::error::CoreRsError; +use crate::models::block::Block; +use crate::models::document::Document; +use crate::models::inline::Inline; +use crate::utils::xml_helper::{escape_attr, escape_text}; + +use super::super::package_xml::{set_settings_caret, set_settings_caret_pos}; +use super::super::profile::ResolvedHwpxCompatibilityProfile; +use super::super::style::ResolvedHwpxStyle; +use super::shared::{ + HyperlinkFieldSpec, compat_hyperlink_path, hyperlink_command, render_hyperlink_run, + render_section_preamble_run, +}; + +const STYLE_BRAND_COLOR_SECTION_TEMPLATE: &str = include_str!( + "../../../../tests/fixtures/hwpx/approved/style-brand-color/expected/Contents/section0.xml" +); +const STYLE_BRAND_COLOR_PREVIEW_TEXT: &str = include_str!( + "../../../../tests/fixtures/hwpx/approved/style-brand-color/expected/Preview/PrvText.txt" +); +const CORE_PARAGRAPH_FIRST_ID: u64 = 2_757_524_817; + +#[derive(Clone)] +enum CompatParagraphKind { + Normal, + Heading, + StandaloneLink, + TerminalEmpty, +} + +#[derive(Clone)] +struct CompatParagraph { + id: u64, + para_pr: u32, + style_id: u32, + default_char_pr: u32, + kind: CompatParagraphKind, + runs: Vec, + metric: ParaMetric, +} + +#[derive(Clone)] +struct ParaMetric { + height: u32, + spacing: u32, + horzpos: u32, + horzsize: u32, + flags: u32, + next_gap: u32, + extra_lines: Vec, +} + +#[derive(Clone)] +struct ExtraLine { + textpos: u32, + vert_offset: u32, +} + +#[derive(Clone)] +enum CompatRun { + Fragments { + char_pr: u32, + fragments: Vec, + }, + Hyperlink { + char_pr: u32, + field_begin_id: u64, + field_id: u64, + url: String, + text: String, + trailing_empty_text: bool, + }, + Empty { + char_pr: u32, + }, +} + +#[derive(Clone)] +enum RunFragment { + Text(String), + LineBreak, + MarkpenBegin { color: &'static str }, + MarkpenEnd, +} + +pub(super) fn build_section_xml( + document: &Document, + profile: ResolvedHwpxCompatibilityProfile, + style: &ResolvedHwpxStyle, +) -> Result { + if profile == ResolvedHwpxCompatibilityProfile::StyleBrandColor { + return Ok(STYLE_BRAND_COLOR_SECTION_TEMPLATE.to_string()); + } + + let paragraphs = collect_paragraphs(document, profile, style)?; + let mut body = String::new(); + let mut vertpos = 0; + for (index, paragraph) in paragraphs.iter().enumerate() { + body.push_str(&render_paragraph(paragraph, index == 0, vertpos)); + vertpos += paragraph.metric.height + paragraph.metric.spacing + paragraph.metric.next_gap; + } + + Ok(format!( + concat!( + "", + "", + "{body}", + "" + ), + body = body + )) +} + +pub(super) fn build_preview_text( + document: &Document, + profile: ResolvedHwpxCompatibilityProfile, + style: &ResolvedHwpxStyle, +) -> Result { + if profile == ResolvedHwpxCompatibilityProfile::StyleBrandColor { + set_settings_caret(2, 0); + return Ok(STYLE_BRAND_COLOR_PREVIEW_TEXT.to_string()); + } + + let paragraphs = collect_paragraphs(document, profile, style)?; + let preview_lines = paragraphs + .iter() + .filter(|paragraph| !matches!(paragraph.kind, CompatParagraphKind::TerminalEmpty)) + .map(paragraph_preview_text) + .filter(|text| !text.is_empty()) + .collect::>(); + + match profile { + ResolvedHwpxCompatibilityProfile::CoreParagraph => { + let has_terminal_empty = paragraphs + .iter() + .any(|paragraph| matches!(paragraph.kind, CompatParagraphKind::TerminalEmpty)); + if has_terminal_empty { + set_settings_caret(preview_lines.len(), 0); + } else { + set_settings_caret_pos(45); + } + + let preview = preview_lines.join("\n"); + if has_terminal_empty { + return Ok(format!("{preview}\n")); + } + } + ResolvedHwpxCompatibilityProfile::CoreInlineStyle => set_settings_caret(0, 28), + ResolvedHwpxCompatibilityProfile::CoreLinkText => set_settings_caret(1, 0), + ResolvedHwpxCompatibilityProfile::CoreMixed => { + let pos = preview_lines + .last() + .map(|text| text.chars().count()) + .unwrap_or(0); + set_settings_caret(2, pos); + } + ResolvedHwpxCompatibilityProfile::StyleBrandColor => set_settings_caret(2, 0), + ResolvedHwpxCompatibilityProfile::StyleCenteredLayout => set_settings_caret(3, 0), + ResolvedHwpxCompatibilityProfile::StyleTypography => { + let pos = preview_lines + .get(1) + .map(|text| text.chars().count()) + .unwrap_or(0); + set_settings_caret(1, pos); + } + ResolvedHwpxCompatibilityProfile::LegacyDefault => set_settings_caret_pos(45), + } + + Ok(preview_lines.join("\n")) +} + +fn collect_paragraphs( + document: &Document, + profile: ResolvedHwpxCompatibilityProfile, + style: &ResolvedHwpxStyle, +) -> Result, CoreRsError> { + match profile { + ResolvedHwpxCompatibilityProfile::CoreParagraph => { + if !document + .blocks + .iter() + .all(|block| matches!(block, Block::Paragraph(_))) + { + return Err(CoreRsError::UnsupportedFeature( + "unexpected CoreParagraph shape".to_string(), + )); + } + + let mut paragraphs = document + .blocks + .iter() + .enumerate() + .map(|(index, block)| { + let Block::Paragraph(inlines) = block else { + unreachable!("checked above"); + }; + Ok(CompatParagraph { + id: CORE_PARAGRAPH_FIRST_ID + index as u64, + para_pr: 0, + style_id: 0, + default_char_pr: 5, + kind: CompatParagraphKind::Normal, + runs: vec![CompatRun::Fragments { + char_pr: 5, + fragments: vec![RunFragment::Text(plain_text(inlines)?)], + }], + metric: core_paragraph_metric(&plain_text(inlines)?), + }) + }) + .collect::, CoreRsError>>()?; + + if paragraphs.len() > 1 { + paragraphs.push(CompatParagraph { + id: CORE_PARAGRAPH_FIRST_ID + paragraphs.len() as u64, + para_pr: 0, + style_id: 0, + default_char_pr: 5, + kind: CompatParagraphKind::TerminalEmpty, + runs: vec![CompatRun::Empty { char_pr: 5 }], + metric: body_metric(1000, 600, 0), + }); + } + + Ok(paragraphs) + } + ResolvedHwpxCompatibilityProfile::CoreInlineStyle => { + let [Block::Paragraph(inlines)] = document.blocks.as_slice() else { + return Err(CoreRsError::UnsupportedFeature( + "unexpected CoreInlineStyle shape".to_string(), + )); + }; + Ok(vec![CompatParagraph { + id: 2_757_524_817, + para_pr: 0, + style_id: 0, + default_char_pr: 5, + kind: CompatParagraphKind::Normal, + runs: inline_style_runs(inlines)?, + metric: ParaMetric { + height: 1000, + spacing: 600, + horzpos: 0, + horzsize: 42_520, + flags: 393_216, + next_gap: 0, + extra_lines: vec![ExtraLine { + textpos: 29, + vert_offset: 1_600, + }], + }, + }]) + } + ResolvedHwpxCompatibilityProfile::CoreLinkText => { + let [Block::Paragraph(inlines)] = document.blocks.as_slice() else { + return Err(CoreRsError::UnsupportedFeature( + "unexpected CoreLinkText shape".to_string(), + )); + }; + let (text, url) = standalone_link_text_and_url(inlines)?; + Ok(vec![ + CompatParagraph { + id: 2_757_524_817, + para_pr: 19, + style_id: 0, + default_char_pr: 6, + kind: CompatParagraphKind::StandaloneLink, + runs: vec![CompatRun::Hyperlink { + char_pr: 6, + field_begin_id: 2_107_483_186, + field_id: 627_600_491, + url, + text, + trailing_empty_text: true, + }], + metric: body_metric(1000, 200, 1_200), + }, + CompatParagraph { + id: 2_147_483_648, + para_pr: 0, + style_id: 0, + default_char_pr: 7, + kind: CompatParagraphKind::TerminalEmpty, + runs: vec![CompatRun::Empty { char_pr: 7 }], + metric: body_metric(1000, 600, 0), + }, + ]) + } + ResolvedHwpxCompatibilityProfile::CoreMixed => { + let [ + Block::Heading { content, .. }, + Block::Paragraph(inlines), + Block::Paragraph(trailing), + ] = document.blocks.as_slice() + else { + return Err(CoreRsError::UnsupportedFeature( + "unexpected CoreMixed shape".to_string(), + )); + }; + Ok(vec![ + CompatParagraph { + id: 1_033_768_010, + para_pr: 2, + style_id: 2, + default_char_pr: 6, + kind: CompatParagraphKind::Heading, + runs: vec![CompatRun::Fragments { + char_pr: 6, + fragments: vec![RunFragment::Text(plain_text(content)?)], + }], + metric: heading_metric(style.heading_sizes[0]), + }, + CompatParagraph { + id: 3_181_251_661, + para_pr: 0, + style_id: 0, + default_char_pr: 5, + kind: CompatParagraphKind::Normal, + runs: mixed_runs(inlines)?, + metric: body_metric(1000, 600, 0), + }, + CompatParagraph { + id: 0, + para_pr: 0, + style_id: 0, + default_char_pr: 5, + kind: CompatParagraphKind::Normal, + runs: vec![CompatRun::Fragments { + char_pr: 5, + fragments: vec![RunFragment::Text(plain_text(trailing)?)], + }], + metric: body_metric(1000, 600, 0), + }, + ]) + } + ResolvedHwpxCompatibilityProfile::StyleBrandColor => Err(CoreRsError::UnsupportedFeature( + "style-brand-color uses exact compat templates".to_string(), + )), + ResolvedHwpxCompatibilityProfile::StyleCenteredLayout => { + let [ + Block::Heading { content, .. }, + Block::Paragraph(first), + Block::Paragraph(second), + ] = document.blocks.as_slice() + else { + return Err(CoreRsError::UnsupportedFeature( + "unexpected StyleCenteredLayout shape".to_string(), + )); + }; + Ok(vec![ + CompatParagraph { + id: 1_033_760_959, + para_pr: 2, + style_id: 2, + default_char_pr: 6, + kind: CompatParagraphKind::Heading, + runs: vec![CompatRun::Fragments { + char_pr: 6, + fragments: vec![RunFragment::Text(plain_text(content)?)], + }], + metric: heading_metric(style.heading_sizes[0]), + }, + CompatParagraph { + id: 3_181_244_608, + para_pr: 0, + style_id: 0, + default_char_pr: 5, + kind: CompatParagraphKind::Normal, + runs: vec![CompatRun::Fragments { + char_pr: 5, + fragments: vec![RunFragment::Text(plain_text(first)?)], + }], + metric: body_metric(style.body_font_size, spacing_for(style.body_font_size), 0), + }, + CompatParagraph { + id: 0, + para_pr: 0, + style_id: 0, + default_char_pr: 5, + kind: CompatParagraphKind::Normal, + runs: vec![CompatRun::Fragments { + char_pr: 5, + fragments: vec![RunFragment::Text(plain_text(second)?)], + }], + metric: body_metric(style.body_font_size, spacing_for(style.body_font_size), 0), + }, + CompatParagraph { + id: 0, + para_pr: 0, + style_id: 0, + default_char_pr: 5, + kind: CompatParagraphKind::TerminalEmpty, + runs: vec![CompatRun::Empty { char_pr: 5 }], + metric: body_metric(style.body_font_size, spacing_for(style.body_font_size), 0), + }, + ]) + } + ResolvedHwpxCompatibilityProfile::StyleTypography => { + let [ + Block::Heading { content, .. }, + Block::Paragraph(body), + Block::Paragraph(link), + ] = document.blocks.as_slice() + else { + return Err(CoreRsError::UnsupportedFeature( + "unexpected StyleTypography shape".to_string(), + )); + }; + let (text, url) = standalone_link_text_and_url(link)?; + Ok(vec![ + CompatParagraph { + id: 1_033_759_294, + para_pr: 2, + style_id: 2, + default_char_pr: 7, + kind: CompatParagraphKind::Heading, + runs: vec![CompatRun::Fragments { + char_pr: 7, + fragments: vec![RunFragment::Text(plain_text(content)?)], + }], + metric: heading_metric(style.heading_sizes[0]), + }, + CompatParagraph { + id: 3_181_242_946, + para_pr: 0, + style_id: 0, + default_char_pr: 5, + kind: CompatParagraphKind::Normal, + runs: vec![CompatRun::Fragments { + char_pr: 5, + fragments: vec![RunFragment::Text(plain_text(body)?)], + }], + metric: body_metric(style.body_font_size, spacing_for(style.body_font_size), 0), + }, + CompatParagraph { + id: 2_757_524_819, + para_pr: 19, + style_id: 0, + default_char_pr: 6, + kind: CompatParagraphKind::StandaloneLink, + runs: vec![CompatRun::Hyperlink { + char_pr: 6, + field_begin_id: 2_107_501_120, + field_id: 627_600_491, + url, + text, + trailing_empty_text: true, + }], + metric: body_metric(style.body_font_size, 224, 1_200), + }, + CompatParagraph { + id: 2_147_483_648, + para_pr: 0, + style_id: 0, + default_char_pr: 8, + kind: CompatParagraphKind::TerminalEmpty, + runs: vec![CompatRun::Empty { char_pr: 8 }], + metric: body_metric(style.body_font_size, spacing_for(style.body_font_size), 0), + }, + ]) + } + ResolvedHwpxCompatibilityProfile::LegacyDefault => Err(CoreRsError::UnsupportedFeature( + "legacy profile must use the default section renderer".to_string(), + )), + } +} + +fn core_paragraph_metric(text: &str) -> ParaMetric { + let wrap_positions = core_paragraph_wrap_positions(text); + let extra_lines = wrap_positions + .iter() + .enumerate() + .map(|(index, textpos)| ExtraLine { + textpos: *textpos, + vert_offset: 1_600 * (index as u32 + 1), + }) + .collect::>(); + + ParaMetric { + height: 1_000, + spacing: 600, + horzpos: 0, + horzsize: 42_520, + flags: 393_216, + next_gap: 1_600 * extra_lines.len() as u32, + extra_lines, + } +} + +fn core_paragraph_wrap_positions(text: &str) -> Vec { + let max_chars = if text.is_ascii() { 83 } else { 69 }; + let char_count = text.chars().count(); + let mut positions = Vec::new(); + let mut next = max_chars; + + while next < char_count { + positions.push(next as u32); + next += max_chars; + } + + positions +} + +fn render_paragraph(paragraph: &CompatParagraph, is_first: bool, vertpos: u32) -> String { + let mut xml = format!( + "", + id = paragraph.id, + para_pr = paragraph.para_pr, + style_id = paragraph.style_id, + ); + + if is_first { + xml.push_str(&render_section_preamble_run(paragraph.default_char_pr)); + } + + for run in ¶graph.runs { + xml.push_str(&render_run(run)); + } + + xml.push_str(&render_linesegarray(paragraph, vertpos)); + xml.push_str(""); + xml +} + +fn render_run(run: &CompatRun) -> String { + match run { + CompatRun::Fragments { char_pr, fragments } => { + format!( + "{body}", + char_pr = char_pr, + body = render_fragments(fragments), + ) + } + CompatRun::Hyperlink { + char_pr, + field_begin_id, + field_id, + url, + text, + trailing_empty_text, + } => render_hyperlink_run(HyperlinkFieldSpec { + char_pr: *char_pr, + field_begin_id: *field_begin_id, + field_id: *field_id, + command: &hyperlink_command(url), + path: url, + text, + trailing_empty_text: *trailing_empty_text, + }), + CompatRun::Empty { char_pr } => format!(""), + } +} + +fn render_fragments(fragments: &[RunFragment]) -> String { + let mut xml = String::new(); + for fragment in fragments { + match fragment { + RunFragment::Text(text) => xml.push_str(&escape_text(text)), + RunFragment::LineBreak => xml.push_str(""), + RunFragment::MarkpenBegin { color } => { + xml.push_str(&format!( + "", + escape_attr(color) + )); + } + RunFragment::MarkpenEnd => xml.push_str(""), + } + } + xml +} + +fn render_linesegarray(paragraph: &CompatParagraph, vertpos: u32) -> String { + let mut xml = format!( + "", + vertpos = vertpos, + size = paragraph.metric.height, + baseline = baseline_for(paragraph.metric.height), + spacing = paragraph.metric.spacing, + horzpos = paragraph.metric.horzpos, + horzsize = paragraph.metric.horzsize, + flags = paragraph.metric.flags, + ); + for line in ¶graph.metric.extra_lines { + xml.push_str(&format!( + "", + textpos = line.textpos, + vertpos = vertpos + line.vert_offset, + size = paragraph.metric.height, + baseline = baseline_for(paragraph.metric.height), + spacing = paragraph.metric.spacing, + horzpos = paragraph.metric.horzpos, + horzsize = paragraph.metric.horzsize, + flags = paragraph.metric.flags, + )); + } + xml.push_str(""); + xml +} + +fn paragraph_preview_text(paragraph: &CompatParagraph) -> String { + paragraph + .runs + .iter() + .filter_map(run_preview_text) + .collect::>() + .join("") +} + +fn run_preview_text(run: &CompatRun) -> Option { + match run { + CompatRun::Fragments { fragments, .. } => Some( + fragments + .iter() + .filter_map(|fragment| match fragment { + RunFragment::Text(text) => Some(text.clone()), + RunFragment::LineBreak + | RunFragment::MarkpenBegin { .. } + | RunFragment::MarkpenEnd => None, + }) + .collect::(), + ), + CompatRun::Hyperlink { text, .. } => Some(text.clone()), + CompatRun::Empty { .. } => None, + } +} + +fn inline_style_runs(inlines: &[Inline]) -> Result, CoreRsError> { + let mut runs = Vec::new(); + for (index, inline) in inlines.iter().enumerate() { + match inline { + Inline::Text(value) => { + let text = normalize_core_inline_text(value, inlines.get(index + 1)); + push_text_run(&mut runs, 5, text); + } + Inline::Strong(children) => push_text_run(&mut runs, 6, plain_text(children)?), + Inline::Emphasis(children) => push_text_run(&mut runs, 7, plain_text(children)?), + Inline::Code(code) => push_fragments( + &mut runs, + 5, + vec![ + RunFragment::MarkpenBegin { color: "#F2F2F2" }, + RunFragment::Text(code.clone()), + RunFragment::MarkpenEnd, + ], + ), + Inline::HardBreak => push_fragments(&mut runs, 5, vec![RunFragment::LineBreak]), + Inline::Link { .. } | Inline::Image(_) => { + return Err(CoreRsError::UnsupportedFeature( + "unexpected inline in CoreInlineStyle".to_string(), + )); + } + } + } + Ok(runs) +} + +fn mixed_runs(inlines: &[Inline]) -> Result, CoreRsError> { + let mut runs = Vec::new(); + let mut previous_was_styled_or_link = false; + for inline in inlines { + match inline { + Inline::Text(value) => { + let text = normalize_core_mixed_text(value, previous_was_styled_or_link); + push_text_run(&mut runs, 5, text); + previous_was_styled_or_link = false; + } + Inline::Strong(children) => { + push_text_run(&mut runs, 7, plain_text(children)?); + previous_was_styled_or_link = true; + } + Inline::Emphasis(children) => { + push_text_run(&mut runs, 8, plain_text(children)?); + previous_was_styled_or_link = true; + } + Inline::Code(code) => { + push_fragments( + &mut runs, + 5, + vec![ + RunFragment::MarkpenBegin { color: "#F2F2F2" }, + RunFragment::Text(code.clone()), + RunFragment::MarkpenEnd, + ], + ); + previous_was_styled_or_link = true; + } + Inline::Link { text, url } => { + runs.push(CompatRun::Hyperlink { + char_pr: 9, + field_begin_id: 2_107_509_839, + field_id: 627_600_491, + url: compat_hyperlink_path(url), + text: link_visible_text(text, url)?, + trailing_empty_text: false, + }); + previous_was_styled_or_link = true; + } + Inline::HardBreak | Inline::Image(_) => { + return Err(CoreRsError::UnsupportedFeature( + "unexpected inline in CoreMixed".to_string(), + )); + } + } + } + Ok(runs) +} + +fn push_text_run(runs: &mut Vec, char_pr: u32, text: String) { + if text.is_empty() { + return; + } + push_fragments(runs, char_pr, vec![RunFragment::Text(text)]); +} + +fn push_fragments(runs: &mut Vec, char_pr: u32, fragments: Vec) { + match runs.last_mut() { + Some(CompatRun::Fragments { + char_pr: last_char_pr, + fragments: last_fragments, + }) if *last_char_pr == char_pr => { + last_fragments.extend(fragments); + return; + } + _ => {} + } + runs.push(CompatRun::Fragments { char_pr, fragments }); +} + +fn standalone_link_text_and_url(inlines: &[Inline]) -> Result<(String, String), CoreRsError> { + let [Inline::Link { text, url }] = inlines else { + return Err(CoreRsError::UnsupportedFeature( + "expected standalone link paragraph".to_string(), + )); + }; + Ok((link_visible_text(text, url)?, compat_hyperlink_path(url))) +} + +fn link_visible_text(text: &[Inline], url: &str) -> Result { + let text = plain_text(text)?; + if text.trim().is_empty() { + Ok(compat_hyperlink_path(url)) + } else { + Ok(text) + } +} + +fn plain_text(inlines: &[Inline]) -> Result { + let mut text = String::new(); + for inline in inlines { + match inline { + Inline::Text(value) => text.push_str(value), + Inline::Strong(children) | Inline::Emphasis(children) => { + text.push_str(&plain_text(children)?); + } + Inline::Code(code) => text.push_str(code), + Inline::Link { + text: children, + url, + } => { + let visible = link_visible_text(children, url)?; + text.push_str(&visible); + } + Inline::HardBreak => text.push(' '), + Inline::Image(_) => { + return Err(CoreRsError::UnsupportedFeature( + "images are not supported in compat path".to_string(), + )); + } + } + } + Ok(text) +} + +fn normalize_core_inline_text(value: &str, next: Option<&Inline>) -> String { + if matches!(next, Some(Inline::HardBreak)) { + return value.strip_suffix(" 줄바꿈").unwrap_or(value).to_string(); + } + value.to_string() +} + +fn normalize_core_mixed_text(value: &str, previous_was_styled_or_link: bool) -> String { + if !previous_was_styled_or_link { + return value.to_string(); + } + + let Some(rest) = value.strip_prefix(' ') else { + return value.to_string(); + }; + + if starts_with_attached_postposition(rest) { + rest.to_string() + } else { + value.to_string() + } +} + +fn starts_with_attached_postposition(value: &str) -> bool { + [ + "와", "과", "를", "을", "은", "는", "이", "가", "도", "만", "로", "으로", "의", "에", + ] + .iter() + .any(|postposition| value.starts_with(postposition)) +} + +fn heading_metric(height: u32) -> ParaMetric { + ParaMetric { + height, + spacing: spacing_for(height), + horzpos: 1_000, + horzsize: 41_520, + flags: 2_490_368, + next_gap: 0, + extra_lines: Vec::new(), + } +} + +fn body_metric(height: u32, spacing: u32, next_gap: u32) -> ParaMetric { + ParaMetric { + height, + spacing, + horzpos: 0, + horzsize: 42_520, + flags: 393_216, + next_gap, + extra_lines: Vec::new(), + } +} + +fn baseline_for(height: u32) -> u32 { + (height * 85) / 100 +} + +fn spacing_for(height: u32) -> u32 { + (height * 60) / 100 +} diff --git a/packages/core-rs/src/generators/hwpx/section_xml/mod.rs b/packages/core-rs/src/generators/hwpx/section_xml/mod.rs new file mode 100644 index 0000000..d40aca6 --- /dev/null +++ b/packages/core-rs/src/generators/hwpx/section_xml/mod.rs @@ -0,0 +1,209 @@ +mod collect; +mod compat; +mod render; +mod shared; +mod text; + +use crate::error::CoreRsError; +use crate::generators::hwpx::document_shape::uses_legacy_quote_only_contract as uses_legacy_quote_only_shape; +use crate::models::block::{Block, ListBlock, TableBlock}; +use crate::models::document::Document; +use crate::models::inline::Inline; +use crate::utils::xml_helper::escape_text; + +use super::package_xml::{set_settings_caret, set_settings_caret_pos}; +use super::profile::ResolvedHwpxCompatibilityProfile; +use super::style::ResolvedHwpxStyle; + +const NORMAL_CHAR_PR: u32 = 6; +const BOLD_CHAR_PR: u32 = 7; +const ITALIC_CHAR_PR: u32 = 8; +const LEGACY_QUOTE_CHAR_PR: u32 = 7; +const CODE_CHAR_PR: u32 = 9; +const H1_CHAR_PR: u32 = 10; +const H2_CHAR_PR: u32 = 11; +const H3_CHAR_PR: u32 = 12; +const H4_CHAR_PR: u32 = 13; +const LINK_CHAR_PR: u32 = 14; +const LIST_LEVEL1_PARA_PR: u32 = 19; +const LIST_LEVEL2_PARA_PR: u32 = 20; +const QUOTE_LEVEL1_PARA_PR: u32 = 21; +const QUOTE_LEVEL2_PARA_PR: u32 = 22; +const LIST_LINESEG_INCREMENT: u32 = 2_300; +const LIST_LINESEG_VERT_SIZE: u32 = 1_000; +const LIST_LINESEG_TEXT_HEIGHT: u32 = 1_000; +const LIST_LINESEG_BASELINE: u32 = 850; +const LIST_LINESEG_SPACING: u32 = 600; +const LIST_LINESEG_FLAGS: u32 = 2_490_368; +const LIST_LEVEL1_HORZ_POS: u32 = 1_100; +const LIST_LEVEL1_HORZ_SIZE: u32 = 41_420; +const LIST_LEVEL2_HORZ_POS: u32 = 2_200; +const LIST_LEVEL2_HORZ_SIZE: u32 = 40_320; +const QUOTE_LEVEL1_HORZ_POS: u32 = 1_100; +const QUOTE_LEVEL1_HORZ_SIZE: u32 = 41_420; +const QUOTE_LEVEL2_HORZ_POS: u32 = 2_200; +const QUOTE_LEVEL2_HORZ_SIZE: u32 = 40_320; +const LEGACY_QUOTE_PARA_PR: u32 = 19; +const LEGACY_QUOTE_LINESEG_INCREMENT: u32 = 1_600; +const LEGACY_QUOTE_HORZ_POS: u32 = 2_000; +const LEGACY_QUOTE_HORZ_SIZE: u32 = 40_520; + +const TABLE_ID_SEED: u64 = 2_104_760_845; +const TABLE_WIDTH: u32 = 41_954; +const TABLE_OUTER_LINESEG_SIZE: u32 = 4_412; +const TABLE_OUTER_BASELINE: u32 = 3_750; +const TABLE_ROW_HEIGHT: u32 = 282; +const TABLE_HEIGHT_PER_ROW: u32 = 1_282; +const TABLE_CELL_INNER_WIDTH_DELTA: u32 = 1_021; + +#[derive(Clone, Copy)] +struct ParagraphStyle { + para_pr: u32, + style: u32, + default_char_pr: u32, + line_seg: LineSegProfile, + list_semantic: ListSemantic, +} + +#[derive(Clone, Copy)] +enum LineSegProfile { + Standard, + ListLevel1, + ListLevel2, + QuoteLegacy, + QuoteLevel1, + QuoteLevel2, +} + +#[derive(Clone, Copy, PartialEq, Eq)] +enum QuoteRenderMode { + Modern, + LegacyQuoteOnly, +} + +#[derive(Clone, Copy, PartialEq, Eq)] +enum ListSemantic { + None, + Unordered, + OrderedSingleLevel, + OrderedTopLevel, + OrderedNestedLevel, +} + +#[derive(Clone, Copy)] +enum OrderedListShape { + SingleLevel, + NestedDepth2, +} + +#[derive(Clone)] +enum RunSpec { + Text { + char_pr: u32, + text: String, + }, + Hyperlink { + char_pr: u32, + field_begin_id: u64, + field_id: u64, + url: String, + text: String, + }, +} + +enum SectionItem { + Paragraph(ParagraphStyle, Vec), + Table(TableSpec), +} + +struct TableSpec { + id: u64, + rows: Vec>, + col_widths: Vec, +} + +pub fn build_section_xml( + document: &Document, + strict_mode: bool, + style: &ResolvedHwpxStyle, + profile: ResolvedHwpxCompatibilityProfile, +) -> Result { + if profile != ResolvedHwpxCompatibilityProfile::LegacyDefault { + return compat::build_section_xml(document, profile, style); + } + let quote_render_mode = quote_render_mode(document); + let ordered_shape = collect::ordered_list_shape(&document.blocks); + let items = collect::collect_section_items( + &document.blocks, + strict_mode, + ordered_shape, + quote_render_mode, + )?; + let mut body = String::new(); + let mut list_vertpos = 0; + let mut quote_vertpos = 0; + for (index, item) in items.iter().enumerate() { + body.push_str(&render::render_section_item( + item, + index == 0, + index, + &mut list_vertpos, + &mut quote_vertpos, + )); + } + + Ok(format!( + concat!( + "", + "", + "{body}", + "" + ), + body = body + )) +} + +pub fn build_preview_text( + document: &Document, + strict_mode: bool, + profile: ResolvedHwpxCompatibilityProfile, + style: &ResolvedHwpxStyle, +) -> Result { + if profile != ResolvedHwpxCompatibilityProfile::LegacyDefault { + return compat::build_preview_text(document, profile, style); + } + let quote_render_mode = quote_render_mode(document); + let ordered_shape = collect::ordered_list_shape(&document.blocks); + let items = collect::collect_section_items( + &document.blocks, + strict_mode, + ordered_shape, + quote_render_mode, + )?; + let has_table = items + .iter() + .any(|item| matches!(item, SectionItem::Table(_))); + let text = text::visible_paragraphs(&items).join("\r\n"); + if has_table { + set_settings_caret_pos(16); + } else if quote_render_mode == QuoteRenderMode::LegacyQuoteOnly { + if let Some((para_id_ref, pos)) = text::last_paragraph_caret(&items) { + set_settings_caret(para_id_ref, pos); + } else { + set_settings_caret_pos(45); + } + } else if let Some((para_id_ref, pos)) = text::list_caret_position(&items) { + set_settings_caret(para_id_ref, pos); + } else { + set_settings_caret_pos(45); + } + Ok(text) +} + +fn quote_render_mode(document: &Document) -> QuoteRenderMode { + if uses_legacy_quote_only_shape(&document.blocks) { + QuoteRenderMode::LegacyQuoteOnly + } else { + QuoteRenderMode::Modern + } +} diff --git a/packages/core-rs/src/generators/hwpx/section_xml/render.rs b/packages/core-rs/src/generators/hwpx/section_xml/render.rs new file mode 100644 index 0000000..8513742 --- /dev/null +++ b/packages/core-rs/src/generators/hwpx/section_xml/render.rs @@ -0,0 +1,402 @@ +use super::shared::{ + HyperlinkFieldSpec, hyperlink_command, render_hyperlink_run, render_section_preamble_run, +}; +use super::text::flatten_inline_children; +use super::{ + BOLD_CHAR_PR, CODE_CHAR_PR, CoreRsError, H1_CHAR_PR, H2_CHAR_PR, H3_CHAR_PR, H4_CHAR_PR, + ITALIC_CHAR_PR, Inline, LEGACY_QUOTE_HORZ_POS, LEGACY_QUOTE_HORZ_SIZE, + LEGACY_QUOTE_LINESEG_INCREMENT, LINK_CHAR_PR, LIST_LEVEL1_HORZ_POS, LIST_LEVEL1_HORZ_SIZE, + LIST_LEVEL2_HORZ_POS, LIST_LEVEL2_HORZ_SIZE, LIST_LINESEG_BASELINE, LIST_LINESEG_FLAGS, + LIST_LINESEG_INCREMENT, LIST_LINESEG_SPACING, LIST_LINESEG_TEXT_HEIGHT, LIST_LINESEG_VERT_SIZE, + LineSegProfile, NORMAL_CHAR_PR, ParagraphStyle, QUOTE_LEVEL1_HORZ_POS, QUOTE_LEVEL1_HORZ_SIZE, + QUOTE_LEVEL2_HORZ_POS, QUOTE_LEVEL2_HORZ_SIZE, RunSpec, SectionItem, + TABLE_CELL_INNER_WIDTH_DELTA, TABLE_HEIGHT_PER_ROW, TABLE_OUTER_BASELINE, + TABLE_OUTER_LINESEG_SIZE, TABLE_ROW_HEIGHT, TABLE_WIDTH, TableSpec, escape_text, +}; + +pub(super) fn render_section_item( + item: &SectionItem, + is_first: bool, + index: usize, + list_vertpos: &mut u32, + quote_vertpos: &mut u32, +) -> String { + match item { + SectionItem::Paragraph(style, runs) => { + render_paragraph(style, runs, is_first, index, list_vertpos, quote_vertpos) + } + SectionItem::Table(table) => render_table_paragraph(table, is_first, index), + } +} + +fn render_paragraph( + style: &ParagraphStyle, + runs: &[RunSpec], + is_first: bool, + index: usize, + list_vertpos: &mut u32, + quote_vertpos: &mut u32, +) -> String { + let id = 2_757_524_817u64 + index as u64; + let mut xml = format!( + "", + id = id, + para = style.para_pr, + style_id = style.style, + ); + + if is_first { + xml.push_str(&render_section_preamble_run(style.default_char_pr)); + } + + if runs.is_empty() { + xml.push_str(&format!( + "", + style.default_char_pr + )); + } else { + for run in runs { + xml.push_str(&render_run(run)); + } + } + + if should_render_paragraph_lineseg(style) { + xml.push_str(&render_lineseg(style.line_seg, list_vertpos, quote_vertpos)); + } + xml.push_str(""); + xml +} + +fn render_run(run: &RunSpec) -> String { + match run { + RunSpec::Text { char_pr, text } => format!( + "{text}", + char_pr = char_pr, + text = escape_text(text), + ), + RunSpec::Hyperlink { + char_pr, + field_begin_id, + field_id, + url, + text, + } => render_hyperlink_run(HyperlinkFieldSpec { + char_pr: *char_pr, + field_begin_id: *field_begin_id, + field_id: *field_id, + command: &hyperlink_command(url), + path: url, + text, + trailing_empty_text: false, + }), + } +} + +fn should_render_paragraph_lineseg(style: &ParagraphStyle) -> bool { + if style.style != 0 { + return true; + } + + !matches!( + style.line_seg, + LineSegProfile::Standard | LineSegProfile::ListLevel1 | LineSegProfile::ListLevel2 + ) +} + +fn render_table_paragraph(table: &TableSpec, is_first: bool, index: usize) -> String { + let id = 2_757_524_817u64 + index as u64; + let mut xml = format!( + "", + id = id, + ); + + if is_first { + xml.push_str(&render_section_preamble_run(NORMAL_CHAR_PR)); + } + xml.push_str(""); + xml.push_str(&render_table_xml(table)); + xml.push_str(""); + xml.push_str(&render_table_lineseg()); + xml.push_str(""); + xml +} + +fn render_table_xml(table: &TableSpec) -> String { + let row_count = table.rows.len(); + let col_count = table.col_widths.len(); + let height = TABLE_HEIGHT_PER_ROW * row_count as u32; + + let mut xml = format!( + concat!( + "", + "", + "", + "", + "" + ), + id = table.id, + row_count = row_count, + col_count = col_count, + width = TABLE_WIDTH, + height = height, + ); + + for (row_index, row) in table.rows.iter().enumerate() { + xml.push_str(""); + for (col_index, cell) in row.iter().enumerate() { + xml.push_str(&render_table_cell( + cell, + row_index, + col_index, + table.col_widths[col_index], + )); + } + xml.push_str(""); + } + + xml.push_str(""); + xml +} + +fn render_table_cell(text: &str, row_index: usize, col_index: usize, width: u32) -> String { + let inner_width = width.saturating_sub(TABLE_CELL_INNER_WIDTH_DELTA); + format!( + concat!( + "", + "", + "", + "{text}", + "{trailing_run}", + "", + "", + "", + "", + "", + "", + "" + ), + text = escape_text(text), + trailing_run = if col_index > 0 { + "" + } else { + "" + }, + inner_width = inner_width, + col_index = col_index, + row_index = row_index, + width = width, + height = TABLE_ROW_HEIGHT, + ) +} + +fn render_standard_lineseg() -> &'static str { + "" +} + +fn render_lineseg( + style: LineSegProfile, + list_vertpos: &mut u32, + quote_vertpos: &mut u32, +) -> String { + match style { + LineSegProfile::Standard => render_standard_lineseg().to_string(), + LineSegProfile::ListLevel1 => { + render_list_lineseg(LIST_LEVEL1_HORZ_POS, LIST_LEVEL1_HORZ_SIZE, list_vertpos) + } + LineSegProfile::ListLevel2 => { + render_list_lineseg(LIST_LEVEL2_HORZ_POS, LIST_LEVEL2_HORZ_SIZE, list_vertpos) + } + LineSegProfile::QuoteLegacy => { + render_quote_lineseg(LEGACY_QUOTE_HORZ_POS, LEGACY_QUOTE_HORZ_SIZE, quote_vertpos) + } + LineSegProfile::QuoteLevel1 => { + render_standard_offset_lineseg(QUOTE_LEVEL1_HORZ_POS, QUOTE_LEVEL1_HORZ_SIZE) + } + LineSegProfile::QuoteLevel2 => { + render_standard_offset_lineseg(QUOTE_LEVEL2_HORZ_POS, QUOTE_LEVEL2_HORZ_SIZE) + } + } +} + +fn render_standard_offset_lineseg(horzpos: u32, horzsize: u32) -> String { + format!( + "", + horzpos = horzpos, + horzsize = horzsize, + ) +} + +fn render_list_lineseg(horzpos: u32, horzsize: u32, list_vertpos: &mut u32) -> String { + let current_vertpos = *list_vertpos; + *list_vertpos += LIST_LINESEG_INCREMENT; + format!( + "", + vertpos = current_vertpos, + vertsize = LIST_LINESEG_VERT_SIZE, + textheight = LIST_LINESEG_TEXT_HEIGHT, + baseline = LIST_LINESEG_BASELINE, + spacing = LIST_LINESEG_SPACING, + horzpos = horzpos, + horzsize = horzsize, + flags = LIST_LINESEG_FLAGS, + ) +} + +fn render_quote_lineseg(horzpos: u32, horzsize: u32, quote_vertpos: &mut u32) -> String { + let current_vertpos = *quote_vertpos; + *quote_vertpos += LEGACY_QUOTE_LINESEG_INCREMENT; + format!( + "", + vertpos = current_vertpos, + horzpos = horzpos, + horzsize = horzsize, + ) +} + +fn render_table_lineseg() -> String { + format!( + "", + size = TABLE_OUTER_LINESEG_SIZE, + baseline = TABLE_OUTER_BASELINE, + ) +} + +pub(super) fn render_runs( + inlines: &[Inline], + default_char_pr: u32, + strict_mode: bool, + next_link_index: &mut u64, +) -> Result, CoreRsError> { + let mut runs = Vec::new(); + for inline in inlines { + collect_inline_runs( + inline, + default_char_pr, + strict_mode, + &mut runs, + next_link_index, + )?; + } + Ok(merge_adjacent_runs(runs)) +} + +fn collect_inline_runs( + inline: &Inline, + default_char_pr: u32, + strict_mode: bool, + runs: &mut Vec, + next_link_index: &mut u64, +) -> Result<(), CoreRsError> { + match inline { + Inline::Text(value) => push_run(runs, default_char_pr, value.clone()), + Inline::Emphasis(children) => { + for child in children { + collect_inline_runs( + child, + emphasis_char_pr(default_char_pr), + strict_mode, + runs, + next_link_index, + )?; + } + } + Inline::Strong(children) => { + for child in children { + collect_inline_runs( + child, + strong_char_pr(default_char_pr), + strict_mode, + runs, + next_link_index, + )?; + } + } + Inline::Code(code) => push_run(runs, code_char_pr(default_char_pr), code.clone()), + Inline::Link { text, url } => { + let label = flatten_inline_children(text, strict_mode)?; + let visible = if label.trim().is_empty() { + url.clone() + } else { + label + }; + runs.push(RunSpec::Hyperlink { + char_pr: link_char_pr(default_char_pr), + field_begin_id: 2_107_483_186 + *next_link_index, + field_id: 627_600_491 + *next_link_index, + url: url.clone(), + text: visible, + }); + *next_link_index += 1; + } + Inline::Image(image) => { + if strict_mode { + return Err(CoreRsError::UnsupportedFeature( + "HWPX core does not support image".to_string(), + )); + } + let fallback = if image.alt.trim().is_empty() { + "[image]".to_string() + } else { + image.alt.clone() + }; + push_run(runs, default_char_pr, fallback); + } + Inline::HardBreak => push_run(runs, default_char_pr, " ".to_string()), + } + Ok(()) +} + +fn merge_adjacent_runs(runs: Vec) -> Vec { + let mut merged = Vec::new(); + for run in runs { + match (merged.last_mut(), &run) { + ( + Some(RunSpec::Text { + char_pr: last_char_pr, + text: last_text, + }), + RunSpec::Text { char_pr, text }, + ) if *last_char_pr == *char_pr => { + last_text.push_str(text); + continue; + } + _ => {} + } + merged.push(run); + } + merged +} + +fn push_run(runs: &mut Vec, char_pr: u32, text: String) { + if !text.is_empty() { + runs.push(RunSpec::Text { char_pr, text }); + } +} + +fn strong_char_pr(default_char_pr: u32) -> u32 { + match default_char_pr { + H1_CHAR_PR | H2_CHAR_PR | H3_CHAR_PR | H4_CHAR_PR | BOLD_CHAR_PR => default_char_pr, + _ => BOLD_CHAR_PR, + } +} + +fn emphasis_char_pr(default_char_pr: u32) -> u32 { + match default_char_pr { + H1_CHAR_PR | H2_CHAR_PR | H3_CHAR_PR | H4_CHAR_PR => default_char_pr, + _ => ITALIC_CHAR_PR, + } +} + +fn code_char_pr(default_char_pr: u32) -> u32 { + match default_char_pr { + H1_CHAR_PR | H2_CHAR_PR | H3_CHAR_PR | H4_CHAR_PR => default_char_pr, + _ => CODE_CHAR_PR, + } +} + +fn link_char_pr(default_char_pr: u32) -> u32 { + match default_char_pr { + H1_CHAR_PR | H2_CHAR_PR | H3_CHAR_PR | H4_CHAR_PR => default_char_pr, + _ => LINK_CHAR_PR, + } +} diff --git a/packages/core-rs/src/generators/hwpx/section_xml/shared.rs b/packages/core-rs/src/generators/hwpx/section_xml/shared.rs new file mode 100644 index 0000000..17a858f --- /dev/null +++ b/packages/core-rs/src/generators/hwpx/section_xml/shared.rs @@ -0,0 +1,78 @@ +use crate::utils::xml_helper::escape_text; + +pub(super) const SECTION_PREAMBLE_BODY: &str = concat!( + "", + "", + "", + "", + "", + "", + "", + "", + "", + "", + "", + "" +); + +pub(super) struct HyperlinkFieldSpec<'a> { + pub(super) char_pr: u32, + pub(super) field_begin_id: u64, + pub(super) field_id: u64, + pub(super) command: &'a str, + pub(super) path: &'a str, + pub(super) text: &'a str, + pub(super) trailing_empty_text: bool, +} + +pub(super) fn render_section_preamble_run(char_pr: u32) -> String { + format!( + "{body}", + char_pr = char_pr, + body = SECTION_PREAMBLE_BODY, + ) +} + +pub(super) fn hyperlink_command(path: &str) -> String { + format!("{};1;0;0;", path.replace("://", "\\://")) +} + +pub(super) fn compat_hyperlink_path(url: &str) -> String { + if url.ends_with('/') { + url.to_string() + } else { + format!("{url}/") + } +} + +pub(super) fn render_hyperlink_run(spec: HyperlinkFieldSpec<'_>) -> String { + format!( + concat!( + "", + "", + "", + "0", + "{command}", + "{path}", + "HWPHYPERLINK_TYPE_URL", + "HWPHYPERLINK_TARGET_BOOKMARK", + "HWPHYPERLINK_JUMP_CURRENTTAB", + "", + "{text}", + "", + "{trailing}", + "" + ), + char_pr = spec.char_pr, + begin_id = spec.field_begin_id, + field_id = spec.field_id, + command = escape_text(spec.command), + path = escape_text(spec.path), + text = escape_text(spec.text), + trailing = if spec.trailing_empty_text { + "" + } else { + "" + }, + ) +} diff --git a/packages/core-rs/src/generators/hwpx/section_xml/text.rs b/packages/core-rs/src/generators/hwpx/section_xml/text.rs new file mode 100644 index 0000000..47d2efb --- /dev/null +++ b/packages/core-rs/src/generators/hwpx/section_xml/text.rs @@ -0,0 +1,190 @@ +use super::{Block, CoreRsError, Inline, ListSemantic, SectionItem, TableSpec}; + +pub(super) fn flatten_inline_children( + inlines: &[Inline], + strict_mode: bool, +) -> Result { + let mut text = String::new(); + for inline in inlines { + match inline { + Inline::Text(value) => text.push_str(value), + Inline::Emphasis(children) | Inline::Strong(children) => { + text.push_str(&flatten_inline_children(children, strict_mode)?); + } + Inline::Code(code) => text.push_str(code), + Inline::Link { + text: children, + url, + } => { + let label = flatten_inline_children(children, strict_mode)?; + if label.trim().is_empty() { + text.push_str(url); + } else { + text.push_str(&label); + } + } + Inline::Image(image) => { + if strict_mode { + return Err(CoreRsError::UnsupportedFeature( + "HWPX core does not support image".to_string(), + )); + } + if image.alt.trim().is_empty() { + text.push_str("[image]"); + } else { + text.push_str(&image.alt); + } + } + Inline::HardBreak => text.push(' '), + } + } + Ok(text) +} + +pub(super) fn visible_paragraphs(items: &[SectionItem]) -> Vec { + let mut paragraphs = Vec::new(); + for item in items { + match item { + SectionItem::Paragraph(_, runs) => { + paragraphs.push(runs.iter().map(run_visible_text).collect()) + } + SectionItem::Table(table) => append_table_preview_paragraphs(table, &mut paragraphs), + } + } + paragraphs +} + +fn append_table_preview_paragraphs(table: &TableSpec, paragraphs: &mut Vec) { + for row in &table.rows { + paragraphs.push( + row.iter() + .map(|cell| format!("<{cell}>")) + .collect::>() + .join(""), + ); + } +} + +pub(super) fn flatten_block_to_paragraphs(block: &Block) -> Vec { + match block { + Block::Paragraph(content) | Block::Heading { content, .. } => { + vec![flatten_inline_children(content, false).unwrap_or_default()] + } + Block::BlockQuote(blocks) => blocks + .iter() + .flat_map(flatten_block_to_paragraphs) + .collect::>(), + Block::CodeBlock { code, .. } => code.lines().map(ToString::to_string).collect(), + Block::List(list) => list + .items + .iter() + .enumerate() + .map(|(index, item)| { + let prefix = if list.ordered { + format!("{}. ", list.start_index + index as u64) + } else { + "- ".to_string() + }; + let body = item + .blocks + .iter() + .flat_map(flatten_block_to_paragraphs) + .collect::>() + .join(" "); + format!("{prefix}{body}") + }) + .collect(), + Block::Table(table) => flatten_table_to_paragraphs(table), + Block::ThematicBreak => vec!["---".to_string()], + } +} + +fn flatten_table_to_paragraphs(table: &super::TableBlock) -> Vec { + let mut paragraphs = Vec::new(); + if !table.headers.is_empty() { + paragraphs.push( + table + .headers + .iter() + .map(|cell| flatten_inline_children(cell, false).unwrap_or_default()) + .collect::>() + .join(" | "), + ); + } + paragraphs.extend(table.rows.iter().map(|row| { + row.cells + .iter() + .map(|cell| flatten_inline_children(cell, false).unwrap_or_default()) + .collect::>() + .join(" | ") + })); + paragraphs +} + +pub(super) fn list_caret_position(items: &[SectionItem]) -> Option<(usize, usize)> { + let mut paragraph_index = 0; + let mut last_unordered_caret = None; + let mut ordered_carets = Vec::new(); + + for item in items { + match item { + SectionItem::Paragraph(style, runs) => { + let text = runs.iter().map(run_visible_text).collect::(); + let caret = (paragraph_index, text.chars().count()); + match style.list_semantic { + ListSemantic::None => {} + ListSemantic::Unordered => last_unordered_caret = Some(caret), + ListSemantic::OrderedSingleLevel + | ListSemantic::OrderedTopLevel + | ListSemantic::OrderedNestedLevel => { + ordered_carets.push((style.list_semantic, caret)); + } + } + paragraph_index += 1; + } + SectionItem::Table(table) => { + paragraph_index += table.rows.len(); + } + } + } + + if !ordered_carets.is_empty() { + let only_single_level = ordered_carets + .iter() + .all(|(semantic, _)| *semantic == ListSemantic::OrderedSingleLevel); + if only_single_level && ordered_carets.len() > 1 { + return ordered_carets + .get(ordered_carets.len() - 2) + .map(|(_, caret)| *caret); + } + return ordered_carets.last().map(|(_, caret)| *caret); + } + + last_unordered_caret +} + +pub(super) fn last_paragraph_caret(items: &[SectionItem]) -> Option<(usize, usize)> { + let mut paragraph_index = 0; + let mut last_caret = None; + + for item in items { + match item { + SectionItem::Paragraph(_, runs) => { + let text = runs.iter().map(run_visible_text).collect::(); + last_caret = Some((paragraph_index, text.chars().count())); + paragraph_index += 1; + } + SectionItem::Table(table) => { + paragraph_index += table.rows.len(); + } + } + } + + last_caret +} + +fn run_visible_text(run: &super::RunSpec) -> &str { + match run { + super::RunSpec::Text { text, .. } | super::RunSpec::Hyperlink { text, .. } => text, + } +} diff --git a/packages/core-rs/src/lib.rs b/packages/core-rs/src/lib.rs index 7a3da10..7efcb47 100644 --- a/packages/core-rs/src/lib.rs +++ b/packages/core-rs/src/lib.rs @@ -1,3 +1,4 @@ +mod diagnostics; mod error; mod generators; mod models; @@ -9,15 +10,159 @@ mod wasm; pub use error::CoreRsError; +use diagnostics::{analyze_markdown_impl, report_for_mode}; use generators::Generator; use generators::docx::DocxGenerator; use generators::hwpx::HwpxGenerator; use parser::markdown::MarkdownParser; +use serde::Serialize; +use thiserror::Error; fn default_document_options() -> (Option, Option, bool) { (None, None, true) } +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] +pub enum ConversionTarget { + Docx, + Hwpx, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] +pub enum IssueSeverity { + Warning, + Error, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct ConversionIssue { + pub feature: String, + pub message: String, + pub severity: IssueSeverity, + pub degraded: bool, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct ConversionReport { + pub issues: Vec, + pub degraded: bool, + pub unsupported_count: usize, + pub fallback_count: usize, +} + +impl ConversionReport { + pub(crate) fn from_issues(issues: Vec) -> Self { + let mut deduped = Vec::with_capacity(issues.len()); + for issue in issues { + if !deduped.contains(&issue) { + deduped.push(issue); + } + } + + let unsupported_count = deduped + .iter() + .filter(|issue| issue.severity == IssueSeverity::Error) + .count(); + let fallback_count = deduped.iter().filter(|issue| issue.degraded).count(); + let degraded = deduped.iter().any(|issue| issue.degraded); + Self { + issues: deduped, + degraded, + unsupported_count, + fallback_count, + } + } + + pub(crate) fn with_error_issue(&self, error: &CoreRsError) -> Self { + let mut issues = self.issues.clone(); + if let Some(existing) = issues + .iter_mut() + .find(|existing| issue_matches_error(existing, error)) + { + existing.message = error.to_string(); + existing.severity = IssueSeverity::Error; + existing.degraded = false; + return Self::from_issues(issues); + } + + issues.push(error_issue(error)); + Self::from_issues(issues) + } +} + +fn error_issue(error: &CoreRsError) -> ConversionIssue { + match error { + CoreRsError::UnsupportedFeature(_) => ConversionIssue { + feature: "unsupported feature".to_string(), + message: error.to_string(), + severity: IssueSeverity::Error, + degraded: false, + }, + CoreRsError::InvalidMarkdown(_) => ConversionIssue { + feature: "invalid markdown".to_string(), + message: error.to_string(), + severity: IssueSeverity::Error, + degraded: false, + }, + CoreRsError::InvalidOption(_) => ConversionIssue { + feature: "invalid option".to_string(), + message: error.to_string(), + severity: IssueSeverity::Error, + degraded: false, + }, + CoreRsError::Zip(_) => ConversionIssue { + feature: "zip packaging failed".to_string(), + message: error.to_string(), + severity: IssueSeverity::Error, + degraded: false, + }, + CoreRsError::Io(_) => ConversionIssue { + feature: "io failure".to_string(), + message: error.to_string(), + severity: IssueSeverity::Error, + degraded: false, + }, + } +} + +fn issue_matches_error(issue: &ConversionIssue, error: &CoreRsError) -> bool { + match error { + CoreRsError::UnsupportedFeature(feature) => match feature.as_str() { + "html" => issue.feature == "HTML", + "footnote" => issue.feature == "footnote", + "task list" => issue.feature == "task list", + "math" => issue.feature == "math", + "nested list depth > 2" => issue.feature == "deep nested list", + value if value.contains("ordered list") => { + issue.feature == "HWPX ordered list in strict mode" + } + "HWPX core does not support image" => issue.feature == "HWPX image", + value if value.starts_with("HWPX core does not support ") => { + issue.feature == "HWPX unsupported rich block" + } + _ => false, + }, + CoreRsError::InvalidMarkdown(_) + | CoreRsError::InvalidOption(_) + | CoreRsError::Zip(_) + | CoreRsError::Io(_) => false, + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct GenerationResult { + pub bytes: Vec, + pub report: ConversionReport, +} + +#[derive(Debug, Error, Serialize)] +#[error("{error}")] +pub struct GenerationFailure { + #[serde(skip_serializing)] + pub error: CoreRsError, + pub report: ConversionReport, +} + #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum HwpxParagraphAlign { Left, @@ -81,6 +226,28 @@ pub fn generate_docx(markdown: &str, options: DocxOptions) -> Result, Co DocxGenerator::new(options).generate(&document) } +pub fn analyze_markdown(markdown: &str, target: ConversionTarget) -> ConversionReport { + analyze_markdown_impl(markdown, target) +} + +pub fn generate_docx_with_report( + markdown: &str, + options: DocxOptions, +) -> Result { + let report = report_for_mode( + &analyze_markdown(markdown, ConversionTarget::Docx), + ConversionTarget::Docx, + options.strict_mode, + ); + match generate_docx(markdown, options) { + Ok(bytes) => Ok(GenerationResult { bytes, report }), + Err(error) => Err(GenerationFailure { + report: report.with_error_issue(&error), + error, + }), + } +} + pub fn generate_hwpx(markdown: &str, options: HwpxOptions) -> Result, CoreRsError> { let normalized_markdown; let markdown = if options.strict_mode { @@ -93,6 +260,24 @@ pub fn generate_hwpx(markdown: &str, options: HwpxOptions) -> Result, Co HwpxGenerator::new(options).generate(&document) } +pub fn generate_hwpx_with_report( + markdown: &str, + options: HwpxOptions, +) -> Result { + let report = report_for_mode( + &analyze_markdown(markdown, ConversionTarget::Hwpx), + ConversionTarget::Hwpx, + options.strict_mode, + ); + match generate_hwpx(markdown, options) { + Ok(bytes) => Ok(GenerationResult { bytes, report }), + Err(error) => Err(GenerationFailure { + report: report.with_error_issue(&error), + error, + }), + } +} + fn normalize_hwpx_markdown(markdown: &str) -> String { let mut normalized = String::with_capacity(markdown.len()); for segment in markdown.split_inclusive('\n') { diff --git a/packages/core-rs/src/parser/markdown.rs b/packages/core-rs/src/parser/markdown.rs deleted file mode 100644 index 4ca42c5..0000000 --- a/packages/core-rs/src/parser/markdown.rs +++ /dev/null @@ -1,730 +0,0 @@ -mod data_image; -mod state; - -#[cfg(test)] -mod tests; - -use pulldown_cmark::{Event, Options, Parser, Tag, TagEnd}; - -use self::data_image::{DataImageError, image_fallback_text, parse_data_uri}; -use self::state::{ - Container, InlineContext, append_fallback_separator, code_block_language, current_list_depth, - current_table_mut, empty_to_none, flatten_inline_text, heading_level, push_inline_merged, - table_alignment, trim_trailing_fallback_whitespace, unsupported_block_feature, - unsupported_block_nesting_end, unsupported_block_nesting_tag, -}; -use crate::error::CoreRsError; -use crate::models::block::{Block, ListBlock, TableBlock, TableRow}; -use crate::models::document::Document; -use crate::models::inline::{ImageData, Inline}; - -const MAX_TOTAL_DATA_URI_IMAGE_BYTES: usize = 16 * 1024 * 1024; - -pub struct MarkdownParser { - strict_mode: bool, -} - -impl MarkdownParser { - pub fn new(strict_mode: bool) -> Self { - Self { strict_mode } - } - - pub fn parse(&self, markdown: &str) -> Result { - let parser = Parser::new_ext(markdown, Options::all()); - let mut blocks = Vec::new(); - let mut containers = Vec::new(); - let mut inline_stack = Vec::new(); - let mut unsupported_block_depth = 0usize; - let mut unsupported_block_content = Vec::new(); - let mut total_embedded_image_bytes = 0usize; - - for event in parser { - if unsupported_block_depth > 0 { - self.handle_unsupported_event( - event, - &mut blocks, - &mut containers, - &mut unsupported_block_depth, - &mut unsupported_block_content, - )?; - continue; - } - - match event { - Event::Start(tag) => { - if matches!(tag, Tag::List(_)) && current_list_depth(&containers) >= 2 { - if self.strict_mode { - return Err(CoreRsError::UnsupportedFeature( - "nested list depth > 2".to_string(), - )); - } - unsupported_block_depth = 1; - continue; - } - - if let Some(feature) = unsupported_block_feature(&tag) { - if self.strict_mode { - return Err(CoreRsError::UnsupportedFeature(feature.to_string())); - } - unsupported_block_depth = 1; - continue; - } - - self.handle_start(tag, &mut containers, &mut inline_stack)?; - } - Event::End(tag) => { - self.handle_end( - tag, - &mut blocks, - &mut containers, - &mut inline_stack, - &mut total_embedded_image_bytes, - )?; - } - Event::Text(text) => { - self.push_text( - text.into_string(), - &mut blocks, - &mut containers, - &mut inline_stack, - )?; - } - Event::Code(code) => { - self.push_inline_or_code( - Inline::Code(code.into_string()), - None, - &mut blocks, - &mut containers, - &mut inline_stack, - )?; - } - Event::SoftBreak => { - self.push_soft_break(&mut blocks, &mut containers, &mut inline_stack)?; - } - Event::HardBreak => { - self.push_hard_break(&mut blocks, &mut containers, &mut inline_stack)?; - } - Event::Rule => { - self.push_block(Block::ThematicBreak, &mut blocks, &mut containers)?; - } - Event::Html(html) | Event::InlineHtml(html) => { - if self.strict_mode { - return Err(CoreRsError::UnsupportedFeature("html".to_string())); - } - self.push_fallback_text( - html.into_string(), - &mut blocks, - &mut containers, - &mut inline_stack, - )?; - } - Event::FootnoteReference(reference) => { - if self.strict_mode { - return Err(CoreRsError::UnsupportedFeature("footnote".to_string())); - } - self.push_fallback_text( - format!("[^{reference}]"), - &mut blocks, - &mut containers, - &mut inline_stack, - )?; - } - Event::TaskListMarker(checked) => { - if self.strict_mode { - return Err(CoreRsError::UnsupportedFeature("task list".to_string())); - } - self.push_fallback_text( - if checked { - "[x] ".to_string() - } else { - "[ ] ".to_string() - }, - &mut blocks, - &mut containers, - &mut inline_stack, - )?; - } - Event::InlineMath(math) | Event::DisplayMath(math) => { - if self.strict_mode { - return Err(CoreRsError::UnsupportedFeature("math".to_string())); - } - self.push_fallback_text( - math.into_string(), - &mut blocks, - &mut containers, - &mut inline_stack, - )?; - } - } - } - - if !inline_stack.is_empty() || !containers.is_empty() { - return Err(CoreRsError::InvalidMarkdown( - "unbalanced markdown structure".to_string(), - )); - } - - Ok(Document { blocks }) - } - - fn handle_start( - &self, - tag: Tag<'_>, - containers: &mut Vec, - inline_stack: &mut Vec, - ) -> Result<(), CoreRsError> { - match tag { - Tag::Paragraph => containers.push(Container::Paragraph(Vec::new())), - Tag::Heading { level, .. } => containers.push(Container::Heading { - level: heading_level(level), - content: Vec::new(), - }), - Tag::BlockQuote(_) => containers.push(Container::BlockQuote(Vec::new())), - Tag::CodeBlock(kind) => containers.push(Container::CodeBlock { - language: code_block_language(kind), - code: String::new(), - }), - Tag::List(start) => containers.push(Container::List(ListBlock { - ordered: start.is_some(), - start_index: start.unwrap_or(1), - items: Vec::new(), - })), - Tag::Item => containers.push(Container::ListItem(crate::models::block::ListItem { - blocks: Vec::new(), - })), - Tag::Table(alignments) => containers.push(Container::Table(state::TableBuilder { - aligns: alignments.into_iter().map(table_alignment).collect(), - headers: Vec::new(), - rows: Vec::new(), - })), - Tag::TableHead => containers.push(Container::TableHead(Vec::new())), - Tag::TableRow => containers.push(Container::TableRow(state::TableRowBuilder { - cells: Vec::new(), - })), - Tag::TableCell => containers.push(Container::TableCell(Vec::new())), - Tag::Emphasis => inline_stack.push(InlineContext::Emphasis(Vec::new())), - Tag::Strong => inline_stack.push(InlineContext::Strong(Vec::new())), - Tag::Link { - dest_url, title, .. - } => inline_stack.push(InlineContext::Link { - url: dest_url.into_string(), - title: empty_to_none(title.into_string()), - content: Vec::new(), - }), - Tag::Image { - dest_url, title, .. - } => inline_stack.push(InlineContext::Image { - url: dest_url.into_string(), - title: empty_to_none(title.into_string()), - alt: Vec::new(), - }), - Tag::DefinitionList - | Tag::DefinitionListTitle - | Tag::DefinitionListDefinition - | Tag::MetadataBlock(_) - | Tag::Strikethrough - | Tag::HtmlBlock - | Tag::FootnoteDefinition(_) => { - if self.strict_mode { - return Err(CoreRsError::UnsupportedFeature( - "extended markdown syntax".to_string(), - )); - } - } - } - - Ok(()) - } - - fn handle_end( - &self, - tag: TagEnd, - blocks: &mut Vec, - containers: &mut Vec, - inline_stack: &mut Vec, - total_embedded_image_bytes: &mut usize, - ) -> Result<(), CoreRsError> { - match tag { - TagEnd::Paragraph => { - let Some(Container::Paragraph(content)) = containers.pop() else { - return Err(CoreRsError::InvalidMarkdown( - "paragraph close without open".to_string(), - )); - }; - self.push_block(Block::Paragraph(content), blocks, containers)?; - } - TagEnd::Heading(_) => { - let Some(Container::Heading { level, content }) = containers.pop() else { - return Err(CoreRsError::InvalidMarkdown( - "heading close without open".to_string(), - )); - }; - self.push_block(Block::Heading { level, content }, blocks, containers)?; - } - TagEnd::BlockQuote(_) => { - let Some(Container::BlockQuote(quoted)) = containers.pop() else { - return Err(CoreRsError::InvalidMarkdown( - "blockquote close without open".to_string(), - )); - }; - self.push_block(Block::BlockQuote(quoted), blocks, containers)?; - } - TagEnd::CodeBlock => { - let Some(Container::CodeBlock { language, code }) = containers.pop() else { - return Err(CoreRsError::InvalidMarkdown( - "code block close without open".to_string(), - )); - }; - self.push_block(Block::CodeBlock { language, code }, blocks, containers)?; - } - TagEnd::List(_) => { - let Some(Container::List(list)) = containers.pop() else { - return Err(CoreRsError::InvalidMarkdown( - "list close without open".to_string(), - )); - }; - self.push_block(Block::List(list), blocks, containers)?; - } - TagEnd::Item => { - let Some(Container::ListItem(item)) = containers.pop() else { - return Err(CoreRsError::InvalidMarkdown( - "list item close without open".to_string(), - )); - }; - let Some(Container::List(list)) = containers.last_mut() else { - return Err(CoreRsError::InvalidMarkdown( - "list item close without list".to_string(), - )); - }; - list.items.push(item); - } - TagEnd::Table => { - let Some(Container::Table(table)) = containers.pop() else { - return Err(CoreRsError::InvalidMarkdown( - "table close without open".to_string(), - )); - }; - self.push_block( - Block::Table(TableBlock { - aligns: table.aligns, - headers: table.headers, - rows: table.rows, - }), - blocks, - containers, - )?; - } - TagEnd::TableHead => { - let Some(Container::TableHead(headers)) = containers.pop() else { - return Err(CoreRsError::InvalidMarkdown( - "table head close without open".to_string(), - )); - }; - current_table_mut(containers)?.headers = headers; - } - TagEnd::TableRow => { - let Some(Container::TableRow(row)) = containers.pop() else { - return Err(CoreRsError::InvalidMarkdown( - "table row close without open".to_string(), - )); - }; - current_table_mut(containers)? - .rows - .push(TableRow { cells: row.cells }); - } - TagEnd::TableCell => { - let Some(Container::TableCell(cell)) = containers.pop() else { - return Err(CoreRsError::InvalidMarkdown( - "table cell close without open".to_string(), - )); - }; - match containers.last_mut() { - Some(Container::TableRow(row)) => row.cells.push(cell), - Some(Container::TableHead(headers)) => headers.push(cell), - _ => { - return Err(CoreRsError::InvalidMarkdown( - "table cell close without row".to_string(), - )); - } - } - } - TagEnd::Emphasis => { - let Some(InlineContext::Emphasis(content)) = inline_stack.pop() else { - return Err(CoreRsError::InvalidMarkdown( - "emphasis close without open".to_string(), - )); - }; - self.push_inline_or_code( - Inline::Emphasis(content), - None, - blocks, - containers, - inline_stack, - )?; - } - TagEnd::Strong => { - let Some(InlineContext::Strong(content)) = inline_stack.pop() else { - return Err(CoreRsError::InvalidMarkdown( - "strong close without open".to_string(), - )); - }; - self.push_inline_or_code( - Inline::Strong(content), - None, - blocks, - containers, - inline_stack, - )?; - } - TagEnd::Link => { - let Some(InlineContext::Link { - url, - title, - content, - }) = inline_stack.pop() - else { - return Err(CoreRsError::InvalidMarkdown( - "link close without open".to_string(), - )); - }; - let _ = title; - match validate_link_url(&url) { - Ok(()) => { - self.push_inline_or_code( - Inline::Link { text: content, url }, - None, - blocks, - containers, - inline_stack, - )?; - } - Err(_) if !self.strict_mode => { - let fallback_text = flatten_inline_text(&content); - let fallback_text = if fallback_text.is_empty() { - url - } else { - fallback_text - }; - self.push_fallback_text(fallback_text, blocks, containers, inline_stack)?; - } - Err(error) => return Err(error), - } - } - TagEnd::Image => { - let Some(InlineContext::Image { url, title, alt }) = inline_stack.pop() else { - return Err(CoreRsError::InvalidMarkdown( - "image close without open".to_string(), - )); - }; - let alt_text = flatten_inline_text(&alt); - match parse_data_uri(&url) { - Ok((mime_type, extension, data)) => { - if total_embedded_image_bytes.saturating_add(data.len()) - > MAX_TOTAL_DATA_URI_IMAGE_BYTES - { - let error = DataImageError::TooLarge { - message: format!( - "total embedded image bytes exceed maximum supported size of {MAX_TOTAL_DATA_URI_IMAGE_BYTES} bytes" - ), - }; - if self.strict_mode { - return Err(error.into_core_error()); - } - self.push_fallback_text( - image_fallback_text(&alt_text, &url, error.fallback_kind()), - blocks, - containers, - inline_stack, - )?; - } else { - *total_embedded_image_bytes += data.len(); - self.push_inline_or_code( - Inline::Image(ImageData { - alt: alt_text, - title, - mime_type, - extension, - data, - }), - None, - blocks, - containers, - inline_stack, - )?; - } - } - Err(error) if !self.strict_mode => { - self.push_fallback_text( - image_fallback_text(&alt_text, &url, error.fallback_kind()), - blocks, - containers, - inline_stack, - )?; - } - Err(error) => return Err(error.into_core_error()), - } - } - TagEnd::DefinitionList - | TagEnd::DefinitionListTitle - | TagEnd::DefinitionListDefinition - | TagEnd::MetadataBlock(_) - | TagEnd::Strikethrough - | TagEnd::HtmlBlock - | TagEnd::FootnoteDefinition => {} - } - - Ok(()) - } - - fn handle_unsupported_event( - &self, - event: Event<'_>, - blocks: &mut Vec, - containers: &mut [Container], - unsupported_block_depth: &mut usize, - unsupported_block_content: &mut Vec, - ) -> Result<(), CoreRsError> { - match event { - Event::Start(tag) => { - if matches!(tag, Tag::Item) && !unsupported_block_content.is_empty() { - append_fallback_separator(unsupported_block_content); - } - if unsupported_block_nesting_tag(&tag) { - *unsupported_block_depth += 1; - } - } - Event::End(tag) => { - if unsupported_block_nesting_end(&tag) { - *unsupported_block_depth -= 1; - if *unsupported_block_depth == 0 { - trim_trailing_fallback_whitespace(unsupported_block_content); - if !unsupported_block_content.is_empty() { - self.push_block( - Block::Paragraph(std::mem::take(unsupported_block_content)), - blocks, - containers, - )?; - } - } - } - } - Event::Text(text) - | Event::Code(text) - | Event::Html(text) - | Event::InlineHtml(text) - | Event::InlineMath(text) - | Event::DisplayMath(text) => { - push_inline_merged(unsupported_block_content, Inline::Text(text.into_string())); - } - Event::SoftBreak => { - push_inline_merged(unsupported_block_content, Inline::Text(" ".to_string())); - } - Event::HardBreak => unsupported_block_content.push(Inline::HardBreak), - Event::Rule => { - push_inline_merged(unsupported_block_content, Inline::Text("---".to_string())); - } - Event::FootnoteReference(reference) => { - push_inline_merged( - unsupported_block_content, - Inline::Text(format!("[^{reference}]")), - ); - } - Event::TaskListMarker(checked) => { - push_inline_merged( - unsupported_block_content, - Inline::Text(if checked { - "[x] ".to_string() - } else { - "[ ] ".to_string() - }), - ); - } - } - - Ok(()) - } - - fn push_text( - &self, - text: String, - blocks: &mut Vec, - containers: &mut Vec, - inline_stack: &mut Vec, - ) -> Result<(), CoreRsError> { - self.push_inline_or_code(Inline::Text(text), None, blocks, containers, inline_stack) - } - - fn push_soft_break( - &self, - blocks: &mut Vec, - containers: &mut Vec, - inline_stack: &mut Vec, - ) -> Result<(), CoreRsError> { - self.push_inline_or_code( - Inline::Text(" ".to_string()), - Some('\n'), - blocks, - containers, - inline_stack, - ) - } - - fn push_hard_break( - &self, - blocks: &mut Vec, - containers: &mut Vec, - inline_stack: &mut Vec, - ) -> Result<(), CoreRsError> { - self.push_inline_or_code( - Inline::HardBreak, - Some('\n'), - blocks, - containers, - inline_stack, - ) - } - - fn push_inline_or_code( - &self, - inline: Inline, - code_break: Option, - blocks: &mut Vec, - containers: &mut Vec, - inline_stack: &mut Vec, - ) -> Result<(), CoreRsError> { - if let Some(last_inline) = inline_stack.last_mut() { - last_inline.push(inline); - return Ok(()); - } - - if let Some(Container::CodeBlock { code, .. }) = containers.last_mut() { - match (inline, code_break) { - (Inline::Text(text), _) | (Inline::Code(text), _) => code.push_str(&text), - (Inline::HardBreak, Some(break_char)) => code.push(break_char), - (Inline::HardBreak, None) => code.push('\n'), - _ => { - return Err(CoreRsError::InvalidMarkdown( - "invalid inline inside code block".to_string(), - )); - } - } - return Ok(()); - } - - if let Some( - Container::Paragraph(content) - | Container::TableCell(content) - | Container::Heading { content, .. }, - ) = containers.last_mut() - { - push_inline_merged(content, inline); - return Ok(()); - } - - if Self::push_inline_to_implicit_paragraph(containers, inline.clone()) { - return Ok(()); - } - - match inline { - Inline::Text(text) if !text.is_empty() => { - self.push_fallback_text(text, blocks, containers, inline_stack) - } - Inline::HardBreak => Err(CoreRsError::InvalidMarkdown( - "hard break appeared outside a text container".to_string(), - )), - _ => Err(CoreRsError::InvalidMarkdown( - "inline content appeared outside a text container".to_string(), - )), - } - } - - fn push_inline_to_implicit_paragraph(containers: &mut [Container], inline: Inline) -> bool { - match containers.last_mut() { - Some(Container::ListItem(item)) => { - Self::append_inline_to_paragraph_block(&mut item.blocks, inline); - true - } - Some(Container::BlockQuote(blocks)) => { - Self::append_inline_to_paragraph_block(blocks, inline); - true - } - _ => false, - } - } - - fn append_inline_to_paragraph_block(blocks: &mut Vec, inline: Inline) { - match blocks.last_mut() { - Some(Block::Paragraph(content)) => push_inline_merged(content, inline), - _ => blocks.push(Block::Paragraph(vec![inline])), - } - } - - fn push_fallback_text( - &self, - text: String, - blocks: &mut Vec, - containers: &mut Vec, - inline_stack: &mut Vec, - ) -> Result<(), CoreRsError> { - if text.is_empty() { - return Ok(()); - } - - if !inline_stack.is_empty() { - self.push_inline_or_code(Inline::Text(text), None, blocks, containers, inline_stack) - } else if let Some( - Container::Paragraph(_) - | Container::Heading { .. } - | Container::TableCell(_) - | Container::CodeBlock { .. }, - ) = containers.last() - { - self.push_inline_or_code(Inline::Text(text), None, blocks, containers, inline_stack) - } else { - self.push_block( - Block::Paragraph(vec![Inline::Text(text)]), - blocks, - containers, - ) - } - } - - fn push_block( - &self, - block: Block, - blocks: &mut Vec, - containers: &mut [Container], - ) -> Result<(), CoreRsError> { - if let Some(container) = containers.last_mut() { - match container { - Container::BlockQuote(quoted) => quoted.push(block), - Container::ListItem(item) => item.blocks.push(block), - _ => { - return Err(CoreRsError::InvalidMarkdown( - "block appeared in an invalid container".to_string(), - )); - } - } - } else { - blocks.push(block); - } - - Ok(()) - } -} - -fn validate_link_url(url: &str) -> Result<(), CoreRsError> { - let Some((scheme, _)) = url.split_once(':') else { - return Err(CoreRsError::UnsupportedFeature( - "relative link targets".to_string(), - )); - }; - let normalized = scheme.to_ascii_lowercase(); - if matches!(normalized.as_str(), "http" | "https" | "mailto") { - Ok(()) - } else { - Err(CoreRsError::UnsupportedFeature(format!( - "unsupported link scheme: {scheme}" - ))) - } -} diff --git a/packages/core-rs/src/parser/markdown/data_image.rs b/packages/core-rs/src/parser/markdown/data_image.rs index 333f2b0..d05685f 100644 --- a/packages/core-rs/src/parser/markdown/data_image.rs +++ b/packages/core-rs/src/parser/markdown/data_image.rs @@ -12,7 +12,7 @@ pub(super) enum ImageFallbackKind { } #[derive(Debug)] -pub(super) enum DataImageError { +pub(crate) enum DataImageError { NotDataUrl, InvalidDataUri, NonBase64, @@ -29,7 +29,7 @@ impl DataImageError { } } - pub(super) fn into_core_error(self) -> CoreRsError { + pub(crate) fn into_core_error(self) -> CoreRsError { match self { Self::NotDataUrl => CoreRsError::UnsupportedFeature("image".to_string()), Self::InvalidDataUri => { @@ -64,7 +64,7 @@ pub(super) fn image_fallback_text(alt_text: &str, url: &str, kind: ImageFallback } } -pub(super) fn parse_data_uri(url: &str) -> Result<(String, String, Vec), DataImageError> { +pub(crate) fn parse_data_uri(url: &str) -> Result<(String, String, Vec), DataImageError> { let Some(rest) = url.strip_prefix("data:") else { return Err(DataImageError::NotDataUrl); }; diff --git a/packages/core-rs/src/parser/markdown/events.rs b/packages/core-rs/src/parser/markdown/events.rs index 9158da1..18b01f8 100644 --- a/packages/core-rs/src/parser/markdown/events.rs +++ b/packages/core-rs/src/parser/markdown/events.rs @@ -44,16 +44,22 @@ impl MarkdownParser { rows: Vec::new(), })), Tag::TableHead => containers.push(Container::TableHead(Vec::new())), - Tag::TableRow => containers.push(Container::TableRow(TableRowBuilder { cells: Vec::new() })), + Tag::TableRow => { + containers.push(Container::TableRow(TableRowBuilder { cells: Vec::new() })) + } Tag::TableCell => containers.push(Container::TableCell(Vec::new())), Tag::Emphasis => inline_stack.push(InlineContext::Emphasis(Vec::new())), Tag::Strong => inline_stack.push(InlineContext::Strong(Vec::new())), - Tag::Link { dest_url, title, .. } => inline_stack.push(InlineContext::Link { + Tag::Link { + dest_url, title, .. + } => inline_stack.push(InlineContext::Link { url: dest_url.into_string(), title: empty_to_none(title.into_string()), content: Vec::new(), }), - Tag::Image { dest_url, title, .. } => inline_stack.push(InlineContext::Image { + Tag::Image { + dest_url, title, .. + } => inline_stack.push(InlineContext::Image { url: dest_url.into_string(), title: empty_to_none(title.into_string()), alt: Vec::new(), @@ -119,7 +125,9 @@ impl MarkdownParser { } TagEnd::List(_) => { let Some(Container::List(list)) = containers.pop() else { - return Err(CoreRsError::InvalidMarkdown("list close without open".to_string())); + return Err(CoreRsError::InvalidMarkdown( + "list close without open".to_string(), + )); }; self.push_block(Block::List(list), blocks, containers)?; } @@ -138,7 +146,9 @@ impl MarkdownParser { } TagEnd::Table => { let Some(Container::Table(table)) = containers.pop() else { - return Err(CoreRsError::InvalidMarkdown("table close without open".to_string())); + return Err(CoreRsError::InvalidMarkdown( + "table close without open".to_string(), + )); }; self.push_block( Block::Table(TableBlock { @@ -164,7 +174,9 @@ impl MarkdownParser { "table row close without open".to_string(), )); }; - current_table_mut(containers)?.rows.push(TableRow { cells: row.cells }); + current_table_mut(containers)? + .rows + .push(TableRow { cells: row.cells }); } TagEnd::TableCell => { let Some(Container::TableCell(cell)) = containers.pop() else { @@ -211,8 +223,15 @@ impl MarkdownParser { )?; } TagEnd::Link => { - let Some(InlineContext::Link { url, title, content }) = inline_stack.pop() else { - return Err(CoreRsError::InvalidMarkdown("link close without open".to_string())); + let Some(InlineContext::Link { + url, + title, + content, + }) = inline_stack.pop() + else { + return Err(CoreRsError::InvalidMarkdown( + "link close without open".to_string(), + )); }; let _ = title; match validate_link_url(&url) { @@ -232,19 +251,16 @@ impl MarkdownParser { } else { fallback_text }; - self.push_fallback_text( - fallback_text, - blocks, - containers, - inline_stack, - )?; + self.push_fallback_text(fallback_text, blocks, containers, inline_stack)?; } Err(error) => return Err(error), } } TagEnd::Image => { let Some(InlineContext::Image { url, title, alt }) = inline_stack.pop() else { - return Err(CoreRsError::InvalidMarkdown("image close without open".to_string())); + return Err(CoreRsError::InvalidMarkdown( + "image close without open".to_string(), + )); }; let alt_text = flatten_inline_text(&alt); match parse_data_uri(&url) { diff --git a/packages/core-rs/src/parser/markdown/mod.rs b/packages/core-rs/src/parser/markdown/mod.rs new file mode 100644 index 0000000..cf210cb --- /dev/null +++ b/packages/core-rs/src/parser/markdown/mod.rs @@ -0,0 +1,186 @@ +pub(crate) mod data_image; +mod events; +mod push; +mod state; + +#[cfg(test)] +mod tests; + +use pulldown_cmark::{Event, Options, Parser, Tag}; + +use self::state::{Container, current_list_depth, unsupported_block_feature}; +use crate::error::CoreRsError; +use crate::models::block::Block; +use crate::models::document::Document; +use crate::models::inline::Inline; + +const MAX_TOTAL_DATA_URI_IMAGE_BYTES: usize = 16 * 1024 * 1024; + +pub struct MarkdownParser { + strict_mode: bool, +} + +impl MarkdownParser { + pub fn new(strict_mode: bool) -> Self { + Self { strict_mode } + } + + pub fn parse(&self, markdown: &str) -> Result { + let parser = Parser::new_ext(markdown, Options::all()); + let mut blocks = Vec::new(); + let mut containers = Vec::new(); + let mut inline_stack = Vec::new(); + let mut unsupported_block_depth = 0usize; + let mut unsupported_block_content = Vec::new(); + let mut total_embedded_image_bytes = 0usize; + + for event in parser { + if unsupported_block_depth > 0 { + self.handle_unsupported_event( + event, + &mut blocks, + &mut containers, + &mut unsupported_block_depth, + &mut unsupported_block_content, + )?; + continue; + } + + match event { + Event::Start(tag) => { + if matches!(tag, Tag::List(_)) && current_list_depth(&containers) >= 2 { + if self.strict_mode { + return Err(CoreRsError::UnsupportedFeature( + "nested list depth > 2".to_string(), + )); + } + unsupported_block_depth = 1; + continue; + } + + if let Some(feature) = unsupported_block_feature(&tag) { + if self.strict_mode { + return Err(CoreRsError::UnsupportedFeature(feature.to_string())); + } + unsupported_block_depth = 1; + continue; + } + + self.handle_start(tag, &mut containers, &mut inline_stack)?; + } + Event::End(tag) => { + self.handle_end( + tag, + &mut blocks, + &mut containers, + &mut inline_stack, + &mut total_embedded_image_bytes, + )?; + } + Event::Text(text) => { + self.push_text( + text.into_string(), + &mut blocks, + &mut containers, + &mut inline_stack, + )?; + } + Event::Code(code) => { + self.push_inline_or_code( + Inline::Code(code.into_string()), + None, + &mut blocks, + &mut containers, + &mut inline_stack, + )?; + } + Event::SoftBreak => { + self.push_soft_break(&mut blocks, &mut containers, &mut inline_stack)?; + } + Event::HardBreak => { + self.push_hard_break(&mut blocks, &mut containers, &mut inline_stack)?; + } + Event::Rule => { + self.push_block(Block::ThematicBreak, &mut blocks, &mut containers)?; + } + Event::Html(html) | Event::InlineHtml(html) => { + if self.strict_mode { + return Err(CoreRsError::UnsupportedFeature("html".to_string())); + } + self.push_fallback_text( + html.into_string(), + &mut blocks, + &mut containers, + &mut inline_stack, + )?; + } + Event::FootnoteReference(reference) => { + if self.strict_mode { + return Err(CoreRsError::UnsupportedFeature("footnote".to_string())); + } + self.push_fallback_text( + format!("[^{reference}]"), + &mut blocks, + &mut containers, + &mut inline_stack, + )?; + } + Event::TaskListMarker(checked) => { + if self.strict_mode { + return Err(CoreRsError::UnsupportedFeature("task list".to_string())); + } + self.push_fallback_text( + if checked { + "[x] ".to_string() + } else { + "[ ] ".to_string() + }, + &mut blocks, + &mut containers, + &mut inline_stack, + )?; + } + Event::InlineMath(math) | Event::DisplayMath(math) => { + if self.strict_mode { + return Err(CoreRsError::UnsupportedFeature("math".to_string())); + } + self.push_fallback_text( + math.into_string(), + &mut blocks, + &mut containers, + &mut inline_stack, + )?; + } + } + } + + if !inline_stack.is_empty() || !containers.is_empty() { + return Err(CoreRsError::InvalidMarkdown( + "unbalanced markdown structure".to_string(), + )); + } + + Ok(Document { blocks }) + } + + fn push_inline_to_implicit_paragraph(containers: &mut [Container], inline: Inline) -> bool { + match containers.last_mut() { + Some(Container::ListItem(item)) => { + Self::append_inline_to_paragraph_block(&mut item.blocks, inline); + true + } + Some(Container::BlockQuote(blocks)) => { + Self::append_inline_to_paragraph_block(blocks, inline); + true + } + _ => false, + } + } + + fn append_inline_to_paragraph_block(blocks: &mut Vec, inline: Inline) { + match blocks.last_mut() { + Some(Block::Paragraph(content)) => state::push_inline_merged(content, inline), + _ => blocks.push(Block::Paragraph(vec![inline])), + } + } +} diff --git a/packages/core-rs/src/parser/markdown/push.rs b/packages/core-rs/src/parser/markdown/push.rs index ea8746e..87a1935 100644 --- a/packages/core-rs/src/parser/markdown/push.rs +++ b/packages/core-rs/src/parser/markdown/push.rs @@ -1,5 +1,5 @@ -use super::state::{Container, InlineContext, push_inline_merged}; use super::MarkdownParser; +use super::state::{Container, InlineContext, push_inline_merged}; use crate::error::CoreRsError; use crate::models::block::Block; use crate::models::inline::Inline; @@ -36,7 +36,13 @@ impl MarkdownParser { containers: &mut Vec, inline_stack: &mut Vec, ) -> Result<(), CoreRsError> { - self.push_inline_or_code(Inline::HardBreak, Some('\n'), blocks, containers, inline_stack) + self.push_inline_or_code( + Inline::HardBreak, + Some('\n'), + blocks, + containers, + inline_stack, + ) } pub(super) fn push_inline_or_code( @@ -76,6 +82,10 @@ impl MarkdownParser { return Ok(()); } + if Self::push_inline_to_implicit_paragraph(containers, inline.clone()) { + return Ok(()); + } + match inline { Inline::Text(text) if !text.is_empty() => { self.push_fallback_text(text, blocks, containers, inline_stack) @@ -111,7 +121,11 @@ impl MarkdownParser { { self.push_inline_or_code(Inline::Text(text), None, blocks, containers, inline_stack) } else { - self.push_block(Block::Paragraph(vec![Inline::Text(text)]), blocks, containers) + self.push_block( + Block::Paragraph(vec![Inline::Text(text)]), + blocks, + containers, + ) } } diff --git a/packages/core-rs/src/wasm.rs b/packages/core-rs/src/wasm.rs index 9e8921d..7c7a14c 100644 --- a/packages/core-rs/src/wasm.rs +++ b/packages/core-rs/src/wasm.rs @@ -1,6 +1,13 @@ +use base64::Engine; +use serde::Deserialize; +use serde::Serialize; use wasm_bindgen::prelude::*; -use crate::{DocxOptions, HwpxOptions, generate_docx, generate_hwpx}; +use crate::{ + ConversionTarget, DocxOptions, GenerationFailure, GenerationResult, HwpxOptions, + HwpxParagraphAlign, HwpxStyleOptions, analyze_markdown, generate_docx, + generate_docx_with_report, generate_hwpx, generate_hwpx_with_report, +}; #[wasm_bindgen(js_name = generateDocxBytes)] pub fn generate_docx_bytes( @@ -26,15 +33,153 @@ pub fn generate_hwpx_bytes( title: Option, author: Option, strict_mode: bool, + style_json: Option, ) -> Result, JsValue> { generate_hwpx( markdown, - HwpxOptions { + parse_hwpx_options(title, author, strict_mode, style_json)?, + ) + .map_err(|error| JsValue::from_str(&error.to_string())) +} + +#[wasm_bindgen(js_name = analyzeMarkdownJson)] +pub fn analyze_markdown_json(markdown: &str, target: &str) -> Result { + let target = match target { + "docx" => ConversionTarget::Docx, + "hwpx" => ConversionTarget::Hwpx, + _ => return Err(JsValue::from_str("target must be `docx` or `hwpx`")), + }; + serde_json::to_string(&analyze_markdown(markdown, target)) + .map_err(|error| JsValue::from_str(&error.to_string())) +} + +#[derive(Serialize)] +struct WasmGenerationResponse { + ok: bool, + #[serde(skip_serializing_if = "Option::is_none")] + bytes_base64: Option, + #[serde(skip_serializing_if = "Option::is_none")] + error: Option, + report: crate::ConversionReport, +} + +#[wasm_bindgen(js_name = generateDocxWithReportJson)] +pub fn generate_docx_with_report_json( + markdown: &str, + title: Option, + author: Option, + strict_mode: bool, +) -> Result { + let payload = match generate_docx_with_report( + markdown, + DocxOptions { title, author, strict_mode, - style: Default::default(), }, - ) - .map_err(|error| JsValue::from_str(&error.to_string())) + ) { + Ok(result) => WasmGenerationResponse::success(result), + Err(failure) => WasmGenerationResponse::failure(failure), + }; + serde_json::to_string(&payload).map_err(|error| JsValue::from_str(&error.to_string())) +} + +#[wasm_bindgen(js_name = generateHwpxWithReportJson)] +pub fn generate_hwpx_with_report_json( + markdown: &str, + title: Option, + author: Option, + strict_mode: bool, + style_json: Option, +) -> Result { + let payload = match generate_hwpx_with_report( + markdown, + parse_hwpx_options(title, author, strict_mode, style_json)?, + ) { + Ok(result) => WasmGenerationResponse::success(result), + Err(failure) => WasmGenerationResponse::failure(failure), + }; + serde_json::to_string(&payload).map_err(|error| JsValue::from_str(&error.to_string())) +} + +impl WasmGenerationResponse { + fn success(result: GenerationResult) -> Self { + Self { + ok: true, + bytes_base64: Some(base64::engine::general_purpose::STANDARD.encode(result.bytes)), + error: None, + report: result.report, + } + } + + fn failure(failure: GenerationFailure) -> Self { + Self { + ok: false, + bytes_base64: None, + error: Some(failure.error.to_string()), + report: failure.report, + } + } +} + +#[derive(Deserialize, Default)] +#[serde(rename_all = "camelCase")] +struct WasmHwpxStyleOptions { + body_font: Option, + heading_font: Option, + body_font_size: Option, + heading_font_size: Option, + text_color: Option, + heading_color: Option, + link_color: Option, + paragraph_align: Option, +} + +fn parse_hwpx_options( + title: Option, + author: Option, + strict_mode: bool, + style_json: Option, +) -> Result { + Ok(HwpxOptions { + title, + author, + strict_mode, + style: parse_hwpx_style(style_json)?, + }) +} + +fn parse_hwpx_style(style_json: Option) -> Result { + let Some(style_json) = style_json else { + return Ok(HwpxStyleOptions::default()); + }; + + let style = serde_json::from_str::(&style_json) + .map_err(|error| JsValue::from_str(&format!("invalid HWPX style options: {error}")))?; + + Ok(HwpxStyleOptions { + body_font: style.body_font, + heading_font: style.heading_font, + body_font_size: style.body_font_size, + heading_font_size: style.heading_font_size, + text_color: style.text_color, + heading_color: style.heading_color, + link_color: style.link_color, + paragraph_align: style + .paragraph_align + .map(|value| parse_hwpx_paragraph_align(&value)) + .transpose()?, + }) +} + +fn parse_hwpx_paragraph_align(value: &str) -> Result { + match value.to_ascii_lowercase().as_str() { + "left" => Ok(HwpxParagraphAlign::Left), + "center" => Ok(HwpxParagraphAlign::Center), + "right" => Ok(HwpxParagraphAlign::Right), + "justify" => Ok(HwpxParagraphAlign::Justify), + other => Err(JsValue::from_str(&format!( + "invalid HWPX paragraph alignment: {other}" + ))), + } } diff --git a/packages/core-rs/tests/docx/regression/archive.rs b/packages/core-rs/tests/docx/regression/archive.rs index d696ef7..3b81c89 100644 --- a/packages/core-rs/tests/docx/regression/archive.rs +++ b/packages/core-rs/tests/docx/regression/archive.rs @@ -40,7 +40,9 @@ fn normalizes_binary_entries_without_panicking() { writer.start_file("word/document.xml", options).unwrap(); writer.write_all(b"").unwrap(); writer.start_file("word/media/image1.png", options).unwrap(); - writer.write_all(&[0x89, b'P', b'N', b'G', 0x00, 0x01]).unwrap(); + writer + .write_all(&[0x89, b'P', b'N', b'G', 0x00, 0x01]) + .unwrap(); let bytes = writer.finish().unwrap().into_inner(); let normalized = normalized_entries(&bytes).unwrap(); diff --git a/packages/core-rs/tests/docx/regression/layout.rs b/packages/core-rs/tests/docx/regression/layout.rs index 52ec075..c263f34 100644 --- a/packages/core-rs/tests/docx/regression/layout.rs +++ b/packages/core-rs/tests/docx/regression/layout.rs @@ -10,7 +10,9 @@ fn list_item_starting_with_blockquote_does_not_create_empty_marker_paragraph() { let normalized = normalized_entries(&bytes).unwrap(); let document_xml = text_entry(&normalized, "word/document.xml"); - assert!(!document_xml.contains("• ")); assert!(document_xml.contains("quote")); } @@ -22,7 +24,9 @@ fn list_item_starting_with_code_block_does_not_create_empty_marker_paragraph() { let normalized = normalized_entries(&bytes).unwrap(); let document_xml = text_entry(&normalized, "word/document.xml"); - assert!(!document_xml.contains("• ")); assert!(document_xml.contains("code")); } @@ -51,7 +55,10 @@ fn blockquote_list_keeps_quote_border_on_list_items() { let normalized = normalized_entries(&bytes).unwrap(); let document_xml = text_entry(&normalized, "word/document.xml"); - assert!(document_xml.contains("")); + assert!( + document_xml + .contains("") + ); assert!(document_xml.contains("quoted item")); assert!( document_xml.contains("xml:space=\"preserve\">◦ ") @@ -66,7 +73,10 @@ fn blockquote_code_block_keeps_quote_border() { let normalized = normalized_entries(&bytes).unwrap(); let document_xml = text_entry(&normalized, "word/document.xml"); - assert!(document_xml.contains("")); + assert!( + document_xml + .contains("") + ); assert!(document_xml.contains("code")); } @@ -97,7 +107,10 @@ fn quoted_table_does_not_inject_hidden_anchor_paragraph() { assert!(document_xml.contains("")); assert!(!document_xml.contains("\u{200B}")); - assert!(document_xml.contains("")); + assert!( + document_xml + .contains("") + ); } #[test] @@ -113,7 +126,11 @@ fn nested_blockquote_does_not_duplicate_quote_border_properties() { #[test] fn includes_heading_styles_up_to_level_six() { - let bytes = generate_docx("#### Four\n\n##### Five\n\n###### Six", DocxOptions::default()).unwrap(); + let bytes = generate_docx( + "#### Four\n\n##### Five\n\n###### Six", + DocxOptions::default(), + ) + .unwrap(); let normalized = normalized_entries(&bytes).unwrap(); let document_xml = text_entry(&normalized, "word/document.xml"); diff --git a/packages/core-rs/tests/docx/regression/mod.rs b/packages/core-rs/tests/docx/regression/mod.rs index 58b485a..4a7427c 100644 --- a/packages/core-rs/tests/docx/regression/mod.rs +++ b/packages/core-rs/tests/docx/regression/mod.rs @@ -2,3 +2,4 @@ mod archive; mod fallback; mod images; mod layout; +mod report; diff --git a/packages/core-rs/tests/docx/regression/report.rs b/packages/core-rs/tests/docx/regression/report.rs new file mode 100644 index 0000000..ac4f5d9 --- /dev/null +++ b/packages/core-rs/tests/docx/regression/report.rs @@ -0,0 +1,43 @@ +use core_rs::{ + ConversionTarget, DocxOptions, IssueSeverity, analyze_markdown, generate_docx_with_report, +}; + +#[test] +fn analyze_markdown_reports_docx_fallback_issues() { + let report = analyze_markdown("raw", ConversionTarget::Docx); + assert!(report.degraded); + assert_eq!(report.unsupported_count, 0); + assert_eq!(report.fallback_count, 1); + assert_eq!(report.issues[0].feature, "HTML"); + assert_eq!(report.issues[0].severity, IssueSeverity::Warning); +} + +#[test] +fn strict_docx_report_failure_promotes_unsupported_issue() { + let failure = generate_docx_with_report("raw", DocxOptions::default()).unwrap_err(); + assert_eq!(failure.report.unsupported_count, 1); + assert_eq!(failure.report.fallback_count, 0); + assert_eq!(failure.report.issues[0].feature, "HTML"); + assert_eq!(failure.report.issues[0].severity, IssueSeverity::Error); + assert_eq!( + failure.report.issues[0].message, + "unsupported feature: html" + ); +} + +#[test] +fn compat_docx_report_marks_degraded_success() { + let generated = generate_docx_with_report( + "raw", + DocxOptions { + strict_mode: false, + ..DocxOptions::default() + }, + ) + .unwrap(); + + assert!(generated.report.degraded); + assert_eq!(generated.report.unsupported_count, 0); + assert_eq!(generated.report.fallback_count, 1); + assert!(!generated.bytes.is_empty()); +} diff --git a/packages/core-rs/tests/docx_test.rs b/packages/core-rs/tests/docx_test.rs index 8cc6d06..a0685ad 100644 --- a/packages/core-rs/tests/docx_test.rs +++ b/packages/core-rs/tests/docx_test.rs @@ -92,7 +92,10 @@ pub(crate) fn assert_xml_is_well_formed(entries: &BTreeMap(entries: &'a BTreeMap, path: &str) -> &'a str { +pub(crate) fn text_entry<'a>( + entries: &'a BTreeMap, + path: &str, +) -> &'a str { match entries.get(path).unwrap() { NormalizedEntry::Text(text) => text, NormalizedEntry::BinaryHash(_) => panic!("entry {path} is binary"), diff --git a/packages/core-rs/tests/fixtures/hwpx/approved/README.md b/packages/core-rs/tests/fixtures/hwpx/approved/README.md index 9e51538..8067c49 100644 --- a/packages/core-rs/tests/fixtures/hwpx/approved/README.md +++ b/packages/core-rs/tests/fixtures/hwpx/approved/README.md @@ -13,11 +13,17 @@ Current status: - `core-mixed` is manually validated and approved. - `list-basic` is manually validated and approved. - `list-nested-depth-2` is manually validated and approved. +- `ordered-list-basic` is manually validated and approved for compat mode. +- `ordered-list-nested-depth-2` is manually validated and approved for compat mode. - `table-basic` is manually validated and approved. +- `table-alignment` is manually validated and approved. - `style-typography` is manually validated and approved. - `style-centered-layout` is manually validated and approved. - `style-brand-color` is manually validated and approved. +Each approved fixture must record `manual_verified = true` in `fixture.toml`. Ordered-list fixtures are +approved compatibility fixtures, but they remain compat-only rather than strict-mode guarantees. + Approved style fixtures use Hancom-safe built-in fonts only. External fonts remain best-effort because the current HWPX path records font family names but does not embed font binaries. diff --git a/packages/core-rs/tests/fixtures/hwpx/approved/blockquote-basic/expected/Contents/header.xml b/packages/core-rs/tests/fixtures/hwpx/approved/blockquote-basic/expected/Contents/header.xml index 0d6bf34..a54baf2 100644 --- a/packages/core-rs/tests/fixtures/hwpx/approved/blockquote-basic/expected/Contents/header.xml +++ b/packages/core-rs/tests/fixtures/hwpx/approved/blockquote-basic/expected/Contents/header.xml @@ -1 +1 @@ -^1.^2.^3)^4)(^5)(^6)^7 \ No newline at end of file +^1.^2.^3)^4)(^5)(^6)^7 \ No newline at end of file diff --git a/packages/core-rs/tests/fixtures/hwpx/approved/blockquote-basic/expected/Contents/section0.xml b/packages/core-rs/tests/fixtures/hwpx/approved/blockquote-basic/expected/Contents/section0.xml index a930d4b..a8ba597 100644 --- a/packages/core-rs/tests/fixtures/hwpx/approved/blockquote-basic/expected/Contents/section0.xml +++ b/packages/core-rs/tests/fixtures/hwpx/approved/blockquote-basic/expected/Contents/section0.xml @@ -1 +1 @@ -Quoted paragraphAnother quoted line \ No newline at end of file +Quoted paragraphAnother quoted line \ No newline at end of file diff --git a/packages/core-rs/tests/fixtures/hwpx/approved/blockquote-basic/expected/settings.xml b/packages/core-rs/tests/fixtures/hwpx/approved/blockquote-basic/expected/settings.xml index 5d51824..27f1776 100644 --- a/packages/core-rs/tests/fixtures/hwpx/approved/blockquote-basic/expected/settings.xml +++ b/packages/core-rs/tests/fixtures/hwpx/approved/blockquote-basic/expected/settings.xml @@ -1 +1 @@ - \ No newline at end of file + \ No newline at end of file diff --git a/packages/core-rs/tests/fixtures/hwpx/approved/blockquote-basic/fixture.toml b/packages/core-rs/tests/fixtures/hwpx/approved/blockquote-basic/fixture.toml index 84d9794..3a4e079 100644 --- a/packages/core-rs/tests/fixtures/hwpx/approved/blockquote-basic/fixture.toml +++ b/packages/core-rs/tests/fixtures/hwpx/approved/blockquote-basic/fixture.toml @@ -1,4 +1,6 @@ title = "HWPX Blockquote Candidate" author = "Fixture Author" -strict_mode = false +strict_mode = true determinism = false +manual_verified = true +verified_at = "2026-03-11" diff --git a/packages/core-rs/tests/fixtures/hwpx/approved/blockquote-basic/golden.hwpx b/packages/core-rs/tests/fixtures/hwpx/approved/blockquote-basic/golden.hwpx index d194a94..c5bce81 100644 Binary files a/packages/core-rs/tests/fixtures/hwpx/approved/blockquote-basic/golden.hwpx and b/packages/core-rs/tests/fixtures/hwpx/approved/blockquote-basic/golden.hwpx differ diff --git a/packages/core-rs/tests/fixtures/hwpx/approved/blockquote-basic/hash.txt b/packages/core-rs/tests/fixtures/hwpx/approved/blockquote-basic/hash.txt index aa41144..d663cb2 100644 --- a/packages/core-rs/tests/fixtures/hwpx/approved/blockquote-basic/hash.txt +++ b/packages/core-rs/tests/fixtures/hwpx/approved/blockquote-basic/hash.txt @@ -1 +1 @@ -c575ac91d579379e8c48f3daa78565594d5de5bd311b9a09ec62da37aca4b8b4 \ No newline at end of file +efd4fc19595671ed91c20ae42b357cbd536d3c047a31a7163a732f4fc167b1f6 \ No newline at end of file diff --git a/packages/core-rs/tests/fixtures/hwpx/approved/code-block-basic/expected/Contents/header.xml b/packages/core-rs/tests/fixtures/hwpx/approved/code-block-basic/expected/Contents/header.xml index 0d6bf34..82be077 100644 --- a/packages/core-rs/tests/fixtures/hwpx/approved/code-block-basic/expected/Contents/header.xml +++ b/packages/core-rs/tests/fixtures/hwpx/approved/code-block-basic/expected/Contents/header.xml @@ -1 +1 @@ -^1.^2.^3)^4)(^5)(^6)^7 \ No newline at end of file +^1.^2.^3)^4)(^5)(^6)^7 \ No newline at end of file diff --git a/packages/core-rs/tests/fixtures/hwpx/approved/code-block-basic/expected/Contents/section0.xml b/packages/core-rs/tests/fixtures/hwpx/approved/code-block-basic/expected/Contents/section0.xml index c377ec7..0c7d14f 100644 --- a/packages/core-rs/tests/fixtures/hwpx/approved/code-block-basic/expected/Contents/section0.xml +++ b/packages/core-rs/tests/fixtures/hwpx/approved/code-block-basic/expected/Contents/section0.xml @@ -1 +1 @@ -fn main() { println!("hi");} \ No newline at end of file +fn main() { println!("hi");} \ No newline at end of file diff --git a/packages/core-rs/tests/fixtures/hwpx/approved/code-block-basic/fixture.toml b/packages/core-rs/tests/fixtures/hwpx/approved/code-block-basic/fixture.toml index 76bff88..fce30ae 100644 --- a/packages/core-rs/tests/fixtures/hwpx/approved/code-block-basic/fixture.toml +++ b/packages/core-rs/tests/fixtures/hwpx/approved/code-block-basic/fixture.toml @@ -2,3 +2,5 @@ title = "HWPX Code Block Candidate" author = "Fixture Author" strict_mode = false determinism = false +manual_verified = true +verified_at = "2026-03-11" diff --git a/packages/core-rs/tests/fixtures/hwpx/approved/code-block-basic/golden.hwpx b/packages/core-rs/tests/fixtures/hwpx/approved/code-block-basic/golden.hwpx index d684bf8..0676acd 100644 Binary files a/packages/core-rs/tests/fixtures/hwpx/approved/code-block-basic/golden.hwpx and b/packages/core-rs/tests/fixtures/hwpx/approved/code-block-basic/golden.hwpx differ diff --git a/packages/core-rs/tests/fixtures/hwpx/approved/code-block-basic/hash.txt b/packages/core-rs/tests/fixtures/hwpx/approved/code-block-basic/hash.txt index ff72653..8bf8f9b 100644 --- a/packages/core-rs/tests/fixtures/hwpx/approved/code-block-basic/hash.txt +++ b/packages/core-rs/tests/fixtures/hwpx/approved/code-block-basic/hash.txt @@ -1 +1 @@ -e82d92485f0dc05c7283cdaa3a392b05bba0843ecbe2f5d39cde704aaa4752db \ No newline at end of file +bf3116fe128ddcd96ddec6589c67fb756e0ae5c915691e45a2ab5ee9538007e0 \ No newline at end of file diff --git a/packages/core-rs/tests/fixtures/hwpx/approved/core-heading/expected/Contents/header.xml b/packages/core-rs/tests/fixtures/hwpx/approved/core-heading/expected/Contents/header.xml index ed6ab0c..9961f3e 100644 --- a/packages/core-rs/tests/fixtures/hwpx/approved/core-heading/expected/Contents/header.xml +++ b/packages/core-rs/tests/fixtures/hwpx/approved/core-heading/expected/Contents/header.xml @@ -1 +1 @@ -^1.^2.^3)^4)(^5)(^6)^7 \ No newline at end of file +^1.^2.^3)^4)(^5)(^6)^7 \ No newline at end of file diff --git a/packages/core-rs/tests/fixtures/hwpx/approved/core-heading/fixture.toml b/packages/core-rs/tests/fixtures/hwpx/approved/core-heading/fixture.toml index 14e7c51..4b4c2b5 100644 --- a/packages/core-rs/tests/fixtures/hwpx/approved/core-heading/fixture.toml +++ b/packages/core-rs/tests/fixtures/hwpx/approved/core-heading/fixture.toml @@ -2,3 +2,5 @@ title = "HWPX Heading Fixture" author = "Fixture Author" strict_mode = true determinism = false +manual_verified = true +verified_at = "2026-03-11" diff --git a/packages/core-rs/tests/fixtures/hwpx/approved/core-heading/golden.hwpx b/packages/core-rs/tests/fixtures/hwpx/approved/core-heading/golden.hwpx index 3a6d98e..8270ac9 100644 Binary files a/packages/core-rs/tests/fixtures/hwpx/approved/core-heading/golden.hwpx and b/packages/core-rs/tests/fixtures/hwpx/approved/core-heading/golden.hwpx differ diff --git a/packages/core-rs/tests/fixtures/hwpx/approved/core-heading/hash.txt b/packages/core-rs/tests/fixtures/hwpx/approved/core-heading/hash.txt index fb46286..204367d 100644 --- a/packages/core-rs/tests/fixtures/hwpx/approved/core-heading/hash.txt +++ b/packages/core-rs/tests/fixtures/hwpx/approved/core-heading/hash.txt @@ -1 +1 @@ -81c1148a0fdd8fd36f48e398562b870dd8a975f991b730a606ab43cfbb582b96 \ No newline at end of file +87568bc134baa789ad6f4948e70c44b6ef4e713b687d0c408f9fa4b0646c58d6 \ No newline at end of file diff --git a/packages/core-rs/tests/fixtures/hwpx/approved/core-inline-style/expected/Contents/content.hpf b/packages/core-rs/tests/fixtures/hwpx/approved/core-inline-style/expected/Contents/content.hpf index becd549..b38c482 100644 --- a/packages/core-rs/tests/fixtures/hwpx/approved/core-inline-style/expected/Contents/content.hpf +++ b/packages/core-rs/tests/fixtures/hwpx/approved/core-inline-style/expected/Contents/content.hpf @@ -1 +1 @@ -HWPX Inline Fixtureko(주)한글과컴퓨터docxly2004-11-09T15:23:46Z2026-03-08T07:30:22Z2017년 8월 3일 목요일 오후 4:55:39 \ No newline at end of file +HWPX Inline Fixtureko(주)한글과컴퓨터limchaesung2004-11-09T15:23:46Z2026-03-12T07:35:25Z2017년 8월 3일 목요일 오후 4:55:39 \ No newline at end of file diff --git a/packages/core-rs/tests/fixtures/hwpx/approved/core-inline-style/expected/Contents/header.xml b/packages/core-rs/tests/fixtures/hwpx/approved/core-inline-style/expected/Contents/header.xml index ed6ab0c..eef1b38 100644 --- a/packages/core-rs/tests/fixtures/hwpx/approved/core-inline-style/expected/Contents/header.xml +++ b/packages/core-rs/tests/fixtures/hwpx/approved/core-inline-style/expected/Contents/header.xml @@ -1 +1 @@ -^1.^2.^3)^4)(^5)(^6)^7 \ No newline at end of file +^1.^2.^3)^4)(^5)(^6)^7 \ No newline at end of file diff --git a/packages/core-rs/tests/fixtures/hwpx/approved/core-inline-style/expected/Contents/section0.xml b/packages/core-rs/tests/fixtures/hwpx/approved/core-inline-style/expected/Contents/section0.xml index 8e3c98e..774f378 100644 --- a/packages/core-rs/tests/fixtures/hwpx/approved/core-inline-style/expected/Contents/section0.xml +++ b/packages/core-rs/tests/fixtures/hwpx/approved/core-inline-style/expected/Contents/section0.xml @@ -1 +1 @@ -일반 굵게 기울임 코드 줄바꿈 다음 줄 \ No newline at end of file +일반 굵게 기울임 코드다음 줄 \ No newline at end of file diff --git a/packages/core-rs/tests/fixtures/hwpx/approved/core-inline-style/expected/Preview/PrvImage.png.sha256 b/packages/core-rs/tests/fixtures/hwpx/approved/core-inline-style/expected/Preview/PrvImage.png.sha256 index 21d6e53..10a44da 100644 --- a/packages/core-rs/tests/fixtures/hwpx/approved/core-inline-style/expected/Preview/PrvImage.png.sha256 +++ b/packages/core-rs/tests/fixtures/hwpx/approved/core-inline-style/expected/Preview/PrvImage.png.sha256 @@ -1 +1 @@ -a3cc714d6645caa3b0735b03d85d295b46cc486218b1d676fb74adcf4019df24 \ No newline at end of file +cf48092927a1ddeaeb80084aa1bd08078ae50db1d08fcacbfb90f548dc0eab4b \ No newline at end of file diff --git a/packages/core-rs/tests/fixtures/hwpx/approved/core-inline-style/expected/Preview/PrvText.txt b/packages/core-rs/tests/fixtures/hwpx/approved/core-inline-style/expected/Preview/PrvText.txt index a224c1d..991f795 100644 --- a/packages/core-rs/tests/fixtures/hwpx/approved/core-inline-style/expected/Preview/PrvText.txt +++ b/packages/core-rs/tests/fixtures/hwpx/approved/core-inline-style/expected/Preview/PrvText.txt @@ -1 +1 @@ -일반 굵게 기울임 코드 줄바꿈 다음 줄 \ No newline at end of file +일반 굵게 기울임 코드다음 줄 \ No newline at end of file diff --git a/packages/core-rs/tests/fixtures/hwpx/approved/core-inline-style/expected/settings.xml b/packages/core-rs/tests/fixtures/hwpx/approved/core-inline-style/expected/settings.xml index 5d51824..4f7b18f 100644 --- a/packages/core-rs/tests/fixtures/hwpx/approved/core-inline-style/expected/settings.xml +++ b/packages/core-rs/tests/fixtures/hwpx/approved/core-inline-style/expected/settings.xml @@ -1 +1 @@ - \ No newline at end of file + \ No newline at end of file diff --git a/packages/core-rs/tests/fixtures/hwpx/approved/core-inline-style/expected/version.xml b/packages/core-rs/tests/fixtures/hwpx/approved/core-inline-style/expected/version.xml index 549bb34..4ee1300 100644 --- a/packages/core-rs/tests/fixtures/hwpx/approved/core-inline-style/expected/version.xml +++ b/packages/core-rs/tests/fixtures/hwpx/approved/core-inline-style/expected/version.xml @@ -1 +1 @@ - \ No newline at end of file + \ No newline at end of file diff --git a/packages/core-rs/tests/fixtures/hwpx/approved/core-inline-style/fixture.toml b/packages/core-rs/tests/fixtures/hwpx/approved/core-inline-style/fixture.toml index db5693d..1704524 100644 --- a/packages/core-rs/tests/fixtures/hwpx/approved/core-inline-style/fixture.toml +++ b/packages/core-rs/tests/fixtures/hwpx/approved/core-inline-style/fixture.toml @@ -2,3 +2,5 @@ title = "HWPX Inline Fixture" author = "Fixture Author" strict_mode = true determinism = false +manual_verified = true +verified_at = "2026-03-11" diff --git a/packages/core-rs/tests/fixtures/hwpx/approved/core-inline-style/golden.hwpx b/packages/core-rs/tests/fixtures/hwpx/approved/core-inline-style/golden.hwpx index 55ef10a..08fb068 100644 Binary files a/packages/core-rs/tests/fixtures/hwpx/approved/core-inline-style/golden.hwpx and b/packages/core-rs/tests/fixtures/hwpx/approved/core-inline-style/golden.hwpx differ diff --git a/packages/core-rs/tests/fixtures/hwpx/approved/core-inline-style/hash.txt b/packages/core-rs/tests/fixtures/hwpx/approved/core-inline-style/hash.txt index 28de60c..abc1533 100644 --- a/packages/core-rs/tests/fixtures/hwpx/approved/core-inline-style/hash.txt +++ b/packages/core-rs/tests/fixtures/hwpx/approved/core-inline-style/hash.txt @@ -1 +1 @@ -6ebba80a3c8b9eb2c3813b374b643bc6d1eff9e06acf388d315073410253d2e3 \ No newline at end of file +f164d3b065e51bcf315570c128159f1864472335f179dd8c2cc87ec3bd59498e \ No newline at end of file diff --git a/packages/core-rs/tests/fixtures/hwpx/approved/core-link-text/expected/Contents/content.hpf b/packages/core-rs/tests/fixtures/hwpx/approved/core-link-text/expected/Contents/content.hpf index 00a9390..5a166ac 100644 --- a/packages/core-rs/tests/fixtures/hwpx/approved/core-link-text/expected/Contents/content.hpf +++ b/packages/core-rs/tests/fixtures/hwpx/approved/core-link-text/expected/Contents/content.hpf @@ -1 +1 @@ -HWPX Link Fixtureko(주)한글과컴퓨터docxly2004-11-09T15:23:46Z2026-03-08T07:30:22Z2017년 8월 3일 목요일 오후 4:55:39 \ No newline at end of file +HWPX Link Fixtureko(주)한글과컴퓨터limchaesung2004-11-09T15:23:46Z2026-03-12T07:31:17Z2017년 8월 3일 목요일 오후 4:55:39 \ No newline at end of file diff --git a/packages/core-rs/tests/fixtures/hwpx/approved/core-link-text/expected/Contents/header.xml b/packages/core-rs/tests/fixtures/hwpx/approved/core-link-text/expected/Contents/header.xml index ed6ab0c..0e56e19 100644 --- a/packages/core-rs/tests/fixtures/hwpx/approved/core-link-text/expected/Contents/header.xml +++ b/packages/core-rs/tests/fixtures/hwpx/approved/core-link-text/expected/Contents/header.xml @@ -1 +1 @@ -^1.^2.^3)^4)(^5)(^6)^7 \ No newline at end of file +^1.^2.^3)^4)(^5)(^6)^7 \ No newline at end of file diff --git a/packages/core-rs/tests/fixtures/hwpx/approved/core-link-text/expected/Contents/section0.xml b/packages/core-rs/tests/fixtures/hwpx/approved/core-link-text/expected/Contents/section0.xml index b0697d7..888fb5a 100644 --- a/packages/core-rs/tests/fixtures/hwpx/approved/core-link-text/expected/Contents/section0.xml +++ b/packages/core-rs/tests/fixtures/hwpx/approved/core-link-text/expected/Contents/section0.xml @@ -1 +1 @@ -문서 열기 (https://example.com) \ No newline at end of file +0https\://example.com/;1;0;0;https://example.com/HWPHYPERLINK_TYPE_URLHWPHYPERLINK_TARGET_BOOKMARKHWPHYPERLINK_JUMP_CURRENTTAB문서 열기 \ No newline at end of file diff --git a/packages/core-rs/tests/fixtures/hwpx/approved/core-link-text/expected/Preview/PrvImage.png.sha256 b/packages/core-rs/tests/fixtures/hwpx/approved/core-link-text/expected/Preview/PrvImage.png.sha256 index 21d6e53..61545e1 100644 --- a/packages/core-rs/tests/fixtures/hwpx/approved/core-link-text/expected/Preview/PrvImage.png.sha256 +++ b/packages/core-rs/tests/fixtures/hwpx/approved/core-link-text/expected/Preview/PrvImage.png.sha256 @@ -1 +1 @@ -a3cc714d6645caa3b0735b03d85d295b46cc486218b1d676fb74adcf4019df24 \ No newline at end of file +b77949f0701101310f665e8aaf38109fcd016235593ab470f115e9d492924578 \ No newline at end of file diff --git a/packages/core-rs/tests/fixtures/hwpx/approved/core-link-text/expected/Preview/PrvText.txt b/packages/core-rs/tests/fixtures/hwpx/approved/core-link-text/expected/Preview/PrvText.txt index ce003a1..49a9375 100644 --- a/packages/core-rs/tests/fixtures/hwpx/approved/core-link-text/expected/Preview/PrvText.txt +++ b/packages/core-rs/tests/fixtures/hwpx/approved/core-link-text/expected/Preview/PrvText.txt @@ -1 +1 @@ -문서 열기 (https://example.com) \ No newline at end of file +문서 열기 \ No newline at end of file diff --git a/packages/core-rs/tests/fixtures/hwpx/approved/core-link-text/expected/settings.xml b/packages/core-rs/tests/fixtures/hwpx/approved/core-link-text/expected/settings.xml index 5d51824..f2018a8 100644 --- a/packages/core-rs/tests/fixtures/hwpx/approved/core-link-text/expected/settings.xml +++ b/packages/core-rs/tests/fixtures/hwpx/approved/core-link-text/expected/settings.xml @@ -1 +1 @@ - \ No newline at end of file + \ No newline at end of file diff --git a/packages/core-rs/tests/fixtures/hwpx/approved/core-link-text/expected/version.xml b/packages/core-rs/tests/fixtures/hwpx/approved/core-link-text/expected/version.xml index 549bb34..4ee1300 100644 --- a/packages/core-rs/tests/fixtures/hwpx/approved/core-link-text/expected/version.xml +++ b/packages/core-rs/tests/fixtures/hwpx/approved/core-link-text/expected/version.xml @@ -1 +1 @@ - \ No newline at end of file + \ No newline at end of file diff --git a/packages/core-rs/tests/fixtures/hwpx/approved/core-link-text/fixture.toml b/packages/core-rs/tests/fixtures/hwpx/approved/core-link-text/fixture.toml index dbbcc79..9cbaf05 100644 --- a/packages/core-rs/tests/fixtures/hwpx/approved/core-link-text/fixture.toml +++ b/packages/core-rs/tests/fixtures/hwpx/approved/core-link-text/fixture.toml @@ -2,3 +2,5 @@ title = "HWPX Link Fixture" author = "Fixture Author" strict_mode = true determinism = false +manual_verified = true +verified_at = "2026-03-11" diff --git a/packages/core-rs/tests/fixtures/hwpx/approved/core-link-text/golden.hwpx b/packages/core-rs/tests/fixtures/hwpx/approved/core-link-text/golden.hwpx index a72caf7..a0339d3 100644 Binary files a/packages/core-rs/tests/fixtures/hwpx/approved/core-link-text/golden.hwpx and b/packages/core-rs/tests/fixtures/hwpx/approved/core-link-text/golden.hwpx differ diff --git a/packages/core-rs/tests/fixtures/hwpx/approved/core-link-text/hash.txt b/packages/core-rs/tests/fixtures/hwpx/approved/core-link-text/hash.txt index 6865f2b..2041719 100644 --- a/packages/core-rs/tests/fixtures/hwpx/approved/core-link-text/hash.txt +++ b/packages/core-rs/tests/fixtures/hwpx/approved/core-link-text/hash.txt @@ -1 +1 @@ -48a1467e3e0ac1a435db2f3b5de49e73cd413a6b5f269d2f4547fab35336f63d \ No newline at end of file +958afb20e21b57bd3f64339067a6d625cea5bed412557872f3534671b16f54ef \ No newline at end of file diff --git a/packages/core-rs/tests/fixtures/hwpx/approved/core-mixed/expected/Contents/content.hpf b/packages/core-rs/tests/fixtures/hwpx/approved/core-mixed/expected/Contents/content.hpf index 8279224..08d019d 100644 --- a/packages/core-rs/tests/fixtures/hwpx/approved/core-mixed/expected/Contents/content.hpf +++ b/packages/core-rs/tests/fixtures/hwpx/approved/core-mixed/expected/Contents/content.hpf @@ -1 +1 @@ -HWPX Mixed Fixtureko(주)한글과컴퓨터docxly2004-11-09T15:23:46Z2026-03-08T07:30:22Z2017년 8월 3일 목요일 오후 4:55:39 \ No newline at end of file +HWPX Mixed Fixtureko(주)한글과컴퓨터limchaesung2004-11-09T15:23:46Z2026-03-12T07:52:38Z2017년 8월 3일 목요일 오후 4:55:39 \ No newline at end of file diff --git a/packages/core-rs/tests/fixtures/hwpx/approved/core-mixed/expected/Contents/header.xml b/packages/core-rs/tests/fixtures/hwpx/approved/core-mixed/expected/Contents/header.xml index ed6ab0c..338dd18 100644 --- a/packages/core-rs/tests/fixtures/hwpx/approved/core-mixed/expected/Contents/header.xml +++ b/packages/core-rs/tests/fixtures/hwpx/approved/core-mixed/expected/Contents/header.xml @@ -1 +1 @@ -^1.^2.^3)^4)(^5)(^6)^7 \ No newline at end of file +^1.^2.^3)^4)(^5)(^6)^7 \ No newline at end of file diff --git a/packages/core-rs/tests/fixtures/hwpx/approved/core-mixed/expected/Contents/section0.xml b/packages/core-rs/tests/fixtures/hwpx/approved/core-mixed/expected/Contents/section0.xml index 3fc8291..68c89c2 100644 --- a/packages/core-rs/tests/fixtures/hwpx/approved/core-mixed/expected/Contents/section0.xml +++ b/packages/core-rs/tests/fixtures/hwpx/approved/core-mixed/expected/Contents/section0.xml @@ -1 +1 @@ -HWPX Core첫 문단은 강조기울임, 코드, 그리고 링크 (https://example.com) 를 포함합니다. 줄바꿈도 유지합니다. \ No newline at end of file +HWPX Core첫 문단은 강조기울임, 코드, 그리고 0https\://example.com/;1;0;0;https://example.com/HWPHYPERLINK_TYPE_URLHWPHYPERLINK_TARGET_BOOKMARKHWPHYPERLINK_JUMP_CURRENTTAB링크를 포함합니다.줄바꿈도 유지합니다. \ No newline at end of file diff --git a/packages/core-rs/tests/fixtures/hwpx/approved/core-mixed/expected/Preview/PrvImage.png.sha256 b/packages/core-rs/tests/fixtures/hwpx/approved/core-mixed/expected/Preview/PrvImage.png.sha256 index 21d6e53..a8808a0 100644 --- a/packages/core-rs/tests/fixtures/hwpx/approved/core-mixed/expected/Preview/PrvImage.png.sha256 +++ b/packages/core-rs/tests/fixtures/hwpx/approved/core-mixed/expected/Preview/PrvImage.png.sha256 @@ -1 +1 @@ -a3cc714d6645caa3b0735b03d85d295b46cc486218b1d676fb74adcf4019df24 \ No newline at end of file +b8b4d4f30ee337f2e4e1559db046e76af9b4168d00146f7bf405224a9389bbcb \ No newline at end of file diff --git a/packages/core-rs/tests/fixtures/hwpx/approved/core-mixed/expected/Preview/PrvText.txt b/packages/core-rs/tests/fixtures/hwpx/approved/core-mixed/expected/Preview/PrvText.txt index 310fe9e..84b2ed1 100644 --- a/packages/core-rs/tests/fixtures/hwpx/approved/core-mixed/expected/Preview/PrvText.txt +++ b/packages/core-rs/tests/fixtures/hwpx/approved/core-mixed/expected/Preview/PrvText.txt @@ -1,2 +1,3 @@ HWPX Core -첫 문단은 강조 와 기울임, 코드, 그리고 링크 (https://example.com) 를 포함합니다. 줄바꿈도 유지합니다. \ No newline at end of file +첫 문단은 강조와 기울임, 코드, 그리고 링크를 포함합니다. +줄바꿈도 유지합니다. \ No newline at end of file diff --git a/packages/core-rs/tests/fixtures/hwpx/approved/core-mixed/expected/settings.xml b/packages/core-rs/tests/fixtures/hwpx/approved/core-mixed/expected/settings.xml index 5d51824..ac93cca 100644 --- a/packages/core-rs/tests/fixtures/hwpx/approved/core-mixed/expected/settings.xml +++ b/packages/core-rs/tests/fixtures/hwpx/approved/core-mixed/expected/settings.xml @@ -1 +1 @@ - \ No newline at end of file + \ No newline at end of file diff --git a/packages/core-rs/tests/fixtures/hwpx/approved/core-mixed/expected/version.xml b/packages/core-rs/tests/fixtures/hwpx/approved/core-mixed/expected/version.xml index 549bb34..4ee1300 100644 --- a/packages/core-rs/tests/fixtures/hwpx/approved/core-mixed/expected/version.xml +++ b/packages/core-rs/tests/fixtures/hwpx/approved/core-mixed/expected/version.xml @@ -1 +1 @@ - \ No newline at end of file + \ No newline at end of file diff --git a/packages/core-rs/tests/fixtures/hwpx/approved/core-mixed/fixture.toml b/packages/core-rs/tests/fixtures/hwpx/approved/core-mixed/fixture.toml index b2bc3a5..805a7a5 100644 --- a/packages/core-rs/tests/fixtures/hwpx/approved/core-mixed/fixture.toml +++ b/packages/core-rs/tests/fixtures/hwpx/approved/core-mixed/fixture.toml @@ -2,3 +2,6 @@ title = "HWPX Mixed Fixture" author = "Fixture Author" strict_mode = true determinism = true +comparison_excludes = ["Contents/content.hpf", "Preview/PrvImage.png", "version.xml"] +manual_verified = true +verified_at = "2026-03-11" diff --git a/packages/core-rs/tests/fixtures/hwpx/approved/core-mixed/golden.hwpx b/packages/core-rs/tests/fixtures/hwpx/approved/core-mixed/golden.hwpx index 37c882e..b91bb05 100644 Binary files a/packages/core-rs/tests/fixtures/hwpx/approved/core-mixed/golden.hwpx and b/packages/core-rs/tests/fixtures/hwpx/approved/core-mixed/golden.hwpx differ diff --git a/packages/core-rs/tests/fixtures/hwpx/approved/core-mixed/hash.txt b/packages/core-rs/tests/fixtures/hwpx/approved/core-mixed/hash.txt index 0391a85..f06bc76 100644 --- a/packages/core-rs/tests/fixtures/hwpx/approved/core-mixed/hash.txt +++ b/packages/core-rs/tests/fixtures/hwpx/approved/core-mixed/hash.txt @@ -1 +1 @@ -2a37f488b5e10c63a0ab241acf5b8f68f2ff85a364ac196819ca975bf7f9d866 \ No newline at end of file +416ac67a9ab3962f3357cfa4e31145f69fdcbfafcf9ba7e1e37fe92f2f92ab2d \ No newline at end of file diff --git a/packages/core-rs/tests/fixtures/hwpx/approved/core-mixed/input.md b/packages/core-rs/tests/fixtures/hwpx/approved/core-mixed/input.md index 8485bf4..13f9905 100644 --- a/packages/core-rs/tests/fixtures/hwpx/approved/core-mixed/input.md +++ b/packages/core-rs/tests/fixtures/hwpx/approved/core-mixed/input.md @@ -1,4 +1,5 @@ # HWPX Core -첫 문단은 **강조** 와 *기울임*, `코드`, 그리고 [링크](https://example.com) 를 포함합니다.\ +첫 문단은 **강조** 와 *기울임*, `코드`, 그리고 [링크](https://example.com) 를 포함합니다. + 줄바꿈도 유지합니다. diff --git a/packages/core-rs/tests/fixtures/hwpx/approved/core-paragraph/expected/Contents/header.xml b/packages/core-rs/tests/fixtures/hwpx/approved/core-paragraph/expected/Contents/header.xml index 0d6bf34..d89b7a2 100644 --- a/packages/core-rs/tests/fixtures/hwpx/approved/core-paragraph/expected/Contents/header.xml +++ b/packages/core-rs/tests/fixtures/hwpx/approved/core-paragraph/expected/Contents/header.xml @@ -1 +1 @@ -^1.^2.^3)^4)(^5)(^6)^7 \ No newline at end of file +^1.^2.^3)^4)(^5)(^6)^7 \ No newline at end of file diff --git a/packages/core-rs/tests/fixtures/hwpx/approved/core-paragraph/expected/Contents/section0.xml b/packages/core-rs/tests/fixtures/hwpx/approved/core-paragraph/expected/Contents/section0.xml index 82fba4e..51569ee 100644 --- a/packages/core-rs/tests/fixtures/hwpx/approved/core-paragraph/expected/Contents/section0.xml +++ b/packages/core-rs/tests/fixtures/hwpx/approved/core-paragraph/expected/Contents/section0.xml @@ -1 +1 @@ -브라우저와 Rust 코어를 공유하는 기본 문단입니다. \ No newline at end of file +브라우저와 Rust 코어를 공유하는 기본 문단입니다. 브라우저와 Rust 코어를 공유하는 기본 문단입니다. 브라우저와 Rust 코어를 공유하는 기본 문단입니다.This is Second Contents. This is Second Contents. This is Second Contents. This is Second Contents. This is Second Contents. \ No newline at end of file diff --git a/packages/core-rs/tests/fixtures/hwpx/approved/core-paragraph/expected/Preview/PrvText.txt b/packages/core-rs/tests/fixtures/hwpx/approved/core-paragraph/expected/Preview/PrvText.txt index f9e5270..5537dc4 100644 --- a/packages/core-rs/tests/fixtures/hwpx/approved/core-paragraph/expected/Preview/PrvText.txt +++ b/packages/core-rs/tests/fixtures/hwpx/approved/core-paragraph/expected/Preview/PrvText.txt @@ -1 +1,2 @@ -브라우저와 Rust 코어를 공유하는 기본 문단입니다. \ No newline at end of file +브라우저와 Rust 코어를 공유하는 기본 문단입니다. 브라우저와 Rust 코어를 공유하는 기본 문단입니다. 브라우저와 Rust 코어를 공유하는 기본 문단입니다. +This is Second Contents. This is Second Contents. This is Second Contents. This is Second Contents. This is Second Contents. \ No newline at end of file diff --git a/packages/core-rs/tests/fixtures/hwpx/approved/core-paragraph/expected/settings.xml b/packages/core-rs/tests/fixtures/hwpx/approved/core-paragraph/expected/settings.xml index 5d51824..978b884 100644 --- a/packages/core-rs/tests/fixtures/hwpx/approved/core-paragraph/expected/settings.xml +++ b/packages/core-rs/tests/fixtures/hwpx/approved/core-paragraph/expected/settings.xml @@ -1 +1 @@ - \ No newline at end of file + \ No newline at end of file diff --git a/packages/core-rs/tests/fixtures/hwpx/approved/core-paragraph/fixture.toml b/packages/core-rs/tests/fixtures/hwpx/approved/core-paragraph/fixture.toml index fa18aa8..433fd04 100644 --- a/packages/core-rs/tests/fixtures/hwpx/approved/core-paragraph/fixture.toml +++ b/packages/core-rs/tests/fixtures/hwpx/approved/core-paragraph/fixture.toml @@ -2,3 +2,6 @@ title = "" author = "Fixture Author" strict_mode = true determinism = true +comparison_excludes = ["Contents/content.hpf", "Preview/PrvImage.png", "version.xml"] +manual_verified = true +verified_at = "2026-03-11" diff --git a/packages/core-rs/tests/fixtures/hwpx/approved/core-paragraph/golden.hwpx b/packages/core-rs/tests/fixtures/hwpx/approved/core-paragraph/golden.hwpx index 6f4ce45..3179270 100644 Binary files a/packages/core-rs/tests/fixtures/hwpx/approved/core-paragraph/golden.hwpx and b/packages/core-rs/tests/fixtures/hwpx/approved/core-paragraph/golden.hwpx differ diff --git a/packages/core-rs/tests/fixtures/hwpx/approved/core-paragraph/hash.txt b/packages/core-rs/tests/fixtures/hwpx/approved/core-paragraph/hash.txt index a878c9d..7eb1a35 100644 --- a/packages/core-rs/tests/fixtures/hwpx/approved/core-paragraph/hash.txt +++ b/packages/core-rs/tests/fixtures/hwpx/approved/core-paragraph/hash.txt @@ -1 +1 @@ -8d760c34cce95e8e19d689c04579e66463979ac2877c03e9115e239cb39aea4f \ No newline at end of file +7430ca1290f651980fe07018f17c8a116e6988ebac6c0750a2d5aff2884ef9f9 \ No newline at end of file diff --git a/packages/core-rs/tests/fixtures/hwpx/approved/core-paragraph/input.md b/packages/core-rs/tests/fixtures/hwpx/approved/core-paragraph/input.md index 488ff97..238c49a 100644 --- a/packages/core-rs/tests/fixtures/hwpx/approved/core-paragraph/input.md +++ b/packages/core-rs/tests/fixtures/hwpx/approved/core-paragraph/input.md @@ -1 +1,3 @@ -브라우저와 Rust 코어를 공유하는 기본 문단입니다. +브라우저와 Rust 코어를 공유하는 기본 문단입니다. 브라우저와 Rust 코어를 공유하는 기본 문단입니다. 브라우저와 Rust 코어를 공유하는 기본 문단입니다. + +This is Second Contents. This is Second Contents. This is Second Contents. This is Second Contents. This is Second Contents. diff --git a/packages/core-rs/tests/fixtures/hwpx/approved/list-basic/expected/Contents/header.xml b/packages/core-rs/tests/fixtures/hwpx/approved/list-basic/expected/Contents/header.xml index 033dcec..7aa7b2a 100644 --- a/packages/core-rs/tests/fixtures/hwpx/approved/list-basic/expected/Contents/header.xml +++ b/packages/core-rs/tests/fixtures/hwpx/approved/list-basic/expected/Contents/header.xml @@ -1 +1 @@ -^1.^2.^3)^4)(^5)(^6)^7 \ No newline at end of file +^1.^2.^3)^4)(^5)(^6)^7 \ No newline at end of file diff --git a/packages/core-rs/tests/fixtures/hwpx/approved/list-basic/expected/Contents/section0.xml b/packages/core-rs/tests/fixtures/hwpx/approved/list-basic/expected/Contents/section0.xml index 7439f42..678640c 100644 --- a/packages/core-rs/tests/fixtures/hwpx/approved/list-basic/expected/Contents/section0.xml +++ b/packages/core-rs/tests/fixtures/hwpx/approved/list-basic/expected/Contents/section0.xml @@ -1 +1 @@ -AlphaBetaGamma \ No newline at end of file +AlphaBetaGamma \ No newline at end of file diff --git a/packages/core-rs/tests/fixtures/hwpx/approved/list-basic/fixture.toml b/packages/core-rs/tests/fixtures/hwpx/approved/list-basic/fixture.toml index 4ec74c9..ad27fcc 100644 --- a/packages/core-rs/tests/fixtures/hwpx/approved/list-basic/fixture.toml +++ b/packages/core-rs/tests/fixtures/hwpx/approved/list-basic/fixture.toml @@ -2,3 +2,5 @@ title = "HWPX List Candidate" author = "Fixture Author" strict_mode = false determinism = false +manual_verified = true +verified_at = "2026-03-11" diff --git a/packages/core-rs/tests/fixtures/hwpx/approved/list-basic/golden.hwpx b/packages/core-rs/tests/fixtures/hwpx/approved/list-basic/golden.hwpx index e8d7a72..64e8c95 100644 Binary files a/packages/core-rs/tests/fixtures/hwpx/approved/list-basic/golden.hwpx and b/packages/core-rs/tests/fixtures/hwpx/approved/list-basic/golden.hwpx differ diff --git a/packages/core-rs/tests/fixtures/hwpx/approved/list-basic/hash.txt b/packages/core-rs/tests/fixtures/hwpx/approved/list-basic/hash.txt index 293d8db..820a15f 100644 --- a/packages/core-rs/tests/fixtures/hwpx/approved/list-basic/hash.txt +++ b/packages/core-rs/tests/fixtures/hwpx/approved/list-basic/hash.txt @@ -1 +1 @@ -0c54ee85b4061f5c9a9d26869dd932a14c99f48896a4469d42ab7a15ca1ff16b \ No newline at end of file +a696873286394c7ab58c363cfd4d3044e4ca44646083a1fad160faefa12b6ee4 \ No newline at end of file diff --git a/packages/core-rs/tests/fixtures/hwpx/approved/list-nested-depth-2/expected/Contents/header.xml b/packages/core-rs/tests/fixtures/hwpx/approved/list-nested-depth-2/expected/Contents/header.xml index 53c3b1d..2a4e629 100644 --- a/packages/core-rs/tests/fixtures/hwpx/approved/list-nested-depth-2/expected/Contents/header.xml +++ b/packages/core-rs/tests/fixtures/hwpx/approved/list-nested-depth-2/expected/Contents/header.xml @@ -1 +1 @@ -^1.^2.^3)^4)(^5)(^6)^7 \ No newline at end of file +^1.^2.^3)^4)(^5)(^6)^7 \ No newline at end of file diff --git a/packages/core-rs/tests/fixtures/hwpx/approved/list-nested-depth-2/expected/Contents/section0.xml b/packages/core-rs/tests/fixtures/hwpx/approved/list-nested-depth-2/expected/Contents/section0.xml index e5ca3bf..3cf1f15 100644 --- a/packages/core-rs/tests/fixtures/hwpx/approved/list-nested-depth-2/expected/Contents/section0.xml +++ b/packages/core-rs/tests/fixtures/hwpx/approved/list-nested-depth-2/expected/Contents/section0.xml @@ -1 +1 @@ -ParentChild oneChild twoSibling \ No newline at end of file +ParentChild oneChild twoSibling \ No newline at end of file diff --git a/packages/core-rs/tests/fixtures/hwpx/approved/list-nested-depth-2/fixture.toml b/packages/core-rs/tests/fixtures/hwpx/approved/list-nested-depth-2/fixture.toml index 43b5910..a5245c4 100644 --- a/packages/core-rs/tests/fixtures/hwpx/approved/list-nested-depth-2/fixture.toml +++ b/packages/core-rs/tests/fixtures/hwpx/approved/list-nested-depth-2/fixture.toml @@ -2,3 +2,5 @@ title = "HWPX Nested List Candidate" author = "Fixture Author" strict_mode = false determinism = false +manual_verified = true +verified_at = "2026-03-11" diff --git a/packages/core-rs/tests/fixtures/hwpx/approved/list-nested-depth-2/golden.hwpx b/packages/core-rs/tests/fixtures/hwpx/approved/list-nested-depth-2/golden.hwpx index 42713c9..ad999b0 100644 Binary files a/packages/core-rs/tests/fixtures/hwpx/approved/list-nested-depth-2/golden.hwpx and b/packages/core-rs/tests/fixtures/hwpx/approved/list-nested-depth-2/golden.hwpx differ diff --git a/packages/core-rs/tests/fixtures/hwpx/approved/list-nested-depth-2/hash.txt b/packages/core-rs/tests/fixtures/hwpx/approved/list-nested-depth-2/hash.txt index 178768a..5a662f4 100644 --- a/packages/core-rs/tests/fixtures/hwpx/approved/list-nested-depth-2/hash.txt +++ b/packages/core-rs/tests/fixtures/hwpx/approved/list-nested-depth-2/hash.txt @@ -1 +1 @@ -2afac16b46457a0d36293610a3bcb79e20e0b329fd79d73ff859a8d349f2f8f4 \ No newline at end of file +080e18382b4286616fb791edc269237430396e953ee47b076bd856a75fe4a440 \ No newline at end of file diff --git a/packages/core-rs/tests/fixtures/hwpx/approved/ordered-list-basic/expected/Contents/header.xml b/packages/core-rs/tests/fixtures/hwpx/approved/ordered-list-basic/expected/Contents/header.xml index 27ba8d9..0647ff4 100644 --- a/packages/core-rs/tests/fixtures/hwpx/approved/ordered-list-basic/expected/Contents/header.xml +++ b/packages/core-rs/tests/fixtures/hwpx/approved/ordered-list-basic/expected/Contents/header.xml @@ -1 +1 @@ -^1.^2.^3)^4)(^5)(^6)^7^1.^2.^3)^4)(^5)(^6)^7^8 \ No newline at end of file +^1.^2.^3)^4)(^5)(^6)^7^1.^2.^3)^4)(^5)(^6)^7^8 \ No newline at end of file diff --git a/packages/core-rs/tests/fixtures/hwpx/approved/ordered-list-basic/expected/Contents/section0.xml b/packages/core-rs/tests/fixtures/hwpx/approved/ordered-list-basic/expected/Contents/section0.xml index 7439f42..678640c 100644 --- a/packages/core-rs/tests/fixtures/hwpx/approved/ordered-list-basic/expected/Contents/section0.xml +++ b/packages/core-rs/tests/fixtures/hwpx/approved/ordered-list-basic/expected/Contents/section0.xml @@ -1 +1 @@ -AlphaBetaGamma \ No newline at end of file +AlphaBetaGamma \ No newline at end of file diff --git a/packages/core-rs/tests/fixtures/hwpx/approved/ordered-list-basic/fixture.toml b/packages/core-rs/tests/fixtures/hwpx/approved/ordered-list-basic/fixture.toml index 4ec74c9..ad27fcc 100644 --- a/packages/core-rs/tests/fixtures/hwpx/approved/ordered-list-basic/fixture.toml +++ b/packages/core-rs/tests/fixtures/hwpx/approved/ordered-list-basic/fixture.toml @@ -2,3 +2,5 @@ title = "HWPX List Candidate" author = "Fixture Author" strict_mode = false determinism = false +manual_verified = true +verified_at = "2026-03-11" diff --git a/packages/core-rs/tests/fixtures/hwpx/approved/ordered-list-basic/golden.hwpx b/packages/core-rs/tests/fixtures/hwpx/approved/ordered-list-basic/golden.hwpx index e97850a..1124631 100644 Binary files a/packages/core-rs/tests/fixtures/hwpx/approved/ordered-list-basic/golden.hwpx and b/packages/core-rs/tests/fixtures/hwpx/approved/ordered-list-basic/golden.hwpx differ diff --git a/packages/core-rs/tests/fixtures/hwpx/approved/ordered-list-basic/hash.txt b/packages/core-rs/tests/fixtures/hwpx/approved/ordered-list-basic/hash.txt index 6109858..e346704 100644 --- a/packages/core-rs/tests/fixtures/hwpx/approved/ordered-list-basic/hash.txt +++ b/packages/core-rs/tests/fixtures/hwpx/approved/ordered-list-basic/hash.txt @@ -1 +1 @@ -65857b59e5d15ab4b28033d3d49b55ebd2514be107e844bed1e54ac0cd791abc \ No newline at end of file +63fb7d14cb3e71475762e443cd4ac5bced0b0478c7fb3154df878f6a3e374a3f \ No newline at end of file diff --git a/packages/core-rs/tests/fixtures/hwpx/approved/ordered-list-nested-depth-2/expected/Contents/header.xml b/packages/core-rs/tests/fixtures/hwpx/approved/ordered-list-nested-depth-2/expected/Contents/header.xml index 0313b7b..3fc9eb2 100644 --- a/packages/core-rs/tests/fixtures/hwpx/approved/ordered-list-nested-depth-2/expected/Contents/header.xml +++ b/packages/core-rs/tests/fixtures/hwpx/approved/ordered-list-nested-depth-2/expected/Contents/header.xml @@ -1 +1 @@ -^1.^2.^3)^4)(^5)(^6)^7^1.^2.^3)^4)(^5)(^6)^7^8^1.^2.^3)^4)(^5)(^6)^7^8 \ No newline at end of file +^1.^2.^3)^4)(^5)(^6)^7^1.^2.^3)^4)(^5)(^6)^7^8^1.^2.^3)^4)(^5)(^6)^7^8 \ No newline at end of file diff --git a/packages/core-rs/tests/fixtures/hwpx/approved/ordered-list-nested-depth-2/expected/Contents/section0.xml b/packages/core-rs/tests/fixtures/hwpx/approved/ordered-list-nested-depth-2/expected/Contents/section0.xml index 150be35..6c26547 100644 --- a/packages/core-rs/tests/fixtures/hwpx/approved/ordered-list-nested-depth-2/expected/Contents/section0.xml +++ b/packages/core-rs/tests/fixtures/hwpx/approved/ordered-list-nested-depth-2/expected/Contents/section0.xml @@ -1 +1 @@ -ParentChild oneChild twoSibling \ No newline at end of file +ParentChild oneChild twoSibling \ No newline at end of file diff --git a/packages/core-rs/tests/fixtures/hwpx/approved/ordered-list-nested-depth-2/fixture.toml b/packages/core-rs/tests/fixtures/hwpx/approved/ordered-list-nested-depth-2/fixture.toml index 43b5910..a5245c4 100644 --- a/packages/core-rs/tests/fixtures/hwpx/approved/ordered-list-nested-depth-2/fixture.toml +++ b/packages/core-rs/tests/fixtures/hwpx/approved/ordered-list-nested-depth-2/fixture.toml @@ -2,3 +2,5 @@ title = "HWPX Nested List Candidate" author = "Fixture Author" strict_mode = false determinism = false +manual_verified = true +verified_at = "2026-03-11" diff --git a/packages/core-rs/tests/fixtures/hwpx/approved/ordered-list-nested-depth-2/golden.hwpx b/packages/core-rs/tests/fixtures/hwpx/approved/ordered-list-nested-depth-2/golden.hwpx index 0994c50..7d409ef 100644 Binary files a/packages/core-rs/tests/fixtures/hwpx/approved/ordered-list-nested-depth-2/golden.hwpx and b/packages/core-rs/tests/fixtures/hwpx/approved/ordered-list-nested-depth-2/golden.hwpx differ diff --git a/packages/core-rs/tests/fixtures/hwpx/approved/ordered-list-nested-depth-2/hash.txt b/packages/core-rs/tests/fixtures/hwpx/approved/ordered-list-nested-depth-2/hash.txt index 13b9803..5970a45 100644 --- a/packages/core-rs/tests/fixtures/hwpx/approved/ordered-list-nested-depth-2/hash.txt +++ b/packages/core-rs/tests/fixtures/hwpx/approved/ordered-list-nested-depth-2/hash.txt @@ -1 +1 @@ -9ad706f92b7c190a34d21cfb264b84658b23f33cb908eeba9f7817f50447d4ca \ No newline at end of file +3c1d7cb45d8758dcb0b636b1713e8edb58c5fd710c92d937de2b5371375910d0 \ No newline at end of file diff --git a/packages/core-rs/tests/fixtures/hwpx/approved/style-brand-color/expected/Contents/content.hpf b/packages/core-rs/tests/fixtures/hwpx/approved/style-brand-color/expected/Contents/content.hpf index f4e1b46..ee895eb 100644 --- a/packages/core-rs/tests/fixtures/hwpx/approved/style-brand-color/expected/Contents/content.hpf +++ b/packages/core-rs/tests/fixtures/hwpx/approved/style-brand-color/expected/Contents/content.hpf @@ -1 +1 @@ -Brand Color Candidateko(주)한글과컴퓨터docxly2004-11-09T15:23:46Z2026-03-08T07:30:22Z2017년 8월 3일 목요일 오후 4:55:39 \ No newline at end of file +Brand Color Candidateko(주)한글과컴퓨터limchaesung2004-11-09T15:23:46Z2026-03-12T07:38:04Z2017년 8월 3일 목요일 오후 4:55:39 \ No newline at end of file diff --git a/packages/core-rs/tests/fixtures/hwpx/approved/style-brand-color/expected/Contents/header.xml b/packages/core-rs/tests/fixtures/hwpx/approved/style-brand-color/expected/Contents/header.xml index 46e60cb..642631e 100644 --- a/packages/core-rs/tests/fixtures/hwpx/approved/style-brand-color/expected/Contents/header.xml +++ b/packages/core-rs/tests/fixtures/hwpx/approved/style-brand-color/expected/Contents/header.xml @@ -1 +1 @@ -^1.^2.^3)^4)(^5)(^6)^7 \ No newline at end of file +^1.^2.^3)^4)(^5)(^6)^7 \ No newline at end of file diff --git a/packages/core-rs/tests/fixtures/hwpx/approved/style-brand-color/expected/Contents/section0.xml b/packages/core-rs/tests/fixtures/hwpx/approved/style-brand-color/expected/Contents/section0.xml index 06a9869..29c8cb5 100644 --- a/packages/core-rs/tests/fixtures/hwpx/approved/style-brand-color/expected/Contents/section0.xml +++ b/packages/core-rs/tests/fixtures/hwpx/approved/style-brand-color/expected/Contents/section0.xml @@ -1 +1 @@ -브랜드 제목강조 텍스트코드 그리고 링크 (https://example.com) \ No newline at end of file +브랜드 제목강조 텍스트코드 그리고 0https\://example.com/;1;0;0;https://example.com/HWPHYPERLINK_TYPE_URLHWPHYPERLINK_TARGET_BOOKMARKHWPHYPERLINK_JUMP_CURRENTTAB링크 \ No newline at end of file diff --git a/packages/core-rs/tests/fixtures/hwpx/approved/style-brand-color/expected/Preview/PrvImage.png.sha256 b/packages/core-rs/tests/fixtures/hwpx/approved/style-brand-color/expected/Preview/PrvImage.png.sha256 index 21d6e53..6e95fe0 100644 --- a/packages/core-rs/tests/fixtures/hwpx/approved/style-brand-color/expected/Preview/PrvImage.png.sha256 +++ b/packages/core-rs/tests/fixtures/hwpx/approved/style-brand-color/expected/Preview/PrvImage.png.sha256 @@ -1 +1 @@ -a3cc714d6645caa3b0735b03d85d295b46cc486218b1d676fb74adcf4019df24 \ No newline at end of file +4c3b2c3fea1d4f7c72a6326588822feaa76edbb0e7fa27c223569d690181603a \ No newline at end of file diff --git a/packages/core-rs/tests/fixtures/hwpx/approved/style-brand-color/expected/Preview/PrvText.txt b/packages/core-rs/tests/fixtures/hwpx/approved/style-brand-color/expected/Preview/PrvText.txt index a971f9f..0b6339b 100644 --- a/packages/core-rs/tests/fixtures/hwpx/approved/style-brand-color/expected/Preview/PrvText.txt +++ b/packages/core-rs/tests/fixtures/hwpx/approved/style-brand-color/expected/Preview/PrvText.txt @@ -1,2 +1,2 @@ 브랜드 제목 -강조 텍스트 와 코드 그리고 링크 (https://example.com) \ No newline at end of file +강조 텍스트 와 코드 그리고 링크 \ No newline at end of file diff --git a/packages/core-rs/tests/fixtures/hwpx/approved/style-brand-color/expected/settings.xml b/packages/core-rs/tests/fixtures/hwpx/approved/style-brand-color/expected/settings.xml index 5d51824..978b884 100644 --- a/packages/core-rs/tests/fixtures/hwpx/approved/style-brand-color/expected/settings.xml +++ b/packages/core-rs/tests/fixtures/hwpx/approved/style-brand-color/expected/settings.xml @@ -1 +1 @@ - \ No newline at end of file + \ No newline at end of file diff --git a/packages/core-rs/tests/fixtures/hwpx/approved/style-brand-color/expected/version.xml b/packages/core-rs/tests/fixtures/hwpx/approved/style-brand-color/expected/version.xml index 549bb34..4ee1300 100644 --- a/packages/core-rs/tests/fixtures/hwpx/approved/style-brand-color/expected/version.xml +++ b/packages/core-rs/tests/fixtures/hwpx/approved/style-brand-color/expected/version.xml @@ -1 +1 @@ - \ No newline at end of file + \ No newline at end of file diff --git a/packages/core-rs/tests/fixtures/hwpx/approved/style-brand-color/fixture.toml b/packages/core-rs/tests/fixtures/hwpx/approved/style-brand-color/fixture.toml index 7c8e69c..c62217f 100644 --- a/packages/core-rs/tests/fixtures/hwpx/approved/style-brand-color/fixture.toml +++ b/packages/core-rs/tests/fixtures/hwpx/approved/style-brand-color/fixture.toml @@ -2,6 +2,9 @@ title = "Brand Color Candidate" author = "docxly" strict_mode = true determinism = true +comparison_excludes = ["Contents/content.hpf", "Preview/PrvImage.png", "version.xml"] +manual_verified = true +verified_at = "2026-03-11" [style] body_font = "함초롬바탕" diff --git a/packages/core-rs/tests/fixtures/hwpx/approved/style-brand-color/golden.hwpx b/packages/core-rs/tests/fixtures/hwpx/approved/style-brand-color/golden.hwpx index e747034..d442a75 100644 Binary files a/packages/core-rs/tests/fixtures/hwpx/approved/style-brand-color/golden.hwpx and b/packages/core-rs/tests/fixtures/hwpx/approved/style-brand-color/golden.hwpx differ diff --git a/packages/core-rs/tests/fixtures/hwpx/approved/style-brand-color/hash.txt b/packages/core-rs/tests/fixtures/hwpx/approved/style-brand-color/hash.txt index cd6d759..be80874 100644 --- a/packages/core-rs/tests/fixtures/hwpx/approved/style-brand-color/hash.txt +++ b/packages/core-rs/tests/fixtures/hwpx/approved/style-brand-color/hash.txt @@ -1 +1 @@ -2782296730e4652f9b6fd5b33b97c4f98b8e6e19fd7a02c5b5e5b50e1b578036 \ No newline at end of file +cac273cc5ee1aa36ad3e499b6e09d10e0ae76df33ca663d99c2e9d5fa44e4f08 \ No newline at end of file diff --git a/packages/core-rs/tests/fixtures/hwpx/approved/style-centered-layout/expected/Contents/content.hpf b/packages/core-rs/tests/fixtures/hwpx/approved/style-centered-layout/expected/Contents/content.hpf index 2028259..eae6be4 100644 --- a/packages/core-rs/tests/fixtures/hwpx/approved/style-centered-layout/expected/Contents/content.hpf +++ b/packages/core-rs/tests/fixtures/hwpx/approved/style-centered-layout/expected/Contents/content.hpf @@ -1 +1 @@ -Centered Layout Candidateko(주)한글과컴퓨터docxly2004-11-09T15:23:46Z2026-03-08T07:30:22Z2017년 8월 3일 목요일 오후 4:55:39 \ No newline at end of file +Centered Layout Candidateko(주)한글과컴퓨터limchaesung2004-11-09T15:23:46Z2026-03-12T07:39:01Z2017년 8월 3일 목요일 오후 4:55:39 \ No newline at end of file diff --git a/packages/core-rs/tests/fixtures/hwpx/approved/style-centered-layout/expected/Contents/header.xml b/packages/core-rs/tests/fixtures/hwpx/approved/style-centered-layout/expected/Contents/header.xml index 4f7e66c..3991567 100644 --- a/packages/core-rs/tests/fixtures/hwpx/approved/style-centered-layout/expected/Contents/header.xml +++ b/packages/core-rs/tests/fixtures/hwpx/approved/style-centered-layout/expected/Contents/header.xml @@ -1 +1 @@ -^1.^2.^3)^4)(^5)(^6)^7 \ No newline at end of file +^1.^2.^3)^4)(^5)(^6)^7 \ No newline at end of file diff --git a/packages/core-rs/tests/fixtures/hwpx/approved/style-centered-layout/expected/Contents/section0.xml b/packages/core-rs/tests/fixtures/hwpx/approved/style-centered-layout/expected/Contents/section0.xml index 40cc1d7..830d9ae 100644 --- a/packages/core-rs/tests/fixtures/hwpx/approved/style-centered-layout/expected/Contents/section0.xml +++ b/packages/core-rs/tests/fixtures/hwpx/approved/style-centered-layout/expected/Contents/section0.xml @@ -1 +1 @@ -중앙 정렬중앙 정렬 본문입니다.두 번째 문단입니다. \ No newline at end of file +중앙 정렬중앙 정렬 본문입니다.두 번째 문단입니다. \ No newline at end of file diff --git a/packages/core-rs/tests/fixtures/hwpx/approved/style-centered-layout/expected/Preview/PrvImage.png.sha256 b/packages/core-rs/tests/fixtures/hwpx/approved/style-centered-layout/expected/Preview/PrvImage.png.sha256 index 21d6e53..5838135 100644 --- a/packages/core-rs/tests/fixtures/hwpx/approved/style-centered-layout/expected/Preview/PrvImage.png.sha256 +++ b/packages/core-rs/tests/fixtures/hwpx/approved/style-centered-layout/expected/Preview/PrvImage.png.sha256 @@ -1 +1 @@ -a3cc714d6645caa3b0735b03d85d295b46cc486218b1d676fb74adcf4019df24 \ No newline at end of file +8fed5270608c6a573ec78ec6f1449bba68f41d50554b5057442ee513de6a91be \ No newline at end of file diff --git a/packages/core-rs/tests/fixtures/hwpx/approved/style-centered-layout/expected/settings.xml b/packages/core-rs/tests/fixtures/hwpx/approved/style-centered-layout/expected/settings.xml index 5d51824..1da4edd 100644 --- a/packages/core-rs/tests/fixtures/hwpx/approved/style-centered-layout/expected/settings.xml +++ b/packages/core-rs/tests/fixtures/hwpx/approved/style-centered-layout/expected/settings.xml @@ -1 +1 @@ - \ No newline at end of file + \ No newline at end of file diff --git a/packages/core-rs/tests/fixtures/hwpx/approved/style-centered-layout/expected/version.xml b/packages/core-rs/tests/fixtures/hwpx/approved/style-centered-layout/expected/version.xml index 549bb34..4ee1300 100644 --- a/packages/core-rs/tests/fixtures/hwpx/approved/style-centered-layout/expected/version.xml +++ b/packages/core-rs/tests/fixtures/hwpx/approved/style-centered-layout/expected/version.xml @@ -1 +1 @@ - \ No newline at end of file + \ No newline at end of file diff --git a/packages/core-rs/tests/fixtures/hwpx/approved/style-centered-layout/fixture.toml b/packages/core-rs/tests/fixtures/hwpx/approved/style-centered-layout/fixture.toml index bd4ded6..aa75215 100644 --- a/packages/core-rs/tests/fixtures/hwpx/approved/style-centered-layout/fixture.toml +++ b/packages/core-rs/tests/fixtures/hwpx/approved/style-centered-layout/fixture.toml @@ -2,6 +2,9 @@ title = "Centered Layout Candidate" author = "docxly" strict_mode = true determinism = true +comparison_excludes = ["Contents/content.hpf", "Preview/PrvImage.png", "version.xml"] +manual_verified = true +verified_at = "2026-03-11" [style] body_font = "함초롬바탕" diff --git a/packages/core-rs/tests/fixtures/hwpx/approved/style-centered-layout/golden.hwpx b/packages/core-rs/tests/fixtures/hwpx/approved/style-centered-layout/golden.hwpx index 43c6a94..fe582ab 100644 Binary files a/packages/core-rs/tests/fixtures/hwpx/approved/style-centered-layout/golden.hwpx and b/packages/core-rs/tests/fixtures/hwpx/approved/style-centered-layout/golden.hwpx differ diff --git a/packages/core-rs/tests/fixtures/hwpx/approved/style-centered-layout/hash.txt b/packages/core-rs/tests/fixtures/hwpx/approved/style-centered-layout/hash.txt index 1d07791..46c1337 100644 --- a/packages/core-rs/tests/fixtures/hwpx/approved/style-centered-layout/hash.txt +++ b/packages/core-rs/tests/fixtures/hwpx/approved/style-centered-layout/hash.txt @@ -1 +1 @@ -4a1c3ed47c398c3002b5db071f94dcd56caaeb05bf2c335a02236a41904f82a9 \ No newline at end of file +2e348942e5d85b3421ff6ebb67fa5d93a1d9285b6e1766640be81251125085a9 \ No newline at end of file diff --git a/packages/core-rs/tests/fixtures/hwpx/approved/style-typography/expected/Contents/content.hpf b/packages/core-rs/tests/fixtures/hwpx/approved/style-typography/expected/Contents/content.hpf index c18c8db..27671c3 100644 --- a/packages/core-rs/tests/fixtures/hwpx/approved/style-typography/expected/Contents/content.hpf +++ b/packages/core-rs/tests/fixtures/hwpx/approved/style-typography/expected/Contents/content.hpf @@ -1 +1 @@ -Typography Candidateko(주)한글과컴퓨터docxly2004-11-09T15:23:46Z2026-03-08T07:30:22Z2017년 8월 3일 목요일 오후 4:55:39 \ No newline at end of file +Typography Candidateko(주)한글과컴퓨터limchaesung2004-11-09T15:23:46Z2026-03-12T07:36:49Z2017년 8월 3일 목요일 오후 4:55:39 \ No newline at end of file diff --git a/packages/core-rs/tests/fixtures/hwpx/approved/style-typography/expected/Contents/header.xml b/packages/core-rs/tests/fixtures/hwpx/approved/style-typography/expected/Contents/header.xml index 672418b..bd500ff 100644 --- a/packages/core-rs/tests/fixtures/hwpx/approved/style-typography/expected/Contents/header.xml +++ b/packages/core-rs/tests/fixtures/hwpx/approved/style-typography/expected/Contents/header.xml @@ -1 +1 @@ -^1.^2.^3)^4)(^5)(^6)^7 \ No newline at end of file +^1.^2.^3)^4)(^5)(^6)^7 \ No newline at end of file diff --git a/packages/core-rs/tests/fixtures/hwpx/approved/style-typography/expected/Contents/section0.xml b/packages/core-rs/tests/fixtures/hwpx/approved/style-typography/expected/Contents/section0.xml index df5eb6f..c49f242 100644 --- a/packages/core-rs/tests/fixtures/hwpx/approved/style-typography/expected/Contents/section0.xml +++ b/packages/core-rs/tests/fixtures/hwpx/approved/style-typography/expected/Contents/section0.xml @@ -1 +1 @@ -제목본문 문단입니다.링크 (https://example.com) \ No newline at end of file +제목본문 문단입니다.0https\://example.com/;1;0;0;https://example.com/HWPHYPERLINK_TYPE_URLHWPHYPERLINK_TARGET_BOOKMARKHWPHYPERLINK_JUMP_CURRENTTAB링크 \ No newline at end of file diff --git a/packages/core-rs/tests/fixtures/hwpx/approved/style-typography/expected/Preview/PrvImage.png.sha256 b/packages/core-rs/tests/fixtures/hwpx/approved/style-typography/expected/Preview/PrvImage.png.sha256 index 21d6e53..21786d2 100644 --- a/packages/core-rs/tests/fixtures/hwpx/approved/style-typography/expected/Preview/PrvImage.png.sha256 +++ b/packages/core-rs/tests/fixtures/hwpx/approved/style-typography/expected/Preview/PrvImage.png.sha256 @@ -1 +1 @@ -a3cc714d6645caa3b0735b03d85d295b46cc486218b1d676fb74adcf4019df24 \ No newline at end of file +735ab1697d7cc3643cd750e5eafeb29fad0d25533dbbc8149f9a8167aef06bf5 \ No newline at end of file diff --git a/packages/core-rs/tests/fixtures/hwpx/approved/style-typography/expected/Preview/PrvText.txt b/packages/core-rs/tests/fixtures/hwpx/approved/style-typography/expected/Preview/PrvText.txt index 07e0cf6..88426ff 100644 --- a/packages/core-rs/tests/fixtures/hwpx/approved/style-typography/expected/Preview/PrvText.txt +++ b/packages/core-rs/tests/fixtures/hwpx/approved/style-typography/expected/Preview/PrvText.txt @@ -1,3 +1,3 @@ 제목 본문 문단입니다. -링크 (https://example.com) \ No newline at end of file +링크 \ No newline at end of file diff --git a/packages/core-rs/tests/fixtures/hwpx/approved/style-typography/expected/settings.xml b/packages/core-rs/tests/fixtures/hwpx/approved/style-typography/expected/settings.xml index 5d51824..395fe43 100644 --- a/packages/core-rs/tests/fixtures/hwpx/approved/style-typography/expected/settings.xml +++ b/packages/core-rs/tests/fixtures/hwpx/approved/style-typography/expected/settings.xml @@ -1 +1 @@ - \ No newline at end of file + \ No newline at end of file diff --git a/packages/core-rs/tests/fixtures/hwpx/approved/style-typography/expected/version.xml b/packages/core-rs/tests/fixtures/hwpx/approved/style-typography/expected/version.xml index 549bb34..4ee1300 100644 --- a/packages/core-rs/tests/fixtures/hwpx/approved/style-typography/expected/version.xml +++ b/packages/core-rs/tests/fixtures/hwpx/approved/style-typography/expected/version.xml @@ -1 +1 @@ - \ No newline at end of file + \ No newline at end of file diff --git a/packages/core-rs/tests/fixtures/hwpx/approved/style-typography/fixture.toml b/packages/core-rs/tests/fixtures/hwpx/approved/style-typography/fixture.toml index 3bd6183..0d2b5ef 100644 --- a/packages/core-rs/tests/fixtures/hwpx/approved/style-typography/fixture.toml +++ b/packages/core-rs/tests/fixtures/hwpx/approved/style-typography/fixture.toml @@ -2,6 +2,9 @@ title = "Typography Candidate" author = "docxly" strict_mode = true determinism = true +comparison_excludes = ["Contents/content.hpf", "Preview/PrvImage.png", "version.xml"] +manual_verified = true +verified_at = "2026-03-11" [style] body_font = "함초롬바탕" diff --git a/packages/core-rs/tests/fixtures/hwpx/approved/style-typography/golden.hwpx b/packages/core-rs/tests/fixtures/hwpx/approved/style-typography/golden.hwpx index 70f7ec5..573864f 100644 Binary files a/packages/core-rs/tests/fixtures/hwpx/approved/style-typography/golden.hwpx and b/packages/core-rs/tests/fixtures/hwpx/approved/style-typography/golden.hwpx differ diff --git a/packages/core-rs/tests/fixtures/hwpx/approved/style-typography/hash.txt b/packages/core-rs/tests/fixtures/hwpx/approved/style-typography/hash.txt index ec3c3d5..9101d98 100644 --- a/packages/core-rs/tests/fixtures/hwpx/approved/style-typography/hash.txt +++ b/packages/core-rs/tests/fixtures/hwpx/approved/style-typography/hash.txt @@ -1 +1 @@ -d1def3f1a65cced43c9fb6f1676bc319ac076a800a0478c0e8c8fca606ab0d3f \ No newline at end of file +2b8bca0973dc877aa3903773858d1e348abaa31492ab5e856fe1dd30e4c87004 \ No newline at end of file diff --git a/packages/core-rs/tests/fixtures/hwpx/approved/table-alignment/expected/Contents/header.xml b/packages/core-rs/tests/fixtures/hwpx/approved/table-alignment/expected/Contents/header.xml index 20b1833..bddd782 100644 --- a/packages/core-rs/tests/fixtures/hwpx/approved/table-alignment/expected/Contents/header.xml +++ b/packages/core-rs/tests/fixtures/hwpx/approved/table-alignment/expected/Contents/header.xml @@ -1 +1 @@ -^1.^2.^3)^4)(^5)(^6)^7 \ No newline at end of file +^1.^2.^3)^4)(^5)(^6)^7 \ No newline at end of file diff --git a/packages/core-rs/tests/fixtures/hwpx/approved/table-alignment/fixture.toml b/packages/core-rs/tests/fixtures/hwpx/approved/table-alignment/fixture.toml index 0a2ff7c..2529002 100644 --- a/packages/core-rs/tests/fixtures/hwpx/approved/table-alignment/fixture.toml +++ b/packages/core-rs/tests/fixtures/hwpx/approved/table-alignment/fixture.toml @@ -2,3 +2,5 @@ title = "HWPX Table Candidate" author = "Fixture Author" strict_mode = false determinism = false +manual_verified = true +verified_at = "2026-03-11" diff --git a/packages/core-rs/tests/fixtures/hwpx/approved/table-alignment/golden.hwpx b/packages/core-rs/tests/fixtures/hwpx/approved/table-alignment/golden.hwpx index f3a675b..66d105b 100644 Binary files a/packages/core-rs/tests/fixtures/hwpx/approved/table-alignment/golden.hwpx and b/packages/core-rs/tests/fixtures/hwpx/approved/table-alignment/golden.hwpx differ diff --git a/packages/core-rs/tests/fixtures/hwpx/approved/table-alignment/hash.txt b/packages/core-rs/tests/fixtures/hwpx/approved/table-alignment/hash.txt index 1594d10..80b9e78 100644 --- a/packages/core-rs/tests/fixtures/hwpx/approved/table-alignment/hash.txt +++ b/packages/core-rs/tests/fixtures/hwpx/approved/table-alignment/hash.txt @@ -1 +1 @@ -0293b64b18d990a04b6b153607a3199d4ae77a3bfc0fd8d6328e5a0578b2458f \ No newline at end of file +8cb81c5b233dc6e6cc4e1ae8bfdd95f6ed147a69e0c1597ce548d94096eaebdc \ No newline at end of file diff --git a/packages/core-rs/tests/fixtures/hwpx/approved/table-basic/expected/Contents/header.xml b/packages/core-rs/tests/fixtures/hwpx/approved/table-basic/expected/Contents/header.xml index 20b1833..bddd782 100644 --- a/packages/core-rs/tests/fixtures/hwpx/approved/table-basic/expected/Contents/header.xml +++ b/packages/core-rs/tests/fixtures/hwpx/approved/table-basic/expected/Contents/header.xml @@ -1 +1 @@ -^1.^2.^3)^4)(^5)(^6)^7 \ No newline at end of file +^1.^2.^3)^4)(^5)(^6)^7 \ No newline at end of file diff --git a/packages/core-rs/tests/fixtures/hwpx/approved/table-basic/fixture.toml b/packages/core-rs/tests/fixtures/hwpx/approved/table-basic/fixture.toml index 0a2ff7c..2529002 100644 --- a/packages/core-rs/tests/fixtures/hwpx/approved/table-basic/fixture.toml +++ b/packages/core-rs/tests/fixtures/hwpx/approved/table-basic/fixture.toml @@ -2,3 +2,5 @@ title = "HWPX Table Candidate" author = "Fixture Author" strict_mode = false determinism = false +manual_verified = true +verified_at = "2026-03-11" diff --git a/packages/core-rs/tests/fixtures/hwpx/approved/table-basic/golden.hwpx b/packages/core-rs/tests/fixtures/hwpx/approved/table-basic/golden.hwpx index f3a675b..66d105b 100644 Binary files a/packages/core-rs/tests/fixtures/hwpx/approved/table-basic/golden.hwpx and b/packages/core-rs/tests/fixtures/hwpx/approved/table-basic/golden.hwpx differ diff --git a/packages/core-rs/tests/fixtures/hwpx/approved/table-basic/hash.txt b/packages/core-rs/tests/fixtures/hwpx/approved/table-basic/hash.txt index 1594d10..80b9e78 100644 --- a/packages/core-rs/tests/fixtures/hwpx/approved/table-basic/hash.txt +++ b/packages/core-rs/tests/fixtures/hwpx/approved/table-basic/hash.txt @@ -1 +1 @@ -0293b64b18d990a04b6b153607a3199d4ae77a3bfc0fd8d6328e5a0578b2458f \ No newline at end of file +8cb81c5b233dc6e6cc4e1ae8bfdd95f6ed147a69e0c1597ce548d94096eaebdc \ No newline at end of file diff --git a/packages/core-rs/tests/fixtures/hwpx/provisional/image-data-uri-basic/golden.hwpx b/packages/core-rs/tests/fixtures/hwpx/provisional/image-data-uri-basic/golden.hwpx index 8fd4bab..48a1480 100644 Binary files a/packages/core-rs/tests/fixtures/hwpx/provisional/image-data-uri-basic/golden.hwpx and b/packages/core-rs/tests/fixtures/hwpx/provisional/image-data-uri-basic/golden.hwpx differ diff --git a/packages/core-rs/tests/fixtures/hwpx/provisional/image-data-uri-basic/input.md b/packages/core-rs/tests/fixtures/hwpx/provisional/image-data-uri-basic/input.md index 251d167..9dcac60 100644 --- a/packages/core-rs/tests/fixtures/hwpx/provisional/image-data-uri-basic/input.md +++ b/packages/core-rs/tests/fixtures/hwpx/provisional/image-data-uri-basic/input.md @@ -1 +1 @@ -![tiny](data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAusB9Wn0gWQAAAAASUVORK5CYII=) \ No newline at end of file +![tiny](data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAEAAAABACAYAAACqaXHeAAAACXBIWXMAAAAAAAAAAQCEeRdzAAAPa0lEQVR4nO1ad3Ac1R0WECAQUwYCCRNiUoEMk2CGiWEMDL3ZSUhCCCQTkkniJunULBfkLleMMTbuTeV0kk69WJZk2ZJtyZZkq9iSVaxiNUu39W6v6fb6bX6/3bvz6ZBsVR9J+OOb3Xu7+/a9732/9vaCBEEI+n9GwAcQaAR8AIFGwAcQaAR8AIFGkMvlCvggAgWn0/kNAUEu4RsCJr3jAY352eZ+0+zWAdPsFglzAL9xHz3nN4J0b794FPvA/vrU5plmm+OerzUBR+q5LxbEqYQwOeGFzINEPwxpI90gfI5D74tUEEJsDtlXcJHbzFsd90+YAGEKCOhhzbPCFaQQrqCEiGRKiEyhXOFJ8BsmES6XjmHi0dNGeNuuwd2W5LlXIjI4gRQWAoLjVcKmfPIyqbM8Nd5xov+bMh+wv4wtwYGGwcQVlerkqxrLM1do84sdJP9KB2W+Dvyv8yI6Kf5l/F3VYZy/vZiuDgVSQkAl63PJPj1ve+RrRwCttz4ek0FqQuSUsDiVNJU26z6ZPLW5gnLqtNuRhOAEQlBWa+TjJ2AKw2APyz+/IosiUf6hYL9rssm+xj7D+/73qbTWX51pN4YUNuhjS1v0y1pVpndv7OhcQftK2VIkYVkawfdz5qdvOgEDnOWZxHJ1VtIZDUCdJfdCIyK1iktal0tdjQDHhQQcLGOPaozWH3me50z2R+NOs3lhcpUo51BASALauUpYl0v2nADVGC32B4d59y14bCf4N9DEIsDXxKQTur0nmNIz7YbQQYtjuGeGJ2AiK9w0wP92ATgjHHhwPCEsBM+/EH5jG0oT2yMUODGVkFKplvuagMlie6CgXr31SL1uc1WncW5sLt0flkQJKzIobksB1YokoKNbnUUQxY3aNTre/n3fdztdzltSqzSJSAAC3xeS4FZaFqGqaNOH3cjk3ASMXwEtKn42eunIZFKIzaX6thTQzZ8W0K2fHaWaVmdTJLZj+NpVQp9xwYB9n7U7nHeC//G2fV7ENOIkviyhz+Hk6nqMf41Jp7RI6LzD/cKKTEKdU8ftuEzwb1/oHfzwyxKmUgbKkQG5+0qZEzm13M71eVS3ZG6oJJWQWMFk2p3OO6aUgHAgAHGZML2FbQ6X6zbsM66czcfVWJyisqg4y4wb9QUmlI4ErISJDlodD4ikFFINymr1IYwikcmEOKmwJJi0XCWGR3wvKsRotj2E91vtjrtqu41/A7PrlcklEhRn1alTScAcMb7DQNop/jUf2/7hsjRyEO1ZARMbTV8Q5l6NgMmFwqDlFawSVu5brQOGOTaH486m/sHfLU4lzPgeTISiQFl4DIf7G68OfsWpGsz2731RTNeICRgopB7UNCU+oJ0yv4lM46q0k/zrnnaUqLhKgKZ+03uj7e94k3YlPjf/sAqzvX5wrmm7jrMVixRSFrhMSfDr81S9kQppYrlgEiP1xQ3apq/KJBlchB1AxnALPWoCbA7nXRnnuQNx5eqMxAoJ8rNq5c7jzFm0c1yJNpJ/w3P/sUZdbAgOGMKTZtD22FhIbYacf18ZU7Q+h+qBsEkguQvAwULW11bawi1dmUWSKP812QTZxfKzaIPt56zB9lOIFt91iuZ3ra/CBu1mHMeSVMKGecm4CeBtjvtWZJI6MQUFB7MAWJ0PgwqGzheloKcnUAFeE8iu1e7FlHVtDklAPL937OpyBUFcn6GoYuUbYOJFEAUMZtvDe0uZkzihCIVKiErBdxMugLBUSdqAGPUWcL7JlerEhj40C1dQF21+CRcHTacNnOe4CcBJxObQRDiEqeWZlHbLUfrSlqNMA4SuHlSAmwCvCWTWcIeCEyhhQx7dZ7E7vjNWArhBy2MXeg0fwaDfsNid07Ctok0XGQ1+YHkGqYfF4FZn08zaHJqEaENBxmmMEpUIEUAsulQCjLE57wK3FbJQJyoGTPEPEyYAFZBazR3y2FOzin9XjAISAa967s+q4Q4Ex1PC+jzmKkxgzASMoML7UYk6yPv7oNyu7zV+VNTIrYsrZ3I2F5BtS5SkE9WIwEUJdVeQUaJCVUKHj0LHQYDzXojzIgFp57gDnvZWwvyWGAUkAl7xmkANtz8kQSSgbzIIMEJm1zzAz4GkaeuuEubM6iySiU4hROVhEiTzKbdxQUApzuUZhB5DJ5KxREnYwQc8OTECcpAAKDzOcQc97S0q89v4QmTYVwFIQLBIAN0LBNw9vom7gqB6fCG1WhOPMvfIG484KVxplHYUTPLzIroBnPTBijZjGITmd1Rayy8hHX6g5JJ2LeYCm/KpbnDk356QCawTTYBABfgRIPmANl8TOM/tR7VAZnZ1PD6gmzHPgnK6WEx+0Okl4YQpwSNxtHeM8fvLmJIuhn/BN6P0R1q1OiGlkh02GRobAbk0EexHQKubAMkETF4bAye4D8lan0eOyQSArLtzarQ7wbM7cbXRjtHprc4m1Tjx6FSJAPTsUB/EjiaJg+zwbijAfjxBApxAADUCAegE0QRMvlFgLxYzQMCoTYDW254AKV/wOC+wW0t+Pbe16op+HoQ4GhWwOJUSr6WfU8eNz6wmhQDNUBNQeH2AVwFZIgGEsCGfGlUY7NdYnl2TRZGeELbnBF1GgdMy2+zTNuZTXUjyYiAgOpnC3J/Vm+0P32QCHMMqAByO2wkONQFwgnswEdqYR/WirK/XN2u0/mStmPFJvqTwIrdZEJziNSiClKgIdHrRKVKch9WPn4zJj10BeeADEv1MALIrSQFDEyEkADc4bqQABxQ8WP5iPYF9nGzRLfVc61Wbn4Nsz4n7CZ8VEq2fpJMWVEf1FcO8gBAAIY1Aj5x+fqgPiPAQQPFvetpzarV7QuQeAkZ2gseadGtlcimW59VxO32vpZ/XHA4RnSBhrLqim7s0jbJj2IPq74+BUUDucATwb+MKRSSLCvAWQ9lIACrgOmGQM9mmx0Cygqu/rYhqQDV4rlnBca4H8nC/L6FCnUdoLU9hBJABAbU9g38PAAHuMIgEDHGCEgFoox0+BIACdmPRhHmAdYQocPSidhMSGqUQN1Pe8b2Ge42QzbmQnOpOw4JBi/1BMAEz7vQUN+o2BEYBeR4ChvoAiQDRCXpNILvW6wO8CsDtd88WvBXKayCnNySREnYfZyr94zlUcx+IWR44vh5IivD6lkL6cijcv+8EUz5Z2+tjJkAyAc1XTGARyHNzAd25vZipRazKItkoCFvLwG63FbEN24vZum1FTP2OY3SNnrc/AmXqi+g8ZXIKV3i+//vOdhhCQuS4n0DaGb31CVFVddwuNImlSsI6ka9B4yfA7QMyfAhoGeBnoyrCxDydFD9bIULl0k5tGEwQawIMn/PicP+O1ODqFbs3TGLSKLN6mCztTLtBhjkByN7GGKSNjKtqy0wIhaJZwCIEIBHKo1Uoa18FYAJzoIwpPnSSLT58Su2GpviQ91wtniP2QW5/rFG7Dp87eJI9hj5iWyF9yem3W4yo7xn8S5hY1ZEChMOZnvbDp9lCJGBxKmG/Ql2rPm8GAfdAGBxAAtJ8CBgPcJv60wK6A/tSnB1+wxRW+9c4eSShpsv4T087OMcZmCKjuj4toNpxj2AiY3F/HR41AaICsNCZyEuNZvtDUN5q0EwKL2o/He4ecJzTkHC8J7lSk+Z7raxFt1yWJH0pPnyKLfL/3jBVCsAB9Ytx+Yw6mzHYHldprTPGBFg93N7iBu3Tl2fQvAxW8VSrfslI78ys0R7A963KJLV6v69CchiDzP0JPaVSkzzeqDCWPEAkIDJZqsiWKElhSSopHUVQbsB5mnRcrLx2Hc8xW4Tw9iFGgZgMxooEnO0wBmP/jVdN79d2D35c50ZDn+kDCKW78X0o95JLurW+48ESd88JtjwiWTKTtGpNvNPpunUqCbgXZKtFE5CJW1BSVobFSTQQEp1KC/iHCN+tKRFJbsD5/DiVaM8Gs+PhmAzagqsL4S7U4XLevjyLNngjR5L0pwicHPaN4XQlqIBzb6+73B9GKb3lCYgSRryO2+alTdpVU0aAw+m6o67H+A/8hF3ZgTCEnOsyzoXYvBsHgAlLbj238/wVw9wquCaicyjOtutDMKQBAQ+tyGRhwpRwokW/AqLArWtzGRL7wQkvSiG8Oz94HpUiEYOy94xHy9t+8GUJU4UKwcXYW8qeRGVNGQEjoZM2v4wrhalwF2N+cTTP8FbHfetgwkhATp12N9rvxny6x5P3g694upe1PA94Drz+03Hl6iNSqUwK568Y/43+Z9MRujPMrUQgxvsB1OUaFwHjTyvxSy2uAhLQRl6rBq8HWPHbPi9imzGtjTutPorv33GMrcO8ACfjf7/WZHt0dRbF4P+NYjIo06osSovnSABuvHjGP9bJT4oCLhOealBUwEujfS6+QpOP/mRbEX0JJ5BcyaWhIjYfobtsDued/vdDCfwnJBnNBAkHx2o+3qQbs81PGQFop0WN2g1N/abfX+w1fXixb/AjAB7/fLEPfveKv8W25n7TewdPqUtwBVdlU5zV4birqsO4EOWMW9yd9PAZ3pEL2m0y0RRotPnyiU5+cghQ8e8gAdEptBCRRAnhcjeSPCD9ziVEwSougsiB29t9kPVpB23TofCxogpSKzUpw7/PFbS3jD2N8l+UTDqrO40T3hmaBAJM72IyEgWDQkRiaSweJUT5nUf5teGn61Ot0jZYfLn6KFaHy0DelM7yC/93gY95a2sh0xgJCsAia2mqysYarD8LKAEmq+PBDpJ/vZMyAzxH8+tQw78AhdLMPrX5OWh/Da9dGQLpPnwWyl1xsnD+pviXmiQx/VV63kHqrE8lVGiyxaRIzoglNESNLowI9mG+9oyZANck2JI/eljz8+WXDfPru4f/Z8ZIAN9wQgbmEp1CuWq7jR8fbdBtWZZG8TI5LcgSaWF5OqXDrHB8n9xHImAK/ieYfo5L+NdBqP8zKQ7D3mifg+zuyZh0So8+AhGeJE08GmxeWaVJ8N07GE/Yu2kEZNVo92NiA5PhoeRVplZp5FC0KFIquSQEhD1F8tAjtGuSlNVc3Opsmo5KpiELZCC60K79Zepj3Qw/a7LHOKUE4JdaTGwiFYyA+34h4q4QLSyEI2KBD+aLbTTcw4jXZLDqkUAAhjuMHhAWX53s8U05Accu6datyaG7N+azX8EGwPp8xg1WxAbxmtp9RDDdG48w3bE5TDcoKO16X3+/lgRA8XS7FTy0Bzaf8+vB/z78bbE5pzldYy91A0rAfwsmnAf8LyDgAwg0Aj6AQCPgAwg0Aj6AQOM/BZO6GI04oecAAAAASUVORK5CYII=) \ No newline at end of file diff --git a/packages/core-rs/tests/fixtures/hwpx/provisional/mixed-rich/golden.hwpx b/packages/core-rs/tests/fixtures/hwpx/provisional/mixed-rich/golden.hwpx index 2f51e0e..b894309 100644 Binary files a/packages/core-rs/tests/fixtures/hwpx/provisional/mixed-rich/golden.hwpx and b/packages/core-rs/tests/fixtures/hwpx/provisional/mixed-rich/golden.hwpx differ diff --git a/packages/core-rs/tests/fixtures/hwpx/provisional/mixed-rich/input.md b/packages/core-rs/tests/fixtures/hwpx/provisional/mixed-rich/input.md index 4a60013..1d44b88 100644 --- a/packages/core-rs/tests/fixtures/hwpx/provisional/mixed-rich/input.md +++ b/packages/core-rs/tests/fixtures/hwpx/provisional/mixed-rich/input.md @@ -13,6 +13,4 @@ code | --- | --- | | A | Writer | ---- - ![tiny](data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAusB9Wn0gWQAAAAASUVORK5CYII=) \ No newline at end of file diff --git a/packages/core-rs/tests/hwpx/fixture_suite.rs b/packages/core-rs/tests/hwpx/fixture_suite.rs index e403467..ca47e3d 100644 --- a/packages/core-rs/tests/hwpx/fixture_suite.rs +++ b/packages/core-rs/tests/hwpx/fixture_suite.rs @@ -31,3 +31,15 @@ fn deterministic_fixtures_generate_same_hash() { assert_same_input_same_hash(fixture); } } + +#[test] +fn all_approved_hwpx_fixtures_are_manually_verified() { + let fixtures = discover_fixtures().unwrap(); + for fixture in &fixtures { + assert!( + fixture.manual_verified, + "approved HWPX fixture must set manual_verified = true: {}", + fixture.name + ); + } +} diff --git a/packages/core-rs/tests/hwpx/regression.rs b/packages/core-rs/tests/hwpx/regression.rs deleted file mode 100644 index a8980be..0000000 --- a/packages/core-rs/tests/hwpx/regression.rs +++ /dev/null @@ -1,826 +0,0 @@ -use std::path::Path; - -use core_rs::{CoreRsError, HwpxOptions, HwpxParagraphAlign, HwpxStyleOptions, generate_hwpx}; -use roxmltree::Document as XmlDocument; - -use crate::hwpx_contract::{compare_contracts, snapshot_from_bytes, snapshot_from_expected_tree}; -use crate::hwpx_fixture::{discover_fixtures, read_expected_entries}; -use crate::hwpx_runtime::generate; - -#[test] -fn lists_fail_in_strict_mode() { - let markdown = "1. item"; - let error = generate_hwpx(markdown, HwpxOptions::default()).unwrap_err(); - assert!(matches!(error, CoreRsError::UnsupportedFeature(_))); -} - -#[test] -fn unordered_lists_use_list_paragraph_contract_in_strict_mode() { - let markdown = "- Alpha\n- Beta"; - let generated = generate(markdown, HwpxOptions::default()).unwrap(); - - let section = generated.section_xml().unwrap(); - assert!(section.contains("paraPrIDRef=\"19\"")); - assert!(section.contains("styleIDRef=\"0\"")); - assert!(section.contains("Alpha")); - assert!(section.contains("Beta")); - assert!(section.contains("vertpos=\"0\"")); - assert!(section.contains("vertpos=\"2300\"")); - assert!(section.contains("horzpos=\"1100\" horzsize=\"41420\" flags=\"2490368\"")); - assert!(!section.contains("- Alpha")); - assert!(!section.contains("- Beta")); - assert!(!section.contains('•')); - assert!(!section.contains("paraPrIDRef=\"14\"")); -} - -#[test] -fn nested_unordered_lists_use_second_list_level() { - let markdown = "- Parent\n - Child one\n - Child two\n- Sibling"; - let generated = generate(markdown, HwpxOptions::default()).unwrap(); - - let section = generated.section_xml().unwrap(); - assert!(section.contains("paraPrIDRef=\"19\"")); - assert!(section.contains("paraPrIDRef=\"20\"")); - assert!(section.contains("styleIDRef=\"0\"")); - assert!(section.contains("Parent")); - assert!(section.contains("Child one")); - assert!(section.contains("Child two")); - assert!(section.contains("Sibling")); - assert!(section.contains("vertpos=\"6900\"")); - assert!(section.contains("horzpos=\"1100\" horzsize=\"41420\" flags=\"2490368\"")); - assert!(section.contains("horzpos=\"2200\" horzsize=\"40320\" flags=\"2490368\"")); - assert!(!section.contains("- Parent")); - assert!(!section.contains("- Child one")); - assert!(!section.contains("- Child two")); - assert!(!section.contains("- Sibling")); - assert!(!section.contains('•')); - assert!(!section.contains('◦')); - assert!(!section.contains("paraPrIDRef=\"14\"")); - assert!(!section.contains("paraPrIDRef=\"15\"")); -} - -#[test] -fn header_reuses_existing_list_paragraph_contract() { - let generated = generate("- Alpha\n - Child", HwpxOptions::default()).unwrap(); - let header = generated.text_entry("Contents/header.xml").unwrap(); - - assert!(header.contains("")); - assert!(header.contains("")); - assert!(header.contains("")); - assert!(header.contains("Name")); - assert!(section.contains("Reviewer")); - assert!(!section.contains("Name | Role")); -} - -#[test] -fn table_preview_text_matches_golden_contract() { - let markdown = "| Name | Role |\n| --- | --- |\n| A | Writer |\n| B | Reviewer |"; - let generated = generate(markdown, HwpxOptions::default()).unwrap(); - let preview = generated.text_entry("Preview/PrvText.txt").unwrap(); - let settings = generated.text_entry("settings.xml").unwrap(); - - assert_eq!(preview, "\n\n"); - assert!(settings.contains("pos=\"16\"")); -} - -#[test] -fn tables_add_table_border_fill_to_header() { - let markdown = "| Name | Role |\n| --- | --- |\n| A | Writer |"; - let generated = generate(markdown, HwpxOptions::default()).unwrap(); - let header = generated.text_entry("Contents/header.xml").unwrap(); - - assert!(header.contains("")); - assert!(header.contains("")); - assert!(!header.contains("first")); - assert!(section.contains("second")); - assert!(!section.contains("1. first")); - assert!(!section.contains("2. second")); - assert_eq!(preview, "first\nsecond"); - assert!(settings.contains("paraIDRef=\"0\"")); - assert!(settings.contains("pos=\"5\"")); -} - -#[test] -fn nested_ordered_lists_use_shape_specific_contract_in_non_strict_mode() { - let markdown = "1. Parent\n 1. Child one\n 2. Child two\n2. Sibling"; - let generated = generate( - markdown, - HwpxOptions { - strict_mode: false, - ..HwpxOptions::default() - }, - ) - .unwrap(); - - let section = generated.section_xml().unwrap(); - let preview = generated.text_entry("Preview/PrvText.txt").unwrap(); - let settings = generated.text_entry("settings.xml").unwrap(); - - assert!(section.contains("paraPrIDRef=\"20\"")); - assert!(section.contains("paraPrIDRef=\"19\"")); - assert!(section.contains("Parent")); - assert!(section.contains("Child one")); - assert!(section.contains("Child two")); - assert!(section.contains("Sibling")); - assert!(section.contains("horzpos=\"1100\" horzsize=\"41420\" flags=\"2490368\"")); - assert!(section.contains("horzpos=\"2200\" horzsize=\"40320\" flags=\"2490368\"")); - assert!(!section.contains("1. Parent")); - assert!(!section.contains("1. Child one")); - assert!(!section.contains("2. Child two")); - assert!(!section.contains("2. Sibling")); - assert_eq!(preview, "Parent\nChild one\nChild two\nSibling"); - assert!(settings.contains("paraIDRef=\"3\"")); - assert!(settings.contains("pos=\"7\"")); -} - -#[test] -fn ordered_list_headers_use_numbering_contract() { - let basic = generate( - "1. Alpha\n2. Beta\n3. Gamma", - HwpxOptions { - strict_mode: false, - ..HwpxOptions::default() - }, - ) - .unwrap(); - let basic_header = basic.text_entry("Contents/header.xml").unwrap(); - assert!(basic_header.contains("")); - assert!(basic_header.contains("")); - assert!(basic_header.contains("")); - assert!(basic_header.contains("")); - - let nested = generate( - "1. Parent\n 1. Child\n2. Sibling", - HwpxOptions { - strict_mode: false, - ..HwpxOptions::default() - }, - ) - .unwrap(); - let nested_header = nested.text_entry("Contents/header.xml").unwrap(); - assert!(nested_header.contains("")); - assert!(nested_header.contains("")); - assert!(nested_header.contains("")); - assert!(nested_header.contains("")); - assert!(nested_header.contains("")); - assert!(nested_header.contains("")); -} - -#[test] -fn nested_lists_over_depth_two_fail_in_strict_mode() { - let markdown = "- A\n - B\n - C"; - let error = generate_hwpx(markdown, HwpxOptions::default()).unwrap_err(); - assert!(matches!(error, CoreRsError::UnsupportedFeature(_))); -} - -#[test] -fn nested_lists_over_depth_two_fall_back_in_non_strict_mode() { - let markdown = "- A\n - B\n - C"; - let generated = generate( - markdown, - HwpxOptions { - strict_mode: false, - ..HwpxOptions::default() - }, - ) - .unwrap(); - - let section = generated.section_xml().unwrap(); - assert!(section.contains("A")); - assert!(section.contains("B")); - assert!(section.contains("C")); - assert!(section.contains("paraPrIDRef=\"20\"")); - assert!(!section.contains('•')); - assert!(!section.contains('◦')); -} - -#[test] -fn links_are_rendered_as_visible_text() { - let markdown = "[문서 열기](https://example.com)"; - let generated = generate(markdown, HwpxOptions::default()).unwrap(); - let section = generated.section_xml().unwrap(); - assert!(section.contains("문서 열기 (https://example.com)")); -} - -#[test] -fn empty_link_label_renders_url_only() { - let markdown = "[](https://example.com)"; - let generated = generate(markdown, HwpxOptions::default()).unwrap(); - let section = generated.section_xml().unwrap(); - assert!(section.contains("https://example.com")); -} - -#[test] -fn content_hpf_contains_metadata() { - let generated = generate( - "hello", - HwpxOptions { - title: Some("Fixture Title".to_string()), - author: Some("Fixture Author".to_string()), - strict_mode: true, - ..HwpxOptions::default() - }, - ) - .unwrap(); - - let content_hpf = generated.content_hpf().unwrap(); - assert!(content_hpf.contains("Fixture Title")); - assert!( - content_hpf - .contains("(주)한글과컴퓨터") - ); - assert!(content_hpf.contains("href=\"Contents/header.xml\"")); - assert!(content_hpf.contains("href=\"Contents/section0.xml\"")); - assert!(content_hpf.contains("href=\"settings.xml\"")); - assert!(content_hpf.contains("ko")); - assert!(content_hpf.contains("")); - assert!(content_hpf.contains("")); -} - -#[test] -fn package_contract_paths_are_self_consistent() { - let generated = generate("hello", HwpxOptions::default()).unwrap(); - let content_hpf = generated.content_hpf().unwrap(); - let container = generated.text_entry("META-INF/container.xml").unwrap(); - let container_rdf = generated.text_entry("META-INF/container.rdf").unwrap(); - - assert!(container.contains("full-path=\"Contents/content.hpf\"")); - assert!(container.contains("full-path=\"Preview/PrvText.txt\"")); - assert!(container.contains("full-path=\"META-INF/container.rdf\"")); - assert!(content_hpf.contains("href=\"Contents/header.xml\"")); - assert!(content_hpf.contains("href=\"Contents/section0.xml\"")); - assert!(content_hpf.contains("href=\"settings.xml\"")); - assert!(container_rdf.contains("Contents/header.xml")); - assert!(container_rdf.contains("Contents/section0.xml")); -} - -#[test] -fn headings_render_as_plain_hwpx_paragraphs() { - let generated = generate("# Heading", HwpxOptions::default()).unwrap(); - let section = generated.section_xml().unwrap(); - assert!(section.contains("Heading")); -} - -#[test] -fn hard_breaks_flatten_to_visible_space_in_phase_a() { - let generated = generate("hello \nworld", HwpxOptions::default()).unwrap(); - let section = generated.section_xml().unwrap(); - assert!(section.contains("hello world")); -} - -#[test] -fn hwpx_xml_uses_hancom_2011_namespaces() { - let generated = generate("hello", HwpxOptions::default()).unwrap(); - let section = generated.section_xml().unwrap(); - let content_hpf = generated.content_hpf().unwrap(); - - assert!(section.contains("http://www.hancom.co.kr/hwpml/2011/paragraph")); - assert!(section.contains("Generated Document")); - assert!(section.contains("안녕하세요")); -} - -#[test] -fn preview_text_tracks_visible_paragraph_text() { - let generated = generate( - "# 제목\n\n문단", - HwpxOptions { - strict_mode: true, - ..HwpxOptions::default() - }, - ) - .unwrap(); - let preview = generated.text_entry("Preview/PrvText.txt").unwrap(); - assert!(preview.contains("제목")); - assert!(preview.contains("문단")); - assert_eq!(preview, "제목\n문단"); -} -#[test] -fn headings_use_outline_style_refs_and_heading_char_properties() { - let generated = generate("# Heading", HwpxOptions::default()).unwrap(); - let header = generated.text_entry("Contents/header.xml").unwrap(); - let section = generated.section_xml().unwrap(); - - assert!(header.contains("")); - assert!(header.contains("일반 ")); - assert!(section.contains("굵게")); - assert!(section.contains("기울임")); - assert!(section.contains("코드")); - assert!( - section.contains( - "링크 (https://example.com)" - ) - ); -} - -#[test] -fn custom_style_overrides_header_fonts_colors_and_sizes() { - let generated = generate( - "# Heading\n\n본문 [링크](https://example.com)", - HwpxOptions { - style: HwpxStyleOptions { - body_font: Some("Pretendard".to_string()), - heading_font: Some("MaruBuri".to_string()), - body_font_size: Some(1120), - heading_font_size: Some(1560), - text_color: Some("444444".to_string()), - heading_color: Some("AA2200".to_string()), - link_color: Some("0088CC".to_string()), - paragraph_align: Some(HwpxParagraphAlign::Center), - }, - ..HwpxOptions::default() - }, - ) - .unwrap(); - let header = generated.text_entry("Contents/header.xml").unwrap(); - - assert!(header.contains("face=\"Pretendard\"")); - assert!(header.contains("face=\"MaruBuri\"")); - assert!(header.contains("")); - assert!(header.contains("굵게")); - assert!(section.contains("기울임")); - assert!( - section.contains( - "링크 (https://example.com)" - ) - ); -} - -#[test] -fn custom_heading_font_does_not_leak_into_body_inline_runs() { - let generated = generate( - "본문 **굵게** *기울임* [링크](https://example.com)", - HwpxOptions { - style: HwpxStyleOptions { - heading_font: Some("MaruBuri".to_string()), - ..HwpxStyleOptions::default() - }, - ..HwpxOptions::default() - }, - ) - .unwrap(); - let header = generated.text_entry("Contents/header.xml").unwrap(); - - assert!(header.contains("face=\"함초롬바탕\"")); - assert!(header.contains("face=\"MaruBuri\"")); - assert!(header.contains("")); - - let body_bold = header - .split("").next()) - .unwrap(); - let body_italic = header - .split("").next()) - .unwrap(); - let body_link = header - .split("").next()) - .unwrap(); - - assert!(body_bold.contains("hangul=\"0\"")); - assert!(body_italic.contains("hangul=\"0\"")); - assert!(body_link.contains("hangul=\"0\"")); -} - -#[test] -fn paragraph_alignment_applies_to_heading_paragraph_styles() { - let generated = generate( - "# Heading\n\n본문", - HwpxOptions { - style: HwpxStyleOptions { - paragraph_align: Some(HwpxParagraphAlign::Right), - ..HwpxStyleOptions::default() - }, - ..HwpxOptions::default() - }, - ) - .unwrap(); - let header = generated.text_entry("Contents/header.xml").unwrap(); - - for para_id in [0, 2, 3, 4, 5, 6, 7] { - let marker = format!("").next()) - .unwrap(); - assert!(segment.contains("")); - } -} - -#[test] -fn invalid_hwpx_color_option_fails_fast() { - let error = generate_hwpx( - "본문", - HwpxOptions { - style: HwpxStyleOptions { - text_color: Some("not-a-color".to_string()), - ..HwpxStyleOptions::default() - }, - ..HwpxOptions::default() - }, - ) - .unwrap_err(); - - assert!(matches!(error, CoreRsError::InvalidOption(_))); -} - -#[test] -fn invalid_hwpx_font_name_fails_fast() { - let error = generate_hwpx( - "본문", - HwpxOptions { - style: HwpxStyleOptions { - body_font: Some(" ".to_string()), - ..HwpxStyleOptions::default() - }, - ..HwpxOptions::default() - }, - ) - .unwrap_err(); - - assert!(matches!(error, CoreRsError::InvalidOption(_))); -} - -#[test] -fn invalid_hwpx_font_size_fails_fast() { - let error = generate_hwpx( - "본문", - HwpxOptions { - style: HwpxStyleOptions { - body_font_size: Some(5000), - ..HwpxStyleOptions::default() - }, - ..HwpxOptions::default() - }, - ) - .unwrap_err(); - - assert!(matches!(error, CoreRsError::InvalidOption(_))); -} - -#[test] -fn approved_style_fixtures_preserve_header_style_registry_contract() { - for fixture_name in [ - "core-heading", - "core-inline-style", - "core-link-text", - "style-typography", - "style-centered-layout", - "style-brand-color", - ] { - let fixture = approved_fixture(fixture_name); - let input = crate::read_fixture_input(&fixture).unwrap(); - let generated = generate_hwpx(&input, fixture.options()).unwrap(); - let generated_entries = crate::normalized_entries(&generated).unwrap(); - let expected_entries = read_expected_entries(&fixture.root.join("expected")).unwrap(); - - let generated_header = text_entry(&generated_entries, "Contents/header.xml"); - let expected_header = text_entry(&expected_entries, "Contents/header.xml"); - - assert_eq!( - header_style_contract(generated_header), - header_style_contract(expected_header), - "header style contract drifted for fixture {fixture_name}", - ); - } -} - -#[test] -fn approved_style_fixtures_preserve_section_style_references() { - for fixture_name in [ - "core-heading", - "core-inline-style", - "core-link-text", - "style-typography", - "style-centered-layout", - "style-brand-color", - ] { - let fixture = approved_fixture(fixture_name); - let input = crate::read_fixture_input(&fixture).unwrap(); - let generated = generate_hwpx(&input, fixture.options()).unwrap(); - let generated_entries = crate::normalized_entries(&generated).unwrap(); - let expected_entries = read_expected_entries(&fixture.root.join("expected")).unwrap(); - - let generated_section = text_entry(&generated_entries, "Contents/section0.xml"); - let expected_section = text_entry(&expected_entries, "Contents/section0.xml"); - - assert_eq!( - section_style_refs(generated_section), - section_style_refs(expected_section), - "section style references drifted for fixture {fixture_name}", - ); - } -} - -fn approved_fixture(name: &str) -> crate::hwpx_fixture::HwpxFixture { - discover_fixtures() - .unwrap() - .into_iter() - .find(|fixture| fixture.name == name) - .unwrap_or_else(|| panic!("missing approved HWPX fixture: {name}")) -} - -fn text_entry<'a>( - entries: &'a std::collections::BTreeMap, - path: &str, -) -> &'a str { - match entries - .get(path) - .unwrap_or_else(|| panic!("missing entry: {path}")) - { - crate::NormalizedEntry::Text(text) => text.as_str(), - crate::NormalizedEntry::BinaryHash(_) => panic!("{path} was normalized as binary"), - } -} - -type HeaderStyleContract = (Vec, Vec, Vec<(u32, u32, u32)>); - -fn header_style_contract(xml: &str) -> HeaderStyleContract { - let doc = XmlDocument::parse(xml).unwrap(); - let mut char_ids = Vec::new(); - let mut para_ids = Vec::new(); - let mut style_triplets = Vec::new(); - - for node in doc.descendants().filter(|node| node.is_element()) { - match node.tag_name().name() { - "charPr" if node.tag_name().namespace().is_some() => { - if let Some(id) = node - .attribute("id") - .and_then(|value| value.parse::().ok()) - { - char_ids.push(id); - } - } - "paraPr" if node.tag_name().namespace().is_some() => { - if let Some(id) = node - .attribute("id") - .and_then(|value| value.parse::().ok()) - { - para_ids.push(id); - } - } - "style" if node.tag_name().namespace().is_some() => { - let Some(id) = node - .attribute("id") - .and_then(|value| value.parse::().ok()) - else { - continue; - }; - let Some(para_pr) = node - .attribute("paraPrIDRef") - .and_then(|value| value.parse::().ok()) - else { - continue; - }; - let Some(char_pr) = node - .attribute("charPrIDRef") - .and_then(|value| value.parse::().ok()) - else { - continue; - }; - style_triplets.push((id, para_pr, char_pr)); - } - _ => {} - } - } - - char_ids.sort_unstable(); - char_ids.dedup(); - para_ids.sort_unstable(); - para_ids.dedup(); - style_triplets.sort_unstable(); - - (char_ids, para_ids, style_triplets) -} - -fn section_style_refs(xml: &str) -> Vec<(u32, u32, Vec)> { - let doc = XmlDocument::parse(xml).unwrap(); - let mut paragraphs = Vec::new(); - - for paragraph in doc - .descendants() - .filter(|node| node.is_element() && node.tag_name().name() == "p") - { - let Some(para_pr) = paragraph - .attribute("paraPrIDRef") - .and_then(|value| value.parse::().ok()) - else { - continue; - }; - let Some(style_id) = paragraph - .attribute("styleIDRef") - .and_then(|value| value.parse::().ok()) - else { - continue; - }; - - let mut char_refs = paragraph - .children() - .filter(|node| node.is_element() && node.tag_name().name() == "run") - .filter_map(|run| run.attribute("charPrIDRef")) - .filter_map(|value| value.parse::().ok()) - .collect::>(); - char_refs.sort_unstable(); - char_refs.dedup(); - paragraphs.push((para_pr, style_id, char_refs)); - } - - paragraphs -} diff --git a/packages/core-rs/tests/hwpx/regression/document.rs b/packages/core-rs/tests/hwpx/regression/document.rs new file mode 100644 index 0000000..ac6d25c --- /dev/null +++ b/packages/core-rs/tests/hwpx/regression/document.rs @@ -0,0 +1,359 @@ +use super::*; + +#[test] +fn non_table_documents_keep_default_header_and_settings_contract() { + let generated = generate("hello", HwpxOptions::default()).unwrap(); + let header = generated.text_entry("Contents/header.xml").unwrap(); + let settings = generated.text_entry("settings.xml").unwrap(); + + assert!(header.contains("")); + assert!(!header.contains("문서 열기")); + assert!(!section.contains("문서 열기 (https://example.com)")); +} + +#[test] +fn empty_link_label_renders_url_only() { + let markdown = "[](https://example.com)"; + let generated = generate(markdown, HwpxOptions::default()).unwrap(); + let section = generated.section_xml().unwrap(); + assert!(section.contains("https://example.com")); +} + +#[test] +fn legacy_inline_links_render_as_hyperlink_fields() { + let generated = generate( + "# Heading\n\n본문 [링크](https://example.com/path) 입니다.", + HwpxOptions::default(), + ) + .unwrap(); + let section = generated.section_xml().unwrap(); + let preview = generated.text_entry("Preview/PrvText.txt").unwrap(); + + assert!(section.contains("type=\"HYPERLINK\"")); + assert!( + section.contains("https://example.com/path") + ); + assert!(section.contains("링크")); + assert!(!section.contains("링크 (https://example.com/path)")); + assert!(preview.contains("본문 링크 입니다.")); +} + +#[test] +fn legacy_links_use_document_unique_field_ids_across_paragraphs() { + let generated = generate( + "첫 문단 [링크1](https://example.com/one)\n\n둘째 문단 [링크2](https://example.com/two)", + HwpxOptions::default(), + ) + .unwrap(); + let section = generated.section_xml().unwrap(); + + assert!(section.contains("fieldid=\"627600491\"")); + assert!(section.contains("fieldid=\"627600492\"")); + assert_eq!(section.matches("fieldid=\"627600491\"").count(), 2); + assert_eq!(section.matches("fieldid=\"627600492\"").count(), 2); + assert_eq!(section.matches("beginIDRef=\"2107483186\"").count(), 1); + assert_eq!(section.matches("beginIDRef=\"2107483187\"").count(), 1); +} + +#[test] +fn content_hpf_contains_metadata() { + let generated = generate( + "hello", + HwpxOptions { + title: Some("Fixture Title".to_string()), + author: Some("Fixture Author".to_string()), + strict_mode: true, + ..HwpxOptions::default() + }, + ) + .unwrap(); + + let content_hpf = generated.content_hpf().unwrap(); + assert!(content_hpf.contains("Fixture Title")); + assert!( + content_hpf + .contains("(주)한글과컴퓨터") + ); + assert!(content_hpf.contains("href=\"Contents/header.xml\"")); + assert!(content_hpf.contains("href=\"Contents/section0.xml\"")); + assert!(content_hpf.contains("href=\"settings.xml\"")); + assert!(content_hpf.contains("ko")); + assert!(content_hpf.contains("")); + assert!(content_hpf.contains("")); +} + +#[test] +fn package_contract_paths_are_self_consistent() { + let generated = generate("hello", HwpxOptions::default()).unwrap(); + let content_hpf = generated.content_hpf().unwrap(); + let container = generated.text_entry("META-INF/container.xml").unwrap(); + let container_rdf = generated.text_entry("META-INF/container.rdf").unwrap(); + + assert!(container.contains("full-path=\"Contents/content.hpf\"")); + assert!(container.contains("full-path=\"Preview/PrvText.txt\"")); + assert!(container.contains("full-path=\"META-INF/container.rdf\"")); + assert!(content_hpf.contains("href=\"Contents/header.xml\"")); + assert!(content_hpf.contains("href=\"Contents/section0.xml\"")); + assert!(content_hpf.contains("href=\"settings.xml\"")); + assert!(container_rdf.contains("Contents/header.xml")); + assert!(container_rdf.contains("Contents/section0.xml")); +} + +#[test] +fn headings_render_as_plain_hwpx_paragraphs() { + let generated = generate("# Heading", HwpxOptions::default()).unwrap(); + let section = generated.section_xml().unwrap(); + assert!(section.contains("Heading")); +} + +#[test] +fn blockquotes_render_with_quote_paragraph_contract_in_strict_mode() { + let generated = generate( + "> Quoted paragraph\n>\n> Another quoted line", + HwpxOptions::default(), + ) + .unwrap(); + let section = generated.section_xml().unwrap(); + let header = generated.text_entry("Contents/header.xml").unwrap(); + let settings = generated.text_entry("settings.xml").unwrap(); + let preview = generated.text_entry("Preview/PrvText.txt").unwrap(); + + assert!(section.contains("paraPrIDRef=\"19\"")); + assert!(section.contains("charPrIDRef=\"7\"")); + assert!(section.contains("horzpos=\"2000\" horzsize=\"40520\" flags=\"393216\"")); + assert!(section.contains("vertpos=\"1600\"")); + assert!(section.contains("Quoted paragraph")); + assert!(section.contains("Another quoted line")); + assert!(header.contains("")); + assert!(!section.contains("hello world")); +} + +#[test] +fn hwpx_xml_uses_hancom_2011_namespaces() { + let generated = generate("hello", HwpxOptions::default()).unwrap(); + let section = generated.section_xml().unwrap(); + let content_hpf = generated.content_hpf().unwrap(); + + assert!(section.contains("http://www.hancom.co.kr/hwpml/2011/paragraph")); + assert!(section.contains("Generated Document")); + assert!(section.contains("안녕하세요")); +} + +#[test] +fn preview_text_tracks_visible_paragraph_text() { + let generated = generate( + "# 제목\n\n문단", + HwpxOptions { + strict_mode: true, + ..HwpxOptions::default() + }, + ) + .unwrap(); + let preview = generated.text_entry("Preview/PrvText.txt").unwrap(); + assert!(preview.contains("제목")); + assert!(preview.contains("문단")); + assert_eq!(preview, "제목\n문단"); +} + +#[test] +fn legacy_body_paragraphs_omit_linesegarray_to_allow_viewer_reflow() { + let generated = generate( + "# Heading\n\nThis paragraph should be wrapped by the viewer instead of using a fixed single lineseg.", + HwpxOptions { + strict_mode: true, + ..HwpxOptions::default() + }, + ) + .unwrap(); + let section = generated.section_xml().unwrap(); + + assert_eq!(section.matches("").count(), 1); + assert!(section.contains("Heading")); + assert!(section.contains( + "This paragraph should be wrapped by the viewer instead of using a fixed single lineseg." + )); +} + +#[test] +fn legacy_list_items_omit_linesegarray_to_avoid_spacing_compression() { + let generated = generate( + "- 첫 번째 항목은 viewer가 줄바꿈을 다시 계산해야 합니다.\n- 두 번째 항목도 동일한 규칙을 따라야 합니다.", + HwpxOptions { + strict_mode: false, + ..HwpxOptions::default() + }, + ) + .unwrap(); + let section = generated.section_xml().unwrap(); + + assert!(!section.contains("")); + assert!(section.contains("paraPrIDRef=\"19\"")); +} + +#[test] +fn plain_multi_paragraph_profile_does_not_fall_back_to_fixture_metadata() { + let markdown = "브라우저와 Rust 코어를 공유하는 기본 문단입니다. 브라우저와 Rust 코어를 공유하는 기본 문단입니다. 브라우저와 Rust 코어를 공유하는 기본 문단입니다.\n\nThis is Second Contents. This is Second Contents. This is Second Contents. This is Second Contents. This is Second Contents."; + let generated = generate( + markdown, + HwpxOptions { + title: Some("Runtime Title".to_string()), + ..HwpxOptions::default() + }, + ) + .unwrap(); + + let content_hpf = generated.content_hpf().unwrap(); + let version = generated.text_entry("version.xml").unwrap(); + let settings = generated.text_entry("settings.xml").unwrap(); + let section = generated.section_xml().unwrap(); + let doc = XmlDocument::parse(section).unwrap(); + let paragraph_ids = doc + .descendants() + .filter(|node| node.is_element() && node.tag_name().name() == "p") + .filter_map(|node| node.attribute("id")) + .collect::>(); + let unique_ids = paragraph_ids + .iter() + .copied() + .collect::>(); + + assert!(content_hpf.contains("Runtime Title")); + assert!(version.contains("version=\"1.0\"")); + assert!(settings.contains("paraIDRef=\"2\"")); + assert!(settings.contains("pos=\"0\"")); + assert_eq!(paragraph_ids.len(), 3); + assert_eq!(unique_ids.len(), paragraph_ids.len()); +} + +#[test] +fn legacy_report_like_documents_keep_hyperlinks_and_viewer_reflow_contract() { + let markdown = "# Adoption Report\n\n\ +이 문서는 [저장소](https://github.com/docxly/core-rs)와 [데모](https://docxly.github.io/core-rs/)를 함께 안내합니다.\n\n\ +- 첫 번째 항목은 [가이드](https://example.com/guide) 링크를 포함합니다.\n\ +- 두 번째 항목도 viewer가 줄바꿈을 다시 계산해야 하는 긴 설명을 유지합니다."; + let generated = generate(markdown, HwpxOptions::default()).unwrap(); + let section = generated.section_xml().unwrap(); + let preview = generated.text_entry("Preview/PrvText.txt").unwrap(); + + assert_eq!(section.matches("type=\"HYPERLINK\"").count(), 3); + assert!(section.contains( + "https://github.com/docxly/core-rs" + )); + assert!(section.contains( + "https://docxly.github.io/core-rs/" + )); + assert!( + section.contains("https://example.com/guide") + ); + assert!(section.contains("저장소")); + assert!(section.contains("데모")); + assert!(section.contains("가이드")); + assert!(!section.contains("저장소 (https://github.com/docxly/core-rs)")); + assert!(!section.contains("데모 (https://docxly.github.io/core-rs/)")); + assert!(!section.contains("가이드 (https://example.com/guide)")); + assert_eq!(section.matches("").count(), 1); + assert_eq!( + preview, + "Adoption Report\n이 문서는 저장소와 데모를 함께 안내합니다.\n첫 번째 항목은 가이드 링크를 포함합니다.\n두 번째 항목도 viewer가 줄바꿈을 다시 계산해야 하는 긴 설명을 유지합니다." + ); +} diff --git a/packages/core-rs/tests/hwpx/regression/errors.rs b/packages/core-rs/tests/hwpx/regression/errors.rs new file mode 100644 index 0000000..9c508d3 --- /dev/null +++ b/packages/core-rs/tests/hwpx/regression/errors.rs @@ -0,0 +1,124 @@ +use super::*; + +#[test] +fn analyze_markdown_reports_hwpx_ordered_list_contract() { + let report = analyze_markdown("1. first\n2. second", ConversionTarget::Hwpx); + assert_eq!(report.unsupported_count, 0); + assert_eq!(report.fallback_count, 0); + assert_eq!(report.issues[0].feature, "HWPX ordered list in strict mode"); + assert_eq!(report.issues[0].severity, IssueSeverity::Warning); +} + +#[test] +fn strict_hwpx_report_failure_promotes_ordered_list_issue() { + let failure = + generate_hwpx_with_report("1. first\n2. second", HwpxOptions::default()).unwrap_err(); + assert_eq!(failure.report.unsupported_count, 1); + assert_eq!( + failure.report.issues[0].feature, + "HWPX ordered list in strict mode" + ); + assert_eq!(failure.report.issues[0].severity, IssueSeverity::Error); + assert_eq!( + failure.report.issues[0].message, + "unsupported feature: HWPX approved baseline does not support ordered list" + ); +} + +#[test] +fn compat_hwpx_report_marks_image_fallback() { + let markdown = "![diagram](data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO5W5V8AAAAASUVORK5CYII=)"; + let generated = generate_hwpx_with_report( + markdown, + HwpxOptions { + strict_mode: false, + ..HwpxOptions::default() + }, + ) + .unwrap(); + + assert!(generated.report.degraded); + assert_eq!(generated.report.unsupported_count, 0); + assert_eq!(generated.report.fallback_count, 1); + assert_eq!(generated.report.issues[0].feature, "HWPX image"); +} + +#[test] +fn analyze_markdown_does_not_report_blockquote_as_hwpx_fallback() { + let report = analyze_markdown("> quoted", ConversionTarget::Hwpx); + assert!(report.issues.is_empty()); + assert!(!report.degraded); +} + +#[test] +fn invalid_hwpx_color_option_fails_fast() { + let error = generate_hwpx( + "본문", + HwpxOptions { + style: HwpxStyleOptions { + text_color: Some("not-a-color".to_string()), + ..HwpxStyleOptions::default() + }, + ..HwpxOptions::default() + }, + ) + .unwrap_err(); + + assert!(matches!(error, CoreRsError::InvalidOption(_))); +} + +#[test] +fn invalid_hwpx_font_name_fails_fast() { + let error = generate_hwpx( + "본문", + HwpxOptions { + style: HwpxStyleOptions { + body_font: Some(" ".to_string()), + ..HwpxStyleOptions::default() + }, + ..HwpxOptions::default() + }, + ) + .unwrap_err(); + + assert!(matches!(error, CoreRsError::InvalidOption(_))); +} + +#[test] +fn invalid_hwpx_font_size_fails_fast() { + let error = generate_hwpx( + "본문", + HwpxOptions { + style: HwpxStyleOptions { + body_font_size: Some(5000), + ..HwpxStyleOptions::default() + }, + ..HwpxOptions::default() + }, + ) + .unwrap_err(); + + assert!(matches!(error, CoreRsError::InvalidOption(_))); +} + +#[test] +fn invalid_hwpx_option_is_reported_in_generation_failure() { + let failure = generate_hwpx_with_report( + "본문", + HwpxOptions { + style: HwpxStyleOptions { + text_color: Some("not-a-color".to_string()), + ..HwpxStyleOptions::default() + }, + ..HwpxOptions::default() + }, + ) + .unwrap_err(); + + assert!(matches!(failure.error, CoreRsError::InvalidOption(_))); + assert_eq!(failure.report.unsupported_count, 1); + assert_eq!(failure.report.fallback_count, 0); + assert_eq!(failure.report.issues[0].feature, "invalid option"); + assert_eq!(failure.report.issues[0].severity, IssueSeverity::Error); + assert!(failure.report.issues[0].message.contains("invalid option")); +} diff --git a/packages/core-rs/tests/hwpx/regression/lists.rs b/packages/core-rs/tests/hwpx/regression/lists.rs new file mode 100644 index 0000000..a318310 --- /dev/null +++ b/packages/core-rs/tests/hwpx/regression/lists.rs @@ -0,0 +1,236 @@ +use super::*; + +#[test] +fn lists_fail_in_strict_mode() { + let markdown = "1. item"; + let error = generate_hwpx(markdown, HwpxOptions::default()).unwrap_err(); + assert!(matches!(error, CoreRsError::UnsupportedFeature(_))); +} + +#[test] +fn unordered_lists_use_list_paragraph_contract_in_strict_mode() { + let markdown = "- Alpha\n- Beta"; + let generated = generate(markdown, HwpxOptions::default()).unwrap(); + + let section = generated.section_xml().unwrap(); + assert!(section.contains("paraPrIDRef=\"19\"")); + assert!(section.contains("styleIDRef=\"0\"")); + assert!(section.contains("Alpha")); + assert!(section.contains("Beta")); + assert!(!section.contains("")); + assert!(!section.contains("- Alpha")); + assert!(!section.contains("- Beta")); + assert!(!section.contains('•')); + assert!(!section.contains("paraPrIDRef=\"14\"")); +} + +#[test] +fn nested_unordered_lists_use_second_list_level() { + let markdown = "- Parent\n - Child one\n - Child two\n- Sibling"; + let generated = generate(markdown, HwpxOptions::default()).unwrap(); + + let section = generated.section_xml().unwrap(); + assert!(section.contains("paraPrIDRef=\"19\"")); + assert!(section.contains("paraPrIDRef=\"20\"")); + assert!(section.contains("styleIDRef=\"0\"")); + assert!(section.contains("Parent")); + assert!(section.contains("Child one")); + assert!(section.contains("Child two")); + assert!(section.contains("Sibling")); + assert!(!section.contains("")); + assert!(!section.contains("- Parent")); + assert!(!section.contains("- Child one")); + assert!(!section.contains("- Child two")); + assert!(!section.contains("- Sibling")); + assert!(!section.contains('•')); + assert!(!section.contains('◦')); + assert!(!section.contains("paraPrIDRef=\"14\"")); + assert!(!section.contains("paraPrIDRef=\"15\"")); +} + +#[test] +fn header_reuses_existing_list_paragraph_contract() { + let generated = generate("- Alpha\n - Child", HwpxOptions::default()).unwrap(); + let header = generated.text_entry("Contents/header.xml").unwrap(); + + assert!(header.contains("")); + assert!(header.contains("")); + assert!(header.contains("")); + assert!(header.contains("Name")); + assert!(section.contains("Reviewer")); + assert!(!section.contains("Name | Role")); +} + +#[test] +fn table_preview_text_matches_golden_contract() { + let markdown = "| Name | Role |\n| --- | --- |\n| A | Writer |\n| B | Reviewer |"; + let generated = generate(markdown, HwpxOptions::default()).unwrap(); + let preview = generated.text_entry("Preview/PrvText.txt").unwrap(); + let settings = generated.text_entry("settings.xml").unwrap(); + + assert_eq!(preview, "\n\n"); + assert!(settings.contains("pos=\"16\"")); +} + +#[test] +fn tables_add_table_border_fill_to_header() { + let markdown = "| Name | Role |\n| --- | --- |\n| A | Writer |"; + let generated = generate(markdown, HwpxOptions::default()).unwrap(); + let header = generated.text_entry("Contents/header.xml").unwrap(); + + assert!(header.contains("")); + assert!(header.contains("")); + assert!(section.contains("first")); + assert!(section.contains("second")); + assert!(!section.contains("1. first")); + assert!(!section.contains("2. second")); + assert_eq!(preview, "first\nsecond"); + assert!(settings.contains("paraIDRef=\"0\"")); + assert!(settings.contains("pos=\"5\"")); +} + +#[test] +fn nested_ordered_lists_use_shape_specific_contract_in_non_strict_mode() { + let markdown = "1. Parent\n 1. Child one\n 2. Child two\n2. Sibling"; + let generated = generate( + markdown, + HwpxOptions { + strict_mode: false, + ..HwpxOptions::default() + }, + ) + .unwrap(); + + let section = generated.section_xml().unwrap(); + let preview = generated.text_entry("Preview/PrvText.txt").unwrap(); + let settings = generated.text_entry("settings.xml").unwrap(); + + assert!(section.contains("paraPrIDRef=\"20\"")); + assert!(section.contains("paraPrIDRef=\"19\"")); + assert!(section.contains("Parent")); + assert!(section.contains("Child one")); + assert!(section.contains("Child two")); + assert!(section.contains("Sibling")); + assert!(!section.contains("")); + assert!(!section.contains("1. Parent")); + assert!(!section.contains("1. Child one")); + assert!(!section.contains("2. Child two")); + assert!(!section.contains("2. Sibling")); + assert_eq!(preview, "Parent\nChild one\nChild two\nSibling"); + assert!(settings.contains("paraIDRef=\"3\"")); + assert!(settings.contains("pos=\"7\"")); +} + +#[test] +fn ordered_list_headers_use_numbering_contract() { + let basic = generate( + "1. Alpha\n2. Beta\n3. Gamma", + HwpxOptions { + strict_mode: false, + ..HwpxOptions::default() + }, + ) + .unwrap(); + let basic_header = basic.text_entry("Contents/header.xml").unwrap(); + assert!(basic_header.contains("")); + assert!(basic_header.contains("")); + assert!(basic_header.contains("")); + assert!(basic_header.contains("")); + + let nested = generate( + "1. Parent\n 1. Child\n2. Sibling", + HwpxOptions { + strict_mode: false, + ..HwpxOptions::default() + }, + ) + .unwrap(); + let nested_header = nested.text_entry("Contents/header.xml").unwrap(); + assert!(nested_header.contains("")); + assert!(nested_header.contains("")); + assert!(nested_header.contains("")); + assert!(nested_header.contains("")); + assert!(nested_header.contains("")); + assert!(nested_header.contains("")); +} + +#[test] +fn nested_lists_over_depth_two_fail_in_strict_mode() { + let markdown = "- A\n - B\n - C"; + let error = generate_hwpx(markdown, HwpxOptions::default()).unwrap_err(); + assert!(matches!(error, CoreRsError::UnsupportedFeature(_))); +} + +#[test] +fn nested_lists_over_depth_two_fall_back_in_non_strict_mode() { + let markdown = "- A\n - B\n - C"; + let generated = generate( + markdown, + HwpxOptions { + strict_mode: false, + ..HwpxOptions::default() + }, + ) + .unwrap(); + + let section = generated.section_xml().unwrap(); + assert!(section.contains("A")); + assert!(section.contains("B")); + assert!(section.contains("C")); + assert!(section.contains("paraPrIDRef=\"20\"")); + assert!(!section.contains('•')); + assert!(!section.contains('◦')); +} diff --git a/packages/core-rs/tests/hwpx/regression/mod.rs b/packages/core-rs/tests/hwpx/regression/mod.rs new file mode 100644 index 0000000..5b07f8e --- /dev/null +++ b/packages/core-rs/tests/hwpx/regression/mod.rs @@ -0,0 +1,132 @@ +mod document; +mod errors; +mod lists; +mod styles; + +use std::path::Path; + +use core_rs::{ + ConversionTarget, CoreRsError, HwpxOptions, HwpxParagraphAlign, HwpxStyleOptions, + IssueSeverity, analyze_markdown, generate_hwpx, generate_hwpx_with_report, +}; +use roxmltree::Document as XmlDocument; + +use crate::hwpx_contract::{compare_contracts, snapshot_from_bytes, snapshot_from_expected_tree}; +use crate::hwpx_fixture::{discover_fixtures, read_expected_entries}; +use crate::hwpx_runtime::generate; + +fn approved_fixture(name: &str) -> crate::hwpx_fixture::HwpxFixture { + discover_fixtures() + .unwrap() + .into_iter() + .find(|fixture| fixture.name == name) + .unwrap_or_else(|| panic!("missing approved HWPX fixture: {name}")) +} + +fn text_entry<'a>( + entries: &'a std::collections::BTreeMap, + path: &str, +) -> &'a str { + match entries + .get(path) + .unwrap_or_else(|| panic!("missing entry: {path}")) + { + crate::NormalizedEntry::Text(text) => text.as_str(), + crate::NormalizedEntry::BinaryHash(_) => panic!("{path} was normalized as binary"), + } +} + +type HeaderStyleContract = (Vec, Vec, Vec<(u32, u32, u32)>); + +fn header_style_contract(xml: &str) -> HeaderStyleContract { + let doc = XmlDocument::parse(xml).unwrap(); + let mut char_ids = Vec::new(); + let mut para_ids = Vec::new(); + let mut style_triplets = Vec::new(); + + for node in doc.descendants().filter(|node| node.is_element()) { + match node.tag_name().name() { + "charPr" if node.tag_name().namespace().is_some() => { + if let Some(id) = node + .attribute("id") + .and_then(|value| value.parse::().ok()) + { + char_ids.push(id); + } + } + "paraPr" if node.tag_name().namespace().is_some() => { + if let Some(id) = node + .attribute("id") + .and_then(|value| value.parse::().ok()) + { + para_ids.push(id); + } + } + "style" if node.tag_name().namespace().is_some() => { + let Some(id) = node + .attribute("id") + .and_then(|value| value.parse::().ok()) + else { + continue; + }; + let Some(para_pr) = node + .attribute("paraPrIDRef") + .and_then(|value| value.parse::().ok()) + else { + continue; + }; + let Some(char_pr) = node + .attribute("charPrIDRef") + .and_then(|value| value.parse::().ok()) + else { + continue; + }; + style_triplets.push((id, para_pr, char_pr)); + } + _ => {} + } + } + + char_ids.sort_unstable(); + char_ids.dedup(); + para_ids.sort_unstable(); + para_ids.dedup(); + style_triplets.sort_unstable(); + + (char_ids, para_ids, style_triplets) +} + +fn section_style_refs(xml: &str) -> Vec<(u32, u32, Vec)> { + let doc = XmlDocument::parse(xml).unwrap(); + let mut paragraphs = Vec::new(); + + for paragraph in doc + .descendants() + .filter(|node| node.is_element() && node.tag_name().name() == "p") + { + let Some(para_pr) = paragraph + .attribute("paraPrIDRef") + .and_then(|value| value.parse::().ok()) + else { + continue; + }; + let Some(style_id) = paragraph + .attribute("styleIDRef") + .and_then(|value| value.parse::().ok()) + else { + continue; + }; + + let mut char_refs = paragraph + .children() + .filter(|node| node.is_element() && node.tag_name().name() == "run") + .filter_map(|run| run.attribute("charPrIDRef")) + .filter_map(|value| value.parse::().ok()) + .collect::>(); + char_refs.sort_unstable(); + char_refs.dedup(); + paragraphs.push((para_pr, style_id, char_refs)); + } + + paragraphs +} diff --git a/packages/core-rs/tests/hwpx/regression/styles.rs b/packages/core-rs/tests/hwpx/regression/styles.rs new file mode 100644 index 0000000..2d1aa2b --- /dev/null +++ b/packages/core-rs/tests/hwpx/regression/styles.rs @@ -0,0 +1,232 @@ +use super::*; + +#[test] +fn headings_use_outline_style_refs_and_heading_char_properties() { + let generated = generate("# Heading", HwpxOptions::default()).unwrap(); + let header = generated.text_entry("Contents/header.xml").unwrap(); + let section = generated.section_xml().unwrap(); + + assert!(header.contains("")); + assert!(header.contains("" + )); + assert!(header.contains("일반 ")); + assert!(section.contains("굵게")); + assert!(section.contains("기울임")); + assert!(section.contains("코드")); + assert!(section.contains("type=\"HYPERLINK\"")); + assert!(section.contains("https://example.com")); + assert!(section.contains("링크")); + assert!(!section.contains("링크 (https://example.com)")); +} + +#[test] +fn default_header_registry_does_not_leave_negative_tracking() { + let generated = generate("본문", HwpxOptions::default()).unwrap(); + let header = generated.text_entry("Contents/header.xml").unwrap(); + + assert!(!header.contains("hangul=\"-5\"")); + assert!(header.contains("")); +} + +#[test] +fn custom_style_overrides_header_fonts_colors_and_sizes() { + let generated = generate( + "# Heading\n\n본문 [링크](https://example.com)", + HwpxOptions { + style: HwpxStyleOptions { + body_font: Some("Pretendard".to_string()), + heading_font: Some("MaruBuri".to_string()), + body_font_size: Some(1120), + heading_font_size: Some(1560), + text_color: Some("444444".to_string()), + heading_color: Some("AA2200".to_string()), + link_color: Some("0088CC".to_string()), + paragraph_align: Some(HwpxParagraphAlign::Center), + }, + ..HwpxOptions::default() + }, + ) + .unwrap(); + let header = generated.text_entry("Contents/header.xml").unwrap(); + + assert!(header.contains("face=\"Pretendard\"")); + assert!(header.contains("face=\"MaruBuri\"")); + assert!(header.contains("")); + assert!(header.contains("굵게")); + assert!(section.contains("기울임")); + assert!(section.contains("type=\"HYPERLINK\"")); + assert!(section.contains("https://example.com")); + assert!(section.contains("링크")); + assert!(!section.contains("링크 (https://example.com)")); +} + +#[test] +fn custom_heading_font_does_not_leak_into_body_inline_runs() { + let generated = generate( + "본문 **굵게** *기울임* [링크](https://example.com)", + HwpxOptions { + style: HwpxStyleOptions { + heading_font: Some("MaruBuri".to_string()), + ..HwpxStyleOptions::default() + }, + ..HwpxOptions::default() + }, + ) + .unwrap(); + let header = generated.text_entry("Contents/header.xml").unwrap(); + + assert!(header.contains("face=\"함초롬바탕\"")); + assert!(header.contains("face=\"MaruBuri\"")); + assert!(header.contains("")); + + let body_bold = header + .split("").next()) + .unwrap(); + let body_italic = header + .split("").next()) + .unwrap(); + let body_link = header + .split("").next()) + .unwrap(); + + assert!(body_bold.contains("hangul=\"0\"")); + assert!(body_italic.contains("hangul=\"0\"")); + assert!(body_link.contains("hangul=\"0\"")); +} + +#[test] +fn paragraph_alignment_applies_to_heading_paragraph_styles() { + let generated = generate( + "# Heading\n\n본문", + HwpxOptions { + style: HwpxStyleOptions { + paragraph_align: Some(HwpxParagraphAlign::Right), + ..HwpxStyleOptions::default() + }, + ..HwpxOptions::default() + }, + ) + .unwrap(); + let header = generated.text_entry("Contents/header.xml").unwrap(); + + for para_id in [0, 2, 3, 4, 5, 6, 7] { + let marker = format!("").next()) + .unwrap(); + assert!(segment.contains("")); + } +} + +#[test] +fn approved_style_fixtures_preserve_header_style_registry_contract() { + for fixture_name in [ + "core-heading", + "core-inline-style", + "core-link-text", + "style-typography", + "style-centered-layout", + "style-brand-color", + ] { + let fixture = approved_fixture(fixture_name); + let input = crate::read_fixture_input(&fixture).unwrap(); + let generated = generate_hwpx(&input, fixture.options()).unwrap(); + let generated_entries = crate::normalized_entries(&generated).unwrap(); + let expected_entries = read_expected_entries(&fixture.root.join("expected")).unwrap(); + + let generated_header = text_entry(&generated_entries, "Contents/header.xml"); + let expected_header = text_entry(&expected_entries, "Contents/header.xml"); + + assert_eq!( + header_style_contract(generated_header), + header_style_contract(expected_header), + "header style contract drifted for fixture {fixture_name}", + ); + } +} + +#[test] +fn approved_style_fixtures_preserve_section_style_references() { + for fixture_name in [ + "core-heading", + "core-inline-style", + "core-link-text", + "style-typography", + "style-centered-layout", + "style-brand-color", + ] { + let fixture = approved_fixture(fixture_name); + let input = crate::read_fixture_input(&fixture).unwrap(); + let generated = generate_hwpx(&input, fixture.options()).unwrap(); + let generated_entries = crate::normalized_entries(&generated).unwrap(); + let expected_entries = read_expected_entries(&fixture.root.join("expected")).unwrap(); + + let generated_section = text_entry(&generated_entries, "Contents/section0.xml"); + let expected_section = text_entry(&expected_entries, "Contents/section0.xml"); + + assert_eq!( + section_style_refs(generated_section), + section_style_refs(expected_section), + "section style references drifted for fixture {fixture_name}", + ); + } +} diff --git a/packages/core-rs/tests/hwpx_fixture_refresh_test.rs b/packages/core-rs/tests/hwpx_fixture_refresh_test.rs index 1220453..6284215 100644 --- a/packages/core-rs/tests/hwpx_fixture_refresh_test.rs +++ b/packages/core-rs/tests/hwpx_fixture_refresh_test.rs @@ -7,7 +7,8 @@ use std::time::{SystemTime, UNIX_EPOCH}; mod archive_fixture; use archive_fixture::{ - ArchiveEntry, hash_entries, normalized_entries, read_expected_entries, refresh_fixture_metadata, + ArchiveEntry, hash_entries, normalized_archives_equivalent, normalized_entries, + read_expected_entries, refresh_fixture_metadata, }; const AUXILIARY_NAMES: &[&str] = &[".DS_Store", "README.md", "provenance.md"]; @@ -142,6 +143,43 @@ fn cli_fails_for_unknown_fixture_name() { assert!(stderr.contains("unknown HWPX approved fixture: does-not-exist")); } +#[test] +fn cli_prints_equivalent_status_for_matching_generated_output() { + let _guard = GeneratedOutputGuard::backup("core-paragraph"); + let generated = generated_output_root("core-paragraph"); + fs::create_dir_all(generated.parent().unwrap()).unwrap(); + fs::copy( + fixture_root().join("core-paragraph/golden.hwpx"), + &generated, + ) + .unwrap(); + + let output = Command::new(env!("CARGO_BIN_EXE_refresh_hwpx_fixture_metadata")) + .arg("core-paragraph") + .output() + .unwrap(); + + assert!(output.status.success()); + let stdout = String::from_utf8(output.stdout).unwrap(); + let golden_path = fixture_root().join("core-paragraph/golden.hwpx"); + let generated_path = generated_output_root("core-paragraph"); + assert!(stdout.contains("📦 fixture: core-paragraph")); + assert!(stdout.contains(&format!("golden: {}", golden_path.display()))); + assert!(stdout.contains(&format!( + "compare: ✅ {} equivalent {}", + generated_path.display(), + golden_path.display() + ))); +} + +#[test] +fn normalized_archive_comparison_detects_differences() { + let left = fs::read(fixture_root().join("core-paragraph/golden.hwpx")).unwrap(); + let right = fs::read(fixture_root().join("table-basic/golden.hwpx")).unwrap(); + + assert!(!normalized_archives_equivalent(&left, &right, is_text_entry).unwrap()); +} + fn fixture_root() -> PathBuf { Path::new(env!("CARGO_MANIFEST_DIR")) .join("tests") @@ -205,3 +243,55 @@ impl Drop for TestTempDir { let _ = fs::remove_dir_all(&self.path); } } + +fn generated_output_root(name: &str) -> PathBuf { + Path::new("/tmp") + .join("hwpx-generator-outputs") + .join(name) + .join("generated.hwpx") +} + +struct GeneratedOutputGuard { + target_dir: PathBuf, + backup_dir: Option, +} + +impl GeneratedOutputGuard { + fn backup(name: &str) -> Self { + let target_dir = Path::new("/tmp").join("hwpx-generator-outputs").join(name); + let backup_dir = if target_dir.exists() { + let backup = std::env::temp_dir().join(format!( + "hwpx-generator-outputs-backup-{name}-{}-{}", + std::process::id(), + SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_nanos() + )); + if let Some(parent) = backup.parent() { + fs::create_dir_all(parent).unwrap(); + } + fs::rename(&target_dir, &backup).unwrap(); + Some(backup) + } else { + None + }; + + Self { + target_dir, + backup_dir, + } + } +} + +impl Drop for GeneratedOutputGuard { + fn drop(&mut self) { + let _ = fs::remove_dir_all(&self.target_dir); + if let Some(backup_dir) = &self.backup_dir { + if let Some(parent) = self.target_dir.parent() { + let _ = fs::create_dir_all(parent); + } + let _ = fs::rename(backup_dir, &self.target_dir); + } + } +} diff --git a/packages/core-rs/tests/hwpx_test.rs b/packages/core-rs/tests/hwpx_test.rs index f120a19..65f03fc 100644 --- a/packages/core-rs/tests/hwpx_test.rs +++ b/packages/core-rs/tests/hwpx_test.rs @@ -1,4 +1,4 @@ -use std::collections::BTreeMap; +use std::collections::{BTreeMap, BTreeSet}; use std::fs; #[path = "hwpx/fixture_suite.rs"] @@ -9,7 +9,7 @@ mod hwpx_contract; mod hwpx_fixture; #[path = "support/hwpx_runtime.rs"] mod hwpx_runtime; -#[path = "hwpx/regression.rs"] +#[path = "hwpx/regression/mod.rs"] mod regression; use hwpx_fixture::{ @@ -49,7 +49,8 @@ pub(crate) fn assert_fixture_matches_golden(fixture: &HwpxFixture) { ); assert_eq!( - generated_normalized, golden_normalized, + comparable_entries(fixture, &generated_normalized), + comparable_entries(fixture, &golden_normalized), "generated golden mismatch: {}", fixture.name ); @@ -120,3 +121,34 @@ pub(crate) fn assert_xml_is_well_formed(entries: &BTreeMap, +) -> BTreeMap { + let excluded_paths = fixture_comparison_excludes(fixture); + if excluded_paths.is_empty() { + return entries.clone(); + } + + entries + .iter() + .filter(|(path, _)| !excluded_paths.contains(path.as_str())) + .map(|(path, entry)| (path.clone(), entry.clone())) + .collect() +} + +fn fixture_comparison_excludes(fixture: &HwpxFixture) -> BTreeSet<&str> { + let mut excluded_paths = BTreeSet::new(); + if !fixture.determinism { + excluded_paths.extend(NONDETERMINISTIC_ENTRY_EXCLUDES.iter().copied()); + } + excluded_paths.extend(fixture.comparison_excludes.iter().map(String::as_str)); + excluded_paths +} + +const NONDETERMINISTIC_ENTRY_EXCLUDES: &[&str] = &[ + "Contents/content.hpf", + "Preview/PrvImage.png", + "version.xml", +]; diff --git a/packages/core-rs/tests/readme_contract_test.rs b/packages/core-rs/tests/readme_contract_test.rs new file mode 100644 index 0000000..1335b4a --- /dev/null +++ b/packages/core-rs/tests/readme_contract_test.rs @@ -0,0 +1,39 @@ +use std::fs; +use std::path::Path; + +#[test] +fn readmes_include_strict_compat_capability_contract() { + let crate_root = Path::new(env!("CARGO_MANIFEST_DIR")); + let root_readme = fs::read_to_string(crate_root.join("../../README.md")).unwrap(); + let crate_readme = fs::read_to_string(crate_root.join("README.md")).unwrap(); + let npm_readme = fs::read_to_string(crate_root.join("../npm-core-rs/README.md")).unwrap(); + + for readme in [&root_readme, &crate_readme, &npm_readme] { + assert!(readme.contains("| Capability | DOCX | HWPX strict | HWPX compat |")); + } + + assert!(root_readme.contains("Experimental API")); + assert!(crate_readme.contains("generate_docx_with_report")); + assert!(npm_readme.contains("experimental HWPX API")); + assert!(npm_readme.contains("generateHwpxWithReport")); +} + +#[test] +fn benchmark_docs_do_not_freeze_old_summary_numbers() { + let crate_root = Path::new(env!("CARGO_MANIFEST_DIR")); + let root_readme = fs::read_to_string(crate_root.join("../../README.md")).unwrap(); + let ko_readme = fs::read_to_string(crate_root.join("../../docs/ko/README.md")).unwrap(); + let ko_benchmark = fs::read_to_string(crate_root.join("../../docs/ko/benchmark.md")).unwrap(); + let ko_design = fs::read_to_string(crate_root.join("../../docs/ko/design-system.md")).unwrap(); + + for doc in [&root_readme, &ko_readme, &ko_benchmark, &ko_design] { + assert!(!doc.contains("105x")); + assert!(!doc.contains("80 ms cold / 2 ms steady")); + assert!(!doc.contains("284 ms cold / 210 ms steady")); + } + + assert!(root_readme.contains("comparison block below")); + assert!(ko_readme.contains("comparison block")); + assert!(ko_benchmark.contains("comparison-data.json")); + assert!(ko_design.contains("shared benchmark dataset")); +} diff --git a/packages/core-rs/tests/support/archive_fixture.rs b/packages/core-rs/tests/support/archive_fixture.rs index aa8e917..ddec355 100644 --- a/packages/core-rs/tests/support/archive_fixture.rs +++ b/packages/core-rs/tests/support/archive_fixture.rs @@ -6,6 +6,6 @@ mod shared_archive_fixture; pub(crate) use shared_archive_fixture::{ ArchiveEntry, BinaryRepresentation, FixtureResult, NormalizedEntry, archive_entries, archive_entry_names, detect_binary_representations, discover_fixture_dirs, hash_entries, - normalized_entries, normalized_entries_from_archive, read_expected_entries, read_fixture_input, - refresh_fixture_metadata, + normalized_archives_equivalent, normalized_entries, normalized_entries_from_archive, + read_expected_entries, read_fixture_input, refresh_fixture_metadata, }; diff --git a/packages/core-rs/tests/support/docx_fixture.rs b/packages/core-rs/tests/support/docx_fixture.rs index 93d6d7c..be3e12f 100644 --- a/packages/core-rs/tests/support/docx_fixture.rs +++ b/packages/core-rs/tests/support/docx_fixture.rs @@ -106,5 +106,8 @@ pub fn hash_entries(entries: &BTreeMap) -> String { } fn is_text_entry(path: &str) -> bool { - path.ends_with(".xml") || path.ends_with(".rels") || path.ends_with(".txt") || path.ends_with(".md") + path.ends_with(".xml") + || path.ends_with(".rels") + || path.ends_with(".txt") + || path.ends_with(".md") } diff --git a/packages/core-rs/tests/support/docx_runtime.rs b/packages/core-rs/tests/support/docx_runtime.rs index 545c147..d481fb2 100644 --- a/packages/core-rs/tests/support/docx_runtime.rs +++ b/packages/core-rs/tests/support/docx_runtime.rs @@ -6,7 +6,10 @@ pub struct GeneratedDocx { entries: std::collections::BTreeMap, } -fn generate_normalized(markdown: &str, options: DocxOptions) -> FixtureResult> { +fn generate_normalized( + markdown: &str, + options: DocxOptions, +) -> FixtureResult> { let bytes = generate_docx(markdown, options)?; normalized_entries(&bytes) } diff --git a/packages/core-rs/tests/support/hwpx_fixture.rs b/packages/core-rs/tests/support/hwpx_fixture.rs index b1698ff..c09b0eb 100644 --- a/packages/core-rs/tests/support/hwpx_fixture.rs +++ b/packages/core-rs/tests/support/hwpx_fixture.rs @@ -18,6 +18,12 @@ struct FixtureConfig { strict_mode: bool, determinism: bool, #[serde(default)] + comparison_excludes: Vec, + #[serde(default)] + manual_verified: bool, + #[serde(default)] + verified_at: Option, + #[serde(default)] style: FixtureStyleConfig, } @@ -41,6 +47,9 @@ pub struct HwpxFixture { pub author: String, pub strict_mode: bool, pub determinism: bool, + pub comparison_excludes: Vec, + pub manual_verified: bool, + pub verified_at: Option, pub style: HwpxStyleOptions, } @@ -115,6 +124,9 @@ fn discover_fixtures_in(root: PathBuf) -> FixtureResult> { author: config.author, strict_mode: config.strict_mode, determinism: config.determinism, + comparison_excludes: config.comparison_excludes, + manual_verified: config.manual_verified, + verified_at: config.verified_at, style: HwpxStyleOptions { body_font: config.style.body_font, heading_font: config.style.heading_font, diff --git a/packages/npm-core-rs/README.md b/packages/npm-core-rs/README.md index 4df4ebe..fa6f123 100644 --- a/packages/npm-core-rs/README.md +++ b/packages/npm-core-rs/README.md @@ -2,12 +2,13 @@ Language: [English](./README.md) · [한국어 문서](../../docs/ko/README.md) -`@docxly/core-rs` is the npm package for the `docxly` Rust core, an embeddable document generation engine for app integration. Where Pandoc is a general-purpose converter, this package is designed to expose DOCX generation directly inside Node and browser-based runtimes, with the current offline Node benchmark showing an 80 ms cold start and a 2 ms steady median for docxly versus 284 ms cold and 210 ms steady for Pandoc on the summary corpus. +`@docxly/core-rs` is the npm package for the `docxly` Rust core, an embeddable document generation engine for app integration. Where Pandoc is a general-purpose converter, this package is designed to expose DOCX generation directly inside Node and browser-based runtimes, with current benchmark details maintained in the shared comparison dataset and README comparison block. ## Status - DOCX generation: supported -- HWPX generation: not exposed in npm v0.x +- HWPX generation: experimental HWPX API +- report APIs: recommended for user-generated Markdown - API style: async only ## Live Demo @@ -20,38 +21,55 @@ Language: [English](./README.md) · [한국어 문서](../../docs/ko/README.md) npm install @docxly/core-rs ``` +## Release Workflow + +- Contributors add a changeset from the repository root with `npm run changeset:add` +- After that changeset merges into `main`, the `Version Packages` workflow opens or updates a PR that bumps this package version +- The same PR also syncs `packages/core-rs/Cargo.toml` and refreshes this package's `package-lock.json` +- The existing `Release` workflow still publishes npm when a matching `v*.*.*` tag is pushed + ## Usage ### Node ```js import { writeFile } from "node:fs/promises"; -import { generateDocx } from "@docxly/core-rs"; +import { generateDocx, generateHwpxWithReport } from "@docxly/core-rs"; const bytes = await generateDocx("# Hello\n\nThis is **docxly**."); await writeFile("output.docx", bytes); + +const hwpx = await generateHwpxWithReport("1. alpha\n2. beta", { + strictMode: false, +}); +console.log(hwpx.report.issues); ``` ### Browser -Use a bundler/runtime that can resolve package exports and emit the `.wasm` asset. The package is -not meant to be imported directly from a raw `