Skip to content
Merged
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
10 changes: 10 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,16 @@ go red (the depth ones take the test host down, which is the point).
million `<ul>`s. It is read as 0–8, as RTF already did, and written clamped.
- `<script>`/`<style>` inside a paragraph's content came in as text.

**Follow-up, the same day — the four the audit had left for later, measured and fixed**
- **A JPEG could slip past the pixel cap.** Fill bytes before a marker — legal JPEG — were read as a segment
length, the frame header was skipped, the size came back unknown, and a picture of unknown size is decoded
whatever it claims. Markers without a length field are stepped over too.
- **An RTF paste with many vertical merges froze**: 20,000 two-row merges (1.4 MB) took 29.6 s, because each
merge scanned the whole grid. A range of plain cells cannot straddle a merge, so it no longer scans: 0.38 s,
and 2.7 s at the import bound (500×500, 8.7 MB).
- Merging into a cell that starts with a nested table put the merged text into that table's first cell.
- A font name containing `;` ended its RTF font table entry early; the `;` is dropped from the name.

### Changed — the layout cache holds what is on screen, not the whole document (2026-09-30)

Measure, pagination and every walk that only steps past a paragraph (hit-tests, link lookup, block lookup)
Expand Down
12 changes: 7 additions & 5 deletions Project_Roadmap.md
Original file line number Diff line number Diff line change
Expand Up @@ -46,19 +46,21 @@ PR #48·#49·#50), 실기 후속도 PR #52로 들어왔다. 남은 것은 게시
게시 절차: `<Version>` → Unshipped를 Shipped로 이관 → CHANGELOG `[Unreleased]` → 버전 → README 배지(en/ko) → PR로 3-OS CI →
`v1.3.0` 태그 push(**사람이 실행**, Trusted Publishing) → **nuget.org 패키지 페이지 육안 확인**.

**1a. 라운드35 · 전수 감사 (2026-10-01, PR 대기)** — `src` 약 19,500줄을 모델 → 포매터 → 컨트롤 순으로 읽었다. 결함 **16건**
**1a. 라운드35 · 전수 감사 (2026-10-01, PR #59 머지)** — `src` 약 19,500줄을 모델 → 포매터 → 컨트롤 순으로 읽었다. 결함 **16건**
(프로세스 종료 3·데이터 소실 3·보안 1), 전부 빨강 먼저 → 수정 → 개별 반증. 대부분 이전 라운드가 재지 않은 **입력의 크기·깊이** 축이다. 테스트: `Round35AuditTests`.
- 치명(프로세스 종료, catch 불가): HTML `<div>` 2,000겹(11 KB) 붙여넣기, 중첩 표 400단(RTF `\itap` 무제한) 렌더 — 스택 오버플로.
수정: DOM 128단 아래는 글자로 접기(비재귀) + HtmlAgilityPack id 색인 끔(그 제거가 재귀) · RTF 중첩 32단.
- 데이터 소실: 인라인 표 셀로 끝나는 선택 삭제가 그 줄 전체(표 포함)를 지움 · 호스트가 `Document`를 대입해도 undo가 옛 파일로 돌아감 ·
인라인 표 섞인 한 문단 붙여넣기가 표를 버림.
- 보안: 파일의 그림 MIME이 내보낸 HTML `<img src>`에 그대로 → 속성 주입.
- 메모리·시간: 픽셀 폭탄(1.5 MB → 1.6 GB) · 표 증폭(행 × 가장 넓은 행) · `.flow` 미참조 항목 전부 풀기 · RTF `IndexOf` O(n²).
- **사용자 확인 필요**: 그림 디코드 상한 **1억 픽셀**(넘으면 붙여넣기 거절, 문서 안 것은 바이트 보존·미표시) — 200 MP 폰 사진은 걸린다.
- **사용자 결정(10-01, 그대로 확정)**: 그림 디코드 상한 **1억 픽셀**(넘으면 붙여넣기 거절, 문서 안 것은 바이트 보존·미표시) — 200 MP 폰 사진은 걸린다.
가져오는 표 상한 1,000열 / 25만 셀, HTML DOM 128단, RTF 표 중첩 32단, `.flow` 그림 항목 256 MB.
- 기각(실측): `file:///%5C%5Chost` UNC 우회(경로가 `\\\host`가 되어 SMB 안 탐) · 링크 셸 실행(`http(s)`만 연다) · 셀 문단 여백의 서명 누락(셀 측정이 안 씀).
- 남긴 것(미수정, 저): JPEG 헤더 스캔이 채움 바이트에 약함 · 앵커 셀이 중첩 표로 시작하면 병합한 글자가 그 안으로 · RTF 글꼴명의 `;`가 글꼴 표를 깸 ·
RTF 병합 플래그가 셀마다 `MergeCells`(격자 전체 스캔)를 불러 적대적 입력에서 느림(상한 25만 셀로 유한).
- 기각(실측): `file:///%5C%5Chost` UNC 우회(경로가 `\\\host`가 되어 SMB 안 탐) · 링크 셸 실행(`http(s)`만 연다) · 셀 문단 여백의 서명 누락(셀 측정이 안 씀) ·
구분선 `Indent`가 JSON에 없음(메뉴로 못 바꾸고 렌더링도 안 씀 — 보이는 차이 없음).
- 후속(같은 날, 전부 확정·수정): **JPEG 채움 바이트로 1억 픽셀 상한 우회**(크기가 (0,0)으로 읽힘) · 앵커 셀이 중첩 표로 시작하면 병합한 글자가
그 표 안으로 · RTF 글꼴명의 `;`가 글꼴 표 항목을 끊음 · RTF 세로 병합 2만 개(1.4 MB)에 **29.6 s** 멈춤 → 0.38 s(`MergeCells`가 1×1 칸만인
범위에서는 격자 전체 스캔을 건너뜀). 상한 끝(500×500, 8.7 MB)은 2.7 s.
- 포트(WinUIRichEditor)에 같은 결함이 있는지는 미측정 — 포매터가 같은 코드라 대부분 있을 것으로 본다.

**2. 라운드34 · 테스트가 닿지 않던 파일 감사 (2026-09-23, 완료 — PR #53 머지, 포트는 WinUIRichEditor PR #52 머지)** — 선정 기준: 파일에 선언된 메서드 이름이
Expand Down
5 changes: 5 additions & 0 deletions src/AvaloniaRichEditor/Documents/ImageInfo.cs
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,11 @@ public static (double Width, double Height) GetPixelSize(byte[] b)
{
if (b[i] != 0xFF) { i++; continue; }
byte marker = b[i + 1];
// A marker may be preceded by any number of 0xFF fill bytes, and a few markers carry no length.
// Reading either as a segment length skipped past the frame header, and a picture whose size could
// not be read was decoded whatever it claimed — the pixel cap's way round (round 35).
if (marker == 0xFF) { i++; continue; }
if (marker == 0x01 || (marker >= 0xD0 && marker <= 0xD9)) { i += 2; continue; }
// SOF0..SOF15 except DHT(C4)/JPG(C8)/DAC(CC) carry frame dimensions.
if (marker >= 0xC0 && marker <= 0xCF && marker != 0xC4 && marker != 0xC8 && marker != 0xCC)
return (BE16(b, i + 7), BE16(b, i + 5));
Expand Down
24 changes: 22 additions & 2 deletions src/AvaloniaRichEditor/Documents/TableBlock.cs
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
using System;
using System.Collections.Generic;
using System.Linq;

namespace AvaloniaRichEditor.Documents;

Expand Down Expand Up @@ -313,7 +314,11 @@ public void MergeCells(int r0, int c0, int r1, int c1)
// formatter, and enough of them leave a table with no logical cells at all. A covered slot
// reports (0,0) and is therefore skipped here: its anchor is elsewhere in the grid and pulls
// the range over on its own iteration.
bool grew = true;
// Every pass scans the whole grid, and the RTF reader merges once per flagged cell: 20,000 two-row merges in
// a 200x200 table (1.4 MB of RTF) took 30 s (round 35). A range of plain cells alone cannot straddle a merge —
// one reaching into it would leave a covered cell, or an anchor spanning more than one, inside it — so then
// the scan is skipped.
bool grew = !AllPlain(r0, c0, r1, c1);
while (grew)
{
grew = false;
Expand All @@ -339,7 +344,14 @@ public void MergeCells(int r0, int c0, int r1, int c1)
for (int c = c0; c <= c1; c++) { ColSpans[r][c] = 1; RowSpans[r][c] = 1; }

var anchorCell = Cells[r0][c0];
var anchor = anchorCell.Para;
// The anchor cell's OWN first paragraph. Para descends into a leading nested table, so the merged text went
// into that table's first cell instead of the cell being merged into (round 35).
var anchor = anchorCell.Blocks.OfType<Paragraph>().FirstOrDefault();
if (anchor == null)
{
anchor = new Paragraph { Inlines = { new Run { Text = "" } }, Parent = anchorCell };
anchorCell.Blocks.Add(anchor);
}
for (int r = r0; r <= r1; r++)
for (int c = c0; c <= c1; c++)
{
Expand Down Expand Up @@ -386,6 +398,14 @@ public void MergeCells(int r0, int c0, int r1, int c1)
StampCovered(r0, c0, c1 - c0 + 1, r1 - r0 + 1);
}

private bool AllPlain(int r0, int c0, int r1, int c1)
{
for (int r = r0; r <= r1; r++)
for (int c = c0; c <= c1; c++)
if (SpanOf(r, c) != (1, 1)) return false;
return true;
}

/// Splits a merged anchor back into 1×1 cells (covered cells become empty anchors).
public void UnmergeCell(int r, int c)
{
Expand Down
4 changes: 3 additions & 1 deletion src/AvaloniaRichEditor/Formatters/RtfDocumentFormatter.cs
Original file line number Diff line number Diff line change
Expand Up @@ -1193,8 +1193,10 @@ public string Build(FlowDocument doc)
var sb = new StringBuilder();
sb.Append(@"{\rtf1\ansi\ansicpg1252\deff0");
sb.Append(@"{\fonttbl");
// A ';' ends a font table entry and RTF has no escape for one inside a name: "A;B" came out as the font "A"
// followed by stray text (round 35). It goes, as the HTML writer drops quotes from a family.
for (int i = 0; i < _fonts.Count; i++)
sb.Append($@"{{\f{i}\fnil ").Append(EscapeText(_fonts[i].Length == 0 ? "Default" : _fonts[i])).Append(";}");
sb.Append($@"{{\f{i}\fnil ").Append(EscapeText(_fonts[i].Length == 0 ? "Default" : _fonts[i].Replace(";", ""))).Append(";}");
sb.Append('}');
sb.Append(@"{\colortbl;");
foreach (var c in _colors) sb.Append($@"\red{c.R}\green{c.G}\blue{c.B};");
Expand Down
90 changes: 90 additions & 0 deletions tests/AvaloniaRichEditor.Tests/Round35AuditTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -419,4 +419,94 @@ public void AssigningANewDocument_StartsANewHistory()
Assert.Equal("NEW FILE?", host.Editor.GetPlainText());
Assert.True(host.Editor.CanUndo);
}

// ---- the four left for later, fixed the same day ------------------------------------------------------------

// Fill bytes before a marker (legal JPEG) were read as a segment length and skipped the frame header: the size
// came back (0, 0), and a picture of unknown size is decoded whatever it claims — round the pixel cap.
[Fact]
public void AJpegWithFillBytesBeforeItsFrameHeader_StillGivesItsSize()
{
var b = new byte[] { 0xFF, 0xD8, 0xFF, 0xFF, 0xFF, 0xC0, 0x00, 0x11, 0x08, 0xC3, 0x50, 0xEA, 0x60, 0x03, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0 };
Assert.Equal((60000.0, 50000.0), ImageInfo.GetPixelSize(b));
Assert.True(ImageInfo.TooLargeToDecode(b));
}

[Fact]
public void AJpegWithAMarkerThatHasNoLength_StillGivesItsSize()
{
// SOI, then RST0 (no length field), then SOF0 claiming 16 x 8.
var b = new byte[] { 0xFF, 0xD8, 0xFF, 0xD0, 0xFF, 0xC0, 0x00, 0x11, 0x08, 0x00, 0x08, 0x00, 0x10, 0x03, 0, 0, 0, 0, 0, 0, 0, 0 };
Assert.Equal((16.0, 8.0), ImageInfo.GetPixelSize(b));
}

// TableCell.Para descends into a leading nested table, so merged text went into THAT table's first cell.
[Fact]
public void MergingIntoACellThatStartsWithANestedTable_KeepsTheTextInTheCell()
{
var tb = new TableBlock(1, 2);
var anchor = tb.Cells[0][0];
var nested = new TableBlock(1, 1);
anchor.Blocks.Insert(0, nested); // [nested table, empty paragraph] — how the RTF reader builds such a cell
tb.Cells[0][1].Para.Inlines.Add(new Run { Text = "moved" });

tb.MergeCells(0, 0, 0, 1);

static bool Holds(TableCell c) => c.Blocks.OfType<Paragraph>().Any(p => p.Inlines.OfType<Run>().Any(r => r.Text == "moved"));
Assert.True(Holds(anchor));
Assert.False(Holds(nested.Cells[0][0]));
}

[Fact]
public void AFontNameWithASemicolon_DoesNotEndItsFontTableEntry()
{
var doc = new FlowDocument();
doc.Blocks.Add(new Paragraph { Inlines = { new Run { Text = "x", FontFamily = "A;B" }, new Run { Text = "y", FontFamily = "C" } } });
string rtf = RtfDocumentFormatter.Write(doc);
string fonttbl = rtf.Substring(rtf.IndexOf(@"{\fonttbl", StringComparison.Ordinal));
fonttbl = fonttbl.Substring(0, fonttbl.IndexOf(@"{\colortbl", StringComparison.Ordinal));
Assert.DoesNotContain("A;B", fonttbl);
Assert.Contains(@"\f1\fnil AB;}", fonttbl);
Assert.Contains(@"\f2\fnil C;}", fonttbl); // the entry after it is where it should be
}

// Every MergeCells call scanned the whole grid, and the RTF reader makes one per flagged cell: 20,000 two-row
// merges in a 200x200 table (1.4 MB of RTF) froze the paste for 30 s. Now ~0.4 s; the bound is loose on purpose
// (slow CI machines), since what it has to catch is the quadratic one.
[Fact]
public void ManyVerticalMergesInRtf_ParseInLinearTime()
{
const int n = 200;
var sb = new StringBuilder(@"{\rtf1\ansi ");
for (int r = 0; r < n; r++)
{
sb.Append(@"\trowd");
for (int c = 1; c <= n; c++) sb.Append(r % 2 == 0 ? @"\clvmgf" : @"\clvmrg").Append(@"\cellx").Append(c * 100);
for (int c = 0; c < n; c++) sb.Append(@"\pard\intbl x\cell");
sb.Append(@"\row ");
}
sb.Append(@"\pard end\par}");
var sw = System.Diagnostics.Stopwatch.StartNew();
var tb = RtfDocumentFormatter.Parse(sb.ToString()).Blocks.OfType<TableBlock>().Single();
Assert.True(sw.Elapsed.TotalSeconds < 10, $"{sw.Elapsed.TotalSeconds:F1} s");
Assert.Equal((1, 2), tb.SpanOf(0, 0)); // precondition: the merges really happened
}

// The fast path's premise: a range of plain cells can still sit next to a merge, and must not absorb it.
[Fact]
public void MergingPlainCellsBesideAMerge_LeavesThatMergeAlone()
{
var tb = new TableBlock(3, 3);
tb.MergeCells(0, 0, 1, 0); // column 0, rows 0-1
tb.MergeCells(0, 1, 0, 2); // row 0, columns 1-2: plain cells beside the first merge
Assert.Equal((1, 2), tb.SpanOf(0, 0));
Assert.Equal((2, 1), tb.SpanOf(0, 1));
tb.MergeCells(1, 1, 2, 1); // plain cells below the second merge
Assert.Equal((1, 2), tb.SpanOf(1, 1));
Assert.Equal((2, 1), tb.SpanOf(0, 1));
// And a range that DOES touch a merge still grows over it: (2,1) is covered by the rows 1-2 merge, which
// reaches the column-0 merge, which reaches the row-0 one — the whole grid.
tb.MergeCells(2, 0, 2, 1);
Assert.Equal((3, 3), tb.SpanOf(0, 0));
}
}
Loading