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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .gitattributes
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
# These are generated artifacts; provenance checks their exact bytes.
# Prevent CRLF conversion when cloning on Windows.
apps/studio-host/vendor/rhwp-core/** -text
11 changes: 11 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ on:
push:
branches:
- main
- fix/conference-template-layout
workflow_dispatch:

permissions:
Expand All @@ -29,6 +30,16 @@ jobs:
uses: actions/checkout@v5
with:
submodules: true
fetch-depth: 0

- name: Fetch the official base tag for downstream verification
shell: bash
run: |
if node -e "process.exit(require('./config/rhwp-upstream.json').fork ? 0 : 1)"; then
source="$(node -p "require('./config/rhwp-upstream.json').source")"
tag="$(node -p "require('./config/rhwp-upstream.json').tag")"
git -C third_party/rhwp fetch --no-tags "$source" "refs/tags/$tag:refs/tags/$tag"
fi

- name: Install Linux dependencies
shell: bash
Expand Down
2 changes: 1 addition & 1 deletion .gitmodules
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
[submodule "third_party/rhwp"]
path = third_party/rhwp
url = https://github.com/edwardkim/rhwp.git
url = https://github.com/mytrashcan/rhwp.git
11 changes: 8 additions & 3 deletions apps/studio-host/vendor/rhwp-core/PROVENANCE.json
Original file line number Diff line number Diff line change
Expand Up @@ -3,13 +3,18 @@
"source": "https://github.com/edwardkim/rhwp",
"version": "0.8.7",
"tag": "v0.8.7",
"commit": "1a76570e833917d15817415a53c09ad61ab3203f",
"commit": "f97972b063823581a18b18c958efa6de2b029d10",
"rustToolchain": "1.93.1",
"wasmPackVersion": "0.14.0",
"fork": {
"source": "https://github.com/mytrashcan/rhwp",
"baseCommit": "1a76570e833917d15817415a53c09ad61ab3203f",
"reason": "Reserve top-aligned body-wide Square tables across columns without rewriting document wrap settings"
},
"artifacts": {
"rhwp_bg.wasm": {
"bytes": 11803733,
"sha256": "5a77a0f5156c7fbf6db1745095d7886d057b5d05bfba25759112cb46be8e97ec"
"bytes": 11823047,
"sha256": "66b38e77d45a59fcdaed461d41d0f2f71f92a5af87c9ebf5c24a21b400065ddd"
},
"rhwp.js": {
"bytes": 454520,
Expand Down
Binary file modified apps/studio-host/vendor/rhwp-core/rhwp_bg.wasm
Binary file not shown.
2 changes: 1 addition & 1 deletion config/rhwp-studio-overrides.json
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
"schemaVersion": 1,
"upstream": {
"version": "0.8.7",
"commit": "1a76570e833917d15817415a53c09ad61ab3203f",
"commit": "f97972b063823581a18b18c958efa6de2b029d10",
"counterparts": {
"core/font-loader": "64a7f5fa58f0aa4638ac0665875a02eb841e6225bf53b54a7e5d3aaca827cef1",
"core/local-fonts": "78228d2db58ea6467271bfe3bd5018ae7fb16b17480cc23f453cd0dc186fe1c8",
Expand Down
7 changes: 6 additions & 1 deletion config/rhwp-upstream.json
Original file line number Diff line number Diff line change
Expand Up @@ -3,13 +3,18 @@
"source": "https://github.com/edwardkim/rhwp",
"version": "0.8.7",
"tag": "v0.8.7",
"commit": "1a76570e833917d15817415a53c09ad61ab3203f",
"commit": "f97972b063823581a18b18c958efa6de2b029d10",
"rustToolchain": "1.93.1",
"wasmPackVersion": "0.14.0",
"cargoPatches": {
"svg2pdf": {
"path": "vendor/svg2pdf",
"version": "0.13.0"
}
},
"fork": {
"source": "https://github.com/mytrashcan/rhwp",
"baseCommit": "1a76570e833917d15817415a53c09ad61ab3203f",
"reason": "Reserve top-aligned body-wide Square tables across columns without rewriting document wrap settings"
}
}
21 changes: 21 additions & 0 deletions docs/architecture/UPSTREAM.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,27 @@ HOP는 `edwardkim/rhwp`를 읽기 전용 upstream 의존성으로 사용한다.
* 기준 source, 버전과 커밋: `config/rhwp-upstream.json`이 단일 기준선(SSOT)
* HOP 작업 브랜치: `main`

## 명시적 downstream 엔진 패치

학술대회 양식의 페이지 기준 `Square` 표가 2단 본문과 겹치는 문제는 엔진 fork에서
수정한다. `config/rhwp-upstream.json`의 `source`, `version`, `tag`는 공식 기준 릴리스를
유지하고, `fork.source`, `fork.baseCommit`, `fork.reason`으로 실제 패치 공급원과 이유를
기록한다. `commit`과 submodule pointer는 패치의 불변 커밋을 가리킨다. WASM의
`PROVENANCE.json`에도 같은 fork 정보와 산출물 해시를 기록한다.

`pnpm upstream:verify`는 실제 origin, 공식 tag의 baseCommit, 패치의 ancestry와
생성 WASM 해시를 확인한다. `pnpm upstream:update`는 fork가 남아 있는 동안 중단하여
수정을 조용히 덮어쓰지 않는다. 공식 릴리스가 이 수정을 포함하면 origin과 `.gitmodules`를
공식 공급원으로 되돌리고 fork 메타데이터를 제거한 뒤 기존 업데이트 절차로 전환한다.

fork를 처음 clone하여 검증할 때 공식 기준 tag도 가져온다. submodule clone이 tag refs를
생략하는 경우가 있으므로 CI도 같은 준비를 수행한다.

```sh
git -C third_party/rhwp fetch https://github.com/edwardkim/rhwp tag v0.8.7
pnpm upstream:verify
```

## 소유권 규칙

`third_party/rhwp` submodule은 vendor source로 취급한다. HOP 제품 동작을 구현하기 위해 이 폴더 아래 파일을 직접 수정하지 않는다.
Expand Down
51 changes: 51 additions & 0 deletions docs/plans/body-wide-square-table.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
# 여러 단에 걸친 제목 표와 본문 겹침

## 배경과 문제

HOP 0.4.5 / rhwp 0.8.7에서 본문 너비에 가까운 쪽 기준 Square 표와
2단 본문이 겹친다. 첨부 학술대회 양식은 제목·요약을 1×1 표에 두고,
그 아래에 서론과 2단 본문을 배치한다. 표 속성 자체는 정상 파싱된다.

## 목표와 비범위

공간 예약과 실제 배치가 같은 본문 점유 조건을 사용하도록 수정한다.
입력 파일의 textWrap 속성, 저장 바이트, 문단과 제어문자 순서는 변경하지 않는다.
글꼴 대체, 원본 문서 공개, upstream PR 제출은 이 작업 범위 밖이다.

## 제약과 구현 방향

HOP의 third_party/rhwp는 읽기 전용이다. 엔진 수정은 별도 fork에서 진행하고,
HOP에서는 검증한 엔진 커밋, WASM, provenance를 함께 연결해야 한다.
현재 stable v0.8.7을 기준으로 downstream용 수정안을 준비한다.
Square 전체를 TopAndBottom으로 처리하지 않고 여러 단을 막는 쪽/용지 기준
표만 대상으로 한다. 작은 표 옆으로 흐르는 본문과 글앞/글뒤 개체는 반례다.

## 검증 계획

수정 전 원본 WASM에서 첨부 문서를 재현한다. 원본 속성을 바꾸는 실험은
진단에만 사용하고 산출 코드에 포함하지 않는다. 수정 엔진에서 원본 입력의
모든 쪽, 제목 표/본문 겹침, 본문 순서·중복·누락과 저장 후 재열기를 확인한다.
Native와 fresh WASM, HOP studio build 및 해당 upstream 검사를 실행한다.
독립적인 한컴 PDF가 없어 현재 한컴 출력과의 픽셀 일치는 미검증이다.

## 복구

HOP의 엔진 pin과 WASM/provenance 갱신을 같은 커밋에서 되돌린다.
원본 HWP는 읽기만 하며 수정하지 않는다.

## 검증 결과

rhwp fork의 `f97972b063823581a18b18c958efa6de2b029d10` 소스를 사용한다.
Actions에서 native check, WASM clippy, 기존 float_placement unit tests와 fresh
release WASM 빌드가 통과했다. 생성 artifact의 SOURCE_COMMIT으로 검증된 소스와
HOP pin을 연결한다.

첨부 문서는 공개 업로드 없이 로컬에서 검증했다. 전체 3쪽의 텍스트 run이 수정 전과
동일하며, 첫쪽 제목 표의 하단은 y=620이고 본문의 최소 y는 103.9에서 680.5로 바뀌었다.
표의 Square 속성은 유지되고 저장 후 재열기에서도 겹침이 없다. 글앞/글뒤, 겹침 허용,
하단 정렬, 본문 밖, 단 기준, 인라인, 좁은 표, 1단의 9개 변형은 이전 SVG와 byte 단위로
같다. 모든 쪽의 SVG를 PNG로 렌더링하여 배치를 확인했다.

HOP upstream 계약 검증과 studio build도 통과했다. Windows의 CRLF checkout 때문에
source counterpart baseline이 달라지는 문제는 source text만 LF로 정규화하여 해결하며,
생성된 WASM artifact의 provenance는 실제 bytes의 해시를 그대로 검증한다.
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@
"build:desktop": "pnpm --filter hop-desktop build",
"tauri": "pnpm --filter hop-desktop tauri",
"test": "pnpm run test:upstream && pnpm run test:studio && pnpm run test:desktop && pnpm run test:quicklook:macos",
"test:upstream": "node --test tests/update-upstream.test.mjs tests/rhwp-baseline.test.mjs tests/rhwp-boundary.test.mjs tests/hop-version.test.mjs",
"test:upstream": "node --test tests/update-upstream.test.mjs tests/rhwp-baseline.test.mjs tests/rhwp-boundary.test.mjs tests/hop-version.test.mjs tests/rhwp-fork.test.mjs",
"test:studio": "pnpm --filter @golbin/hop-studio-host test",
"test:desktop": "cd apps/desktop/src-tauri && cargo test",
"test:quicklook:macos": "node scripts/test-quicklook-macos.mjs",
Expand Down
26 changes: 24 additions & 2 deletions scripts/lib/rhwp-upstream.mjs
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { createHash } from 'node:crypto';
import assert from 'node:assert/strict';
import { readFile, writeFile } from 'node:fs/promises';
import { dirname, join, relative } from 'node:path';
import { fileURLToPath } from 'node:url';
Expand Down Expand Up @@ -117,6 +118,13 @@ export async function artifactMetadata(path) {
};
}

// Git may check out source text as CRLF on Windows. Source baselines describe
// repository text; generated artifact provenance continues to hash exact bytes.
export async function sourceTextHash(path) {
const text = (await readFile(path, 'utf8')).replaceAll('\r\n', '\n');
return createHash('sha256').update(text).digest('hex');
}

export function repoRelativePath(path) {
return relative(repoRoot, path).replaceAll('\\', '/');
}
Expand All @@ -134,6 +142,7 @@ export async function buildProvenance(lock) {
commit: lock.commit,
rustToolchain: lock.rustToolchain,
wasmPackVersion: lock.wasmPackVersion,
...(lock.fork ? { fork: lock.fork } : {}),
artifacts,
};
}
Expand All @@ -143,11 +152,11 @@ export async function buildStudioOverrideBaseline(manifest, upstream) {
for (const entry of manifest.overrides) {
if (entry.strategy !== 'extension' && entry.strategy !== 'fork') continue;
const relativePath = entry.id.endsWith('.css') ? entry.id : `${entry.id}.ts`;
counterparts[entry.id] = (await artifactMetadata(join(upstreamStudioDir, relativePath))).sha256;
counterparts[entry.id] = await sourceTextHash(join(upstreamStudioDir, relativePath));
}
const assets = {};
for (const relativePath of studioMirroredAssetPaths) {
assets[relativePath] = (await artifactMetadata(join(upstreamDir, 'rhwp-studio', relativePath))).sha256;
assets[relativePath] = await sourceTextHash(join(upstreamDir, 'rhwp-studio', relativePath));
}
return {
version: upstream.version,
Expand Down Expand Up @@ -181,6 +190,19 @@ export function normalizeGitSource(source) {
return normalized.replace(/\.git\/?$/, '').replace(/\/$/, '');
}

// A downstream patch retains the official release identity and records its
// actual source separately. Moving branches are never accepted as pins.
export function pinnedEngineSource(lock) {
assert.equal(normalizeGitSource(lock.source), officialUpstreamSource);
if (!lock.fork) return officialUpstreamSource;
assert.match(lock.fork.source, /^https:\/\/github\.com\/[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/);
assert.notEqual(lock.fork.source, officialUpstreamSource);
assert.match(lock.fork.baseCommit, /^[0-9a-f]{40}$/);
assert.ok(typeof lock.fork.reason === 'string' && lock.fork.reason.trim().length > 0);
assert.match(lock.commit, /^[0-9a-f]{40}$/);
return lock.fork.source;
}

export function escapeRegExp(value) {
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}
3 changes: 3 additions & 0 deletions scripts/update-rhwp-upstream.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,9 @@ if (!tag) {
}

assertStableTag(tag);
if ((await readJson(upstreamLockPath)).fork) {
throw new Error('Resolve the pinned downstream engine patch before updating the official release; see docs/architecture/UPSTREAM.md');
}
await assertSafeWorkingState();
await verifyRhwpUpstream();

Expand Down
12 changes: 11 additions & 1 deletion scripts/verify-rhwp-upstream.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import {
currentUpstreamCommit,
normalizeGitSource,
officialUpstreamSource,
pinnedEngineSource,
parsePackageVersion,
parseRustToolchain,
provenancePath,
Expand All @@ -31,15 +32,23 @@ export async function verifyRhwpUpstream() {
const lock = await readJson(upstreamLockPath);
assert.equal(lock.schemaVersion, 1);
assert.equal(normalizeGitSource(lock.source), officialUpstreamSource);
assert.equal(normalizeGitSource(run('git', ['config', '-f', '.gitmodules', '--get',
'submodule.third_party/rhwp.url'])), pinnedEngineSource(lock),
'fresh submodule clones must use the pinned engine source');
assert.equal(
normalizeGitSource(run('git', ['remote', 'get-url', 'origin'], { cwd: upstreamDir })),
officialUpstreamSource,
pinnedEngineSource(lock),
'submodule origin must match the provenance source',
);
assert.match(lock.version, /^\d+\.\d+\.\d+$/);
assert.equal(lock.tag, `v${lock.version}`);
assert.match(lock.commit, /^[0-9a-f]{40}$/);
assert.equal(currentUpstreamCommit(), lock.commit, 'submodule checkout must match upstream lock');
if (lock.fork) {
assert.equal(run('git', ['rev-parse', `${lock.tag}^{commit}`], { cwd: upstreamDir }),
lock.fork.baseCommit, 'downstream patch must identify the official base release');
run('git', ['merge-base', '--is-ancestor', lock.fork.baseCommit, lock.commit], { cwd: upstreamDir });
}
assert.equal(run('git', ['status', '--porcelain', '--untracked-files=all'], { cwd: upstreamDir }),
'', 'upstream checkout must be clean');

Expand All @@ -55,6 +64,7 @@ export async function verifyRhwpUpstream() {
assert.equal(wasmPackage.name, 'rhwp');
assert.equal(wasmPackage.version, lock.version);
assert.equal(provenance.schemaVersion, 1);
assert.deepEqual(provenance.fork, lock.fork, 'downstream provenance must match the lock');
for (const field of ['source', 'version', 'tag', 'commit', 'rustToolchain', 'wasmPackVersion']) {
assert.deepEqual(provenance[field], lock[field], `provenance ${field} must match upstream lock`);
}
Expand Down
8 changes: 6 additions & 2 deletions tests/rhwp-baseline.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ test('HOP keeps the rhwp renderer baseline aligned across submodule, vendored WA
assert.equal(provenance.commit, expectedRhwpCommit);
assert.equal(provenance.rustToolchain, upstreamLock.rustToolchain);
assert.equal(provenance.wasmPackVersion, upstreamLock.wasmPackVersion);
assert.deepEqual(provenance.fork, upstreamLock.fork);

for (const [fileName, expected] of Object.entries(provenance.artifacts)) {
const bytes = await readFile(join(repoRoot, 'apps/studio-host/vendor/rhwp-core', fileName));
Expand Down Expand Up @@ -59,8 +60,11 @@ test('HOP keeps the rhwp renderer baseline aligned across submodule, vendored WA
const upstreamDoc = await readFile(join(repoRoot, 'docs/architecture/UPSTREAM.md'), 'utf8');
assert.match(upstreamDoc, /config\/rhwp-upstream\.json/);

const submoduleStatus = git(['submodule', 'status', 'third_party/rhwp']).stdout.trim();
assert.match(submoduleStatus, new RegExp(`^[ +-]?${expectedRhwpCommit} third_party/rhwp\\b`));
const submoduleEntry = git(['ls-files', '--stage', '--', 'third_party/rhwp']).stdout.trim();
assert.match(submoduleEntry, new RegExp(`^160000 ${expectedRhwpCommit} 0\\tthird_party/rhwp$`));
const checkoutCommit = git(['-C', 'third_party/rhwp', 'rev-parse', 'HEAD']).stdout.trim();
assert.equal(checkoutCommit, expectedRhwpCommit);

});

test('active HOP font catalog only references packaged font assets', async () => {
Expand Down
2 changes: 1 addition & 1 deletion tests/rhwp-boundary.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ test('production studio code reaches rhwp internals only through the upstream ad
const manifest = JSON.parse(await readFile(join(repoRoot, 'config/rhwp-studio-overrides.json'), 'utf8'));
const overrideIds = new Set(manifest.overrides.map((entry) => entry.id));
for (const file of await typescriptFiles(studioRoot)) {
const relativePath = relative(studioRoot, file);
const relativePath = relative(studioRoot, file).replaceAll('\\', '/');
if (relativePath.startsWith('upstream/') || relativePath.endsWith('.test.ts')) continue;
const source = await readFile(file, 'utf8');
if (source.includes("'@upstream/") || source.includes('"@upstream/')) {
Expand Down
46 changes: 46 additions & 0 deletions tests/rhwp-fork.test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import {mkdtemp, writeFile, rm} from 'node:fs/promises';
import {join} from 'node:path';
import {tmpdir} from 'node:os';
import { pinnedEngineSource, officialUpstreamSource, sourceTextHash, artifactMetadata } from '../scripts/lib/rhwp-upstream.mjs';

const lock = {
source: officialUpstreamSource,
commit: 'a'.repeat(40),
fork: {
source: 'https://github.com/mytrashcan/rhwp',
baseCommit: 'b'.repeat(40),
reason: 'Reserve body-wide Square tables across columns',
},
};

test('source baselines survive CRLF checkout while artifact hashes retain exact bytes', async () => {
const dir = await mkdtemp(join(tmpdir(), 'hop-source-hash-'));
try {
const lf = join(dir, 'lf.ts');
const crlf = join(dir, 'crlf.ts');
await writeFile(lf, 'const title = "제목";\nexport {title};\n');
await writeFile(crlf, 'const title = "제목";\r\nexport {title};\r\n');
assert.equal(await sourceTextHash(lf), await sourceTextHash(crlf));
assert.notEqual((await artifactMetadata(lf)).sha256, (await artifactMetadata(crlf)).sha256);
} finally {
await rm(dir, {recursive:true, force:true});
}
});

test('official releases and explicitly pinned downstream sources are distinguished', () => {
assert.equal(pinnedEngineSource({ source: officialUpstreamSource }), officialUpstreamSource);
assert.equal(pinnedEngineSource(lock), lock.fork.source);
});

test('downstream pins reject moving refs and undocumented or mismatched sources', () => {
for (const fork of [
{ ...lock.fork, baseCommit: 'main' },
{ ...lock.fork, reason: '' },
{ ...lock.fork, source: 'https://example.com/rhwp' },
{ ...lock.fork, source: officialUpstreamSource },
]) assert.throws(() => pinnedEngineSource({ ...lock, fork }));
assert.throws(() => pinnedEngineSource({ ...lock, commit: 'main' }));
assert.throws(() => pinnedEngineSource({ ...lock, source: lock.fork.source }));
});