diff --git a/CHANGELOG.md b/CHANGELOG.md
index 41474f9..c144242 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -6,6 +6,17 @@ and follows [Semantic Versioning](https://semver.org/).
## [Unreleased]
+### 다른 앱에서 보이는 HTML — 상류 라운드 9 백포트 (2026-09-24)
+
+상류가 2026-08-08에 브라우저로 **측정**해서 고친 HTML 결함 중 두 건이 이 포트에 없었다. 둘 다 이 편집기의 HTML
+읽기로는 완벽하게 돌아오므로(자기 출력에 관대하다) 왕복 테스트로는 보이지 않는다.
+
+- **빈 줄이 브라우저에서 보이지 않았다.** 빈 문단(과 빈 목록 항목)을 내용 없는 `
`로 써서 높이가 0이었다. 이제
+ `
`을 함께 쓰고, 이 편집기는 표식(`data-are-empty`)이 있으면 그 `
`을 내용으로 읽지 않는다 — 한 줄이
+ 두 줄로 불어나지 않는다. 표식 없는 외부 HTML의 빈 요소는 전처럼 버리고, 외부의 `
`은 전처럼 빈 줄이다.
+- **셀 속 그림이 캡션 아래가 아니라 옆에 붙었다.** 셀에 문단 하나와 그림이 있으면 문단을 태그 없이 썼고, `
`는
+ 인라인이라 글자 줄에 붙었다. 셀에 그림이 있으면 문단을 요소로 쓴다. 문단 하나뿐인 셀은 그대로다.
+
### 미게시 변경분 감사 — 결함 3건 (2026-09-24)
1.2.0 이후 들어온 변경을 감사해 찾았다. 셋 다 상류에도 있어 같은 수정을 상류에 옮겼다.
diff --git a/Project_Roadmap.md b/Project_Roadmap.md
index ee98920..9131832 100644
--- a/Project_Roadmap.md
+++ b/Project_Roadmap.md
@@ -99,8 +99,10 @@ WinUI 3 + Win2D 리치텍스트 에디터. `AvaloniaRichEditor`의 WinUI 포트
하나(알려진 폴백, 1.2.0과 같음).
- ~~**실기 확인**~~ → **2026-09-24 사용자 확인, 문제 없음**: 여백 픽커 5단계(레이블·쪽 여백), 쪽 보기의 표 선택
왼쪽 선·그림 선택 테두리(바깥)·쪽 맨 위 그림 윗선, RTF 저장→열기 뒤 픽커 "보통".
-- **상류와 어긋난 곳(새로 확인)**: 상류는 빈 줄을 HTML로 쓸 때 `
`을 넣어 브라우저에서 줄이 보이게 했다(상류 실측)
- (`HtmlExternalRenderingTests`). 포트는 표식만 써서 **브라우저에서는 빈 줄이 안 보일 수 있다** — 옮길지 측정 후 결정.
+- ~~**상류와 어긋난 곳**~~ → **2026-09-24 옮김**: 상류 라운드 9(2026-08-08)의 HTML 수정 4건 중 포트에 없던 2건 —
+ 빈 줄/빈 목록 항목의 `
`(표식이 있으면 읽을 때 버림), 셀에 그림이 있으면 문단을 요소로. 나머지 2건(RTF 가로 병합
+ 기하 표기, RTF 용지)은 이미 있었다. `HtmlExternalRenderingTests`(8), 반증 3종, 퍼즈 20,000시드 통과.
+ ⚠ **실기**: 빈 줄을 복사해 Word·HWP에 붙였을 때 한 줄로 들어가는지(두 줄 아님)는 사람이 봐야 한다.
### 클립보드 경합 재시도 (2026-09-24, PR #55)
클립보드 기록·관리자가 변경 직후 클립보드를 잠깐 연다(`CLIPBRD_E_CANT_OPEN`). 모든 호출부가 이 오류를 삼켜
diff --git a/src/WinUIRichEditor/Formatters/HtmlDocumentFormatter.cs b/src/WinUIRichEditor/Formatters/HtmlDocumentFormatter.cs
index 588c422..3751121 100644
--- a/src/WinUIRichEditor/Formatters/HtmlDocumentFormatter.cs
+++ b/src/WinUIRichEditor/Formatters/HtmlDocumentFormatter.cs
@@ -361,7 +361,11 @@ void TakeSpace()
pre: name == "pre"); // keeps its whitespace/newlines verbatim
// Empty elements are dropped — foreign HTML uses them for spacing — unless this export
// marked one as a blank line the author actually typed (see data-are-empty).
- if (p.Inlines.Count > 0 || child.GetAttributeValue("data-are-empty", "") == "1")
+ bool markedEmpty = child.GetAttributeValue("data-are-empty", "") == "1";
+ // A marked paragraph carries a
so outside renderers give it a line; that
is the blank
+ // line's rendering, not its content, and reading it back would turn one blank line into two.
+ if (markedEmpty) p.Inlines.Clear();
+ if (p.Inlines.Count > 0 || markedEmpty)
flow.Blocks.Add(p);
}
else
@@ -427,10 +431,12 @@ private static void ParseList(HtmlNode listNode, FlowDocument flow, ListKind kin
ApplyBlockLeafFormat(child, "li", p);
ParseInlines(child, p, uri: linkUri, inLink: !string.IsNullOrEmpty(linkUri));
// An empty item is dropped like any empty element — unless our export marked it as a blank item
- // the author made (data-are-empty, as for paragraphs). Dropped regardless, a blank numbered item
- // vanished on the first round trip, and the items either side could merge into one list on the
- // second and lose a marker (fuzz seed 8178, 2026-09-24).
- if (p.Inlines.Count > 0 || child.GetAttributeValue("data-are-empty", "") == "1") flow.Blocks.Add(p);
+ // the author made (data-are-empty, as for paragraphs), and then its
is rendering, not content.
+ // Dropped regardless, a blank numbered item vanished on the first round trip, and the items either
+ // side could merge into one list on the second and lose a marker (fuzz seed 8178, 2026-09-24).
+ bool markedEmpty = child.GetAttributeValue("data-are-empty", "") == "1";
+ if (markedEmpty) p.Inlines.Clear();
+ if (p.Inlines.Count > 0 || markedEmpty) flow.Blocks.Add(p);
// A sublist nested INSIDE the item (the shape most other producers emit) still follows it.
foreach (var nested in child.ChildNodes.Where(n => n.Name.Equals("ul", StringComparison.OrdinalIgnoreCase) || n.Name.Equals("ol", StringComparison.OrdinalIgnoreCase)))
@@ -1127,6 +1133,12 @@ private static void EmitParagraphElement(StringBuilder sb, Paragraph p, bool new
// reattachment would otherwise grab whatever paragraph precedes it.
for (int i = 0; i < p.Inlines.Count; i++)
EmitInline(sb, p.Inlines[i], i == 0, i == p.Inlines.Count - 1, tag[0] == 'h' ? p.HeadingLevel : 0);
+ // The marker tells only THIS reader about the blank line. An element with no content has zero height, so
+ // in a browser the author's blank line (or blank list item) was invisible — upstream measured the gap
+ // across it as the same 16px as between any two paragraphs (round 9; ported 2026-09-24). The
gives it
+ // a line everywhere else — it is what contenteditable editors emit — and the reader drops it when the
+ // marker is present, so one blank line does not become two.
+ if (p.Inlines.Count == 0) sb.Append("
");
sb.Append($"{tag}>");
if (newlineAfter) sb.Append('\n');
}
@@ -1208,10 +1220,14 @@ private static void EmitTable(StringBuilder sb, TableBlock tb, bool asInline = f
// and read those correctly (foreign Word tables with bulleted cells prove it). A lone plain
// paragraph keeps the bare form, so the common cell's bytes do not change and the
// whitespace rules earned there still stand.
- bool manyParagraphs = cell.Blocks.Count(b => b is Paragraph) > 1;
+ // An
is INLINE, so a bare paragraph followed by a block image in the same cell shared the
+ // image's line — the picture sat beside the caption instead of under it. Counting paragraphs
+ // missed it: the cell has only ONE.
and are block-level and break the line by
+ // themselves, so only an image forces the issue (upstream round 9; ported 2026-09-24).
+ bool needsElementForm = cell.Blocks.Count(b => b is Paragraph) > 1 || cell.Blocks.Any(b => b is ImageBlock);
foreach (var cblk in cell.Blocks)
{
- if (cblk is Paragraph cpara && (manyParagraphs || NeedsOwnElement(cpara)))
+ if (cblk is Paragraph cpara && (needsElementForm || NeedsOwnElement(cpara)))
{
if (cpara.IsListItem) cellLists.Sync(cpara.ListType, cpara.ListMarker, cpara.ListLevel);
else cellLists.CloseAll();
diff --git a/tests/WinUIRichEditor.Tests/HtmlExternalRenderingTests.cs b/tests/WinUIRichEditor.Tests/HtmlExternalRenderingTests.cs
new file mode 100644
index 0000000..b499ddd
--- /dev/null
+++ b/tests/WinUIRichEditor.Tests/HtmlExternalRenderingTests.cs
@@ -0,0 +1,166 @@
+using System;
+using System.Linq;
+using WinUIRichEditor.Documents;
+using Xunit;
+using WinUIRichEditor.Formatters;
+
+namespace WinUIRichEditor.Tests;
+
+/// Two HTML defects only an OUTSIDE renderer shows, ported from upstream round 9 (AvaloniaRichEditor
+/// HtmlExternalRenderingTests, 2026-08-08) on 2026-09-24 — the port had neither fix. Both round-trip
+/// through this project's own reader perfectly, because the reader is lenient about exactly what its own writer
+/// emits; upstream found them by measuring the output in a browser. So these assertions read the WRITTEN
+/// MARKUP, not just the model — a symmetry check cannot see a line break that was never written.
+public class HtmlExternalRenderingTests
+{
+ private static string Text(Paragraph p) => string.Concat(p.Inlines.OfType().Select(r => r.Text));
+
+ private static FlowDocument Doc(params Block[] blocks)
+ {
+ var doc = new FlowDocument();
+ doc.Blocks.AddRange(blocks);
+ return doc;
+ }
+
+ private static Paragraph P(string? text)
+ {
+ var p = new Paragraph();
+ if (text != null) p.Inlines.Add(new Run { Text = text });
+ return p;
+ }
+
+ // The markup between a marked element's opening and its closing tag.
+ private static string MarkedElement(string html, string closeTag)
+ {
+ int at = html.IndexOf("data-are-empty", StringComparison.Ordinal);
+ Assert.True(at >= 0, "the blank line must still be marked for this reader");
+ return html.Substring(at, html.IndexOf(closeTag, at, StringComparison.Ordinal) - at);
+ }
+
+ // ---- 1. An author's blank line was invisible outside this editor ------------------------------
+
+ // The blank line went out as ``. The marker told THIS reader about it, but an
+ // element with no content has zero height, so a browser showed nothing (upstream measured: the gap across
+ // the blank line was the same 16px as between any two paragraphs). The
is what gives it a line —
+ // and it has to be INSIDE the marked element, which is what gives that element height.
+ [Fact]
+ public void AnAuthorsBlankLine_IsGivenALineForOutsideRenderers()
+ {
+ string html = HtmlDocumentFormatter.ToHtml(Doc(P("위"), P(null), P("아래")));
+
+ Assert.Contains("
"));
+ }
+
+ // The same for an empty list item: a blank numbered item is a zero-height too.
+ [Fact]
+ public void AnEmptyListItem_IsGivenALineForOutsideRenderers()
+ {
+ var blank = P(null);
+ blank.ListType = ListKind.Ordered;
+ var a = P("a"); a.ListType = ListKind.Ordered;
+ var b = P("b"); b.ListType = ListKind.Ordered;
+
+ string html = HtmlDocumentFormatter.ToHtml(Doc(a, blank, b));
+
+ Assert.Contains("
"));
+ }
+
+ // The
is the blank line's rendering, not its content. Read back as content, every save/load turns
+ // one blank line into a line holding a break — two lines. Twice, as round trips are run here.
+ [Fact]
+ public void AnAuthorsBlankLine_DoesNotGrowOnRepeatedRoundTrips()
+ {
+ var once = HtmlDocumentFormatter.ParseHtml(HtmlDocumentFormatter.ToHtml(Doc(P("위"), P(null), P("아래"))));
+ var twice = HtmlDocumentFormatter.ParseHtml(HtmlDocumentFormatter.ToHtml(once));
+
+ foreach (var round in new[] { once, twice })
+ {
+ Assert.Equal(3, round.Blocks.Count);
+ // Not just "no text": a
read as content leaves a run holding "\n", which renders as a SECOND
+ // line inside the one blank paragraph.
+ Assert.DoesNotContain(((Paragraph)round.Blocks[1]).Inlines.OfType(), r => !string.IsNullOrEmpty(r.Text));
+ }
+ }
+
+ // Foreign EMPTY elements keep being dropped — web pages use empty /
for spacing, and honouring
+ // them gives every paste that page's vertical rhythm. Only the marker opts in.
+ [Fact]
+ public void AForeignEmptyParagraph_IsStillDropped()
+ {
+ var doc = HtmlDocumentFormatter.ParseHtml("
a
b
");
+
+ Assert.Equal(2, doc.Blocks.Count);
+ }
+
+ // A foreign `
` is NOT empty and never was dropped — it is what contenteditable editors write for
+ // a blank line. It is why the new
is safe: a consumer that strips data- attributes still gets the
+ // blank line, and this reader only needs the marker to know the
is not content of its own.
+ [Fact]
+ public void AForeignBrOnlyParagraph_IsStillABlankLine()
+ {
+ var doc = HtmlDocumentFormatter.ParseHtml("
a
b
");
+
+ Assert.Equal(3, doc.Blocks.Count);
+ Assert.Equal("", Text((Paragraph)doc.Blocks[1]).Trim());
+ }
+
+ // ---- 2. A picture in a cell sat beside the text instead of under it ---------------------------
+
+ private static TableBlock CaptionAndPicture()
+ {
+ var t = new TableBlock(1, 1);
+ var cell = t.Cells[0][0];
+ cell.Blocks.Clear();
+ cell.Blocks.Add(P("캡션"));
+ var pic = new ImageBlock { Width = 80, Height = 50 };
+ pic.SetImageData(OnePixelPng, "image/png");
+ cell.Blocks.Add(pic);
+ return t;
+ }
+
+ private static string CellHtml(string html)
+ {
+ int td = html.IndexOf("
", td, StringComparison.Ordinal) - td);
+ }
+
+ // A cell's paragraph keeps the bare-inline form when that form can represent it, and the rule counted
+ // PARAGRAPHS. A cell holding one paragraph and a block image has only one, so the paragraph went out bare —
+ // and is inline, so the picture landed on the caption's line. and are block-level and
+ // break the line themselves; only an image forces this.
+ [Fact]
+ public void APictureInACell_StartsItsOwnLineInsteadOfJoiningTheText()
+ {
+ string cellHtml = CellHtml(HtmlDocumentFormatter.ToHtml(Doc(CaptionAndPicture())));
+
+ Assert.Contains("().Single().Cells[0][0];
+
+ Assert.Equal("캡션", Text(cell.Blocks.OfType().First()));
+ Assert.Single(cell.Blocks.OfType());
+ }
+
+ // A plain one-paragraph cell keeps the bare form — the reason the rule exists; the whitespace behaviour
+ // earned there depends on those exact bytes.
+ [Fact]
+ public void APlainCell_KeepsItsBareForm()
+ {
+ var t = new TableBlock(1, 1);
+ t.Cells[0][0].Para.Inlines[0] = new Run { Text = "평문" };
+
+ Assert.DoesNotContain(" |