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($""); 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("