From 7d365f727a6cf02ce97db13d02d93a82a7131d1e Mon Sep 17 00:00:00 2001 From: centwon Date: Thu, 1 Oct 2026 09:24:08 +0900 Subject: [PATCH 1/3] =?UTF-8?q?feat:=20JSON=20=EC=9E=91=EC=84=B1=EA=B8=B0?= =?UTF-8?q?=20=EA=B2=BD=EB=9F=89=ED=99=94=20=E2=80=94=20=EA=B8=B0=EB=B3=B8?= =?UTF-8?q?=EA=B0=92=20=EC=83=9D=EB=9E=B5=C2=B7=ED=95=9C=EA=B8=80=20?= =?UTF-8?q?=EB=B9=84=EC=9D=B4=EC=8A=A4=EC=BC=80=EC=9D=B4=ED=94=84=C2=B7?= =?UTF-8?q?=EB=93=A4=EC=97=AC=EC=93=B0=EA=B8=B0=20=EC=97=86=EC=9D=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 스키마는 그대로("1.0"). 판독기가 없을 때 가정하는 값과 같은 필드는 쓰지 않는다 (run의 Bold/Italic/Underline/Strikethrough/FontSize 10, 문단 기본 필드, 기본 Type, Rows/Columns, 병합 없는 표의 span 격자, 레거시 IsListItem). 비ASCII는 그대로 쓰고 HTML 민감 문자만 이스케이프(UnicodeRanges.All). 들여쓰기 없음. corpus 240.6 -> 37.7 KB, 3,043 -> 378 KB. 구 판독기 호환: 변경 전 트리가 새 출력 18개를 읽어 자기 출력과 바이트 동일(JSON·.flow). 구 형식 fixture(format-1.0-verbose-kitchen-sink.json)를 새 판독기가 그대로 읽는 테스트. 그 밖: 메이저 버전이 더 높은 문서는 로드 경로가 거부(JsonException) · 모르는 블록/인라인 Type은 RichEditorDiagnostics에 보고 · 문단 MarginTop NaN 직렬화 예외 · DOCUMENT_FORMAT.md를 코드에 맞춤(손상=예외, 여백 15mm, 자동 MarginTop, 가져오기 상한). 테스트 LeanJsonFormatTests 14, 반증 9. unit 1307 + render 55. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 28 + Project_Roadmap.md | 6 + docs/DOCUMENT_FORMAT.md | 98 +-- .../Formatters/DocumentPackage.cs | 2 +- .../Formatters/DocumentSerializer.cs | 172 ++++-- .../DocumentSerializerTests.cs | 4 +- .../format-1.0-verbose-kitchen-sink.json | 579 ++++++++++++++++++ .../LeanJsonFormatTests.cs | 185 ++++++ 8 files changed, 967 insertions(+), 107 deletions(-) create mode 100644 tests/AvaloniaRichEditor.Tests/Fixtures/format-1.0-verbose-kitchen-sink.json create mode 100644 tests/AvaloniaRichEditor.Tests/LeanJsonFormatTests.cs diff --git a/CHANGELOG.md b/CHANGELOG.md index 900846c..ef559b0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,34 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Changed — the JSON writer leaves out what every reader assumes (2026-10-01) + +**The schema is unchanged** (still `"1.0"`), and every reader since 1.0 reads the new output exactly as before — +checked directly: the tree before this change read 18 documents written the new way (the fixpoint set and corpus +pages) and produced, byte for byte, what it produces from its own output, as JSON and as `.flow`. + +- Fields equal to the value a reader assumes when they are absent are not written: a run's `Bold`/`Italic`/ + `Underline`/`Strikethrough` false and `FontSize` 10, a paragraph's `HeadingLevel`, `ListLevel`, `Indent`, `IsQuote`, + `MarginRight`, `ListType` `None`, `TextAlignment` `Left`, each block kind's own default bottom margin, `Type` + `Paragraph`/`Run`, a table's `Rows`/`Columns` (readers rebuild them) and its span grids when nothing is merged. + The legacy `IsListItem` — documented as never written — had been written on every paragraph. +- **Non-ASCII text is written as it is.** Korean went out as `정보…`, six bytes a character and unreadable + in a diff. Characters HTML is sensitive to (`< > & ' "`) are still escaped, so the JSON can be embedded in a page. +- **Not indented.** Indentation was half of every document. +- Measured on `tests/corpus`: 240.6 → 37.7 KB, 3,043 → 378 KB; the kitchen-sink document 15.9 → 1.3 KB. A `.flow` + package changes little on disk (its `document.json` was already deflated). +- ⚠ **Hosts that compare saved JSON byte for byte** (snapshot tests) will see the new form. + +Also: +- **A newer major format is refused** by `LoadJson`, `LoadJsonAsync` and `LoadPackageAsync` (`JsonException`, like a + damaged file): it would come in with what this reader does not know turned into empty paragraphs, and a save would + write that over the file. `DocumentSerializer.Deserialize` stays lenient. An unknown block or inline `Type` within + the same major is read as text and reported to `RichEditorDiagnostics`. +- A paragraph whose `MarginTop` a host set to `Block.AutoTopMargin` (NaN) threw on save — JSON has no NaN. +- `docs/DOCUMENT_FORMAT.md` caught up with the code: damaged input throws (it said "empty document"), the default page + margin is 15 mm (it said 12.7/10.6), an absent `MarginTop` on a table, picture or divider is automatic (it said 0), + and the round-35 import bounds. + ### Fixed — round 35: a full audit; the size and depth of untrusted input (2026-10-01) Every source file was read. Most of what turned up is one axis earlier rounds never measured: how LARGE and how diff --git a/Project_Roadmap.md b/Project_Roadmap.md index d5d17af..99be46d 100644 --- a/Project_Roadmap.md +++ b/Project_Roadmap.md @@ -46,6 +46,12 @@ PR #48·#49·#50), 실기 후속도 PR #52로 들어왔다. 남은 것은 게시 게시 절차: `` → Unshipped를 Shipped로 이관 → CHANGELOG `[Unreleased]` → 버전 → README 배지(en/ko) → PR로 3-OS CI → `v1.3.0` 태그 push(**사람이 실행**, Trusted Publishing) → **nuget.org 패키지 페이지 육안 확인**. +**1b. JSON 작성기 경량화 (2026-10-01)** — 스키마 그대로, 작성기만: 기본값 필드 생략·한글 비이스케이프·들여쓰기 없음. +corpus 240.6 → 37.7 KB, 3,043 → 378 KB. **구 판독기 호환은 구 트리로 직접 확인**(18개 문서, JSON·.flow 바이트 동일). +메이저 버전이 더 높은 문서는 로드 경로가 거부, 모르는 `Type`은 진단 보고. 명세(`docs/DOCUMENT_FORMAT.md`)의 어긋남도 정정. +사용자 결정: 들여쓰기 옵션 없이 제거, 메이저 거부 도입. 포트도 같은 형식이라 같이 옮길 것. +포트 대조(라운드35)는 끝났다 — WinUIRichEditor PR #62(14건 백포트, 상류 픽셀 상한은 포트에 불필요). + **1a. 라운드35 · 전수 감사 (2026-10-01, PR #59 머지)** — `src` 약 19,500줄을 모델 → 포매터 → 컨트롤 순으로 읽었다. 결함 **16건** (프로세스 종료 3·데이터 소실 3·보안 1), 전부 빨강 먼저 → 수정 → 개별 반증. 대부분 이전 라운드가 재지 않은 **입력의 크기·깊이** 축이다. 테스트: `Round35AuditTests`. - 치명(프로세스 종료, catch 불가): HTML `
` 2,000겹(11 KB) 붙여넣기, 중첩 표 400단(RTF `\itap` 무제한) 렌더 — 스택 오버플로. diff --git a/docs/DOCUMENT_FORMAT.md b/docs/DOCUMENT_FORMAT.md index 98a9597..ec7956b 100644 --- a/docs/DOCUMENT_FORMAT.md +++ b/docs/DOCUMENT_FORMAT.md @@ -43,10 +43,15 @@ FlowDocument ### 2.1 직렬화 일반 규칙 -- 인코딩: UTF-8, `System.Text.Json` 소스 생성 컨텍스트(AOT 호환), 들여쓰기 출력. -- **null인 필드는 생략**된다(`WhenWritingNull`). 숫자/불리언 기본값(`0`, `false`, `"Indent": 0` 등)은 기록된다. +- 인코딩: UTF-8, `System.Text.Json` 소스 생성 컨텍스트(AOT 호환). **들여쓰기 없음**(2026-10-01부터 — 이전 판은 들여썼다). +- **한글 등 비ASCII 글자는 이스케이프하지 않는다**(`JavaScriptEncoder.Create(UnicodeRanges.All)`). HTML에 민감한 `<`·`>`·`&`·`'`·`"`는 + 여전히 `<` 식으로 이스케이프하므로 JSON을 웹 페이지에 끼워 넣어도 안전하다. (이전 판은 한글을 모두 `\uXXXX`로 썼다.) +- **판독기가 없을 때 가정하는 값과 같은 필드는 쓰지 않는다**(아래 필드 표의 "읽기 기본값"). `Type`이 `"Paragraph"`/`"Run"`이면 + `Type`도 생략한다. 이 규칙으로 쓴 문서는 1.0 이후의 **모든 판독기가 그대로 읽는다**(구 판독기로 직접 확인함 — §4). - **판독기는 모르는 필드를 무시해야 한다**(System.Text.Json 기본 동작). 전방 호환의 근거. -- 파싱 실패 시 `Deserialize`는 예외 대신 **빈 문서**를 반환한다. +- **손상된 입력은 예외다.** 유효하지 않은 JSON은 `JsonException`, `Blocks`가 없는 JSON(다른 앱의 파일)은 에디터의 로드 경로 + (`LoadJson`·`LoadJsonAsync`·`LoadPackageAsync`)에서 `JsonException`이다 — 빈 문서로 읽으면 호스트가 원본을 덮어쓰게 되기 때문. + 리터럴 `null`은 빈 문서다. ### 2.2 루트: `FlowDocumentDto` @@ -65,26 +70,28 @@ FlowDocument "Footer": null, // 바닥글 텍스트(없으면 생략) "ShowPageNumbers": false, "MarginLeft": 25, // 페이지 여백 **mm**, 변마다 하나. 기본값이면 생략 - "MarginTop": 20, // 기본 좌우 12.7mm(=0.5인치) · 상하 10.6mm + "MarginTop": 20, // 기본 사방 15mm "MarginRight": 25, "MarginBottom": 20 } } ``` -- **여백(`Margin*`)**: 용지 가장자리와 본문 사이의 띠(머리글·바닥글·쪽번호가 그려지는 곳). 단위는 **밀리미터**다 — 용지 크기가 mm로 정의되고 Word·아래한글도 mm로 보여 주며, RTF는 물리 길이(twips)로 싣는다(렌더는 96dpi DIP로 환산: 1mm = 96/25.4 ≈ 3.7795px). 변마다 하나이며 **기본값(좌우 12.7 · 상하 10.6mm)이면 생략**되므로 여백을 건드리지 않은 문서의 바이트는 그대로다. 일부 변만 있으면 나머지는 기본값. **본문을 놓을 자리가 남지 않는 값**(음수·NaN·무한대, 또는 마주 보는 두 변의 합이 용지보다 큼)은 한 변만 고치지 않고 **네 변 모두 기본값으로 되돌린다** — 파일이 뜻한 바가 아니므로 절반만 적용하지 않는다. - - RTF는 정수 twips라 mm가 정확히 왕복되지 않는다(25mm → 1417 → 24.994). 오차는 최대 1 twip(≈0.018mm). +- **여백(`Margin*`)**: 용지 가장자리와 본문 사이의 띠(머리글·바닥글·쪽번호가 그려지는 곳). 단위는 **밀리미터**다 — 용지 크기가 mm로 정의되고 Word·아래한글도 mm로 보여 주며, RTF는 물리 길이(twips)로 싣는다(렌더는 96dpi DIP로 환산: 1mm = 96/25.4 ≈ 3.7795px). 변마다 하나이며 **기본값(사방 15mm)이면 생략**되므로 여백을 건드리지 않은 문서의 바이트는 그대로다. 일부 변만 있으면 나머지는 기본값. **본문을 놓을 자리가 남지 않는 값**(음수·NaN·무한대, 또는 마주 보는 두 변의 합이 용지보다 큼)은 한 변만 고치지 않고 **네 변 모두 기본값으로 되돌린다** — 파일이 뜻한 바가 아니므로 절반만 적용하지 않는다. + - RTF는 정수 twips라 mm가 정확히 왕복되지 않는다. 0.1mm 단위 값은 그대로 돌아오고(15mm → 850 → 15), 그 밖의 값은 정확한 길이를 유지한다(Word의 1.25인치 = 31.75mm). - **`PageSetup`(선택)**: 워드프로세서식 페이지 설정. 로드 시 에디터의 용지/방향/머리글·바닥글/쪽번호 속성에 적용되고, 이후 페이지 속성을 바꾸면 문서로 다시 캡처된다. **기본 상태(용지 `Continuous`, 머리글/바닥글/쪽번호 없음)면 통째로 생략**되므로 평범한 문서의 바이트는 이전과 동일하다. 열거값은 이름으로 직렬화되어 미래의 알 수 없는 값은 기본값으로 안전하게 강등된다. 이 필드를 모르는 (구) 판독기는 무시한다 — 추가 필드라 버전 증가 없음. #### 버전 이력 -> **버전 표기**: 포맷 버전은 이제 **SemVer 문자열**(`"Version": "1.0"`)이다. 판독기는 로직에서 이 값을 분기하지 않으며(정보용 스탬프), **레거시 정수형(`1`·`2`)도 그대로 읽는다**(숫자/문자열 모두 허용). 필드가 없으면 `"1"`(레거시)로 간주. +> **버전 표기**: 포맷 버전은 **SemVer 문자열**(`"Version": "1.0"`)이다. **레거시 정수형(`1`·`2`)도 그대로 읽는다**(숫자/문자열 모두 허용, 둘 다 `"1.0"`보다 오래된 형식). 필드가 없으면 `"1"`(레거시)로 간주. +> 판독기가 분기하는 것은 **메이저**뿐이다: 에디터의 로드 경로는 메이저가 지원 범위(현재 1)보다 큰 문서를 `JsonException`으로 거부한다(§4). 공개 `DocumentSerializer.Deserialize`는 관대하게 읽는다. | 버전 | 변경 | 읽기 호환 | |---|---|---| | (없음)/`1` | 초기 형식. 이미지는 블록마다 인라인 base64(`ImageBase64`) | 항상 지원(레거시 폴백) | | `2` | 문서 수준 `Images` 풀 도입. 블록은 `ImageRef`(SHA-256 hex 키)로 참조. 동일 이미지 1회 저장 | v1 필드(`ImageBase64`, `IsListItem`)는 읽기 폴백 유지 | | `"1.0"` (현재) | 안정 기준선. 정수→SemVer 표기 전환 + 이미지 풀 + **글자 크기 pt** + **비례 줄 간격(`LineSpacing`)** | 레거시 정수 버전 문서를 그대로 읽음 | +| `"1.0"` (2026-10-01 작성기) | **스키마 변경 없음.** 작성기가 기본값 필드·기본 `Type`·1뿐인 병합 격자·`Rows`/`Columns`를 생략하고, 들여쓰지 않고, 한글을 이스케이프하지 않는다. 같은 문서가 4~12분의 1(corpus 실측 240.6 → 37.7 KB, 3,043 → 378 KB) | 1.0 이후 모든 판독기가 그대로 읽음 | - 풀 키 = **원본 인코딩 바이트의 SHA-256, 대문자 16진 문자열** (`Convert.ToHexString`). - 로드 시 풀 항목은 한 번만 디코드되고, 같은 키를 참조하는 모든 블록이 **동일한 `byte[]` 인스턴스를 공유**한다. @@ -93,15 +100,17 @@ FlowDocument ### 2.3 블록: `BlockDto` -블록은 `Type` 판별자를 가진 평면(flat) 객체다. 값: `"Paragraph"`(기본), `"Table"`, `"Image"`, `"Divider"`. 알 수 없는 `Type`은 Paragraph로 읽힌다. +블록은 `Type` 판별자를 가진 평면(flat) 객체다. 값: `"Paragraph"`(기본 — 쓰지 않는다), `"Table"`, `"Image"`, `"Divider"`. 알 수 없는 `Type`(같은 메이저의 더 새 판이 만든 블록)은 그 텍스트를 가진 Paragraph로 읽히고 `RichEditorDiagnostics`에 `NotSupportedException`으로 보고된다 — 그 문서를 저장하면 그 블록은 빠진다. #### 공통 필드 (모든 블록) -| 필드 | 타입 | 쓰기 | 읽기 기본값 | -|---|---|---|---| -| `Indent` | number | 항상 | 0 (왼쪽 여백 px) | -| `MarginTop` | number? | 항상 | 없으면 **0** | -| `MarginBottom` | number? | 항상 | 없으면 문단 **0**(HWP처럼 줄 간격만으로 구분), 이미지·표 **10**, Divider 0 | +아래 모든 표에서 **"읽기 기본값"과 같은 값은 쓰지 않는다**. + +| 필드 | 타입 | 읽기 기본값 | +|---|---|---| +| `Indent` | number? | 0 (왼쪽 여백 px) | +| `MarginTop` | number? | 문단 **0**. 표·이미지·구분선은 **자동**(NaN = `Block.AutoTopMargin`, 윗글에서 한 줄 간격) — 그래서 이 셋은 자동일 때 생략, 0을 포함한 명시 값은 기록 | +| `MarginBottom` | number? | 문단 **0**(HWP처럼 줄 간격만으로 구분), 이미지·표 **10**, Divider **0** | #### `Type: "Paragraph"` @@ -114,11 +123,11 @@ FlowDocument | `MarginRight` | number? | 오른쪽 여백 px(줄바꿈 폭 축소). **문단 전용**. 없으면 0 | | `ListType` | string | `"None"`/`"Bullet"`/`"Ordered"`. 파싱 실패 시 레거시 `IsListItem` 참조 | | `ListMarker` | string? | 글머리표/번호 모양: `Disc`/`Circle`/`Square`/`Dash`(글머리표), `Decimal`/`DecimalParen`/`LowerAlpha`/`UpperAlpha`/`LowerRoman`(번호). 없으면 `Default`(•/"1.") | -| `IsListItem` | bool | **v1 레거시, 읽기 전용 폴백**: `ListType` 없고 true면 Bullet | -| `ListLevel` | int | 중첩 리스트 깊이 (0=최상위) | -| `HeadingLevel` | int | 0=본문, 1~6=h1~h6 | +| `IsListItem` | bool? | **v1 레거시, 읽기 전용 폴백**: `ListType` 없고 true면 Bullet. 쓰지 않는다 | +| `ListLevel` | int? | 중첩 리스트 깊이 (0=최상위). 읽을 때 0~8로 자른다 | +| `HeadingLevel` | int? | 0=본문, 1~6=h1~h6 | | `Background` | string? | 문단/셀 배경색 (색상 형식은 §2.5) | -| `IsQuote` | bool | 인용 블록(blockquote) 여부 | +| `IsQuote` | bool? | 인용 블록(blockquote) 여부 | #### `Type: "Image"` (블록 이미지) @@ -134,11 +143,13 @@ FlowDocument | 필드 | 타입 | 의미 / 읽기 규칙 | |---|---|---| -| `Rows`, `Columns` | int | 행/열 수. **로드 시 `Cells` 격자에서 재계산**되므로 참고값 | +| `Rows`, `Columns` | int? | 행/열 수. **쓰지 않는다** — 판독기는 `Cells` 격자에서 재계산한다(구 파일의 `Columns`는 열 수의 하한으로만 쓰임) | | `ColumnWidths` | number[] | 열 너비 px (열 수만큼) | -| `RowHeights` | number[] | 행 최소 높이 px. **빈 배열 또는 0 = 자동(내용 높이)** | -| `Cells` | BlockDto[][] | 행 우선(row-major) **밀집 격자**. 평범한 1문단 셀은 Paragraph형 BlockDto(레거시 호환), 다중 블록·비문단 셀은 `Type:"Cell"` 래퍼(아래). 병합으로 가려진 칸도 자리는 유지 | -| `ColSpans`, `RowSpans` | int[][] | 셀 병합 격자(밀집, `Cells`와 같은 크기). 앵커 셀=병합 칸 수(평범한 셀은 1), **가려진(covered) 셀=0**. 없으면 전부 1×1 | +| `RowHeights` | number[]? | 행 최소 높이 px. **없음·빈 배열·0 = 자동(내용 높이)**. 비어 있으면 쓰지 않는다 | +| `Cells` | BlockDto[][] | 행 우선(row-major) **밀집 격자**. 평범한 1문단 셀은 Paragraph형 BlockDto(레거시 호환), 다중 블록·비문단 셀은 `Type:"Cell"` 래퍼(아래). 병합으로 가려진 칸도 자리는 유지. 행이 하나도 없는 표는 읽지 않는다 | +| `ColSpans`, `RowSpans` | int[][]? | 셀 병합 격자(밀집, `Cells`와 같은 크기). 앵커 셀=병합 칸 수(평범한 셀은 1), **가려진(covered) 셀=0**. 없으면 전부 1×1 — **병합이 없는 표는 쓰지 않는다** | + +가져오기 상한(신뢰할 수 없는 입력, 2026-10-01 라운드35): 표는 **1,000열·25만 셀**까지 읽고 넘는 셀은 버린다(가장 넓은 행이 모든 행을 채우는 증폭 방지). 병합 규약: 병합 영역의 왼쪽-위 셀이 **앵커**이며 `ColSpans[r][c]`/`RowSpans[r][c]`에 병합 크기를 갖는다. 영역 내 나머지 칸은 두 배열 모두 0으로 마킹되고, 그 칸의 `Cells` 내용은 무시된다(빈 문단 권장). 격자는 항상 직사각형이어야 한다. @@ -160,19 +171,19 @@ FlowDocument ### 2.4 인라인: `InlineDto` -`Type` 판별자: `"Run"`(기본), `"Image"`, 또는 `"Table"`. +`Type` 판별자: `"Run"`(기본 — 쓰지 않는다), `"Image"`, 또는 `"Table"`. 알 수 없는 `Type`은 그 `Text`를 가진 Run으로 읽고 보고한다(블록과 같다). #### `Type: "Run"` | 필드 | 타입 | 의미 / 읽기 규칙 | |---|---|---| | `Text` | string? | 텍스트. `\n` = 하드 줄바꿈 | -| `Bold`, `Italic` | bool | 굵게/기울임 | -| `FontSize` | number | 글자 크기 **pt**(이전 px). 기본 10, 읽을 때 ≤0이면 10. 렌더 시 ×4/3로 px 변환 | +| `Bold`, `Italic` | bool? | 굵게/기울임. 없으면 false | +| `FontSize` | number? | 글자 크기 **pt**(이전 px). 없거나 ≤0이면 10. 렌더 시 ×4/3로 px 변환 | | `FontFamily` | string? | 글꼴 이름. 없으면 에디터 기본 글꼴. **주의: OS가 현지화한 이름(예: "맑은 고딕")이 저장될 수 있어 다른 OS에서 해석되지 않을 수 있음** | | `Foreground` | string? | 글자색 (§2.5). 없으면 기본(검정) | | `Background` | string? | 형광펜 배경색 | -| `Underline`, `Strikethrough` | bool | 밑줄/취소선 (둘 다 가능) | +| `Underline`, `Strikethrough` | bool? | 밑줄/취소선 (둘 다 가능). 없으면 false | | `NavigateUri` | string? | 하이퍼링크 URL. 다른 장식(밑줄·취소선)이 없으면 밑줄로 렌더하고, 색은 `Foreground`를 따른다. 에디터는 http/https만 연다 | #### `Type: "Image"` (인라인 이미지) @@ -193,35 +204,23 @@ FlowDocument ### 2.6 예제 +실제 출력은 한 줄이다(들여쓰기 없음). 읽기 쉽도록 줄을 나눠 보인다. 생략된 필드는 모두 기본값이다. + ```json { "Version": "1.0", "Blocks": [ - { - "Type": "Paragraph", - "Inlines": [ - { "Type": "Run", "Text": "제목", "Bold": true, "Italic": false, - "FontSize": 20, "Underline": false, "Strikethrough": false } - ], - "TextAlignment": "Left", "MarginTop": 0, "MarginBottom": 10, - "ListType": "None", "HeadingLevel": 1, "Indent": 0, - "IsQuote": false, "ListLevel": 0, "IsListItem": false - }, + { "Inlines": [ { "Text": "제목", "Bold": true, "FontSize": 20 } ], "MarginBottom": 10, "HeadingLevel": 1 }, { "Type": "Image", "ImageRef": "A4DD28DB6E6D3FC0D43CDBEF1E8EF161B353CE67D27E81D400F796BC77045AE6", "Width": 640, "Height": 480, - "Indent": 0, "MarginTop": 0, "MarginBottom": 10 + "MarginTop": 0 }, { - "Type": "Table", "Rows": 1, "Columns": 2, - "ColumnWidths": [100, 100], "RowHeights": [], - "Cells": [[ - { "Type": "Paragraph", "Inlines": [ { "Type": "Run", "Text": "셀1", "Bold": false, "Italic": false, "FontSize": 10, "Underline": false, "Strikethrough": false } ], "HeadingLevel": 0, "Indent": 0, "IsQuote": false, "ListLevel": 0, "IsListItem": false }, - { "Type": "Paragraph", "Inlines": [], "HeadingLevel": 0, "Indent": 0, "IsQuote": false, "ListLevel": 0, "IsListItem": false } - ]], - "ColSpans": [[1, 1]], "RowSpans": [[1, 1]], - "Indent": 0, "MarginTop": 0, "MarginBottom": 10 + "Type": "Table", + "ColumnWidths": [100, 100], + "Cells": [[ { "Inlines": [ { "Text": "셀1" } ] }, { "Inlines": [] } ]] } ], "Images": { @@ -251,8 +250,9 @@ FlowDocument - `document.json`의 `Images[키]`와 `images/키` 엔트리가 1:1 대응한다. base64가 빠지므로 같은 문서의 JSON 대비 약 25% 작다. - 이미지 엔트리는 이미 압축된 형식(JPEG/PNG)이므로 **무압축(Stored)** 으로 저장한다. -- 로드 시: `document.json`이 없으면 빈 문서. `images/` 엔트리의 MIME은 풀 메타에서 읽고, 메타가 없으면 **매직 넘버 스니핑**(png/jpeg/gif/bmp/webp)으로 결정한다. -- 손상된 ZIP/JSON은 예외 없이 빈 문서를 반환한다. +- 로드 시: `images/` 엔트리의 MIME은 풀 메타에서 읽고, 메타가 없거나 평범한 `image/…` 형식이 아니면 **매직 넘버 스니핑**(png/jpeg/gif/bmp/webp)으로 결정한다. +- 로드 시 **문서가 참조하는 `images/` 엔트리만** 읽고, 엔트리 하나가 256 MB를 넘으면 읽지 않는다(zip 폭탄 방지, 라운드35). +- **손상은 예외다**: ZIP이 아니거나 깨졌으면 `InvalidDataException`, `document.json`이 없으면(다른 zip — .docx 등) `InvalidDataException`, `document.json`이 유효한 JSON이 아니면 `JsonException`. - 파일 식별: 데모는 ZIP 매직 `PK`(0x50 0x4B) 스니핑으로 `.flow`와 일반 JSON을 구분한다. - `meta.json`은 **컨테이너 포맷** 버전 마커다(`document.json`의 문서 포맷 버전과 같은 값으로 기록). 컨테이너 레이아웃이 독립적으로 진화할 여지를 남기고, 너무 새로운 패키지를 판독기가 구분할 수 있게 한다. 현재 판독기는 로직에서 사용하지 않으며 **없어도 무방**(이전 버전 `.flow`와 하위호환). @@ -263,13 +263,17 @@ FlowDocument **판독기(reader) 의무** - 모르는 JSON 필드는 무시한다. - `Version`이 없으면 레거시(`"1"`)로 간주하고 폴백(`ImageBase64`, `IsListItem`)을 적용한다. 레거시 정수(`1`·`2`)와 SemVer 문자열(`"1.0"`)을 모두 읽는다. -- `Version`이 현재 지원 버전보다 커도 가능한 만큼 읽는다(현재 구현은 버전 검사로 거부하지 않음). +- `Version`의 **메이저가 더 크면 에디터의 로드 경로는 거부한다**(`JsonException`) — 모르는 것을 빈 문단으로 바꿔 읽고, 저장하면 그걸 원본에 덮어쓰게 되기 때문. 같은 메이저의 더 새 판(예: `"1.4"`)은 가능한 만큼 읽고, 모르는 블록·인라인 `Type`은 보고한다. 공개 `Deserialize`는 메이저와 무관하게 관대하다. +- 없는 필드는 아래 필드 표의 "읽기 기본값"으로 읽는다. **이 기본값은 작성기가 생략에 쓰는 값과 같아야 한다** — 바꾸면 기존 파일의 의미가 바뀐다. **작성기(writer) 의무** - 항상 현재 포맷 버전(`DocumentSerializer.CurrentSchemaVersion` = `"1.0"`)을 기록한다. +- 판독기의 기본값과 같은 필드는 쓰지 않는다(§2.1). 2026-10-01 이전의 작성기는 모든 필드를 들여 써서 기록했다 — 두 형태 모두 같은 스키마이고, 판독기는 둘 다 읽는다(구 형태: `tests/AvaloniaRichEditor.Tests/Fixtures/format-1.0-verbose-kitchen-sink.json`). - 이미지 바이트를 재인코딩하지 않는다(원본 보존). 풀 키는 반드시 바이트의 SHA-256 hex. - 레거시 쓰기 필드(`ImageBase64`, `IsListItem`)는 **쓰지 않는다** (읽기 폴백 전용). +**구 판독기 호환을 확인한 방법(2026-10-01)**: 변경 전 트리가 문서 18개(`FormatFixpointTests`의 14종 + corpus 실문서 4개)를 쓰고 → 새 작성기가 다시 쓰고 → 변경 전 트리가 그걸 읽어 다시 쓴 결과가 자기 출력과 **바이트 단위로 같았다**(JSON·`.flow` 모두). 작성기의 생략 규칙을 넓힐 때는 같은 확인을 다시 할 것. + **스키마를 바꿀 때** 1. 기존 문서를 깨뜨리는 변경(필드 의미 변경·제거)이면 `CurrentSchemaVersion`을 올리고(SemVer 문자열 — 호환 깨짐은 메이저, 하위호환 추가는 마이너) 읽기 폴백을 추가한다. 필드 *추가*는 버전 증가 없이 가능하다(생략=기본값 규칙 유지). 2. 이 문서의 버전 이력 표(§2.2)와 필드 표를 갱신한다. diff --git a/src/AvaloniaRichEditor/Formatters/DocumentPackage.cs b/src/AvaloniaRichEditor/Formatters/DocumentPackage.cs index 406be48..e744c43 100644 --- a/src/AvaloniaRichEditor/Formatters/DocumentPackage.cs +++ b/src/AvaloniaRichEditor/Formatters/DocumentPackage.cs @@ -55,7 +55,7 @@ internal static void WriteDto(FlowDocumentDto dto, Dictionary & ' "), so the + // JSON can still be embedded in a page. Not indented: indentation was half of every document. (2026-10-01) + internal static DocumentJsonContext Wire { get; } = new(new JsonSerializerOptions + { + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, + Encoder = System.Text.Encodings.Web.JavaScriptEncoder.Create(System.Text.Unicode.UnicodeRanges.All), + }); +} /// Serializes and deserializes a to/from the library's JSON format. /// Images are stored as base64 of their original encoded bytes (no re-encoding); bitmaps @@ -53,7 +64,7 @@ internal static string SerializeDto(FlowDocumentDto dto, Dictionary SupportedMajorVersion) + throw new JsonException($"The document is format {dto.Version}, newer than this reader ({CurrentSchemaVersion})."); + } + + private const int SupportedMajorVersion = 1; + + // The major of a SemVer "M.m" version; null for the legacy integer form, which has no dot. + private static int? MajorVersion(string? version) + { + if (version == null) return null; + int dot = version.IndexOf('.'); + return dot > 0 && int.TryParse(version.AsSpan(0, dot), System.Globalization.NumberStyles.None, + System.Globalization.CultureInfo.InvariantCulture, out int major) ? major : null; } // Thread-free half of Deserialize: JSON parsing + base64 decode only, no model objects. Async @@ -190,7 +217,7 @@ private static BlockDto BlockToDto(Block block, Dictionary(tb.ColumnWidths), - RowHeights = new List(tb.RowHeights), + RowHeights = tb.RowHeights.Count > 0 ? new List(tb.RowHeights) : null, Cells = new List>() }; foreach (var row in tb.Cells) @@ -260,10 +287,14 @@ private static BlockDto BlockToDto(Block block, Dictionary>(); - td.RowSpans = new List>(); - foreach (var row in tb.ColSpans) td.ColSpans.Add(new List(row)); - foreach (var row in tb.RowSpans) td.RowSpans.Add(new List(row)); + // Only a table with a merge carries the span grids; absent, every reader reads them as all 1s. + if (HasMerge(tb)) + { + td.ColSpans = new List>(); + td.RowSpans = new List>(); + foreach (var row in tb.ColSpans) td.ColSpans.Add(new List(row)); + foreach (var row in tb.RowSpans) td.RowSpans.Add(new List(row)); + } return td; default: return new BlockDto { Type = "Paragraph", Inlines = new List() }; @@ -272,40 +303,46 @@ private static BlockDto BlockToDto(Block block, Dictionary pool) { + // A field that holds what a reader assumes when it is absent is not written (Unless): every reader since + // 1.0 defaults the same way, so they all read this back unchanged — and a run no longer spends 90 bytes on + // "Italic":false,"Underline":false,… A typed Korean document came out at a quarter of its size (the + // encoder, see Wire, did the rest). "Paragraph" and "Run" are the readers' default Type too. var d = new BlockDto { - Type = "Paragraph", + Type = null, Inlines = new List(), - TextAlignment = p.TextAlignment.ToString(), + TextAlignment = p.TextAlignment == TextAlignment.Left ? null : p.TextAlignment.ToString(), LineHeight = NanToNull(p.LineHeight), LineSpacing = NanToNull(p.LineSpacing), - MarginTop = p.MarginTop, - MarginBottom = p.MarginBottom, - MarginRight = p.MarginRight, - ListType = p.ListType.ToString(), + // NaN (a host's AutoTopMargin on a paragraph) has no JSON spelling — writing it threw — and a paragraph + // reads an absent top margin as 0, which is what it draws for NaN too. + MarginTop = double.IsNaN(p.MarginTop) ? null : Unless(p.MarginTop, 0), + MarginBottom = Unless(p.MarginBottom, 0), + MarginRight = Unless(p.MarginRight, 0), + ListType = p.ListType == ListKind.None ? null : p.ListType.ToString(), ListMarker = p.ListMarker == ListMarkerStyle.Default ? null : p.ListMarker.ToString(), - HeadingLevel = p.HeadingLevel, + HeadingLevel = Unless(p.HeadingLevel, 0), Background = BrushToString(p.Background), - Indent = p.Indent, - IsQuote = p.IsQuote, - ListLevel = p.ListLevel + Indent = Unless(p.Indent, 0), + IsQuote = Unless(p.IsQuote, false), + ListLevel = Unless(p.ListLevel, 0) }; foreach (var inline in p.Inlines) { if (inline is Run r) d.Inlines.Add(new InlineDto { - Type = "Run", + Type = null, Text = r.Text, - Bold = r.FontWeight == FontWeight.Bold, - Italic = r.FontStyle == FontStyle.Italic, - FontSize = r.FontSize, + Bold = Unless(r.FontWeight == FontWeight.Bold, false), + Italic = Unless(r.FontStyle == FontStyle.Italic, false), + FontSize = Unless(r.FontSize, 10), Foreground = BrushToString(r.Foreground), Background = BrushToString(r.Background), FontFamily = r.FontFamily, NavigateUri = r.NavigateUri, - Underline = HasDecoration(r.TextDecorations, TextDecorationLocation.Underline), - Strikethrough = HasDecoration(r.TextDecorations, TextDecorationLocation.Strikethrough) + Underline = Unless(HasDecoration(r.TextDecorations, TextDecorationLocation.Underline), false), + Strikethrough = Unless(HasDecoration(r.TextDecorations, TextDecorationLocation.Strikethrough), false) }); else if (inline is InlineImage im) d.Inlines.Add(new InlineDto @@ -344,7 +381,7 @@ private static BlockDto ParagraphToDto(Paragraph p, Dictionary maxCols) maxCols = row.Count; tb.Columns = TableBlock.ImportColumns(tb.Rows, maxCols); foreach (var row in tb.Cells) @@ -435,10 +472,17 @@ private static BlockDto ParagraphToDto(Paragraph p, Dictionary RichEditorDiagnostics.Report(new NotSupportedException( + $"Unknown {what} type \"{type}\" in the document: read as plain text; saving drops what it was.")); + private static Paragraph DtoToParagraph(BlockDto d, Dictionary pool) { var p = new Paragraph @@ -448,14 +492,14 @@ private static Paragraph DtoToParagraph(BlockDto d, Dictionary tags (round 35). - ListLevel = Math.Clamp(d.ListLevel, 0, 8), - ListType = Enum.TryParse(d.ListType, out var lk) ? lk : (d.IsListItem ? ListKind.Bullet : ListKind.None), + ListLevel = Math.Clamp(d.ListLevel ?? 0, 0, 8), + ListType = Enum.TryParse(d.ListType, out var lk) ? lk : (d.IsListItem == true ? ListKind.Bullet : ListKind.None), ListMarker = Enum.TryParse(d.ListMarker, out var lm) ? lm : ListMarkerStyle.Default }; if (Enum.TryParse(d.TextAlignment, out var ta)) p.TextAlignment = ta; @@ -481,20 +525,23 @@ private static Paragraph DtoToParagraph(BlockDto d, Dictionary 0 ? fs : 10, // pt; body default (absent, or a damaged <= 0) Foreground = StringToBrush(id.Foreground), Background = StringToBrush(id.Background), FontFamily = id.FontFamily, // A file is untrusted input: a script link is dropped as the HTML reader drops it // (port audit, 2026-09-24). NavigateUri = id.NavigateUri is { } uri ? HtmlDocumentFormatter.SafeHref(uri) : null, - TextDecorations = BuildDecorations(id.Underline, id.Strikethrough) + TextDecorations = BuildDecorations(id.Underline == true, id.Strikethrough == true) }); + } } return p; } @@ -529,6 +576,17 @@ void Walk(IEnumerable? blocks) private static double? NanToNull(double v) => double.IsNaN(v) ? (double?)null : v; + // `value`, or null (not written) when it is what every reader assumes for an absent field. + private static T? Unless(T value, T absent) where T : struct + => EqualityComparer.Default.Equals(value, absent) ? null : value; + + private static bool HasMerge(TableBlock tb) + { + foreach (var row in tb.ColSpans) foreach (int v in row) if (v != 1) return true; + foreach (var row in tb.RowSpans) foreach (int v in row) if (v != 1) return true; + return false; + } + private static bool HasDecoration(TextDecorationCollection? decos, TextDecorationLocation loc) { if (decos == null) return false; @@ -642,7 +700,7 @@ internal class ImagePoolDto internal class BlockDto { - public string Type { get; set; } = "Paragraph"; + public string? Type { get; set; } = "Paragraph"; // written as null (absent) for a paragraph // Paragraph public List? Inlines { get; set; } @@ -654,14 +712,14 @@ internal class BlockDto public double? MarginTop { get; set; } public double? MarginBottom { get; set; } public double? MarginRight { get; set; } // paragraphs only (narrows the wrap width) - public bool IsListItem { get; set; } // legacy (read fallback); replaced by ListType + public bool? IsListItem { get; set; } // legacy (read fallback); replaced by ListType — never written public string? ListType { get; set; } public string? ListMarker { get; set; } // bullet glyph / number format; null = Default - public int HeadingLevel { get; set; } + public int? HeadingLevel { get; set; } public string? Background { get; set; } - public double Indent { get; set; } - public bool IsQuote { get; set; } - public int ListLevel { get; set; } + public double? Indent { get; set; } + public bool? IsQuote { get; set; } + public int? ListLevel { get; set; } // Image block public string? ImageRef { get; set; } // v2: key into FlowDocumentDto.Images @@ -673,8 +731,8 @@ internal class BlockDto public double? Height { get; set; } // Table block - public int Rows { get; set; } - public int Columns { get; set; } + public int? Rows { get; set; } // not written: readers rebuild it from Cells + public int? Columns { get; set; } // not written: readers rebuild it from Cells (old files: a floor on the width) public List? ColumnWidths { get; set; } public List? RowHeights { get; set; } public List>? Cells { get; set; } @@ -689,19 +747,19 @@ internal class BlockDto internal class InlineDto { - public string Type { get; set; } = "Run"; + public string? Type { get; set; } = "Run"; // written as null (absent) for a run // Run public string? Text { get; set; } - public bool Bold { get; set; } - public bool Italic { get; set; } - public double FontSize { get; set; } = 10; // pt; body default + public bool? Bold { get; set; } + public bool? Italic { get; set; } + public double? FontSize { get; set; } // pt; absent = the 10 pt body default public string? Foreground { get; set; } public string? Background { get; set; } public string? FontFamily { get; set; } public string? NavigateUri { get; set; } - public bool Underline { get; set; } - public bool Strikethrough { get; set; } + public bool? Underline { get; set; } + public bool? Strikethrough { get; set; } // Inline image public string? ImageRef { get; set; } // v2: key into FlowDocumentDto.Images diff --git a/tests/AvaloniaRichEditor.Tests/DocumentSerializerTests.cs b/tests/AvaloniaRichEditor.Tests/DocumentSerializerTests.cs index 56bde09..df29618 100644 --- a/tests/AvaloniaRichEditor.Tests/DocumentSerializerTests.cs +++ b/tests/AvaloniaRichEditor.Tests/DocumentSerializerTests.cs @@ -123,7 +123,7 @@ public void Serialize_IsIdempotent() public void Serialize_WritesSchemaVersion() { var json = DocumentSerializer.Serialize(SampleDoc()); - Assert.Contains($"\"Version\": \"{DocumentSerializer.CurrentSchemaVersion}\"", json); + Assert.Contains($"\"Version\":\"{DocumentSerializer.CurrentSchemaVersion}\"", json); } [Fact] @@ -136,7 +136,7 @@ public void Deserialize_AcceptsLegacyNumericVersion_AndReSerializesAsSemVer() Assert.Equal("hi", Assert.IsType(doc.Blocks[0]).Text()); var json = DocumentSerializer.Serialize(doc); - Assert.Contains("\"Version\": \"1.0\"", json); + Assert.Contains("\"Version\":\"1.0\"", json); } [Fact] diff --git a/tests/AvaloniaRichEditor.Tests/Fixtures/format-1.0-verbose-kitchen-sink.json b/tests/AvaloniaRichEditor.Tests/Fixtures/format-1.0-verbose-kitchen-sink.json new file mode 100644 index 0000000..3e99d54 --- /dev/null +++ b/tests/AvaloniaRichEditor.Tests/Fixtures/format-1.0-verbose-kitchen-sink.json @@ -0,0 +1,579 @@ +{ + "Version": "1.0", + "Blocks": [ + { + "Type": "Paragraph", + "Inlines": [ + { + "Type": "Run", + "Text": "top", + "Bold": false, + "Italic": false, + "FontSize": 10, + "Underline": false, + "Strikethrough": false + } + ], + "TextAlignment": "Left", + "MarginTop": 0, + "MarginBottom": 0, + "MarginRight": 0, + "IsListItem": false, + "ListType": "None", + "HeadingLevel": 0, + "Indent": 0, + "IsQuote": false, + "ListLevel": 0, + "Rows": 0, + "Columns": 0 + }, + { + "Type": "Table", + "MarginBottom": 10, + "IsListItem": false, + "HeadingLevel": 0, + "Indent": 0, + "IsQuote": false, + "ListLevel": 0, + "Rows": 2, + "Columns": 2, + "ColumnWidths": [ + 100, + 100 + ], + "RowHeights": [], + "Cells": [ + [ + { + "Type": "Paragraph", + "Inlines": [ + { + "Type": "Run", + "Text": "", + "Bold": false, + "Italic": false, + "FontSize": 10, + "Underline": false, + "Strikethrough": false + }, + { + "Type": "Run", + "Text": "r0c0", + "Bold": false, + "Italic": false, + "FontSize": 10, + "Underline": false, + "Strikethrough": false + }, + { + "Type": "Run", + "Text": " ", + "Bold": false, + "Italic": false, + "FontSize": 10, + "Underline": false, + "Strikethrough": false + }, + { + "Type": "Run", + "Text": "", + "Bold": false, + "Italic": false, + "FontSize": 10, + "Underline": false, + "Strikethrough": false + }, + { + "Type": "Run", + "Text": "r0c1", + "Bold": false, + "Italic": false, + "FontSize": 10, + "Underline": false, + "Strikethrough": false + } + ], + "TextAlignment": "Left", + "MarginTop": 0, + "MarginBottom": 0, + "MarginRight": 0, + "IsListItem": false, + "ListType": "None", + "HeadingLevel": 0, + "Indent": 0, + "IsQuote": false, + "ListLevel": 0, + "Rows": 0, + "Columns": 0 + }, + { + "Type": "Paragraph", + "Inlines": [ + { + "Type": "Run", + "Text": "", + "Bold": false, + "Italic": false, + "FontSize": 10, + "Underline": false, + "Strikethrough": false + } + ], + "TextAlignment": "Left", + "MarginTop": 0, + "MarginBottom": 0, + "MarginRight": 0, + "IsListItem": false, + "ListType": "None", + "HeadingLevel": 0, + "Indent": 0, + "IsQuote": false, + "ListLevel": 0, + "Rows": 0, + "Columns": 0 + } + ], + [ + { + "Type": "Cell", + "IsListItem": false, + "HeadingLevel": 0, + "Indent": 0, + "IsQuote": false, + "ListLevel": 0, + "Rows": 0, + "Columns": 0, + "Blocks": [ + { + "Type": "Paragraph", + "Inlines": [ + { + "Type": "Run", + "Text": "", + "Bold": false, + "Italic": false, + "FontSize": 10, + "Underline": false, + "Strikethrough": false + }, + { + "Type": "Run", + "Text": "r1c0", + "Bold": false, + "Italic": false, + "FontSize": 10, + "Underline": false, + "Strikethrough": false + } + ], + "TextAlignment": "Left", + "MarginTop": 0, + "MarginBottom": 0, + "MarginRight": 0, + "IsListItem": false, + "ListType": "None", + "HeadingLevel": 0, + "Indent": 0, + "IsQuote": false, + "ListLevel": 0, + "Rows": 0, + "Columns": 0 + }, + { + "Type": "Table", + "MarginBottom": 10, + "IsListItem": false, + "HeadingLevel": 0, + "Indent": 0, + "IsQuote": false, + "ListLevel": 0, + "Rows": 1, + "Columns": 2, + "ColumnWidths": [ + 100, + 100 + ], + "RowHeights": [], + "Cells": [ + [ + { + "Type": "Paragraph", + "Inlines": [ + { + "Type": "Run", + "Text": "", + "Bold": false, + "Italic": false, + "FontSize": 10, + "Underline": false, + "Strikethrough": false + }, + { + "Type": "Run", + "Text": "r0c0", + "Bold": false, + "Italic": false, + "FontSize": 10, + "Underline": false, + "Strikethrough": false + } + ], + "TextAlignment": "Left", + "MarginTop": 0, + "MarginBottom": 0, + "MarginRight": 0, + "IsListItem": false, + "ListType": "None", + "HeadingLevel": 0, + "Indent": 0, + "IsQuote": false, + "ListLevel": 0, + "Rows": 0, + "Columns": 0 + }, + { + "Type": "Paragraph", + "Inlines": [ + { + "Type": "Run", + "Text": "", + "Bold": false, + "Italic": false, + "FontSize": 10, + "Underline": false, + "Strikethrough": false + }, + { + "Type": "Run", + "Text": "r0c1", + "Bold": false, + "Italic": false, + "FontSize": 10, + "Underline": false, + "Strikethrough": false + } + ], + "TextAlignment": "Left", + "MarginTop": 0, + "MarginBottom": 0, + "MarginRight": 0, + "IsListItem": false, + "ListType": "None", + "HeadingLevel": 0, + "Indent": 0, + "IsQuote": false, + "ListLevel": 0, + "Rows": 0, + "Columns": 0 + } + ] + ], + "ColSpans": [ + [ + 1, + 1 + ] + ], + "RowSpans": [ + [ + 1, + 1 + ] + ] + } + ] + }, + { + "Type": "Cell", + "IsListItem": false, + "HeadingLevel": 0, + "Indent": 0, + "IsQuote": false, + "ListLevel": 0, + "Rows": 0, + "Columns": 0, + "Blocks": [ + { + "Type": "Paragraph", + "Inlines": [ + { + "Type": "Run", + "Text": "", + "Bold": false, + "Italic": false, + "FontSize": 10, + "Underline": false, + "Strikethrough": false + }, + { + "Type": "Run", + "Text": "r1c1", + "Bold": false, + "Italic": false, + "FontSize": 10, + "Underline": false, + "Strikethrough": false + } + ], + "TextAlignment": "Left", + "MarginTop": 0, + "MarginBottom": 0, + "MarginRight": 0, + "IsListItem": false, + "ListType": "None", + "HeadingLevel": 0, + "Indent": 0, + "IsQuote": false, + "ListLevel": 0, + "Rows": 0, + "Columns": 0 + }, + { + "Type": "Paragraph", + "Inlines": [ + { + "Type": "Run", + "Text": "second para in cell", + "Bold": false, + "Italic": false, + "FontSize": 10, + "Underline": false, + "Strikethrough": false + } + ], + "TextAlignment": "Left", + "MarginTop": 0, + "MarginBottom": 0, + "MarginRight": 0, + "IsListItem": false, + "ListType": "None", + "HeadingLevel": 0, + "Indent": 0, + "IsQuote": false, + "ListLevel": 0, + "Rows": 0, + "Columns": 0 + } + ] + } + ] + ], + "ColSpans": [ + [ + 2, + 0 + ], + [ + 1, + 1 + ] + ], + "RowSpans": [ + [ + 1, + 0 + ], + [ + 1, + 1 + ] + ] + }, + { + "Type": "Paragraph", + "Inlines": [ + { + "Type": "Run", + "Text": "inline ", + "Bold": false, + "Italic": false, + "FontSize": 10, + "Underline": false, + "Strikethrough": false + }, + { + "Type": "Table", + "Bold": false, + "Italic": false, + "FontSize": 10, + "Underline": false, + "Strikethrough": false, + "Table": { + "Type": "Table", + "MarginBottom": 10, + "IsListItem": false, + "HeadingLevel": 0, + "Indent": 0, + "IsQuote": false, + "ListLevel": 0, + "Rows": 1, + "Columns": 1, + "ColumnWidths": [ + 100 + ], + "RowHeights": [], + "Cells": [ + [ + { + "Type": "Paragraph", + "Inlines": [ + { + "Type": "Run", + "Text": "", + "Bold": false, + "Italic": false, + "FontSize": 10, + "Underline": false, + "Strikethrough": false + }, + { + "Type": "Run", + "Text": "in-line", + "Bold": false, + "Italic": false, + "FontSize": 10, + "Underline": false, + "Strikethrough": false + } + ], + "TextAlignment": "Left", + "MarginTop": 0, + "MarginBottom": 0, + "MarginRight": 0, + "IsListItem": false, + "ListType": "None", + "HeadingLevel": 0, + "Indent": 0, + "IsQuote": false, + "ListLevel": 0, + "Rows": 0, + "Columns": 0 + } + ] + ], + "ColSpans": [ + [ + 1 + ] + ], + "RowSpans": [ + [ + 1 + ] + ] + } + }, + { + "Type": "Run", + "Text": " tail", + "Bold": false, + "Italic": false, + "FontSize": 10, + "Underline": false, + "Strikethrough": false + } + ], + "TextAlignment": "Left", + "MarginTop": 0, + "MarginBottom": 0, + "MarginRight": 0, + "IsListItem": false, + "ListType": "Bullet", + "HeadingLevel": 0, + "Indent": 0, + "IsQuote": false, + "ListLevel": 0, + "Rows": 0, + "Columns": 0 + }, + { + "Type": "Image", + "MarginBottom": 10, + "IsListItem": false, + "HeadingLevel": 0, + "Indent": 0, + "IsQuote": false, + "ListLevel": 0, + "ImageRef": "C414CD0E204DE974F73753C7E28D7638E7B3691BB8B1A2BAB6B25BB7FED7CE77", + "Width": 24, + "Height": 24, + "Rows": 0, + "Columns": 0 + }, + { + "Type": "Divider", + "MarginBottom": 0, + "IsListItem": false, + "HeadingLevel": 0, + "Indent": 0, + "IsQuote": false, + "ListLevel": 0, + "Rows": 0, + "Columns": 0 + }, + { + "Type": "Paragraph", + "Inlines": [ + { + "Type": "Run", + "Text": "", + "Bold": false, + "Italic": false, + "FontSize": 10, + "Underline": false, + "Strikethrough": false + } + ], + "TextAlignment": "Left", + "MarginTop": 0, + "MarginBottom": 0, + "MarginRight": 0, + "IsListItem": false, + "ListType": "None", + "HeadingLevel": 0, + "Indent": 0, + "IsQuote": false, + "ListLevel": 0, + "Rows": 0, + "Columns": 0 + }, + { + "Type": "Paragraph", + "Inlines": [ + { + "Type": "Run", + "Text": "end", + "Bold": false, + "Italic": false, + "FontSize": 10, + "Underline": false, + "Strikethrough": false + } + ], + "TextAlignment": "Left", + "MarginTop": 0, + "MarginBottom": 0, + "MarginRight": 0, + "IsListItem": false, + "ListType": "None", + "HeadingLevel": 0, + "Indent": 0, + "IsQuote": false, + "ListLevel": 0, + "Rows": 0, + "Columns": 0 + } + ], + "Images": { + "C414CD0E204DE974F73753C7E28D7638E7B3691BB8B1A2BAB6B25BB7FED7CE77": { + "Data": "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==", + "MimeType": "image/png" + } + }, + "PageSetup": { + "PageSize": "A4", + "Orientation": "Portrait", + "ShowPageBoundaries": true, + "ShowPageNumbers": false + } +} \ No newline at end of file diff --git a/tests/AvaloniaRichEditor.Tests/LeanJsonFormatTests.cs b/tests/AvaloniaRichEditor.Tests/LeanJsonFormatTests.cs new file mode 100644 index 0000000..fd60c06 --- /dev/null +++ b/tests/AvaloniaRichEditor.Tests/LeanJsonFormatTests.cs @@ -0,0 +1,185 @@ +using System; +using System.IO; +using System.IO.Compression; +using System.Linq; +using System.Reflection; +using System.Text.Json; +using Avalonia.Headless.XUnit; +using AvaloniaRichEditor.Controls; +using AvaloniaRichEditor.Documents; +using AvaloniaRichEditor.Formatters; +using Xunit; + +namespace AvaloniaRichEditor.Tests; + +// The writer leaves out what every reader assumes (2026-10-01). A run spent ~90 bytes on "Italic":false, +// "Underline":false,…; Korean went out as \uXXXX; and every document was indented. Measured on tests/corpus: +// 240.6 KB -> 37.7 KB, 3,043 KB -> 378 KB. Nothing a reader does changed, and the 1.0-1.3 readers were checked +// against the new output directly: an OLD tree read 18 documents (the fixpoint set + corpus pages) written lean +// and produced exactly what it produces from its own output, as JSON and as .flow. +public class LeanJsonFormatTests +{ + private static FlowDocument Build(string name) + => (FlowDocument)typeof(FormatFixpointTests).GetMethod("Build", BindingFlags.NonPublic | BindingFlags.Static)!.Invoke(null, [name])!; + + private static FlowDocument OneParagraph(params Inline[] inlines) + { + var doc = new FlowDocument(); + doc.Blocks.Clear(); + var p = new Paragraph(); + p.Inlines.AddRange(inlines); + doc.Blocks.Add(p); + return doc; + } + + [Fact] + public void APlainRun_WritesItsText_AndNothingElse() + { + string json = DocumentSerializer.Serialize(OneParagraph(new Run { Text = "안녕" })); + Assert.Equal("{\"Version\":\"1.0\",\"Blocks\":[{\"Inlines\":[{\"Text\":\"안녕\"}]}]}", json); + // And reading it back fills in what was left out. + var p = (Paragraph)DocumentSerializer.Deserialize(json).Blocks[0]; + var r = (Run)p.Inlines[0]; + Assert.Equal((10.0, Avalonia.Media.FontWeight.Normal, Avalonia.Media.FontStyle.Normal), (r.FontSize, r.FontWeight, r.FontStyle)); + Assert.Null(r.TextDecorations); + Assert.Equal((0, ListKind.None, Avalonia.Media.TextAlignment.Left, 0.0, false), (p.HeadingLevel, p.ListType, p.TextAlignment, p.Indent, p.IsQuote)); + } + + // Letters stay as they are; what an HTML page is sensitive to is still escaped, so the JSON can be embedded. + [Fact] + public void KoreanIsWrittenAsItIs_AndHtmlSensitiveCharactersAreNot() + { + string json = DocumentSerializer.Serialize(OneParagraph(new Run { Text = "정보공시 & 'x'" })); + Assert.Contains("정보공시", json); + Assert.DoesNotContain("\\uC815", json); + Assert.DoesNotContain("", json); + Assert.DoesNotContain("&", json.Replace("\\u0026", "")); + Assert.Equal("정보공시 & 'x'", ((Run)((Paragraph)DocumentSerializer.Deserialize(json).Blocks[0]).Inlines[0]).Text); + } + + [Fact] + public void WhatDiffersFromTheDefault_IsWritten() + { + var doc = OneParagraph(new Run { Text = "b", FontWeight = Avalonia.Media.FontWeight.Bold, FontStyle = Avalonia.Media.FontStyle.Italic, FontSize = 12 }); + var p = (Paragraph)doc.Blocks[0]; + p.HeadingLevel = 2; p.ListType = ListKind.Ordered; p.ListLevel = 1; p.IsQuote = true; p.Indent = 20; + p.TextAlignment = Avalonia.Media.TextAlignment.Center; p.MarginRight = 5; p.MarginTop = 3; + string json = DocumentSerializer.Serialize(doc); + foreach (var field in new[] { "\"Bold\":true", "\"Italic\":true", "\"FontSize\":12", "\"HeadingLevel\":2", "\"ListType\":\"Ordered\"", + "\"ListLevel\":1", "\"IsQuote\":true", "\"Indent\":20", "\"TextAlignment\":\"Center\"", "\"MarginRight\":5", "\"MarginTop\":3" }) + Assert.Contains(field, json); + Assert.Equal(json, DocumentSerializer.Serialize(DocumentSerializer.Deserialize(json))); + } + + // Each block kind reads its own default for an absent bottom margin (paragraph 0, picture/table 10): what equals + // it is left out, anything else is written. + [Fact] + public void BlockMargins_AreLeftOutOnlyAtTheirOwnDefault() + { + var doc = new FlowDocument(); + doc.Blocks.Clear(); + doc.Blocks.Add(new Paragraph { Inlines = { new Run { Text = "p" } } }); + doc.Blocks.Add(new TableBlock(1, 1)); + doc.Blocks.Add(new TableBlock(1, 1) { MarginBottom = 0 }); + doc.Blocks.Add(new DividerBlock { MarginBottom = 10 }); + var back = DocumentSerializer.Deserialize(DocumentSerializer.Serialize(doc)); + Assert.Equal(10, back.Blocks.OfType().First().MarginBottom); + Assert.Equal(0, back.Blocks.OfType().Last().MarginBottom); + Assert.Equal(10, back.Blocks.OfType().Single().MarginBottom); + } + + [Fact] + public void OnlyAMergedTable_CarriesItsSpanGrids() + { + var doc = new FlowDocument(); + doc.Blocks.Clear(); + doc.Blocks.Add(new TableBlock(2, 2)); + Assert.DoesNotContain("ColSpans", DocumentSerializer.Serialize(doc)); + ((TableBlock)doc.Blocks[0]).MergeCells(0, 0, 0, 1); + string merged = DocumentSerializer.Serialize(doc); + Assert.Contains("\"ColSpans\"", merged); + Assert.Equal((2, 1), DocumentSerializer.Deserialize(merged).Blocks.OfType().Single().SpanOf(0, 0)); + } + + // The format as 1.0-1.3 wrote it — every field, indented — written by the tree before this change from the + // fixpoint suite's kitchen-sink document. Reading it must give what the document is. + [AvaloniaFact] + public void TheVerboseFormatEarlierVersionsWrote_StillReadsWhole() + { + string verbose = File.ReadAllText(Path.Combine(AppContext.BaseDirectory, "..", "..", "..", "Fixtures", "format-1.0-verbose-kitchen-sink.json")); + Assert.Contains("\"Italic\": false", verbose); // precondition: it is the old, verbose form + Assert.Equal(DocumentSerializer.Serialize(Build("kitchen-sink")), DocumentSerializer.Serialize(DocumentSerializer.Deserialize(verbose))); + } + + [AvaloniaFact] + public void APackagesDocumentJson_IsLeanToo() + { + using var ms = new MemoryStream(); + DocumentPackage.Save(Build("kitchen-sink"), ms); + ms.Position = 0; + using var zip = new ZipArchive(ms, ZipArchiveMode.Read); + using var reader = new StreamReader(zip.GetEntry("document.json")!.Open()); + string json = reader.ReadToEnd(); + Assert.DoesNotContain("\"Italic\":false", json); + Assert.DoesNotContain("\n", json); + } + + // NaN — a host's AutoTopMargin put on a paragraph — has no JSON spelling: writing it threw. + [Fact] + public void AParagraphWithAnAutoTopMargin_Serializes() + { + var doc = OneParagraph(new Run { Text = "x" }); + ((Paragraph)doc.Blocks[0]).MarginTop = Block.AutoTopMargin; + string? json = null; + Assert.Null(Record.Exception(() => json = DocumentSerializer.Serialize(doc))); + Assert.Equal(0, ((Paragraph)DocumentSerializer.Deserialize(json!).Blocks[0]).MarginTop); + } + + // ---- versions ------------------------------------------------------------------------------------------ + + // A newer MAJOR format would come in with what this reader does not know turned into empty paragraphs, and a + // save would write that over the file. The loading paths refuse it, like a damaged file. + [AvaloniaFact] + public async System.Threading.Tasks.Task ANewerMajorFormat_IsRefusedByTheLoadingPaths() + { + const string newer = "{\"Version\":\"2.0\",\"Blocks\":[{\"Inlines\":[{\"Text\":\"x\"}]}]}"; + var ed = new RichEditor(); + ed.LoadJson("{\"Version\":\"1.0\",\"Blocks\":[{\"Inlines\":[{\"Text\":\"kept\"}]}]}"); + Assert.Throws(() => ed.LoadJson(newer)); + Assert.Contains("kept", ed.GetPlainText()); // the open document is left alone + await Assert.ThrowsAsync(() => ed.LoadJsonAsync(newer)); // awaited: blocking would deadlock the UI thread + // The public Deserialize stays lenient, as it always was. + Assert.Equal("x", ((Paragraph)DocumentSerializer.Deserialize(newer).Blocks[0]).Text()); + } + + [AvaloniaTheory] + [InlineData("\"1.0\"")] + [InlineData("\"1.9\"")] + [InlineData("1")] // the legacy integers predate "1.0" + [InlineData("2")] + public void OlderAndSameMajorFormats_Load(string version) + { + var ed = new RichEditor(); + ed.LoadJson("{\"Version\":" + version + ",\"Blocks\":[{\"Inlines\":[{\"Text\":\"x\"}]}]}"); + Assert.Contains("x", ed.GetPlainText()); + } + + // Within the same major, a block type this reader does not know is read as text — and said so, since saving + // then drops it. + [Fact] + public void AnUnknownBlockType_IsReported() + { + RichEditorDiagnostics.Reset(); + Exception? seen = null; + void Handler(object? s, RichEditorFaultEventArgs e) { if (e.Exception is NotSupportedException) seen = e.Exception; } + RichEditorDiagnostics.Fault += Handler; + try + { + var doc = DocumentSerializer.Deserialize("{\"Version\":\"1.4\",\"Blocks\":[{\"Type\":\"Chart\",\"Inlines\":[{\"Text\":\"caption\"}]}]}"); + Assert.Equal("caption", ((Paragraph)doc.Blocks[0]).Text()); + } + finally { RichEditorDiagnostics.Fault -= Handler; } + Assert.NotNull(seen); + Assert.Contains("Chart", seen!.Message); + } +} From 6a8a4703c503e6d33b2cda9d36c4af0f87820d78 Mon Sep 17 00:00:00 2001 From: centwon Date: Thu, 1 Oct 2026 13:06:38 +0900 Subject: [PATCH 2/3] =?UTF-8?q?fix:=20=ED=95=9C=20=EB=AC=B8=EC=84=9C?= =?UTF-8?q?=EB=8A=94=20=EB=91=90=20=EC=97=90=EB=94=94=ED=84=B0=EC=97=90?= =?UTF-8?q?=EC=84=9C=20=EA=B0=99=EC=9D=80=20=EB=B0=94=EC=9D=B4=ED=8A=B8=20?= =?UTF-8?q?=E2=80=94=20=EB=A1=9C=EB=93=9C=20=EC=8B=9C=20run=20=ED=95=A9?= =?UTF-8?q?=EC=B9=98=EA=B8=B0=C2=B7=EC=83=89=20#AARRGGBB=C2=B7=ED=95=84?= =?UTF-8?q?=EB=93=9C=20=EC=88=9C=EC=84=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - JSON 로드도 같은 서식의 이웃 run을 합친다(HTML·RTF와 포트처럼) - 색은 Color.ToString()(이름·소문자) 대신 #AARRGGBB 대문자 — 사양대로, 포트와 같게 - Alt·VAlign 선언 순서를 포트와 맞춤 - Fixtures/format-1.0-interchange.json: 모든 필드를 쓰는 정본 문서, 포트도 같은 파일·같은 테스트 - 만든 모델과 비교하던 왕복 테스트 3개는 한 번 읽은 출력 기준으로 Co-Authored-By: Claude Opus 5.5 --- .../Formatters/DocumentSerializer.cs | 16 +++++-- .../Fixtures/format-1.0-interchange.json | 1 + .../FlowPackageTests.cs | 3 +- .../FullFidelityRoundTripTests.cs | 3 +- .../LeanJsonFormatTests.cs | 42 ++++++++++++++++++- 5 files changed, 58 insertions(+), 7 deletions(-) create mode 100644 tests/AvaloniaRichEditor.Tests/Fixtures/format-1.0-interchange.json diff --git a/src/AvaloniaRichEditor/Formatters/DocumentSerializer.cs b/src/AvaloniaRichEditor/Formatters/DocumentSerializer.cs index c27e483..b2598ea 100644 --- a/src/AvaloniaRichEditor/Formatters/DocumentSerializer.cs +++ b/src/AvaloniaRichEditor/Formatters/DocumentSerializer.cs @@ -187,6 +187,10 @@ internal static FlowDocument FromDto(FlowDocumentDto? dto, Dictionary - brush is ISolidColorBrush s ? s.Color.ToString() : null; + brush is ISolidColorBrush s ? $"#{s.Color.A:X2}{s.Color.R:X2}{s.Color.G:X2}{s.Color.B:X2}" : null; private static IBrush? StringToBrush(string? value) { @@ -722,13 +728,14 @@ internal class BlockDto public int? ListLevel { get; set; } // Image block + // Declaration order is the written order, and it matches the port's (WinUIRichEditor) so the two + // editors write one document as the same bytes. public string? ImageRef { get; set; } // v2: key into FlowDocumentDto.Images - public string? Alt { get; set; } // accessibility description; omitted when null (format unchanged) - public string? VAlign { get; set; } // cell vertical alignment; omitted when Top (the default) public string? ImageBase64 { get; set; } // v1 legacy: inline base64 (read fallback) public string? MimeType { get; set; } // of ImageBase64 bytes; absent in legacy docs => image/png public double? Width { get; set; } public double? Height { get; set; } + public string? Alt { get; set; } // accessibility description; omitted when null (format unchanged) // Table block public int? Rows { get; set; } // not written: readers rebuild it from Cells @@ -743,6 +750,7 @@ internal class BlockDto // Only emitted for multi-block or non-paragraph cells; plain one-paragraph cells use the legacy // single-paragraph DTO form for backward compatibility. public List? Blocks { get; set; } + public string? VAlign { get; set; } // cell vertical alignment; omitted when Top (the default) } internal class InlineDto @@ -763,11 +771,11 @@ internal class InlineDto // Inline image public string? ImageRef { get; set; } // v2: key into FlowDocumentDto.Images - public string? Alt { get; set; } // accessibility description; omitted when null public string? ImageBase64 { get; set; } // v1 legacy: inline base64 (read fallback) public string? MimeType { get; set; } // of ImageBase64 bytes; absent in legacy docs => image/png public double? Width { get; set; } public double? Height { get; set; } + public string? Alt { get; set; } // accessibility description; omitted when null // Inline table (Type == "Table", milestone B): the wrapped grid serialized as a block table DTO, so // nested tables / multi-block cells / spans all round-trip via the same recursive machinery. diff --git a/tests/AvaloniaRichEditor.Tests/Fixtures/format-1.0-interchange.json b/tests/AvaloniaRichEditor.Tests/Fixtures/format-1.0-interchange.json new file mode 100644 index 0000000..ca4696f --- /dev/null +++ b/tests/AvaloniaRichEditor.Tests/Fixtures/format-1.0-interchange.json @@ -0,0 +1 @@ +{"Version":"1.0","Blocks":[{"Inlines":[{"Text":"제목"}],"HeadingLevel":1},{"Inlines":[{"Text":"굵게","Bold":true,"Italic":true,"FontSize":12,"Foreground":"#FFFF0000","Background":"#8012ABCD","FontFamily":"Malgun Gothic","Underline":true,"Strikethrough":true},{"Text":"링크","NavigateUri":"https://example.com/"},{"Type":"Image","ImageRef":"4FF6AB670A58C14270E034E2090D9A432CAA263A14E0A25785386B0C12F880B5","Width":16,"Height":16,"Alt":"아이콘"}],"TextAlignment":"Center","LineSpacing":2,"MarginTop":4,"MarginBottom":6,"MarginRight":8,"Background":"#FFFFFF00","Indent":20,"IsQuote":true},{"Inlines":[{"Text":"항목"}],"ListType":"Ordered","ListLevel":1},{"Type":"Image","ImageRef":"4FF6AB670A58C14270E034E2090D9A432CAA263A14E0A25785386B0C12F880B5","Width":120,"Height":80,"Alt":"그림"},{"Type":"Table","ColumnWidths":[100,200],"RowHeights":[30,0],"Cells":[[{"Inlines":[{"Text":"병합"}],"VAlign":"Center"},{"Inlines":[{"Text":""}]}],[{"Type":"Cell","Blocks":[{"Inlines":[{"Text":"a"}]},{"Type":"Divider"},{"Inlines":[{"Text":"b"}]}],"VAlign":"Center"},{"Inlines":[{"Text":"d"}],"VAlign":"Bottom"}]],"ColSpans":[[2,0],[1,1]],"RowSpans":[[1,0],[1,1]]},{"Inlines":[{"Text":"앞"},{"Type":"Table","Table":{"Type":"Table","ColumnWidths":[50],"Cells":[[{"Inlines":[{"Text":"속"}]}]]}},{"Text":"뒤"}]},{"Type":"Divider","MarginBottom":10}],"Images":{"4FF6AB670A58C14270E034E2090D9A432CAA263A14E0A25785386B0C12F880B5":{"Data":"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR4nGP4z8DwHwAFAAH/iZk9HQAAAABJRU5ErkJggg==","MimeType":"image/png"}},"PageSetup":{"PageSize":"A4","Orientation":"Landscape","ShowPageBoundaries":true,"Header":"머리","Footer":"꼬리","ShowPageNumbers":true,"MarginLeft":20}} \ No newline at end of file diff --git a/tests/AvaloniaRichEditor.Tests/FlowPackageTests.cs b/tests/AvaloniaRichEditor.Tests/FlowPackageTests.cs index 8451d3f..ea229a2 100644 --- a/tests/AvaloniaRichEditor.Tests/FlowPackageTests.cs +++ b/tests/AvaloniaRichEditor.Tests/FlowPackageTests.cs @@ -49,7 +49,8 @@ public void RoundTrip_MatchesJsonSerialization() var doc = SampleDoc(); using var ms = SaveToStream(doc); var doc2 = DocumentPackage.Load(ms); - Assert.Equal(DocumentSerializer.Serialize(doc), DocumentSerializer.Serialize(doc2)); + // Against the JSON load, not the built model: loading joins equal neighbouring runs either way. + Assert.Equal(DocumentSerializer.Serialize(DocumentSerializer.Deserialize(DocumentSerializer.Serialize(doc))), DocumentSerializer.Serialize(doc2)); } [Fact] diff --git a/tests/AvaloniaRichEditor.Tests/FullFidelityRoundTripTests.cs b/tests/AvaloniaRichEditor.Tests/FullFidelityRoundTripTests.cs index 929ce78..d8d1e1d 100644 --- a/tests/AvaloniaRichEditor.Tests/FullFidelityRoundTripTests.cs +++ b/tests/AvaloniaRichEditor.Tests/FullFidelityRoundTripTests.cs @@ -142,7 +142,8 @@ public void Json_PreservesEveryStructure() [Fact] public void Json_IsIdempotentForTheWholeSink() { - string a = DocumentSerializer.Serialize(Sink()); + // From a file, not the built model: loading joins equal neighbouring runs, which the built sink has. + string a = DocumentSerializer.Serialize(DocumentSerializer.Deserialize(DocumentSerializer.Serialize(Sink()))); string b = DocumentSerializer.Serialize(DocumentSerializer.Deserialize(a)); Assert.Equal(a, b); } diff --git a/tests/AvaloniaRichEditor.Tests/LeanJsonFormatTests.cs b/tests/AvaloniaRichEditor.Tests/LeanJsonFormatTests.cs index fd60c06..fe9a4bb 100644 --- a/tests/AvaloniaRichEditor.Tests/LeanJsonFormatTests.cs +++ b/tests/AvaloniaRichEditor.Tests/LeanJsonFormatTests.cs @@ -101,6 +101,45 @@ public void OnlyAMergedTable_CarriesItsSpanGrids() Assert.Equal((2, 1), DocumentSerializer.Deserialize(merged).Blocks.OfType().Single().SpanOf(0, 0)); } + // ---- one document, one spelling, in both editors (the port writes the same) ------------------------------ + + // Color.ToString() spells a known colour by NAME ("Red") and anything else as lowercase hex; the format says + // #AARRGGBB, which is what the port writes. Both readers take either, so this is about the bytes. + [AvaloniaFact] + public void Colours_AreWrittenAsUppercaseArgbHex() + { + var red = new Avalonia.Media.Immutable.ImmutableSolidColorBrush(Avalonia.Media.Colors.Red); + var other = new Avalonia.Media.Immutable.ImmutableSolidColorBrush(Avalonia.Media.Color.FromArgb(0x80, 0x12, 0xAB, 0xCD)); + string json = DocumentSerializer.Serialize(OneParagraph(new Run { Text = "a", Foreground = red, Background = other })); + Assert.Contains("\"Foreground\":\"#FFFF0000\"", json); + Assert.Contains("\"Background\":\"#8012ABCD\"", json); + var r = (Run)((Paragraph)DocumentSerializer.Deserialize(json).Blocks[0]).Inlines[0]; + Assert.Equal(Avalonia.Media.Colors.Red, ((Avalonia.Media.ISolidColorBrush)r.Foreground!).Color); + } + + // A file may split one line into any number of runs; it loads as one model whatever the split (as HTML and + // RTF already did, and as the port does). + [AvaloniaFact] + public void EqualRunsInAFile_LoadAsOne() + { + const string split = "{\"Version\":\"1.0\",\"Blocks\":[{\"Inlines\":[{\"Text\":\"ab\"},{\"Text\":\"cd\"},{\"Text\":\"e\",\"Bold\":true},{\"Text\":\"f\",\"Bold\":true}]}]}"; + var p = (Paragraph)DocumentSerializer.Deserialize(split).Blocks[0]; + Assert.Equal(new[] { "abcd", "ef" }, p.Inlines.OfType().Select(r => r.Text)); + } + + // The interchange contract with the port: one document using every field, in the canonical form. The port + // (WinUIRichEditor) holds the same file and the same test, so a change to what either editor writes — a + // field's order, a colour's spelling, a default — fails in the repository that made it. + [AvaloniaFact] + public void TheInterchangeDocument_ReadsAndWritesBackByteForByte() + { + string path = Path.Combine(AppContext.BaseDirectory, "..", "..", "..", "Fixtures", "format-1.0-interchange.json"); + string canonical = File.ReadAllText(path); + string written = DocumentSerializer.Serialize(DocumentSerializer.Deserialize(canonical)); + if (written != canonical) File.WriteAllText(Path.Combine(Path.GetTempPath(), "interchange-actual.json"), written); + Assert.Equal(canonical, written); + } + // The format as 1.0-1.3 wrote it — every field, indented — written by the tree before this change from the // fixpoint suite's kitchen-sink document. Reading it must give what the document is. [AvaloniaFact] @@ -108,7 +147,8 @@ public void TheVerboseFormatEarlierVersionsWrote_StillReadsWhole() { string verbose = File.ReadAllText(Path.Combine(AppContext.BaseDirectory, "..", "..", "..", "Fixtures", "format-1.0-verbose-kitchen-sink.json")); Assert.Contains("\"Italic\": false", verbose); // precondition: it is the old, verbose form - Assert.Equal(DocumentSerializer.Serialize(Build("kitchen-sink")), DocumentSerializer.Serialize(DocumentSerializer.Deserialize(verbose))); + Assert.Equal(DocumentSerializer.Serialize(DocumentSerializer.Deserialize(DocumentSerializer.Serialize(Build("kitchen-sink")))), + DocumentSerializer.Serialize(DocumentSerializer.Deserialize(verbose))); } [AvaloniaFact] From 1f8b1ec0b0581ebd7a489e43e2c1dbd26d83e93d Mon Sep 17 00:00:00 2001 From: centwon Date: Thu, 1 Oct 2026 14:13:03 +0900 Subject: [PATCH 3/3] =?UTF-8?q?docs:=20=ED=95=9C=20=EB=AC=B8=EC=84=9C?= =?UTF-8?q?=EB=8A=94=20=EB=91=90=20=EC=97=90=EB=94=94=ED=84=B0=EC=97=90?= =?UTF-8?q?=EC=84=9C=20=EA=B0=99=EC=9D=80=20=EB=B0=94=EC=9D=B4=ED=8A=B8=20?= =?UTF-8?q?=E2=80=94=20=EB=AA=85=EC=84=B8=C2=B7CHANGELOG?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 로드 시 run 합치기, 색 #AARRGGBB 대문자(이전 판의 이름·소문자 표기 기록), 행 없는 표, 교환 정본 파일 Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 11 +++++++++++ docs/DOCUMENT_FORMAT.md | 7 ++++++- 2 files changed, 17 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ef559b0..ce83c9d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,17 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Changed — one document is the same bytes in both editors (2026-10-01) + +- JSON and `.flow` loads join equal-format neighbouring runs, as the HTML and RTF readers already did and as the port + does: a file split one line into any number of runs, and the same content now loads as the same model. +- Colours are written `#AARRGGBB` in upper case, as the format states and the port writes. `Color.ToString()` wrote a + known colour's NAME (`"Red"`) and anything else in lower case. Every reader takes either. +- `Alt` and `VAlign` are written where the port writes them (field order is the written order). +- `tests/…/Fixtures/format-1.0-interchange.json` uses every field in the canonical form. The port holds the same file + and the same test — read it, write it back, byte for byte — so a change to what either editor writes fails in the + repository that made it. + ### Changed — the JSON writer leaves out what every reader assumes (2026-10-01) **The schema is unchanged** (still `"1.0"`), and every reader since 1.0 reads the new output exactly as before — diff --git a/docs/DOCUMENT_FORMAT.md b/docs/DOCUMENT_FORMAT.md index ec7956b..aed6253 100644 --- a/docs/DOCUMENT_FORMAT.md +++ b/docs/DOCUMENT_FORMAT.md @@ -11,6 +11,8 @@ HTML 입출력(`ToHtml`/`LoadHtml`)은 **교환용**이며 손실이 있을 수 > 구현 소스: [`DocumentSerializer.cs`](../src/AvaloniaRichEditor/Formatters/DocumentSerializer.cs), [`DocumentPackage.cs`](../src/AvaloniaRichEditor/Formatters/DocumentPackage.cs). 이 명세와 코드가 다르면 코드가 우선이고, 이 문서를 고친다. +> **WinUI 포트와 같은 형식, 같은 바이트**: WinUIRichEditor는 같은 형식을 읽고 쓰고, 2026-10-01부터 한 문서를 **같은 바이트로** 쓴다(둘 다 로드 시 같은 서식의 이웃 run을 합치고, 색은 `#AARRGGBB` 대문자, 필드 순서도 같다). 두 레포가 같은 정본 파일 [`format-1.0-interchange.json`](../tests/AvaloniaRichEditor.Tests/Fixtures/format-1.0-interchange.json)(모든 필드를 쓰는 문서)을 두고 "읽고 다시 쓰면 바이트 동일"을 각자 테스트한다 — 어느 쪽이 쓰는 방식을 바꾸면 바꾼 쪽에서 빨개진다. + --- ## 1. 문서 모델 개요 @@ -49,6 +51,7 @@ FlowDocument - **판독기가 없을 때 가정하는 값과 같은 필드는 쓰지 않는다**(아래 필드 표의 "읽기 기본값"). `Type`이 `"Paragraph"`/`"Run"`이면 `Type`도 생략한다. 이 규칙으로 쓴 문서는 1.0 이후의 **모든 판독기가 그대로 읽는다**(구 판독기로 직접 확인함 — §4). - **판독기는 모르는 필드를 무시해야 한다**(System.Text.Json 기본 동작). 전방 호환의 근거. +- **같은 서식의 이웃 run은 로드할 때 합친다**(HTML·RTF 가져오기와 같은 규칙, 2026-10-01부터 JSON·`.flow`도). 파일이 한 줄을 몇 개의 run으로 나눴든 같은 모델이 된다 — 텍스트와 오프셋은 그대로다. - **손상된 입력은 예외다.** 유효하지 않은 JSON은 `JsonException`, `Blocks`가 없는 JSON(다른 앱의 파일)은 에디터의 로드 경로 (`LoadJson`·`LoadJsonAsync`·`LoadPackageAsync`)에서 `JsonException`이다 — 빈 문서로 읽으면 호스트가 원본을 덮어쓰게 되기 때문. 리터럴 `null`은 빈 문서다. @@ -92,6 +95,7 @@ FlowDocument | `2` | 문서 수준 `Images` 풀 도입. 블록은 `ImageRef`(SHA-256 hex 키)로 참조. 동일 이미지 1회 저장 | v1 필드(`ImageBase64`, `IsListItem`)는 읽기 폴백 유지 | | `"1.0"` (현재) | 안정 기준선. 정수→SemVer 표기 전환 + 이미지 풀 + **글자 크기 pt** + **비례 줄 간격(`LineSpacing`)** | 레거시 정수 버전 문서를 그대로 읽음 | | `"1.0"` (2026-10-01 작성기) | **스키마 변경 없음.** 작성기가 기본값 필드·기본 `Type`·1뿐인 병합 격자·`Rows`/`Columns`를 생략하고, 들여쓰지 않고, 한글을 이스케이프하지 않는다. 같은 문서가 4~12분의 1(corpus 실측 240.6 → 37.7 KB, 3,043 → 378 KB) | 1.0 이후 모든 판독기가 그대로 읽음 | +| `"1.0"` (2026-10-01, 같은 날 뒤) | **스키마 변경 없음.** 로드 시 같은 서식의 run을 합침. 색을 `#AARRGGBB` 대문자로 씀(이전 판은 알려진 색을 이름 `"Red"`으로, 나머지를 소문자로 썼다). `Alt`·`VAlign` 필드 순서를 포트와 맞춤 | 1.0 이후 모든 판독기가 그대로 읽음 | - 풀 키 = **원본 인코딩 바이트의 SHA-256, 대문자 16진 문자열** (`Convert.ToHexString`). - 로드 시 풀 항목은 한 번만 디코드되고, 같은 키를 참조하는 모든 블록이 **동일한 `byte[]` 인스턴스를 공유**한다. @@ -150,6 +154,7 @@ FlowDocument | `ColSpans`, `RowSpans` | int[][]? | 셀 병합 격자(밀집, `Cells`와 같은 크기). 앵커 셀=병합 칸 수(평범한 셀은 1), **가려진(covered) 셀=0**. 없으면 전부 1×1 — **병합이 없는 표는 쓰지 않는다** | 가져오기 상한(신뢰할 수 없는 입력, 2026-10-01 라운드35): 표는 **1,000열·25만 셀**까지 읽고 넘는 셀은 버린다(가장 넓은 행이 모든 행을 채우는 증폭 방지). +행이 하나도 없는 표(`"Cells": []`)는 표가 아니다 — 블록이든 인라인이든 읽지 않는다(두 에디터 공통). 병합 규약: 병합 영역의 왼쪽-위 셀이 **앵커**이며 `ColSpans[r][c]`/`RowSpans[r][c]`에 병합 크기를 갖는다. 영역 내 나머지 칸은 두 배열 모두 0으로 마킹되고, 그 칸의 `Cells` 내용은 무시된다(빈 문단 권장). 격자는 항상 직사각형이어야 한다. @@ -200,7 +205,7 @@ FlowDocument ### 2.5 색상 문자열 -`Avalonia.Media.Color.ToString()` 출력 = **`#AARRGGBB`** 16진 문자열(예: 불투명 빨강 `#ffff0000`). 읽기는 `Color.Parse`이므로 `#RRGGBB`, 명명 색상(`"Red"`)도 허용되지만, **쓰기는 항상 `#AARRGGBB`로 통일**한다. 파싱 실패 시 null(기본색) 처리. 단색(SolidColorBrush)만 직렬화된다 — 그라데이션 등은 저장 시 탈락. +**`#AARRGGBB`** 대문자 16진 문자열(예: 불투명 빨강 `#FFFF0000`). 읽기는 `Color.Parse`이므로 `#RRGGBB`, 소문자, 명명 색상(`"Red"`)도 허용되지만, **쓰기는 항상 `#AARRGGBB` 대문자**다(WinUI 포트와 같은 표기). 2026-10-01 이전 판은 `Color.ToString()`을 그대로 써서 알려진 색을 이름(`"Red"`)으로, 나머지를 소문자 16진으로 기록했다 — 두 판독기 모두 그 표기를 읽는다. 파싱 실패 시 null(기본색) 처리. 단색(SolidColorBrush)만 직렬화된다 — 그라데이션 등은 저장 시 탈락. ### 2.6 예제