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
11 changes: 11 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,17 @@ and follows [Semantic Versioning](https://semver.org/).

## [Unreleased]

### 다른 앱에서 보이는 HTML — 상류 라운드 9 백포트 (2026-09-24)

상류가 2026-08-08에 브라우저로 **측정**해서 고친 HTML 결함 중 두 건이 이 포트에 없었다. 둘 다 이 편집기의 HTML
읽기로는 완벽하게 돌아오므로(자기 출력에 관대하다) 왕복 테스트로는 보이지 않는다.

- **빈 줄이 브라우저에서 보이지 않았다.** 빈 문단(과 빈 목록 항목)을 내용 없는 `<p>`로 써서 높이가 0이었다. 이제
`<br/>`을 함께 쓰고, 이 편집기는 표식(`data-are-empty`)이 있으면 그 `<br/>`을 내용으로 읽지 않는다 — 한 줄이
두 줄로 불어나지 않는다. 표식 없는 외부 HTML의 빈 요소는 전처럼 버리고, 외부의 `<p><br></p>`은 전처럼 빈 줄이다.
- **셀 속 그림이 캡션 아래가 아니라 옆에 붙었다.** 셀에 문단 하나와 그림이 있으면 문단을 태그 없이 썼고, `<img>`는
인라인이라 글자 줄에 붙었다. 셀에 그림이 있으면 문단을 요소로 쓴다. 문단 하나뿐인 셀은 그대로다.

### 미게시 변경분 감사 — 결함 3건 (2026-09-24)

1.2.0 이후 들어온 변경을 감사해 찾았다. 셋 다 상류에도 있어 같은 수정을 상류에 옮겼다.
Expand Down
6 changes: 4 additions & 2 deletions Project_Roadmap.md
Original file line number Diff line number Diff line change
Expand Up @@ -99,8 +99,10 @@ WinUI 3 + Win2D 리치텍스트 에디터. `AvaloniaRichEditor`의 WinUI 포트
하나(알려진 폴백, 1.2.0과 같음).
- ~~**실기 확인**~~ → **2026-09-24 사용자 확인, 문제 없음**: 여백 픽커 5단계(레이블·쪽 여백), 쪽 보기의 표 선택
왼쪽 선·그림 선택 테두리(바깥)·쪽 맨 위 그림 윗선, RTF 저장→열기 뒤 픽커 "보통".
- **상류와 어긋난 곳(새로 확인)**: 상류는 빈 줄을 HTML로 쓸 때 `<br>`을 넣어 브라우저에서 줄이 보이게 했다(상류 실측)
(`HtmlExternalRenderingTests`). 포트는 표식만 써서 **브라우저에서는 빈 줄이 안 보일 수 있다** — 옮길지 측정 후 결정.
- ~~**상류와 어긋난 곳**~~ → **2026-09-24 옮김**: 상류 라운드 9(2026-08-08)의 HTML 수정 4건 중 포트에 없던 2건 —
빈 줄/빈 목록 항목의 `<br/>`(표식이 있으면 읽을 때 버림), 셀에 그림이 있으면 문단을 요소로. 나머지 2건(RTF 가로 병합
기하 표기, RTF 용지)은 이미 있었다. `HtmlExternalRenderingTests`(8), 반증 3종, 퍼즈 20,000시드 통과.
⚠ **실기**: 빈 줄을 복사해 Word·HWP에 붙였을 때 한 줄로 들어가는지(두 줄 아님)는 사람이 봐야 한다.

### 클립보드 경합 재시도 (2026-09-24, PR #55)
클립보드 기록·관리자가 변경 직후 클립보드를 잠깐 연다(`CLIPBRD_E_CANT_OPEN`). 모든 호출부가 이 오류를 삼켜
Expand Down
30 changes: 23 additions & 7 deletions src/WinUIRichEditor/Formatters/HtmlDocumentFormatter.cs
Original file line number Diff line number Diff line change
Expand Up @@ -361,7 +361,11 @@ void TakeSpace()
pre: name == "pre"); // <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 <br> so outside renderers give it a line; that <br> 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
Expand Down Expand Up @@ -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 <br> 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)))
Expand Down Expand Up @@ -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 <br> 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("<br/>");
sb.Append($"</{tag}>");
if (newlineAfter) sb.Append('\n');
}
Expand Down Expand Up @@ -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 <img> 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. <hr> and <table> 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();
Expand Down
166 changes: 166 additions & 0 deletions tests/WinUIRichEditor.Tests/HtmlExternalRenderingTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,166 @@
using System;
using System.Linq;
using WinUIRichEditor.Documents;
using Xunit;
using WinUIRichEditor.Formatters;

namespace WinUIRichEditor.Tests;

/// <summary>Two HTML defects only an OUTSIDE renderer shows, ported from upstream round 9 (AvaloniaRichEditor
/// <c>HtmlExternalRenderingTests</c>, 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.</summary>
public class HtmlExternalRenderingTests
{
private static string Text(Paragraph p) => string.Concat(p.Inlines.OfType<Run>().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 `<p data-are-empty="1"></p>`. 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 <br> 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("<br", MarkedElement(html, "</p>"));
}

// The same for an empty list item: a blank numbered item is a zero-height <li> 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("<br", MarkedElement(html, "</li>"));
}

// The <br> 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 <br> 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<Run>(), r => !string.IsNullOrEmpty(r.Text));
}
}

// Foreign EMPTY elements keep being dropped — web pages use empty <p>/<div> 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("<p>a</p><p></p><p>b</p>");

Assert.Equal(2, doc.Blocks.Count);
}

// A foreign `<p><br></p>` is NOT empty and never was dropped — it is what contenteditable editors write for
// a blank line. It is why the new <br> is safe: a consumer that strips data- attributes still gets the
// blank line, and this reader only needs the marker to know the <br> is not content of its own.
[Fact]
public void AForeignBrOnlyParagraph_IsStillABlankLine()
{
var doc = HtmlDocumentFormatter.ParseHtml("<p>a</p><p><br></p><p>b</p>");

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);
return html.Substring(td, html.IndexOf("</td>", 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 <img> is inline, so the picture landed on the caption's line. <hr> and <table> 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("<p", cellHtml);
Assert.True(cellHtml.IndexOf("<p", StringComparison.Ordinal) < cellHtml.IndexOf("<img", StringComparison.Ordinal),
"the caption's element must come before the picture");
}

// The promotion must not change what comes back: still a paragraph and an image, still two blocks.
[Fact]
public void APictureInACell_StillRoundTripsAsTwoBlocks()
{
var back = HtmlDocumentFormatter.ParseHtml(HtmlDocumentFormatter.ToHtml(Doc(CaptionAndPicture())));
var cell = back.Blocks.OfType<TableBlock>().Single().Cells[0][0];

Assert.Equal("캡션", Text(cell.Blocks.OfType<Paragraph>().First()));
Assert.Single(cell.Blocks.OfType<ImageBlock>());
}

// 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("<p", CellHtml(HtmlDocumentFormatter.ToHtml(Doc(t))));
}

// 1x1 transparent PNG.
private static readonly byte[] OnePixelPng = Convert.FromBase64String(
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==");
}
Loading