diff --git a/.gitignore b/.gitignore index 3eb848f..ac04211 100644 --- a/.gitignore +++ b/.gitignore @@ -7,3 +7,4 @@ packages/npm-core-rs/site-dist/ packages/npm-core-rs/*.tgz packages/npm-core-rs/tmp/ target/ +packages/core-rs/src/generators/hwpx/reference/*-sample.hwpx diff --git a/README.md b/README.md index 9acc732..7e41f90 100644 --- a/README.md +++ b/README.md @@ -1,16 +1,66 @@ -# docxly core-rs +
+

docxly core-rs

+

Embeddable Rust/WASM document generation for DOCX and HWPX.

+

Where Pandoc is a general-purpose converter, docxly is designed to live inside Node services, browser workflows, and product surfaces as a library.

+

+ CI + npm + demo + license +

+
-`docxly/core-rs` is a Rust-first mono repo for a Markdown-based DOCX/HWPX generation library, plus an npm-facing WASM wrapper package. +Language: [English](/Users/limchaesung/Github/docxly/core-rs/README.md) · [한국어](/Users/limchaesung/Github/docxly/core-rs/docs/ko/README.md) · [Docs](/Users/limchaesung/Github/docxly/core-rs/docs/README.md) -The project is being developed with a TDD-first workflow. The current milestone implements the DOCX rich slice and is bringing up HWPX compatibility from a minimal package baseline. +`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. -## Live Demo +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. -Try the browser demo on GitHub Pages: +## Install -- https://docxly.github.io/core-rs/ +The fastest way to start using docxly today is the npm package: -The live page uses the published WASM wrapper and downloads a real `.docx` file directly in the browser. +```bash +npm install @docxly/core-rs +``` + +The Rust crate is the source of truth in this repository and can be consumed from the workspace or as a path dependency: + +```toml +[dependencies] +core-rs = { path = "packages/core-rs" } +``` + +## Quick Start + +### Node + +```js +import { writeFile } from "node:fs/promises"; +import { generateDocx } from "@docxly/core-rs"; + +const bytes = await generateDocx("# Hello\n\nThis is **docxly**."); +await writeFile("output.docx", bytes); +``` + +### Rust + +```rust +use std::fs; + +use core_rs::{DocxOptions, generate_docx}; + +let docx = generate_docx("# Hello\n\nThis is **docxly**.", DocxOptions::default())?; +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. +- 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. ## Live Demo @@ -20,6 +70,39 @@ Try the browser demo on GitHub Pages: The live page uses the published WASM wrapper and downloads a real `.docx` file directly in the browser. + + +## Why docxly instead of Pandoc? + +Pandoc is a general-purpose converter; docxly is an embeddable generation engine. + +Use docxly when document generation must live inside a Node service, browser workflow, or product surface. Use Pandoc when you need broad format conversion and a CLI-first publishing workflow. + +| 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. + +- 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. +- Cold timings include WASM initialization for docxly and process startup for Pandoc. +- Steady timings are medians from 15 runs after one warm-up per corpus. + + + ## Workspace Layout ```text @@ -43,9 +126,9 @@ The live page uses the published WASM wrapper and downloads a real `.docx` file - 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 -- In progress: HWPX compatibility bring-up from a minimal Hancom-compatible package baseline -- Current HWPX CI gates only manually approved fixtures; `core-paragraph`, `core-heading`, `core-inline-style`, `core-link-text`, `core-mixed`, `style-typography`, `style-centered-layout`, and `style-brand-color` are the current approved baselines -- Stale compatibility snapshots are quarantined and used only for reverse-engineering +- 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 ## Supported Markdown Today @@ -104,7 +187,7 @@ Current options: Notes: - `generate_docx` returns a deterministic `.docx` archive as `Vec` -- `generate_hwpx` currently targets a minimal compatibility baseline and is still being validated against Hancom +- `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 @@ -113,7 +196,8 @@ Notes: The current HWPX path is narrower than the DOCX rich slice. -- approved baseline: `core-paragraph`, `core-heading`, `core-inline-style`, `core-link-text`, `core-mixed` +- 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 @@ -121,11 +205,8 @@ The current HWPX path is narrower than the DOCX rich slice. - 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: - - lists - tables - images - - blockquotes - - fenced code blocks Example HWPX generation: @@ -273,10 +354,11 @@ hash.txt ## HWPX Reference Material -- `/Users/limchaesung/Github/docxly/core-rs/packages/core-rs/src/generators/hwpx/docs/README.md` -- `/Users/limchaesung/Github/docxly/core-rs/packages/core-rs/src/generators/hwpx/docs/schema-md/index.md` +- `packages/core-rs/src/generators/hwpx/docs/README.md` +- `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 with Markdown conversions of official Hancom PDF references, KS X 6101 source metadata, and a synthetic compatibility corpus. +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. ## Development Notes diff --git a/docs/README.md b/docs/README.md new file mode 100644 index 0000000..c0afbc8 --- /dev/null +++ b/docs/README.md @@ -0,0 +1,15 @@ +# Docs + +`docxly` documentation is organized by entrypoint, similar to a product docs hub. + +## Languages + +- [English (repository README)](/Users/limchaesung/Github/docxly/core-rs/README.md) +- [한국어](/Users/limchaesung/Github/docxly/core-rs/docs/ko/README.md) + +## Sections + +- [Getting Started](/Users/limchaesung/Github/docxly/core-rs/docs/ko/getting-started.md) +- [Runtime Guide](/Users/limchaesung/Github/docxly/core-rs/docs/ko/runtime.md) +- [Benchmark and Positioning](/Users/limchaesung/Github/docxly/core-rs/docs/ko/benchmark.md) +- [Design System](/Users/limchaesung/Github/docxly/core-rs/docs/ko/design-system.md) diff --git a/docs/ko/README.md b/docs/ko/README.md new file mode 100644 index 0000000..466d070 --- /dev/null +++ b/docs/ko/README.md @@ -0,0 +1,31 @@ +# docxly 문서 + +`docxly`는 DOCX와 HWPX 생성을 애플리케이션 내부에 직접 내장하기 위한 Rust/WASM 문서 생성 엔진입니다. + +Pandoc이 범용 문서 변환기라면, `docxly`는 Node 서비스, 브라우저 워크플로, 제품 UI 안에 직접 들어가는 라이브러리 경로에 초점을 맞춥니다. + +## 시작하기 + +- [빠른 시작](/Users/limchaesung/Github/docxly/core-rs/docs/ko/getting-started.md) +- [런타임 가이드](/Users/limchaesung/Github/docxly/core-rs/docs/ko/runtime.md) +- [성능 비교와 포지셔닝](/Users/limchaesung/Github/docxly/core-rs/docs/ko/benchmark.md) +- [디자인 시스템](/Users/limchaesung/Github/docxly/core-rs/docs/ko/design-system.md) + +## 핵심 수치 + +- 현재 summary benchmark 기준 `docxly`는 `80 ms cold / 2 ms steady` +- 같은 코퍼스에서 Pandoc은 `284 ms cold / 210 ms steady` +- steady 기준으로 `105x` 빠른 수치가 측정됨 + +## 언제 docxly를 선택해야 하나 + +- 문서 생성을 별도 CLI 프로세스가 아니라 애플리케이션 내부 라이브러리로 붙이고 싶을 때 +- Node와 브라우저에서 같은 Rust 코어를 재사용하고 싶을 때 +- DOCX뿐 아니라 HWPX까지 같은 저장소와 API 흐름으로 확장하고 싶을 때 +- 테스트 가능한 결정적 출력과 fixture 기반 검증이 필요할 때 + +## 바로 가기 + +- [루트 README](/Users/limchaesung/Github/docxly/core-rs/README.md) +- [npm package README](/Users/limchaesung/Github/docxly/core-rs/packages/npm-core-rs/README.md) +- [라이브 데모](https://docxly.github.io/core-rs/) diff --git a/docs/ko/benchmark.md b/docs/ko/benchmark.md new file mode 100644 index 0000000..83afccf --- /dev/null +++ b/docs/ko/benchmark.md @@ -0,0 +1,42 @@ +# 성능 비교와 포지셔닝 + +## 요약 + +`docxly`는 Pandoc의 대체 CLI가 아니라, 앱 내부에 문서 생성을 임베드하기 위한 라이브러리 경로를 제공합니다. + +- `docxly`: embeddable generation engine +- `Pandoc`: general-purpose converter + +## 현재 summary benchmark + +- `docxly`: `80 ms cold / 2 ms steady` +- `Pandoc`: `284 ms cold / 210 ms steady` +- steady median 기준 `105x` + +측정 조건: + +- 환경: `darwin 25.2.0 / arm64` +- Node: `v23.7.0` +- Pandoc: `3.9` +- 기준: complex DOCX benchmark corpus + +## 해석 + +- cold start는 초기화 비용을 포함합니다. + - `docxly`: WASM initialization 포함 + - `Pandoc`: process startup 포함 +- steady median은 warm-up 이후 반복 생성 비용을 의미합니다. +- 이 수치는 브라우저 실측이 아니라 offline Node benchmark입니다. + +## 언제 Pandoc이 더 적합한가 + +- 폭넓은 문서 포맷 변환이 필요할 때 +- CLI 중심 출판 파이프라인이 있을 때 +- `reference.docx` 기반 커스터마이징 흐름이 필요할 때 + +## 언제 docxly가 더 적합한가 + +- 앱 내부에 직접 문서 생성 기능을 붙여야 할 때 +- Node와 브라우저에서 같은 코어를 재사용해야 할 때 +- HWPX까지 같은 제품 흐름 안에서 다뤄야 할 때 +- 별도 프로세스 의존성 없이 라이브러리 형태로 배포하고 싶을 때 diff --git a/docs/ko/design-system.md b/docs/ko/design-system.md new file mode 100644 index 0000000..685bff7 --- /dev/null +++ b/docs/ko/design-system.md @@ -0,0 +1,292 @@ +# docxly 데모 디자인 시스템 + +`docxly` 데모 UI를 blue-first 제품 스타일로 재구성하기 위한 구현 기준 문서다. 이 문서는 데모 페이지를 바로 다시 설계할 수 있도록 토큰, 위계, 컴포넌트 규칙, 로고 슬롯 규칙을 결정 완료 상태로 정의한다. + +## Overview + +### 제품 성격 + +- embeddable document engine +- Node, browser, Rust 코어를 공유하는 문서 생성 제품 +- CLI 도구가 아니라 앱 내부에 들어가는 라이브러리 경험이 핵심 + +### 대상 사용자 + +- 앱과 서비스에 문서 생성을 직접 내장하려는 개발자 +- 브라우저와 Node에서 같은 코어를 쓰고 싶은 팀 +- DOCX뿐 아니라 HWPX까지 같은 제품 흐름으로 확장하려는 팀 + +### 디자인 목표 + +- product-like clarity +- generator-first usability +- blue-first technical brand identity + +## Brand Foundation + +### 핵심 브랜드 문장 + +- `docxly is an embeddable document generation engine.` +- `docxly brings DOCX and HWPX generation into the product surface, not a separate conversion step.` + +### 로고 슬롯 규칙 + +- 위치: hero 내부 맨 위 좌측 +- 형태: `logo mark + wordmark` 또는 `docxly` 텍스트 lockup +- 최소 높이: desktop 28px, mobile 24px +- clear space: 로고 높이의 0.5배 +- 기본 배경: 밝은 surface 위 단색 사용 +- dark surface 위 사용 시 단색 역상 버전만 허용 + +### 로고 없는 상태의 fallback + +- 실제 로고 자산이 없으면 `docxly` 워드마크 텍스트를 사용한다. +- fallback 서체는 sans-serif display 계열로 고정한다. +- fallback은 headline과 분리된 독립 요소여야 하며, hero heading 안에 합치지 않는다. + +## Color System + +### Core Tokens + +| Token | Value | Role | +| --- | --- | --- | +| `--color-primary-050` | `#eff6ff` | page tint, subtle highlight | +| `--color-primary-100` | `#dbeafe` | soft border, soft chip | +| `--color-primary-500` | `#2563eb` | selected state, active fill | +| `--color-primary-600` | `#1d4ed8` | primary CTA | +| `--color-primary-700` | `#1e40af` | hover / pressed CTA | +| `--color-neutral-950` | `#0f172a` | headline, strong surface | +| `--color-neutral-700` | `#334155` | body text | +| `--color-neutral-500` | `#64748b` | muted text | +| `--color-neutral-200` | `#e2e8f0` | border, divider | +| `--color-surface` | `#ffffff` | base panel surface | +| `--color-surface-muted` | `#f8fafc` | muted background | +| `--color-success` | `#15803d` | success status | +| `--color-error` | `#b91c1c` | error status | + +### Usage Rules + +- primary CTA는 `primary-600` +- primary CTA hover는 `primary-700` +- selected tab, selected chip, active proof highlight는 `primary-500` +- soft tint 배경은 `primary-050` +- border 기본값은 `neutral-200` +- body text는 `neutral-700` +- muted helper text는 `neutral-500` +- dark comparison/proof strip 표면은 `neutral-950` 기반으로 사용 + +### Explicit Constraints + +- 기존 warm/orange accent는 primary palette에서 제거한다. +- warning/emphasis 보조색도 이번 문서 기준에서는 정의하지 않는다. +- text on primary는 white only다. +- 본문 텍스트 대비는 WCAG AA 이상을 유지한다. + +## Typography + +### Type Roles + +- display: hero headline 전용 +- heading: section title, card title +- body: paragraph, helper, note +- mono: install command, textarea, generated status +- label: tab, eyebrow, compact metadata + +### Type Scale + +| Token | Desktop | Mobile | Usage | +| --- | --- | --- | --- | +| `display-1` | `56/1.0` | `40/1.02` | hero headline | +| `heading-2` | `32/1.05` | `26/1.08` | section heading | +| `body-1` | `16/1.6` | `16/1.6` | default paragraph | +| `body-2` | `14/1.55` | `14/1.55` | helper, note | +| `label` | `12/1.2` | `12/1.2` | uppercase label | + +### Type Rules + +- hero body는 최대 2문장 +- comparison meta는 1줄만 허용 +- install helper는 1문장만 허용 +- note/debug 문구는 body-2로만 표현 + +## Layout + +### Page Frame + +- page width: max 1120px +- panel radius: 20px +- hero top padding: 40px desktop, 24px mobile +- section gap: 24px + +### Spacing Scale + +- `8` +- `12` +- `16` +- `24` +- `32` +- `48` + +### Hero Layout + +- desktop: `content column + install card` 2열 +- content column 내부 순서: + - logo + - one-line value proposition + - short supporting sentence + - external links max 2개 + - compact proof strip +- comparison strip은 content column 내부에만 배치 +- install card는 독립 보조 카드 1개만 허용 + +### Mobile Layout + +- 모바일 1열 순서: + - logo/value proposition + - install card + - compact proof strip + - generator panel +- `390x844` 기준 first viewport 안에 `logo + headline + install action`이 보여야 한다. +- first viewport 안에 proof strip 전체가 보일 필요는 없지만, strip 시작부는 보여야 한다. + +## Component Rules + +### Hero + +- 최대 2개 text paragraph +- 외부 링크 최대 2개 +- hero 안에 독립 강조 카드 2개 초과 금지 +- value proposition은 1문장으로 끝낸다 + +### Install Card + +- command +- copy button +- 1-line helper +- 1-line proof copy using the benchmark summary headline number + +canonical install proof: + +- `Install the embeddable DOCX engine that measured 105x faster than Pandoc on the summary benchmark.` + +금지: + +- 여러 installation option 동시 노출 +- verbose explanation +- secondary CTA 추가 + +### Proof Strip + +- KPI 3개 고정 + - docxly steady + - pandoc steady + - speed ratio +- 1줄 해석 허용 +- badge 1개 허용 +- long metadata는 1줄만 허용 + +금지: + +- comparison table +- dual comparison cards +- 긴 explanatory paragraph +- feature matrix + +### Generator Panel + +- 데모의 가장 높은 interaction priority 유지 +- 유지 대상: + - format tabs + - markdown textarea + - title input + - author input + - strict mode toggle + - generate button + - status + - note + +### Tabs + +- active = filled blue +- inactive = neutral ghost +- uppercase label +- tab label은 한 단어 또는 짧은 약어만 허용 + +### Buttons + +- variants: + - primary + - secondary + - ghost +- primary는 blue fill +- secondary는 neutral tint +- ghost는 borderless text action + +## Content Hierarchy + +### First Screen Order + +1. logo +2. one-line value proposition +3. short supporting sentence +4. primary install action +5. compact proof strip + +install proof line은 comparison strip보다 먼저 읽히는 핵심 설치 유도 문장으로 배치한다. + +### Comparison Handling + +- comparison은 landing에서 제거하지 않는다. +- 하지만 역할은 `compact support proof`로만 제한한다. +- `Choose docxly / Choose Pandoc` 장문 카피는 기본 landing에서 제거 대상이다. +- feature matrix와 장문 포지셔닝 설명은 docs 또는 하단 secondary content로 이동한다. + +## Do / Don't + +### Do + +- hero에서 한 가지 핵심 행동만 강조한다. +- proof는 숫자 중심으로 압축한다. +- blue tokens만으로 CTA와 active state를 통일한다. +- generator panel을 가장 중요한 작업 영역으로 유지한다. +- 로고를 headline과 별도 계층으로 분리한다. + +### Don't + +- 상단에 독립 강조 카드 3개 이상 배치하지 않는다. +- comparison table을 landing first screen에 노출하지 않는다. +- warm/orange palette를 primary accent로 사용하지 않는다. +- 긴 비교 카피를 generator보다 먼저 배치하지 않는다. +- install card와 comparison strip을 같은 강도의 경쟁 블록으로 만들지 않는다. + +## Acceptance Criteria + +이 문서만 읽고 구현자는 추가 질문 없이 다음을 수행할 수 있어야 한다. + +- CSS custom property 정의 +- hero 구조 재배치 +- install card / compact proof strip / generator panel 위계 적용 +- 로고 자산 도입 전 fallback lockup 구현 + +추가 완료 조건: + +- 모바일 `390x844` 기준 first screen에 `logo + headline + install action`이 모두 보여야 한다는 기준이 명시돼 있어야 한다. +- comparison은 `support proof`로만 남고, 장문 설명과 표는 기본 landing에서 제거 대상으로 명시돼 있어야 한다. +- primary accent가 blue token family로 통일된다고 명시돼 있어야 한다. + +## Migration Notes + +현재 데모 UI에서 제거 또는 축소해야 하는 요소: + +- warm/orange 중심 accent +- landing 상단의 장문 comparison explanation +- feature matrix table +- dual comparison choice blocks +- hero에서 경쟁하는 다중 강조 카드 + +새 UI로 옮길 때 유지해야 하는 요소: + +- install command 복사 흐름 +- summary benchmark proof +- HWPX/DOCX format switching +- browser-local generation 메시지 diff --git a/docs/ko/getting-started.md b/docs/ko/getting-started.md new file mode 100644 index 0000000..c9323de --- /dev/null +++ b/docs/ko/getting-started.md @@ -0,0 +1,52 @@ +# 빠른 시작 + +## 설치 + +가장 빠른 시작 경로는 npm 패키지입니다. + +```bash +npm install @docxly/core-rs +``` + +Rust crate는 이 저장소의 워크스페이스에서 바로 사용할 수 있습니다. + +```toml +[dependencies] +core-rs = { path = "packages/core-rs" } +``` + +## Node 예제 + +```js +import { writeFile } from "node:fs/promises"; +import { generateDocx } from "@docxly/core-rs"; + +const bytes = await generateDocx("# Hello\n\nThis is **docxly**."); +await writeFile("output.docx", bytes); +``` + +## Rust 예제 + +```rust +use std::fs; + +use core_rs::{DocxOptions, generate_docx}; + +let docx = generate_docx("# Hello\n\nThis is **docxly**.", DocxOptions::default())?; +fs::write("output.docx", docx)?; +``` + +## 로컬 데모 실행 + +저장소 루트에서 아래 명령을 실행합니다. + +```bash +npm install +npm run demo +``` + +정적 Pages 산출물만 생성하려면: + +```bash +npm run build:pages +``` diff --git a/docs/ko/runtime.md b/docs/ko/runtime.md new file mode 100644 index 0000000..256e37d --- /dev/null +++ b/docs/ko/runtime.md @@ -0,0 +1,45 @@ +# 런타임 가이드 + +## 지원 런타임 + +- Node +- 브라우저 번들러 환경 +- Rust workspace/path dependency + +## Node + +`@docxly/core-rs`는 비동기 API로 DOCX 생성을 제공합니다. + +- 기본 엔트리포인트: `generateDocx(markdown, options)` +- 반환값: `Promise` +- 주요 옵션: `title`, `author`, `strictMode` + +## 브라우저 + +브라우저에서는 `.wasm` 자산을 처리할 수 있는 번들러가 필요합니다. + +- raw ` + + diff --git a/packages/npm-core-rs/demo/main.js b/packages/npm-core-rs/demo/main.js index d2f14e8..f75468e 100644 --- a/packages/npm-core-rs/demo/main.js +++ b/packages/npm-core-rs/demo/main.js @@ -1,44 +1,276 @@ -import { generateDocx } from "./browser-client.js"; +import { generateDocx, generateHwpx } from "./browser-client.js"; -const sampleMarkdown = `# Browser Demo +const locales = { + en: { + defaultTitle: "Docxly Adoption Report", + defaultFormat: "docx", + sampleMarkdown: `# Why Teams Should Install docxly -This DOCX file is generated in the browser with **docxly** and the same Rust core used by the npm package. +docxly is an **embeddable document generation engine** for teams that need DOCX or HWPX output inside a product, not as a separate conversion step. -> Markdown in. DOCX out. Browser only. +This report explains why installation is justified when product teams need faster document generation, browser-local workflows, and one shared Rust core across runtimes. -## Highlights +## Executive Summary -1. WebAssembly runtime -2. No backend conversion -3. Local download +> Install docxly when document generation must live inside your application, your browser workflow, or your delivery pipeline without depending on an external conversion service. -| Capability | Status | -| --- | --- | -| Markdown parsing | Browser | -| DOCX packaging | Browser | -| Open source package | npm + GitHub |`; +- **105x faster than Pandoc** on the current summary DOCX benchmark +- Browser-local generation with the same Rust core used in Node +- HWPX and DOCX supported from the same product surface +- Open-source package with [public repository](https://github.com/docxly/core-rs) + +## Why Installation Pays Off + +### 1. Product teams need an embeddable engine + +If your team generates proposals, reports, exports, or customer-facing files inside an application, the document engine should be part of the product stack. + +- No backend conversion dependency +- No separate CLI orchestration for the main flow +- One package to integrate into Node and browser contexts + +### 2. Speed changes user experience + +The current benchmark headline is simple: docxly measured **105x faster** than Pandoc on the summary DOCX benchmark. + +- Faster steady-state generation +- Better fit for interactive product workflows +- Lower friction for install justification + +### 3. One core supports multiple output paths + +docxly keeps the same core architecture across document workflows. + +- DOCX generation for broad office compatibility +- HWPX generation for Korean document workflows +- Shared Markdown-to-document model across runtimes + +## Recommended Installation Decision + +\`\`\`bash +npm install @docxly/core-rs +\`\`\` + +Install docxly if your team wants document generation to be a feature of the product instead of a separate conversion stage.`, + formatLabels: { + hwpx: "HWPX", + docx: "DOCX", + }, + defaultFilenames: { + hwpx: "docxly-browser-demo.hwpx", + docx: "docxly-browser-demo.docx", + }, + statusDescriptions: { + hwpx: "Generating HWPX in the browser...", + docx: "Generating DOCX in the browser...", + }, + comparison: { + unavailable: "Unavailable", + unavailableHeadline: "Offline benchmark data unavailable.", + availableHeadline: "Summary DOCX benchmark powered by the shared comparison dataset.", + fallbackProof: + "Install the embeddable DOCX engine backed by the same Rust core in Node, browser, and HWPX workflows.", + installProof: (ratio) => + `Install the embeddable DOCX engine that measured ${ratio} faster than Pandoc on the summary benchmark.`, + label: "Offline Node benchmark for library selection, not browser runtime timing.", + metaUnavailable: "comparison data unavailable", + meta: (data) => + `Measured on ${data.machine_label} at ${data.measured_at} with Node ${data.node_version} and Pandoc ${data.pandoc_version}.`, + badge: "HWPX support is docxly-only", + }, + copy: { + idle: "Copy", + success: "Copied", + fail: "Copy manually", + }, + formatSummary(activeFormat, defaultFormat) { + if (activeFormat === defaultFormat && defaultFormat === "hwpx") { + return "HWPX is the default path for Korean-language users in this demo. Switch tabs to generate DOCX instead."; + } + + if (activeFormat === defaultFormat) { + return "DOCX is the default path for non-Korean users in this demo. Switch tabs to generate HWPX instead."; + } + + if (defaultFormat === "hwpx") { + return "DOCX is one tab away from the Korean-language default. Switch back to HWPX to return to the default path."; + } + + return "HWPX is one tab away from the language-based default. Switch back to DOCX to return to the default path."; + }, + generateLabel: (formatLabel) => `Generate ${formatLabel}`, + ready: (formatLabel) => `Ready to generate ${formatLabel}.`, + success: (filename, elapsed, byteLength) => + `Generation started for ${filename} in ${elapsed} (${byteLength} bytes).`, + failed: (elapsed, message) => `Generation failed in ${elapsed}: ${message}`, + }, + ko: { + defaultTitle: "Docxly 도입 제안서", + defaultFormat: "hwpx", + sampleMarkdown: `# 왜 지금 docxly를 설치해야 하는가 + +docxly는 별도 변환 단계가 아니라 제품 안에서 직접 DOCX와 HWPX를 생성해야 하는 팀을 위한 **내장형 문서 생성 엔진**입니다. + +이 보고서는 제품 팀이 더 빠른 문서 생성, 브라우저 로컬 워크플로, 그리고 런타임 전반에서 공유되는 하나의 Rust 코어를 원할 때 왜 docxly 설치가 합리적인지 설명합니다. + +## 핵심 요약 + +> 문서 생성이 외부 변환 서비스가 아니라 애플리케이션과 브라우저 워크플로 안에 들어가야 한다면 docxly를 설치해야 합니다. + +- 현재 요약 DOCX 벤치마크에서 **Pandoc 대비 105배 빠른 속도** +- Node와 동일한 Rust 코어를 사용하는 브라우저 로컬 생성 +- 하나의 제품 표면에서 HWPX와 DOCX를 모두 지원 +- [공개 저장소](https://github.com/docxly/core-rs)를 가진 오픈소스 패키지 + +## 설치가 곧 제품 경쟁력이 되는 이유 + +### 1. 제품 안에 들어가는 엔진이 필요합니다 + +제안서, 보고서, 내보내기 문서, 고객용 결과물을 애플리케이션 안에서 생성한다면 문서 엔진도 제품 스택의 일부여야 합니다. + +- 메인 흐름에서 별도 백엔드 변환 의존성이 없습니다 +- 핵심 사용자 경험을 위해 별도의 CLI 오케스트레이션이 필요하지 않습니다 +- Node와 브라우저 컨텍스트를 하나의 패키지로 통합할 수 있습니다 + +### 2. 속도는 사용자 경험을 바꿉니다 + +현재 벤치마크의 핵심 문장은 명확합니다. docxly는 요약 DOCX 벤치마크에서 Pandoc보다 **105배 빠르게** 측정되었습니다. + +- 반복 생성 구간에서 더 빠른 steady-state 성능 +- 인터랙티브한 제품 워크플로에 더 적합한 응답성 +- 설치 의사결정을 쉽게 만드는 명확한 수치 + +### 3. 하나의 코어가 여러 문서 경로를 지원합니다 + +docxly는 문서 워크플로 전체에서 같은 코어 아키텍처를 유지합니다. + +- 광범위한 오피스 호환성을 위한 DOCX 생성 +- 한국 문서 워크플로를 위한 HWPX 생성 +- 런타임 전반에서 공유되는 Markdown 문서 모델 + +## 권장 설치 결정 + +\`\`\`bash +npm install @docxly/core-rs +\`\`\` + +문서 생성을 별도 변환 단계가 아니라 제품 기능으로 만들고 싶다면 docxly를 설치해야 합니다.`, + formatLabels: { + hwpx: "HWPX", + docx: "DOCX", + }, + defaultFilenames: { + hwpx: "docxly-브라우저-데모.hwpx", + docx: "docxly-브라우저-데모.docx", + }, + statusDescriptions: { + hwpx: "브라우저에서 HWPX를 생성하고 있습니다...", + docx: "브라우저에서 DOCX를 생성하고 있습니다...", + }, + comparison: { + unavailable: "준비 중", + unavailableHeadline: "오프라인 벤치마크 데이터를 불러오지 못했습니다.", + availableHeadline: "공유 비교 데이터셋으로 측정한 요약 DOCX 벤치마크입니다.", + fallbackProof: + "Node, 브라우저, HWPX 워크플로 전체에서 같은 Rust 코어를 사용하는 내장형 DOCX 엔진을 설치하세요.", + installProof: (ratio) => + `요약 벤치마크에서 Pandoc보다 ${ratio} 빠르게 측정된 내장형 DOCX 엔진을 설치하세요.`, + label: "브라우저 실행 시간이 아닌, 라이브러리 선택을 위한 Node 벤치마크입니다.", + metaUnavailable: "비교 데이터를 불러오지 못했습니다.", + meta: (data) => + `${data.machine_label}에서 ${data.measured_at}에 측정했으며, Node ${data.node_version} 및 Pandoc ${data.pandoc_version} 기준입니다.`, + badge: "HWPX 지원은 docxly 전용입니다", + }, + copy: { + idle: "복사", + success: "복사됨", + fail: "수동 복사", + }, + formatSummary(activeFormat, defaultFormat) { + if (activeFormat === defaultFormat && defaultFormat === "hwpx") { + return "한국어 사용자에게는 HWPX가 기본 경로입니다. 다른 형식이 필요하면 DOCX 탭으로 전환하세요."; + } + + if (activeFormat === defaultFormat) { + return "비한국어 사용자에게는 DOCX가 기본 경로입니다. HWPX가 필요하면 탭을 전환하세요."; + } + + if (defaultFormat === "hwpx") { + return "DOCX는 한국어 기본 경로에서 한 탭 떨어져 있습니다. 기본값으로 돌아가려면 HWPX를 선택하세요."; + } + + return "HWPX는 언어 기반 기본 경로에서 한 탭 떨어져 있습니다. 기본값으로 돌아가려면 DOCX를 선택하세요."; + }, + generateLabel: (formatLabel) => `${formatLabel} 생성`, + ready: (formatLabel) => `${formatLabel}를 생성할 준비가 되었습니다.`, + success: (filename, elapsed, byteLength) => + `${filename} 생성이 ${elapsed} 만에 시작되었습니다. (${byteLength} bytes)`, + failed: (elapsed, message) => `${elapsed} 후 생성에 실패했습니다: ${message}`, + }, +}; const markdownInput = document.querySelector("#markdown-input"); const titleInput = document.querySelector("#title-input"); const authorInput = document.querySelector("#author-input"); const strictModeInput = document.querySelector("#strict-mode-input"); const generateButton = document.querySelector("#generate-button"); -const downloadLink = document.querySelector("#download-link"); +const formatTabs = Array.from(document.querySelectorAll(".format-tab")); +const formatSummary = document.querySelector("#format-summary"); +const generateButtonLabel = document.querySelector("#generate-button-label"); const status = document.querySelector("#status"); const copyInstallButton = document.querySelector("#copy-install-button"); const installCommand = document.querySelector("#install-command"); -const defaultFilename = "docxly-browser-demo.docx"; -let activeObjectUrl = null; +const installProof = document.querySelector("#install-proof"); +const comparisonHeadline = document.querySelector("#comparison-headline"); +const comparisonDocxlyMs = document.querySelector("#comparison-docxly-ms"); +const comparisonPandocMs = document.querySelector("#comparison-pandoc-ms"); +const comparisonRatio = document.querySelector("#comparison-ratio"); +const comparisonLabel = document.querySelector("#comparison-label"); +const comparisonMeta = document.querySelector("#comparison-meta"); +const comparisonHwpxBadge = document.querySelector("#comparison-hwpx-badge"); +const mimeTypes = { + hwpx: "application/haansofthwp", + docx: "application/vnd.openxmlformats-officedocument.wordprocessingml.document", +}; +const pageLocale = (document.body.dataset.pageLocale || document.documentElement.lang || "en") + .toLowerCase() + .trim(); +const ui = pageLocale.startsWith("ko") ? locales.ko : locales.en; +const rootPath = document.body.dataset.rootPath || "."; +const comparisonDataUrl = new URL("comparison-data.json", new URL(`${rootPath}/`, window.location.href)); +const defaultFormat = ui.defaultFormat; +let activeFormat = defaultFormat; let copyResetTimer = null; -markdownInput.value = sampleMarkdown; +markdownInput.value = ui.sampleMarkdown; +titleInput.value = ui.defaultTitle; function setStatus(message, type = "idle") { status.textContent = message; status.dataset.state = type; } -function buildFilename(rawTitle) { +function formatElapsedMs(startedAt) { + return `${Math.round(performance.now() - startedAt)} ms`; +} + +function formatMetricMs(value) { + return Number.isFinite(value) ? `${Math.round(value)} ms` : ui.comparison.unavailable; +} + +function formatMetricRatio(value) { + if (!Number.isFinite(value)) { + return ui.comparison.unavailable; + } + + if (Math.abs(value - Math.round(value)) < 0.01) { + return `${Math.round(value)}x`; + } + + return value >= 10 ? `${value.toFixed(1)}x` : `${value.toFixed(2)}x`; +} + +function buildFilename(rawTitle, format) { const normalized = (rawTitle || "") .trim() .replace(/[<>:"/\\|?*\u0000-\u001f]/g, "") @@ -46,19 +278,35 @@ function buildFilename(rawTitle) { .replace(/-+/g, "-") .replace(/^-|-$/g, ""); - const base = normalized || "docxly-browser-demo"; - return base.toLowerCase().endsWith(".docx") ? base : `${base}.docx`; + const base = normalized || ui.defaultFilenames[format].replace(/\.[^.]+$/, ""); + const extension = `.${format}`; + return base.toLowerCase().endsWith(extension) ? base : `${base}${extension}`; } -function updateDownloadLinkLabel(filename) { - downloadLink.innerHTML = ` - - Download ${filename} again - `; +function setCopyButton(state) { + const labels = ui.copy; + const variants = { + idle: { icon: "⧉", label: labels.idle }, + success: { icon: "✓", label: labels.success }, + fail: { icon: "!", label: labels.fail }, + }; + const variant = variants[state]; + copyInstallButton.innerHTML = `${variant.label}`; +} + +function updateFormatUi() { + for (const tab of formatTabs) { + const isActive = tab.dataset.format === activeFormat; + tab.classList.toggle("is-active", isActive); + tab.setAttribute("aria-selected", String(isActive)); + } + + generateButtonLabel.textContent = ui.generateLabel(ui.formatLabels[activeFormat]); + formatSummary.textContent = ui.formatSummary(activeFormat, defaultFormat); } function resetCopyButton() { - copyInstallButton.innerHTML = 'Copy'; + setCopyButton("idle"); } async function copyInstallCommand() { @@ -66,73 +314,112 @@ async function copyInstallCommand() { try { await navigator.clipboard.writeText(command); - copyInstallButton.innerHTML = 'Copied'; + setCopyButton("success"); window.clearTimeout(copyResetTimer); copyResetTimer = window.setTimeout(resetCopyButton, 1600); } catch { - copyInstallButton.innerHTML = 'Copy manually'; + setCopyButton("fail"); window.clearTimeout(copyResetTimer); copyResetTimer = window.setTimeout(resetCopyButton, 2000); } } function triggerDownload(blob, filename) { - if (activeObjectUrl) { - URL.revokeObjectURL(activeObjectUrl); - } - - activeObjectUrl = URL.createObjectURL(blob); - downloadLink.href = activeObjectUrl; - downloadLink.download = filename; - updateDownloadLinkLabel(filename); - downloadLink.classList.remove("hidden"); - + const objectUrl = URL.createObjectURL(blob); const triggerLink = document.createElement("a"); - triggerLink.href = activeObjectUrl; + triggerLink.href = objectUrl; triggerLink.download = filename; - triggerLink.textContent = `Download ${filename}`; + triggerLink.textContent = `Generate ${filename}`; triggerLink.style.position = "fixed"; triggerLink.style.left = "-9999px"; triggerLink.style.top = "0"; document.body.append(triggerLink); triggerLink.click(); triggerLink.remove(); + window.setTimeout(() => URL.revokeObjectURL(objectUrl), 0); +} + +function renderComparisonUnavailable() { + comparisonHeadline.textContent = ui.comparison.unavailableHeadline; + comparisonDocxlyMs.textContent = ui.comparison.unavailable; + comparisonPandocMs.textContent = ui.comparison.unavailable; + comparisonRatio.textContent = ui.comparison.unavailable; + installProof.textContent = ui.comparison.fallbackProof; + comparisonLabel.textContent = ui.comparison.label; + comparisonMeta.textContent = ui.comparison.metaUnavailable; + comparisonHwpxBadge.textContent = ui.comparison.badge; } -window.addEventListener("pagehide", () => { - if (activeObjectUrl) { - URL.revokeObjectURL(activeObjectUrl); - activeObjectUrl = null; +function renderComparison(data) { + const summary = data?.benchmarks?.summary; + + if (!summary) { + renderComparisonUnavailable(); + return; } -}); + + comparisonHeadline.textContent = ui.comparison.availableHeadline; + comparisonDocxlyMs.textContent = formatMetricMs(summary.steady_median_ms?.docxly); + comparisonPandocMs.textContent = formatMetricMs(summary.steady_median_ms?.pandoc); + comparisonRatio.textContent = formatMetricRatio(summary.speed_ratio); + installProof.textContent = ui.comparison.installProof(formatMetricRatio(summary.speed_ratio)); + comparisonLabel.textContent = ui.comparison.label; + comparisonMeta.textContent = ui.comparison.meta(data); + comparisonHwpxBadge.textContent = ui.comparison.badge; +} + +async function loadComparison() { + try { + const response = await fetch(comparisonDataUrl, { cache: "no-store" }); + if (!response.ok) { + throw new Error(`failed to load comparison data: ${response.status}`); + } + + const data = await response.json(); + renderComparison(data); + } catch { + renderComparisonUnavailable(); + } +} copyInstallButton.addEventListener("click", () => { void copyInstallCommand(); }); +for (const tab of formatTabs) { + tab.addEventListener("click", () => { + activeFormat = tab.dataset.format; + updateFormatUi(); + setStatus(ui.ready(ui.formatLabels[activeFormat])); + }); +} + generateButton.addEventListener("click", async () => { generateButton.disabled = true; - downloadLink.classList.add("hidden"); - downloadLink.removeAttribute("href"); - setStatus("Generating DOCX in the browser...", "pending"); + setStatus(ui.statusDescriptions[activeFormat], "pending"); + const startedAt = performance.now(); try { - const bytes = await generateDocx(markdownInput.value, { + const generator = activeFormat === "hwpx" ? generateHwpx : generateDocx; + const bytes = await generator(markdownInput.value, { title: titleInput.value || undefined, author: authorInput.value || undefined, strictMode: strictModeInput.checked, }); - const blob = new Blob([bytes], { - type: "application/vnd.openxmlformats-officedocument.wordprocessingml.document", - }); - const filename = buildFilename(titleInput.value || defaultFilename); + const blob = new Blob([bytes], { type: mimeTypes[activeFormat] }); + const filename = buildFilename(titleInput.value || ui.defaultFilenames[activeFormat], activeFormat); triggerDownload(blob, filename); - setStatus(`Download started for ${filename} (${bytes.length} bytes).`, "success"); + setStatus(ui.success(filename, formatElapsedMs(startedAt), bytes.length), "success"); } catch (error) { const message = error instanceof Error ? error.message : String(error); - setStatus(`Generation failed: ${message}`, "error"); + setStatus(ui.failed(formatElapsedMs(startedAt), message), "error"); } finally { generateButton.disabled = false; } }); + +updateFormatUi(); +resetCopyButton(); +setStatus(ui.ready(ui.formatLabels[activeFormat])); +void loadComparison(); diff --git a/packages/npm-core-rs/demo/styles.css b/packages/npm-core-rs/demo/styles.css index 4eac735..dd7b2cd 100644 --- a/packages/npm-core-rs/demo/styles.css +++ b/packages/npm-core-rs/demo/styles.css @@ -1,17 +1,19 @@ :root { color-scheme: light; - --bg: #f3ede0; - --panel: rgba(255, 252, 245, 0.94); - --ink: #18241f; - --muted: #5e675d; - --line: rgba(24, 36, 31, 0.15); - --accent: #d96f32; - --accent-strong: #bc5620; - --accent-soft: rgba(217, 111, 50, 0.14); - --accent-ink: #fff8f1; - --success: #1f7a4c; - --error: #a3301c; - --shadow: 0 24px 80px rgba(36, 28, 18, 0.14); + --color-primary-050: #eff6ff; + --color-primary-100: #dbeafe; + --color-primary-500: #2563eb; + --color-primary-600: #1d4ed8; + --color-primary-700: #1e40af; + --color-neutral-950: #0f172a; + --color-neutral-700: #334155; + --color-neutral-500: #64748b; + --color-neutral-200: #e2e8f0; + --color-surface: #ffffff; + --color-surface-muted: #f8fafc; + --color-success: #15803d; + --color-error: #b91c1c; + --shadow: 0 24px 80px rgba(15, 23, 42, 0.08); } * { @@ -22,229 +24,360 @@ body { margin: 0; min-height: 100vh; background: - radial-gradient(circle at top left, rgba(217, 111, 50, 0.16), transparent 28%), - radial-gradient(circle at top right, rgba(24, 36, 31, 0.08), transparent 24%), - linear-gradient(180deg, #f7f1e5 0%, var(--bg) 100%); - color: var(--ink); - font: 16px/1.5 "Iowan Old Style", "Palatino Linotype", "URW Palladio L", serif; + radial-gradient(circle at top left, rgba(37, 99, 235, 0.16), transparent 26%), + linear-gradient(180deg, var(--color-primary-050) 0%, #f8fbff 100%); + color: var(--color-neutral-700); + font: 16px/1.6 "Iowan Old Style", "Palatino Linotype", "URW Palladio L", serif; } .page { - width: min(980px, calc(100vw - 32px)); + width: min(1120px, calc(100vw - 32px)); margin: 0 auto; - padding: 48px 0 64px; + padding: 40px 0 64px; } -.hero { +.panel { + padding: 24px; + border: 1px solid var(--color-neutral-200); + border-radius: 20px; + background: rgba(255, 255, 255, 0.94); + box-shadow: var(--shadow); +} + +.hero-panel { margin-bottom: 24px; } -.hero-actions { +.hero-topbar { display: flex; - flex-wrap: wrap; - gap: 12px; - margin-top: 20px; + align-items: flex-start; + justify-content: space-between; + gap: 16px; + margin-bottom: 20px; } -.hero-link { +.brand-lockup { display: inline-flex; align-items: center; gap: 12px; - min-height: 56px; - padding: 12px 16px; - border: 1px solid rgba(24, 36, 31, 0.14); - border-radius: 20px; - background: rgba(255, 255, 255, 0.72); - color: var(--ink); - text-decoration: none; - font: 700 14px/1 "IBM Plex Sans", "Segoe UI", sans-serif; - box-shadow: 0 10px 24px rgba(36, 28, 18, 0.06); -} - -.hero-link-primary { - border-color: transparent; - background: var(--accent); - color: var(--accent-ink); } -.hero-link-secondary { - background: rgba(24, 36, 31, 0.92); - border-color: transparent; - color: #fff8f1; +.lang-switch { + display: inline-flex; + flex-wrap: wrap; + gap: 8px; } -.hero-link-icon { +.lang-switch-link { display: inline-flex; align-items: center; - justify-content: center; - width: 36px; - height: 36px; - border-radius: 12px; - background: rgba(255, 255, 255, 0.12); - flex: 0 0 auto; + min-height: 34px; + padding: 0 12px; + border: 1px solid var(--color-neutral-200); + border-radius: 999px; + background: var(--color-surface-muted); + color: var(--color-neutral-700); + text-decoration: none; + font: 700 12px/1 "IBM Plex Sans", "Segoe UI", sans-serif; + letter-spacing: 0.06em; } -.hero-link-secondary .hero-link-icon { - background: rgba(255, 255, 255, 0.14); +.lang-switch-link.is-active { + border-color: var(--color-primary-100); + background: var(--color-primary-050); + color: var(--color-primary-700); } -.hero-link-icon svg { - width: 20px; - height: 20px; +.brand-logo { + width: auto; + height: 28px; + object-fit: contain; } -.hero-link-copy { +.brand-copy { display: grid; - gap: 4px; + gap: 2px; } -.hero-link-copy strong { - font-size: 14px; - letter-spacing: 0.04em; +.brand-wordmark { + color: var(--color-neutral-950); + font: 700 22px/1 "IBM Plex Sans", "Segoe UI", sans-serif; + letter-spacing: -0.03em; +} + +.brand-tagline, +.panel-eyebrow, +.comparison-kpi-label, +.field span, +.checkbox span, +.install-card-eyebrow { + display: block; + margin: 0; + color: var(--color-primary-600); + font: 700 12px/1.2 "IBM Plex Sans", "Segoe UI", sans-serif; + letter-spacing: 0.14em; text-transform: uppercase; } -.hero-link-copy small { - font: 500 12px/1.2 "IBM Plex Sans", "Segoe UI", sans-serif; - letter-spacing: 0; - text-transform: none; - opacity: 0.82; +h1 { + max-width: 13ch; + margin: 0; + color: var(--color-neutral-950); + font-size: clamp(2.5rem, 5vw, 4.4rem); + line-height: 0.95; +} + +h2 { + margin: 0; + color: var(--color-neutral-950); + font-size: clamp(1.45rem, 2.5vw, 2rem); + line-height: 1.05; +} + +.lede, +.panel-copy, +.comparison-headline, +.comparison-meta, +.install-card-copy, +.note, +.format-summary { + color: var(--color-neutral-500); +} + +.lede { + max-width: 48rem; + margin: 16px 0 0; } -.hero-grid { +.hero-shell { display: grid; - grid-template-columns: repeat(3, minmax(0, 1fr)); - gap: 16px; + grid-template-columns: minmax(0, 1.45fr) minmax(320px, 0.82fr); + gap: 24px; margin-top: 24px; } -.hero-card { - padding: 18px; - border: 1px solid var(--line); - border-radius: 20px; - background: rgba(255, 251, 244, 0.82); - box-shadow: 0 14px 36px rgba(36, 28, 18, 0.08); +.hero-main { + display: grid; + gap: 16px; } -.hero-card h2 { - margin: 0 0 10px; - font: 700 14px/1.2 "IBM Plex Sans", "Segoe UI", sans-serif; +.hero-actions { + display: flex; + flex-wrap: wrap; + gap: 10px; +} + +.hero-link { + display: inline-flex; + align-items: center; + justify-content: center; + min-height: 44px; + padding: 0 16px; + border-radius: 999px; + text-decoration: none; + font: 700 13px/1 "IBM Plex Sans", "Segoe UI", sans-serif; letter-spacing: 0.08em; text-transform: uppercase; } -.hero-card p, -.hero-card pre { - margin: 0; +.hero-link-primary { + background: var(--color-primary-600); + color: #ffffff; } -.hero-card-code { - background: #18241f; - color: #f7f1e5; +.hero-link-primary:hover { + background: var(--color-primary-700); } -.hero-card-head { - display: flex; - align-items: center; - justify-content: space-between; +.hero-link-secondary { + background: var(--color-surface-muted); + color: var(--color-neutral-700); +} + +.comparison-strip { + padding: 18px; + border: 1px solid rgba(255, 255, 255, 0.08); + border-radius: 20px; + background: linear-gradient(150deg, var(--color-neutral-950), #16284f); + color: #ffffff; +} + +.comparison-strip-head { + display: grid; + grid-template-columns: minmax(0, 1.1fr) minmax(0, 0.9fr); + gap: 16px; + align-items: end; + margin-bottom: 16px; +} + +.comparison-strip-head h2 { + color: #ffffff; + font-size: clamp(1.2rem, 2vw, 1.55rem); +} + +.comparison-headline, +.comparison-meta { + margin: 0; + color: rgba(255, 255, 255, 0.72); +} + +.comparison-kpis { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 12px; margin-bottom: 12px; } -.hero-card-head h2 { - margin: 0; +.comparison-kpi { + padding: 14px; + border: 1px solid rgba(255, 255, 255, 0.12); + border-radius: 16px; + background: rgba(255, 255, 255, 0.05); } -.copy-button { +.comparison-kpi-label { + margin-bottom: 6px; + color: rgba(255, 255, 255, 0.68); +} + +.comparison-kpi-value { + display: block; + font: 700 clamp(1.35rem, 2.5vw, 2rem)/1 "IBM Plex Sans", "Segoe UI", sans-serif; +} + +.comparison-label { + margin: 0 0 6px; + color: #bfdbfe; + font: 700 12px/1.2 "IBM Plex Sans", "Segoe UI", sans-serif; + letter-spacing: 0.08em; + text-transform: uppercase; +} + +.comparison-meta { + margin-bottom: 14px; + font-size: 14px; +} + +.comparison-badge { display: inline-flex; align-items: center; - gap: 6px; min-height: 34px; - padding: 0 12px; - border: 1px solid rgba(255, 255, 255, 0.16); + padding: 0 14px; border-radius: 999px; - background: rgba(255, 255, 255, 0.08); - color: inherit; + background: rgba(59, 130, 246, 0.16); + color: #dbeafe; font: 700 12px/1 "IBM Plex Sans", "Segoe UI", sans-serif; - letter-spacing: 0.06em; + letter-spacing: 0.08em; text-transform: uppercase; - cursor: pointer; } -.copy-button:hover { - background: rgba(255, 255, 255, 0.14); +.install-card { + display: grid; + gap: 14px; + padding: 20px; + border-radius: 20px; + background: var(--color-surface); + border: 1px solid var(--color-primary-100); +} + +.install-card h2 { + max-width: 14ch; +} + +.install-proof { + margin: 0; + color: var(--color-neutral-950); + font: 700 18px/1.45 "IBM Plex Sans", "Segoe UI", sans-serif; } -.hero-card-code pre { +.install-card pre { overflow-x: auto; + margin: 0; padding: 14px 16px; border-radius: 16px; - background: rgba(255, 255, 255, 0.06); + background: var(--color-neutral-950); + color: #ffffff; white-space: pre-wrap; word-break: break-word; font: 15px/1.5 "SFMono-Regular", "Consolas", monospace; } -.hero-card-caption { - margin-top: 12px; - color: rgba(247, 241, 229, 0.72); - font-size: 14px; +.install-card-actions { + display: flex; + align-items: center; } -.eyebrow { - margin: 0 0 8px; - color: var(--accent); - font: 700 12px/1.2 "IBM Plex Sans", "Segoe UI", sans-serif; - letter-spacing: 0.16em; +.copy-button { + display: inline-flex; + align-items: center; + gap: 6px; + min-height: 40px; + padding: 0 14px; + border: 1px solid var(--color-primary-100); + border-radius: 999px; + background: var(--color-primary-050); + color: var(--color-primary-700); + font: 700 12px/1 "IBM Plex Sans", "Segoe UI", sans-serif; + letter-spacing: 0.06em; text-transform: uppercase; + cursor: pointer; } -h1 { - margin: 0; - font-size: clamp(2.4rem, 5vw, 4.2rem); - line-height: 0.94; +.copy-button:hover { + background: var(--color-primary-100); } -.lede { - max-width: 56rem; - margin: 16px 0 0; - color: var(--muted); +.install-card-copy { + margin: 0; } -.panel { - padding: 24px; - border: 1px solid var(--line); - border-radius: 24px; - background: var(--panel); - box-shadow: var(--shadow); - backdrop-filter: blur(16px); +.generator-panel { + display: grid; + gap: 18px; } .panel-heading { display: grid; - grid-template-columns: minmax(0, 1.2fr) minmax(0, 1fr); + grid-template-columns: minmax(0, 1.1fr) minmax(0, 1fr); gap: 16px; align-items: end; - margin-bottom: 20px; } -.panel-heading h2 { +.panel-copy { margin: 0; - font-size: clamp(1.6rem, 3vw, 2.3rem); - line-height: 0.98; } -.panel-eyebrow { - margin: 0 0 8px; - color: var(--accent); - font: 700 12px/1.2 "IBM Plex Sans", "Segoe UI", sans-serif; - letter-spacing: 0.16em; +.format-tabs { + display: inline-flex; + flex-wrap: wrap; + gap: 8px; + padding: 6px; + border: 1px solid var(--color-neutral-200); + border-radius: 999px; + background: var(--color-surface-muted); +} + +.format-tab { + min-height: 42px; + padding: 0 18px; + border: 0; + border-radius: 999px; + background: transparent; + color: var(--color-neutral-500); + font: 700 13px/1 "IBM Plex Sans", "Segoe UI", sans-serif; + letter-spacing: 0.08em; text-transform: uppercase; + cursor: pointer; } -.panel-copy { - margin: 0; - color: var(--muted); +.format-tab.is-active { + background: var(--color-primary-500); + color: #ffffff; +} + +.format-summary { + margin: -6px 0 0; + font-size: 14px; } .field, @@ -254,20 +387,16 @@ h1 { .field span, .checkbox span { - display: block; margin-bottom: 8px; - font: 700 12px/1.2 "IBM Plex Sans", "Segoe UI", sans-serif; - letter-spacing: 0.06em; - text-transform: uppercase; } textarea, input { width: 100%; - border: 1px solid var(--line); + border: 1px solid var(--color-neutral-200); border-radius: 16px; - background: rgba(255, 255, 255, 0.8); - color: var(--ink); + background: #ffffff; + color: var(--color-neutral-700); padding: 14px 16px; font: 15px/1.5 "SFMono-Regular", "Consolas", monospace; } @@ -275,18 +404,12 @@ input { textarea { min-height: 260px; resize: vertical; - margin-bottom: 20px; } .grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 16px; - margin-bottom: 16px; -} - -.checkbox { - margin-bottom: 20px; } .checkbox input { @@ -301,13 +424,10 @@ textarea { .actions { display: flex; - flex-wrap: wrap; - gap: 12px; align-items: center; } -button, -.download { +.action-button { display: inline-flex; align-items: center; justify-content: center; @@ -316,37 +436,27 @@ button, padding: 0 18px; border: 0; border-radius: 999px; - text-decoration: none; + box-shadow: 0 14px 28px rgba(37, 99, 235, 0.16); font: 700 14px/1 "IBM Plex Sans", "Segoe UI", sans-serif; letter-spacing: 0.04em; text-transform: uppercase; } -.action-button, -.download { - box-shadow: 0 14px 28px rgba(36, 28, 18, 0.12); -} - .action-button-primary { - background: linear-gradient(180deg, var(--accent) 0%, var(--accent-strong) 100%); - color: var(--accent-ink); + background: var(--color-primary-600); + color: #ffffff; cursor: pointer; } .action-button-primary:hover:not(:disabled) { - transform: translateY(-1px); + background: var(--color-primary-700); } -button:disabled { +.action-button:disabled { opacity: 0.6; cursor: wait; } -.download { - background: var(--accent-soft); - color: var(--accent-strong); -} - .action-symbol { display: inline-flex; align-items: center; @@ -354,57 +464,70 @@ button:disabled { width: 22px; height: 22px; border-radius: 999px; - background: rgba(255, 255, 255, 0.16); + background: rgba(255, 255, 255, 0.18); font-size: 14px; line-height: 1; } -.download .action-symbol { - background: rgba(188, 86, 32, 0.14); -} - -.hidden { - display: none; -} - .status { min-height: 24px; - margin: 16px 0 0; - color: var(--muted); + margin: 0; + color: var(--color-neutral-500); } .status[data-state="pending"] { - color: var(--ink); + color: var(--color-neutral-700); } .status[data-state="success"] { - color: var(--success); + color: var(--color-success); } .status[data-state="error"] { - color: var(--error); + color: var(--color-error); } .note { - margin: 12px 0 0; - color: var(--muted); + margin: 0; font-size: 14px; } @media (max-width: 820px) { - .hero-grid, + .page { + width: min(100vw - 20px, 1120px); + padding: 24px 0 40px; + } + + .brand-logo { + height: 24px; + } + + .hero-topbar { + flex-direction: column; + align-items: flex-start; + } + + .hero-shell, + .comparison-strip-head, + .comparison-kpis, .panel-heading, .grid { grid-template-columns: 1fr; } - .page { - width: min(100vw - 20px, 980px); - padding: 24px 0 40px; + .hero-shell { + gap: 16px; + } + + .hero-main { + order: 2; + } + + .install-card { + order: 1; } - .panel, - .hero-card { + .panel { padding: 18px; } } diff --git a/packages/npm-core-rs/package.json b/packages/npm-core-rs/package.json index 923ba80..2f2b468 100644 --- a/packages/npm-core-rs/package.json +++ b/packages/npm-core-rs/package.json @@ -33,6 +33,8 @@ "access": "public" }, "scripts": { + "benchmark:pandoc": "node ./scripts/benchmark-pandoc.mjs", + "benchmark:pandoc:check": "node ./scripts/benchmark-pandoc-check.mjs", "clean": "rm -rf dist site-dist", "check:version": "node ./scripts/check-version.mjs", "build": "node ./scripts/build.mjs", diff --git a/packages/npm-core-rs/scripts/_benchmark_docxly_once.mjs b/packages/npm-core-rs/scripts/_benchmark_docxly_once.mjs new file mode 100644 index 0000000..22522c7 --- /dev/null +++ b/packages/npm-core-rs/scripts/_benchmark_docxly_once.mjs @@ -0,0 +1,14 @@ +import { readFile, writeFile } from "node:fs/promises"; + +import { generateDocx } from "../dist/node.js"; + +const [, , inputPath, outputPath] = process.argv; + +if (!inputPath || !outputPath) { + console.error("usage: node ./scripts/_benchmark_docxly_once.mjs "); + process.exit(1); +} + +const markdown = await readFile(inputPath, "utf8"); +const bytes = await generateDocx(markdown); +await writeFile(outputPath, bytes); diff --git a/packages/npm-core-rs/scripts/_comparison-common.mjs b/packages/npm-core-rs/scripts/_comparison-common.mjs new file mode 100644 index 0000000..bb8e6a5 --- /dev/null +++ b/packages/npm-core-rs/scripts/_comparison-common.mjs @@ -0,0 +1,158 @@ +import assert from "node:assert/strict"; +import { readFile } from "node:fs/promises"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +export const packageRoot = path.dirname(fileURLToPath(new URL("../package.json", import.meta.url))); +export const repoRoot = path.resolve(packageRoot, "..", ".."); +export const comparisonDataPath = path.join(packageRoot, "demo", "comparison-data.json"); +export const readmePath = path.join(repoRoot, "README.md"); +export const demoIndexPath = path.join(packageRoot, "demo", "index.html"); +export const demoMainPath = path.join(packageRoot, "demo", "main.js"); +export const comparisonStartMarker = ""; +export const comparisonEndMarker = ""; +const decoder = new TextDecoder(); + +export function formatMs(value) { + return Number.isFinite(value) ? `${Math.round(value)} ms` : "Unavailable"; +} + +export function formatRatio(value) { + return Number.isFinite(value) ? `${value.toFixed(2)}x` : "Unavailable"; +} + +export function decodeUtf8(bytes) { + return decoder.decode(bytes); +} + +export function validateComparisonData(data) { + assert.equal(typeof data, "object", "comparison data must be an object"); + assert.equal(typeof data.headline, "string", "headline must be a string"); + assert.equal(typeof data.measured_at, "string", "measured_at must be a string"); + assert.equal(typeof data.machine_label, "string", "machine_label must be a string"); + assert.equal(typeof data.node_version, "string", "node_version must be a string"); + assert.equal(typeof data.pandoc_version, "string", "pandoc_version must be a string"); + assert.equal(typeof data.docxly_version, "string", "docxly_version must be a string"); + assert.equal(typeof data.benchmarks, "object", "benchmarks must be present"); + assert.equal(Array.isArray(data.feature_matrix), true, "feature_matrix must be an array"); + assert.equal(Array.isArray(data.caveats), true, "caveats must be an array"); + + for (const key of ["small", "medium", "large", "summary"]) { + validateBenchmarkEntry(data.benchmarks[key], key); + } + + for (const entry of data.feature_matrix) { + assert.equal(typeof entry.feature, "string", "feature name must be a string"); + assert.equal(typeof entry.docxly, "string", "feature docxly value must be a string"); + assert.equal(typeof entry.pandoc, "string", "feature pandoc value must be a string"); + } + + for (const caveat of data.caveats) { + assert.equal(typeof caveat, "string", "caveat must be a string"); + } + + return data; +} + +function validateBenchmarkEntry(entry, name) { + assert.equal(typeof entry, "object", `${name} benchmark must be an object`); + assert.equal(typeof entry.label, "string", `${name}.label must be a string`); + validateDuelMetric(entry.cold_ms, `${name}.cold_ms`); + validateDuelMetric(entry.steady_median_ms, `${name}.steady_median_ms`); + validateDuelMetric(entry.output_bytes, `${name}.output_bytes`); + assert.equal(typeof entry.speed_ratio, "number", `${name}.speed_ratio must be a number`); +} + +function validateDuelMetric(metric, name) { + assert.equal(typeof metric, "object", `${name} must be an object`); + assert.equal(typeof metric.docxly, "number", `${name}.docxly must be a number`); + assert.equal(typeof metric.pandoc, "number", `${name}.pandoc must be a number`); +} + +export async function loadComparisonData() { + const raw = await readFile(comparisonDataPath, "utf8"); + return validateComparisonData(JSON.parse(raw)); +} + +export function renderReadmeBlock(data) { + validateComparisonData(data); + + const benchmarkRows = ["small", "medium", "large", "summary"] + .map((key) => { + const entry = data.benchmarks[key]; + return [ + entry.label, + formatMs(entry.cold_ms.docxly), + formatMs(entry.cold_ms.pandoc), + formatMs(entry.steady_median_ms.docxly), + formatMs(entry.steady_median_ms.pandoc), + formatRatio(entry.speed_ratio), + ].join(" | "); + }) + .map((row) => `| ${row} |`) + .join("\n"); + + const featureRows = data.feature_matrix + .map( + (entry) => + `| ${escapeCell(entry.feature)} | ${escapeCell(entry.docxly)} | ${escapeCell(entry.pandoc)} |`, + ) + .join("\n"); + + const caveatRows = data.caveats.map((caveat) => `- ${caveat}`).join("\n"); + + return [ + "## Why docxly instead of Pandoc?", + "", + "Pandoc is a general-purpose converter; docxly is an embeddable generation engine.", + "", + "Use docxly when document generation must live inside a Node service, browser workflow, or product surface. Use Pandoc when you need broad format conversion and a CLI-first publishing workflow.", + "", + "| Corpus | docxly cold | Pandoc cold | docxly steady | Pandoc steady | Speed ratio |", + "| --- | --- | --- | --- | --- | --- |", + benchmarkRows, + "", + "| Capability | docxly | Pandoc |", + "| --- | --- | --- |", + featureRows, + "", + `Measured on ${data.machine_label} at ${data.measured_at} with Node ${data.node_version} and Pandoc ${data.pandoc_version}.`, + "", + caveatRows, + "", + ].join("\n"); +} + +export function replaceReadmeBlock(readme, block) { + const start = readme.indexOf(comparisonStartMarker); + const end = readme.indexOf(comparisonEndMarker); + + assert.notEqual(start, -1, "README comparison start marker is missing"); + assert.notEqual(end, -1, "README comparison end marker is missing"); + assert.ok(end > start, "README comparison markers are out of order"); + + const prefix = readme.slice(0, start + comparisonStartMarker.length); + const suffix = readme.slice(end); + return `${prefix}\n\n${block}\n${suffix}`; +} + +export async function readPackageVersion() { + const packageJson = JSON.parse(await readFile(path.join(packageRoot, "package.json"), "utf8")); + return packageJson.version; +} + +export async function assertDemoWiring() { + const [indexHtml, mainJs] = await Promise.all([ + readFile(demoIndexPath, "utf8"), + readFile(demoMainPath, "utf8"), + ]); + + assert.ok(indexHtml.includes('id="comparison-section"'), "demo comparison section is missing"); + assert.ok(indexHtml.includes('id="comparison-ratio"'), "demo comparison KPI is missing"); + assert.ok(mainJs.includes('fetch("./comparison-data.json"'), "demo must fetch comparison JSON"); + assert.ok(mainJs.includes("comparison data unavailable"), "demo fallback copy is missing"); +} + +function escapeCell(value) { + return value.replaceAll("|", "\\|"); +} diff --git a/packages/npm-core-rs/scripts/benchmark-pandoc-check.mjs b/packages/npm-core-rs/scripts/benchmark-pandoc-check.mjs new file mode 100644 index 0000000..415ae16 --- /dev/null +++ b/packages/npm-core-rs/scripts/benchmark-pandoc-check.mjs @@ -0,0 +1,19 @@ +import assert from "node:assert/strict"; +import { readFile } from "node:fs/promises"; + +import { + assertDemoWiring, + loadComparisonData, + readmePath, + renderReadmeBlock, + replaceReadmeBlock, +} from "./_comparison-common.mjs"; + +const data = await loadComparisonData(); +const readme = await readFile(readmePath, "utf8"); +const expected = replaceReadmeBlock(readme, renderReadmeBlock(data)); + +assert.equal(readme, expected, "README comparison block is out of sync with comparison-data.json"); +await assertDemoWiring(); + +console.log("comparison data, README markers, and demo wiring are in sync"); diff --git a/packages/npm-core-rs/scripts/benchmark-pandoc.mjs b/packages/npm-core-rs/scripts/benchmark-pandoc.mjs new file mode 100644 index 0000000..289c8dc --- /dev/null +++ b/packages/npm-core-rs/scripts/benchmark-pandoc.mjs @@ -0,0 +1,378 @@ +import assert from "node:assert/strict"; +import { execFile } from "node:child_process"; +import { mkdir, readFile, rm, writeFile } from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { performance } from "node:perf_hooks"; +import { promisify } from "node:util"; + +import { unzipSync } from "fflate"; + +import { + comparisonDataPath, + decodeUtf8, + formatMs, + packageRoot, + readPackageVersion, + readmePath, + renderReadmeBlock, + replaceReadmeBlock, +} from "./_comparison-common.mjs"; + +const execFileAsync = promisify(execFile); +const tmpRoot = path.join(packageRoot, "tmp", "pandoc-benchmark"); +const docxlyOnceRunner = path.join(packageRoot, "scripts", "_benchmark_docxly_once.mjs"); +const corpusEntries = buildCorpora(); + +await rm(tmpRoot, { recursive: true, force: true }); +await mkdir(tmpRoot, { recursive: true }); + +try { + await execFileAsync("npm", ["run", "build"], { cwd: packageRoot }); + + const { stdout: pandocStdout } = await execFileAsync("pandoc", ["--version"], { cwd: packageRoot }); + const pandocVersion = pandocStdout.split("\n")[0].replace(/^pandoc\s+/u, "").trim(); + assert.ok(pandocVersion, "failed to determine pandoc version"); + + const { generateDocx } = await import("../dist/node.js"); + const docxlyVersion = await readPackageVersion(); + + const benchmarks = {}; + for (const corpus of corpusEntries) { + benchmarks[corpus.key] = await benchmarkCorpus(corpus, generateDocx); + } + + benchmarks.summary = summarizeBenchmarks(benchmarks); + + const comparisonData = { + headline: "Offline Node benchmark for library selection.", + measured_at: new Date().toISOString(), + machine_label: `${os.platform()} ${os.release()} / ${os.arch()}`, + node_version: process.version, + pandoc_version: pandocVersion, + docxly_version: docxlyVersion, + benchmarks, + feature_matrix: [ + { + feature: "Embeddable in app", + docxly: "Yes, library-first for Node and browser bundlers", + pandoc: "CLI-first with process invocation", + }, + { + feature: "Browser-local generation", + docxly: "First-party browser package and WASM path", + pandoc: "Possible through pandoc.wasm, not the primary npm workflow", + }, + { + feature: "npm distribution", + docxly: "Published package", + pandoc: "Not a first-party npm package", + }, + { + feature: "HWPX generation", + docxly: "Supported in the Rust core", + pandoc: "Not supported", + }, + { + feature: "Broad format conversion", + docxly: "Focused on DOCX and HWPX generation", + pandoc: "Wide multi-format conversion", + }, + { + feature: "DOCX reference-template workflow", + docxly: "Not a reference.docx workflow", + pandoc: "Supported via reference.docx", + }, + ], + caveats: [ + "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.", + "Cold timings include WASM initialization for docxly and process startup for Pandoc.", + "Steady timings are medians from 15 runs after one warm-up per corpus.", + ], + }; + + await writeFile(comparisonDataPath, `${JSON.stringify(comparisonData, null, 2)}\n`); + + const readme = await readFile(readmePath, "utf8"); + const nextReadme = replaceReadmeBlock(readme, renderReadmeBlock(comparisonData)); + await writeFile(readmePath, nextReadme); + + for (const [key, entry] of Object.entries(benchmarks)) { + console.log( + `${key}: docxly ${formatMs(entry.steady_median_ms.docxly)} vs Pandoc ${formatMs(entry.steady_median_ms.pandoc)} (${entry.speed_ratio.toFixed(2)}x)`, + ); + } +} catch (error) { + if (isMissingPandoc(error)) { + console.error("pandoc is required for benchmark:pandoc. Install it first and rerun the command."); + process.exit(1); + } + + throw error; +} finally { + await rm(tmpRoot, { recursive: true, force: true }); +} + +async function benchmarkCorpus(corpus, generateDocx) { + const inputPath = path.join(tmpRoot, `${corpus.key}.md`); + const docxlyOutputPath = path.join(tmpRoot, `${corpus.key}-docxly.docx`); + const pandocOutputPath = path.join(tmpRoot, `${corpus.key}-pandoc.docx`); + + await writeFile(inputPath, corpus.markdown); + + const docxlyCold = await measureDocxlyCold(inputPath, docxlyOutputPath); + const pandocCold = await measurePandoc(inputPath, pandocOutputPath); + + verifyDocx(await readFile(docxlyOutputPath), corpus.tokens); + verifyDocx(await readFile(pandocOutputPath), corpus.tokens); + + await generateDocx(corpus.markdown); + await measurePandoc(inputPath, pandocOutputPath); + + const docxlySteadyRuns = []; + const pandocSteadyRuns = []; + + for (let index = 0; index < 15; index += 1) { + docxlySteadyRuns.push(await measureDocxlySteady(generateDocx, corpus.markdown, docxlyOutputPath)); + pandocSteadyRuns.push(await measurePandoc(inputPath, pandocOutputPath)); + } + + const docxlyBytes = (await readFile(docxlyOutputPath)).length; + const pandocBytes = (await readFile(pandocOutputPath)).length; + const docxlySteadyMedian = median(docxlySteadyRuns); + const pandocSteadyMedian = median(pandocSteadyRuns); + + return { + label: corpus.label, + cold_ms: { + docxly: Math.round(docxlyCold), + pandoc: Math.round(pandocCold), + }, + steady_median_ms: { + docxly: Math.round(docxlySteadyMedian), + pandoc: Math.round(pandocSteadyMedian), + }, + output_bytes: { + docxly: docxlyBytes, + pandoc: pandocBytes, + }, + speed_ratio: roundRatio(pandocSteadyMedian / docxlySteadyMedian), + }; +} + +async function measureDocxlyCold(inputPath, outputPath) { + const startedAt = performance.now(); + await execFileAsync("node", [docxlyOnceRunner, inputPath, outputPath], { cwd: packageRoot }); + return performance.now() - startedAt; +} + +async function measureDocxlySteady(generateDocx, markdown, outputPath) { + const startedAt = performance.now(); + const bytes = await generateDocx(markdown); + await writeFile(outputPath, bytes); + return performance.now() - startedAt; +} + +async function measurePandoc(inputPath, outputPath) { + const startedAt = performance.now(); + await execFileAsync("pandoc", ["-f", "markdown", "-t", "docx", "-o", outputPath, inputPath], { + cwd: packageRoot, + }); + return performance.now() - startedAt; +} + +function verifyDocx(bytes, tokens) { + const archive = unzipSync(bytes); + assert.ok(archive["[Content_Types].xml"], "DOCX archive must include [Content_Types].xml"); + assert.ok(archive["_rels/.rels"], "DOCX archive must include _rels/.rels"); + assert.ok(archive["word/document.xml"], "DOCX archive must include word/document.xml"); + + const documentXml = decodeUtf8(archive["word/document.xml"]); + for (const token of tokens) { + assert.ok(documentXml.includes(token), `DOCX output is missing token: ${token}`); + } +} + +function summarizeBenchmarks(benchmarks) { + const corpusKeys = ["small", "medium", "large"]; + return { + label: "Summary", + cold_ms: { + docxly: Math.round(median(corpusKeys.map((key) => benchmarks[key].cold_ms.docxly))), + pandoc: Math.round(median(corpusKeys.map((key) => benchmarks[key].cold_ms.pandoc))), + }, + steady_median_ms: { + docxly: Math.round(median(corpusKeys.map((key) => benchmarks[key].steady_median_ms.docxly))), + pandoc: Math.round(median(corpusKeys.map((key) => benchmarks[key].steady_median_ms.pandoc))), + }, + output_bytes: { + docxly: Math.round(median(corpusKeys.map((key) => benchmarks[key].output_bytes.docxly))), + pandoc: Math.round(median(corpusKeys.map((key) => benchmarks[key].output_bytes.pandoc))), + }, + speed_ratio: roundRatio( + median(corpusKeys.map((key) => benchmarks[key].steady_median_ms.pandoc)) / + median(corpusKeys.map((key) => benchmarks[key].steady_median_ms.docxly)), + ), + }; +} + +function median(values) { + const sorted = [...values].sort((left, right) => left - right); + const middle = Math.floor(sorted.length / 2); + return sorted.length % 2 === 0 + ? (sorted[middle - 1] + sorted[middle]) / 2 + : sorted[middle]; +} + +function roundRatio(value) { + return Number(value.toFixed(2)); +} + +function isMissingPandoc(error) { + return error?.code === "ENOENT" || error?.message?.includes("pandoc"); +} + +function buildCorpora() { + const smallMarkdown = `# Small Benchmark Title + +Small benchmark paragraph with **bold**, *italic*, \`inline code\`, and [an example link](https://example.com). + +Small benchmark keeps a second paragraph to force additional text runs and paragraph nodes in the DOCX tree. + +> Small benchmark quote for DOCX output checks. + +1. First benchmark ordered item +2. Second benchmark ordered item + +- First benchmark bullet + - First benchmark nested bullet +- Second benchmark bullet + +\`\`\`text +small-benchmark-code +\`\`\` + +| Capability | Value | Notes | +| --- | --- | --- | +| Engine | docxly | Small corpus | +| Scope | Small | Baseline | +| Depth | 2 | Nested list enabled |`; + + const mediumSections = Array.from({ length: 12 }, (_, index) => + buildBenchmarkSection({ + number: index + 1, + size: "Medium", + repeatParagraphs: 3, + tableRows: 6, + orderedCount: 3, + unorderedCount: 3, + }), + ).join("\n\n"); + + const largeSections = Array.from({ length: 28 }, (_, index) => + buildBenchmarkSection({ + number: index + 1, + size: "Large", + repeatParagraphs: 5, + tableRows: 10, + orderedCount: 4, + unorderedCount: 4, + }), + ).join("\n\n"); + + return [ + { + key: "small", + label: "Small", + markdown: smallMarkdown, + tokens: [ + "Small Benchmark Title", + "Small benchmark paragraph", + "First benchmark ordered item", + "small-benchmark-code", + ], + }, + { + key: "medium", + label: "Medium", + markdown: `# Medium Benchmark Title\n\n${buildCorpusLead("Medium", 12)}\n\n${mediumSections}\n\n${buildCorpusTail("Medium", 12)}`, + tokens: [ + "Medium Benchmark Title", + "Medium Section 1", + "Medium nested bullet 3-2", + "Medium code block 12", + ], + }, + { + key: "large", + label: "Large", + markdown: `# Large Benchmark Title\n\n${buildCorpusLead("Large", 28)}\n\n${largeSections}\n\n${buildCorpusTail("Large", 28)}`, + tokens: [ + "Large Benchmark Title", + "Large Section 1", + "Large ordered item 5-1", + "Large nested bullet 28-3", + ], + }, + ]; +} + +function buildBenchmarkSection({ + number, + size, + repeatParagraphs, + tableRows: tableRowCount, + orderedCount, + unorderedCount, +}) { + const paragraphs = Array.from({ length: repeatParagraphs }, (_, index) => { + const paragraphNumber = index + 1; + return `${size} paragraph ${number}-${paragraphNumber} combines **bold**, *italic*, \`code-${number}-${paragraphNumber}\`, and [links](https://example.com/${size.toLowerCase()}/${number}/${paragraphNumber}) for DOCX run generation.`; + }).join("\n\n"); + + const orderedList = Array.from({ length: orderedCount }, (_, index) => { + const itemNumber = index + 1; + return `${itemNumber}. ${size} ordered item ${number}-${itemNumber}`; + }).join("\n"); + + const unorderedList = Array.from({ length: unorderedCount }, (_, index) => { + const itemNumber = index + 1; + return `- ${size} unordered item ${number}-${itemNumber}\n - ${size} nested bullet ${number}-${itemNumber}`; + }).join("\n"); + + const tableRows = Array.from({ length: tableRowCount }, (_, index) => { + const rowNumber = index + 1; + return `| ${number}.${rowNumber} | ${size} table value ${number}-${rowNumber} | ${size} table note ${number}-${rowNumber} |`; + }).join("\n"); + + return `## ${size} Section ${number} + +${paragraphs} + +> ${size} quote ${number} keeps blockquote output active for the benchmark corpus. + +${orderedList} + +${unorderedList} + +\`\`\`json +{"section": ${number}, "kind": "${size.toLowerCase()}", "marker": "${size} code block ${number}"} +\`\`\` + +| Metric | Value | Notes | +| --- | --- | --- | +${tableRows}`; +} + +function buildCorpusLead(size, sectionCount) { + return `${size} benchmark lead paragraph with **bold**, *italic*, \`lead-code\`, and [overview link](https://example.com/${size.toLowerCase()}/overview). + +${size} benchmark includes ${sectionCount} sections to exercise repeated block generation, list rendering, table packaging, and code block serialization.`; +} + +function buildCorpusTail(size, sectionCount) { + return `## ${size} Closing Notes + +This closing section confirms that the ${size.toLowerCase()} benchmark rendered ${sectionCount} content sections and preserves a final paragraph for end-of-document verification.`; +} diff --git a/packages/npm-core-rs/scripts/build-pages.mjs b/packages/npm-core-rs/scripts/build-pages.mjs index 9c6f15b..e9fb268 100644 --- a/packages/npm-core-rs/scripts/build-pages.mjs +++ b/packages/npm-core-rs/scripts/build-pages.mjs @@ -28,6 +28,9 @@ await cp(distRoot, path.join(siteRoot, "dist"), { recursive: true }); await cp(path.join(demoRoot, "index.html"), path.join(siteRoot, "index.html")); await cp(path.join(demoRoot, "main.js"), path.join(siteRoot, "main.js")); await cp(path.join(demoRoot, "styles.css"), path.join(siteRoot, "styles.css")); +await cp(path.join(demoRoot, "comparison-data.json"), path.join(siteRoot, "comparison-data.json")); +await cp(path.join(demoRoot, "assets"), path.join(siteRoot, "assets"), { recursive: true }); +await cp(path.join(demoRoot, "ko"), path.join(siteRoot, "ko"), { recursive: true }); const browserClient = await readFile(browserClientSource, "utf8"); const siteBrowserClient = browserClient.replaceAll("../dist/generated/", "./dist/generated/"); diff --git a/packages/npm-core-rs/scripts/demo-server.mjs b/packages/npm-core-rs/scripts/demo-server.mjs index 27f5e20..7072ff2 100644 --- a/packages/npm-core-rs/scripts/demo-server.mjs +++ b/packages/npm-core-rs/scripts/demo-server.mjs @@ -14,6 +14,7 @@ const contentTypes = new Map([ [".html", "text/html; charset=utf-8"], [".js", "text/javascript; charset=utf-8"], [".json", "application/json; charset=utf-8"], + [".png", "image/png"], [".wasm", "application/wasm"], ]); @@ -41,8 +42,20 @@ const server = http.createServer(async (req, res) => { const fileStat = await stat(filePath); if (fileStat.isDirectory()) { - res.writeHead(404); - res.end("Not Found"); + const indexPath = path.join(filePath, "index.html"); + if (!existsSync(indexPath)) { + res.writeHead(404); + res.end("Not Found"); + return; + } + + const indexStat = await stat(indexPath); + res.writeHead(200, { + "Content-Length": indexStat.size, + "Content-Type": "text/html; charset=utf-8", + "Cache-Control": "no-cache", + }); + createReadStream(indexPath).pipe(res); return; } diff --git a/packages/npm-core-rs/src/browser.js b/packages/npm-core-rs/src/browser.js index 7b02971..4a4fc23 100644 --- a/packages/npm-core-rs/src/browser.js +++ b/packages/npm-core-rs/src/browser.js @@ -1,4 +1,4 @@ -import init, { generateDocxBytes } from "./generated/core_rs.js"; +import init, { generateDocxBytes, generateHwpxBytes } from "./generated/core_rs.js"; import wasmUrl from "./generated/core_rs_bg.wasm"; let initPromise; @@ -20,3 +20,13 @@ export async function generateDocx(markdown, options = {}) { options.strictMode ?? true, ); } + +export async function generateHwpx(markdown, options = {}) { + await ensureInit(); + return generateHwpxBytes( + markdown, + options.title ?? null, + options.author ?? null, + options.strictMode ?? true, + ); +} diff --git a/packages/npm-core-rs/src/index.d.ts b/packages/npm-core-rs/src/index.d.ts index 2228ffc..04d519e 100644 --- a/packages/npm-core-rs/src/index.d.ts +++ b/packages/npm-core-rs/src/index.d.ts @@ -4,7 +4,18 @@ export interface DocxOptions { strictMode?: boolean; } +export interface HwpxOptions { + title?: string; + author?: string; + strictMode?: boolean; +} + export declare function generateDocx( markdown: string, options?: DocxOptions, ): Promise; + +export declare function generateHwpx( + markdown: string, + options?: HwpxOptions, +): Promise; diff --git a/packages/npm-core-rs/src/node.js b/packages/npm-core-rs/src/node.js index 5ec4e27..182cea6 100644 --- a/packages/npm-core-rs/src/node.js +++ b/packages/npm-core-rs/src/node.js @@ -1,6 +1,6 @@ import { readFile } from "node:fs/promises"; -import init, { generateDocxBytes } from "./generated/core_rs.js"; +import init, { generateDocxBytes, generateHwpxBytes } from "./generated/core_rs.js"; let initPromise; @@ -24,3 +24,13 @@ export async function generateDocx(markdown, options = {}) { options.strictMode ?? true, ); } + +export async function generateHwpx(markdown, options = {}) { + await ensureInit(); + return generateHwpxBytes( + markdown, + options.title ?? null, + options.author ?? null, + options.strictMode ?? true, + ); +}