feat(submissions): Composer Slice D — rich prose (headings, lists, blockquote, hyperlinks) (m/paliad#141)
Extends the Composer's MD → OOXML walker per the design at
docs/design-submission-generator-v2-2026-05-26.md §12 Slice D from
Slice B's paragraphs + B/I baseline to the full rich-prose feature set:
headings 1-3, bullet + numbered lists, blockquote, inline hyperlinks.
MD walker (internal/services/submission_md.go, +320 / -75 LoC):
- RenderMarkdownToOOXMLWithStyles is the new Slice-D entry point;
RenderMarkdownToOOXML stays as a thin back-compat wrapper.
- splitMarkdownBlocks classifies every line into one of:
paragraph, heading_1/2/3, list_bullet, list_numbered, blockquote.
CommonMark-style 3-space indent tolerance; "N. " and "N) " for
numbered. Blank-line spacing semantics preserved from Slice B.
- renderBlockParagraph applies stylemap[blk.styleKey] (with
fall-back to stylemap["paragraph"]). List blocks emit visible
"• " / "N. " prefix runs so the structure surfaces even if Word
isn't configured with auto-list-numbering — lawyer can apply a
real Word list style post-export. Numbered-list ordinals reset
on every non-list block (so "1. A\nplain\n1. C" renders 1./1.,
not 1./2.).
- parseInlineRuns adds `[label](url)` recognition. Each link gets
routed through the optional HyperlinkAllocator; the walker emits
`<w:hyperlink r:id="{rId}">…runs…</w:hyperlink>` with the
"Hyperlink" character style on each child run. Nil allocator
falls back to plain-text label (URL drops, label survives).
Composer (internal/services/submission_compose.go, +130 / -10 LoC):
- composerLinkAllocator hands the walker fresh rIds (rIdComposer1,
rIdComposer2, …) outside the base's existing namespace; same URL
shared across multiple sections dedupes to one rId.
- patchDocumentXMLRels appends matching <Relationship Type="…/hyperlink"
Target="URL" TargetMode="External"/> entries to
word/_rels/document.xml.rels. Idempotent on rIds already present;
synthesizes a fresh rels part when missing (defensive for stripped
bases). Returns the patched parts slice (caller must overwrite
because append may grow the backing array — fixed in this slice).
- Compose now passes the full stylemap (paragraph + heading_1/2/3 +
list_bullet + list_numbered + blockquote) into the walker, not
just the paragraph-style entry.
Frontend (frontend/src/client/submission-draft.ts):
- Toolbar adds H1/H2/H3 buttons (formatBlock h1/h2/h3), bullet
list, numbered list, blockquote, and a link button that prompts
for a URL + wraps the selection via execCommand("createLink").
- domToMarkdown serializer extends to <h1>/<h2>/<h3>, <ul>/<ol>
with per-item ordinal counter for numbered lists, <blockquote>,
and <a href="…"> → `[label](url)`. Nested <li> handling sits in
the ul/ol branch.
Tests (internal/services/submission_md_test.go, internal/services/
submission_compose_test.go):
- TestRenderMarkdownToOOXML_Heading1 / _Heading2And3 — stylemap
applied.
- _BulletList / _NumberedList / _NumberedListResetsOnNonList —
prefixes + ordinal counter.
- _Blockquote — stylemap applied.
- _Hyperlink — allocator called, w:hyperlink rId wired, Hyperlink
character style on label runs.
- _HyperlinkNilAllocatorFallsBackToPlain — label survives, no
hyperlink tag emitted.
- TestDetectBlockMarker — 13 marker / non-marker cases.
- TestComposer_HeadingsAndLists — end-to-end through Compose with
a multi-construct draft; verifies stylemap presence + content +
ordinal prefixes.
- TestComposer_HyperlinkWiresRels — body has the right
<w:hyperlink r:id="rIdComposer{N}">, document.xml.rels has the
matching <Relationship> rows with External target mode.
- TestComposer_HyperlinkDedupesByURL — two `[label](url)` references
to the same URL share one rId; second allocation gets no new
Relationship row.
Build hygiene: go build/vet/test -short clean (all packages); bun run
build clean (2906 i18n keys).
NOT in scope (Slice D's brief was rich-prose + toolbar):
- Numbering.xml audit on bases — current approach emits visible
"• " / "N. " prefix runs without depending on numbering.xml. A
future slice can swap to `<w:numPr>` if firm-style auto-numbering
becomes a hard requirement.
- DOM-from-Markdown on initial editor paint — the editor still uses
textContent=md, so toolbar-applied formatting reverts to literal
Markdown text after autosave + repaint. Acceptable trade-off for
Slice D; a future polish could parse MD into the DOM on paint.
- Tables, images, footnotes (still design §13 out of scope).
Hard rules honoured:
- NO new migrations (Slice D is pure code).
- NO behavior change for pre-Composer drafts (gate on draft.BaseID
unchanged).
- {{rule.X}} aliases preserved (placeholders pass through the walker
verbatim, get substituted by the v1 SubmissionRenderer pass).
- Q2 ratification preserved (no building_block_id lineage).
- Q9 ratification preserved (4-tier BB visibility from Slice C).
t-paliad-316 Slice D
This commit is contained in:
@@ -1358,12 +1358,21 @@ function renderSectionRow(sec: SubmissionSectionJSON, lang: string, isActive: bo
|
||||
|
||||
li.appendChild(head);
|
||||
|
||||
// Toolbar — shared B/I affordance per section. Slice D extends with
|
||||
// headings, lists, quote.
|
||||
// Toolbar — Slice D rich-prose affordances: B/I + H1/H2/H3 +
|
||||
// bullet/numbered list + blockquote + hyperlink. Plus the Slice C
|
||||
// building-block button. execCommand drives bold/italic/headings/
|
||||
// lists/blockquote; hyperlink uses createLink with a prompt.
|
||||
const toolbar = document.createElement("div");
|
||||
toolbar.className = "submission-draft-section-toolbar";
|
||||
toolbar.appendChild(makeToolbarButton("B", isEN() ? "Bold" : "Fett", "bold"));
|
||||
toolbar.appendChild(makeToolbarButton("I", isEN() ? "Italic" : "Kursiv", "italic"));
|
||||
toolbar.appendChild(makeHeadingButton("H1", isEN() ? "Heading 1" : "Überschrift 1", 1));
|
||||
toolbar.appendChild(makeHeadingButton("H2", isEN() ? "Heading 2" : "Überschrift 2", 2));
|
||||
toolbar.appendChild(makeHeadingButton("H3", isEN() ? "Heading 3" : "Überschrift 3", 3));
|
||||
toolbar.appendChild(makeToolbarButton("•", isEN() ? "Bullet list" : "Aufzählung", "insertUnorderedList"));
|
||||
toolbar.appendChild(makeToolbarButton("1.", isEN() ? "Numbered list" : "Nummerierte Liste", "insertOrderedList"));
|
||||
toolbar.appendChild(makeToolbarButton("”", isEN() ? "Blockquote" : "Zitat", "formatBlock", "blockquote"));
|
||||
toolbar.appendChild(makeLinkButton());
|
||||
// t-paliad-315 Slice C — building-block insert button. Opens a
|
||||
// picker modal filtered to this section's section_key. Paste is
|
||||
// plain-text per Q2 (no lineage stamped).
|
||||
@@ -1421,7 +1430,7 @@ function renderSectionRow(sec: SubmissionSectionJSON, lang: string, isActive: bo
|
||||
return li;
|
||||
}
|
||||
|
||||
function makeToolbarButton(label: string, title: string, format: "bold" | "italic"): HTMLButtonElement {
|
||||
function makeToolbarButton(label: string, title: string, format: string, value?: string): HTMLButtonElement {
|
||||
const btn = document.createElement("button");
|
||||
btn.type = "button";
|
||||
btn.className = "submission-draft-section-toolbar-btn";
|
||||
@@ -1432,7 +1441,7 @@ function makeToolbarButton(label: string, title: string, format: "bold" | "itali
|
||||
// selection target.
|
||||
btn.addEventListener("mousedown", (ev) => {
|
||||
ev.preventDefault();
|
||||
document.execCommand(format, false);
|
||||
document.execCommand(format, false, value);
|
||||
// Trigger the input handler so autosave fires.
|
||||
const editor = document.activeElement as HTMLElement | null;
|
||||
if (editor && editor.classList.contains("submission-draft-section-editor")) {
|
||||
@@ -1442,6 +1451,50 @@ function makeToolbarButton(label: string, title: string, format: "bold" | "itali
|
||||
return btn;
|
||||
}
|
||||
|
||||
// makeHeadingButton emits an `<h1|h2|h3>` wrapping for the active
|
||||
// block via execCommand("formatBlock", "h1") etc. Toggling the same
|
||||
// heading back to a paragraph is handled by clicking the same button
|
||||
// again (the browser's execCommand semantics).
|
||||
function makeHeadingButton(label: string, title: string, level: 1 | 2 | 3): HTMLButtonElement {
|
||||
const btn = document.createElement("button");
|
||||
btn.type = "button";
|
||||
btn.className = "submission-draft-section-toolbar-btn";
|
||||
btn.textContent = label;
|
||||
btn.title = title;
|
||||
btn.addEventListener("mousedown", (ev) => {
|
||||
ev.preventDefault();
|
||||
document.execCommand("formatBlock", false, "h" + level);
|
||||
const editor = document.activeElement as HTMLElement | null;
|
||||
if (editor && editor.classList.contains("submission-draft-section-editor")) {
|
||||
onSectionInput(editor as HTMLDivElement);
|
||||
}
|
||||
});
|
||||
return btn;
|
||||
}
|
||||
|
||||
// makeLinkButton prompts for a URL and wraps the current selection
|
||||
// (or inserts a label-as-URL if nothing selected). The browser's
|
||||
// createLink built-in wires the <a href="…"> tag into the DOM;
|
||||
// domToMarkdown reads it back as `[label](url)`.
|
||||
function makeLinkButton(): HTMLButtonElement {
|
||||
const btn = document.createElement("button");
|
||||
btn.type = "button";
|
||||
btn.className = "submission-draft-section-toolbar-btn";
|
||||
btn.textContent = "🔗";
|
||||
btn.title = isEN() ? "Insert link" : "Link einfügen";
|
||||
btn.addEventListener("mousedown", (ev) => {
|
||||
ev.preventDefault();
|
||||
const url = prompt(isEN() ? "URL:" : "URL:");
|
||||
if (!url) return;
|
||||
document.execCommand("createLink", false, url);
|
||||
const editor = document.activeElement as HTMLElement | null;
|
||||
if (editor && editor.classList.contains("submission-draft-section-editor")) {
|
||||
onSectionInput(editor as HTMLDivElement);
|
||||
}
|
||||
});
|
||||
return btn;
|
||||
}
|
||||
|
||||
function activeSectionEditorID(): string | null {
|
||||
const active = document.activeElement as HTMLElement | null;
|
||||
if (!active || !active.classList.contains("submission-draft-section-editor")) return null;
|
||||
@@ -1496,10 +1549,28 @@ function serializeNode(node: Node): string {
|
||||
if (node.nodeType !== Node.ELEMENT_NODE) return "";
|
||||
const el = node as HTMLElement;
|
||||
const tag = el.tagName.toLowerCase();
|
||||
|
||||
// Lists: handle the wrapper before recursing into items so we can
|
||||
// emit the right per-item Markdown prefix.
|
||||
if (tag === "ul" || tag === "ol") {
|
||||
const items: string[] = [];
|
||||
let counter = 1;
|
||||
for (const child of Array.from(el.childNodes)) {
|
||||
if (child.nodeType === Node.ELEMENT_NODE && (child as HTMLElement).tagName.toLowerCase() === "li") {
|
||||
const liInner = serializeNode(child).replace(/\n+$/g, "");
|
||||
const prefix = tag === "ol" ? `${counter}. ` : "- ";
|
||||
items.push(prefix + liInner);
|
||||
counter++;
|
||||
}
|
||||
}
|
||||
return items.join("\n") + "\n\n";
|
||||
}
|
||||
|
||||
let inner = "";
|
||||
for (const child of Array.from(el.childNodes)) {
|
||||
inner += serializeNode(child);
|
||||
}
|
||||
|
||||
switch (tag) {
|
||||
case "b":
|
||||
case "strong":
|
||||
@@ -1514,6 +1585,26 @@ function serializeNode(node: Node): string {
|
||||
// execCommand and contentEditable insert <div> on Enter in some
|
||||
// browsers, <p> in others. Both are paragraph boundaries.
|
||||
return inner + "\n\n";
|
||||
case "h1":
|
||||
return "# " + inner.replace(/\n+$/g, "") + "\n\n";
|
||||
case "h2":
|
||||
return "## " + inner.replace(/\n+$/g, "") + "\n\n";
|
||||
case "h3":
|
||||
return "### " + inner.replace(/\n+$/g, "") + "\n\n";
|
||||
case "blockquote":
|
||||
// Each line inside the blockquote gets its own "> " prefix per
|
||||
// Markdown convention.
|
||||
return inner.split("\n").map(line => line === "" ? "" : "> " + line).join("\n").replace(/\n+$/g, "") + "\n\n";
|
||||
case "li":
|
||||
// <li> rendered standalone (no <ul>/<ol> ancestor) — emit
|
||||
// bullet by default. The ul/ol branch above handles the
|
||||
// ordered/unordered choice when present.
|
||||
return "- " + inner.replace(/\n+$/g, "") + "\n";
|
||||
case "a": {
|
||||
const href = el.getAttribute("href") ?? "";
|
||||
if (!href || !inner) return inner;
|
||||
return `[${inner}](${href})`;
|
||||
}
|
||||
default:
|
||||
return inner;
|
||||
}
|
||||
|
||||
@@ -111,8 +111,15 @@ func (c *SubmissionComposer) Compose(ctx context.Context, opts ComposeOptions) (
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Per-compose hyperlink allocator. Each unique URL gets a fresh
|
||||
// rId outside the base's existing namespace. The post-pass
|
||||
// (patchDocumentXMLRels) writes the matching Relationship rows
|
||||
// before the zip is repacked. Slice D adds inline `[label](url)`
|
||||
// hyperlink support.
|
||||
linkAlloc := newComposerLinkAllocator()
|
||||
|
||||
// Build the rendered-section map: section_key → OOXML span.
|
||||
style := opts.Base.SectionSpec.Stylemap["paragraph"]
|
||||
stylemap := opts.Base.SectionSpec.Stylemap
|
||||
rendered := make(map[string]string, len(sections))
|
||||
keptSections := make([]SubmissionSection, 0, len(sections))
|
||||
for _, sec := range sections {
|
||||
@@ -123,7 +130,7 @@ func (c *SubmissionComposer) Compose(ctx context.Context, opts ComposeOptions) (
|
||||
if strings.EqualFold(opts.Lang, "en") {
|
||||
md = sec.ContentMDEN
|
||||
}
|
||||
rendered[sec.SectionKey] = RenderMarkdownToOOXML(md, style)
|
||||
rendered[sec.SectionKey] = RenderMarkdownToOOXMLWithStyles(md, stylemap, linkAlloc.Alloc)
|
||||
keptSections = append(keptSections, sec)
|
||||
}
|
||||
// Stable order — already sorted ascending by ListForDraft, but
|
||||
@@ -135,6 +142,19 @@ func (c *SubmissionComposer) Compose(ctx context.Context, opts ComposeOptions) (
|
||||
|
||||
assembledBody := spliceSections(documentXML, rendered, keptSections, sections)
|
||||
|
||||
// Slice D hyperlink patch: when the walker emitted hyperlink rIds
|
||||
// for inline `[label](url)` links, the base's
|
||||
// word/_rels/document.xml.rels needs matching <Relationship>
|
||||
// entries so Word can resolve the rIds. Mutates one zip part in
|
||||
// otherParts (or appends if missing).
|
||||
if linkAlloc.HasLinks() {
|
||||
updatedParts, err := patchDocumentXMLRels(otherParts, linkAlloc.Pairs())
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
otherParts = updatedParts
|
||||
}
|
||||
|
||||
// Re-pack into a zip with the assembled document.xml. All other
|
||||
// parts (styles, fonts, headers, footers, theme, settings) pass
|
||||
// through bit-for-bit at their original mtime + compression.
|
||||
@@ -467,3 +487,121 @@ func readZipEntry(f *zip.File) ([]byte, error) {
|
||||
defer rc.Close()
|
||||
return io.ReadAll(rc)
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
// Slice D — hyperlink wiring
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
// composerLinkAllocator hands out fresh rIds for inline hyperlink
|
||||
// targets discovered by the MD walker. Each unique URL gets one rId
|
||||
// (deduped — repeated links to the same URL share one Relationship).
|
||||
// Allocations land outside the base's rId namespace by prefixing with
|
||||
// "rIdComposer" so they can't collide with existing relationships.
|
||||
type composerLinkAllocator struct {
|
||||
next int
|
||||
byURL map[string]string
|
||||
order []string // URLs in allocation order
|
||||
}
|
||||
|
||||
func newComposerLinkAllocator() *composerLinkAllocator {
|
||||
return &composerLinkAllocator{byURL: map[string]string{}}
|
||||
}
|
||||
|
||||
// Alloc returns the rId for url, allocating one on first sight.
|
||||
func (a *composerLinkAllocator) Alloc(url string) string {
|
||||
if rid, ok := a.byURL[url]; ok {
|
||||
return rid
|
||||
}
|
||||
a.next++
|
||||
rid := fmt.Sprintf("rIdComposer%d", a.next)
|
||||
a.byURL[url] = rid
|
||||
a.order = append(a.order, url)
|
||||
return rid
|
||||
}
|
||||
|
||||
// HasLinks reports whether any links were allocated during this compose.
|
||||
func (a *composerLinkAllocator) HasLinks() bool {
|
||||
return len(a.order) > 0
|
||||
}
|
||||
|
||||
// Pairs returns the (rId, URL) pairs in allocation order. The
|
||||
// document.xml.rels patcher consumes this to emit <Relationship>
|
||||
// elements.
|
||||
func (a *composerLinkAllocator) Pairs() [][2]string {
|
||||
pairs := make([][2]string, 0, len(a.order))
|
||||
for _, url := range a.order {
|
||||
pairs = append(pairs, [2]string{a.byURL[url], url})
|
||||
}
|
||||
return pairs
|
||||
}
|
||||
|
||||
// patchDocumentXMLRels mutates the word/_rels/document.xml.rels entry
|
||||
// in `parts` to append the given (rId, URL) pairs as hyperlink
|
||||
// relationships. If the rels part doesn't exist (some bases omit it
|
||||
// when the body has no relationships), this function appends a fresh
|
||||
// part with the minimal Relationships wrapper.
|
||||
//
|
||||
// Idempotent on (rId, URL) pairs already present (e.g. when a base
|
||||
// already references the URL for some other reason).
|
||||
//
|
||||
// Returns the (possibly extended) parts slice — callers must overwrite
|
||||
// their reference because the append in the no-rels-yet case grows the
|
||||
// backing array.
|
||||
func patchDocumentXMLRels(parts []baseZipPart, pairs [][2]string) ([]baseZipPart, error) {
|
||||
const path = "word/_rels/document.xml.rels"
|
||||
const hyperlinkType = "http://schemas.openxmlformats.org/officeDocument/2006/relationships/hyperlink"
|
||||
|
||||
existingIdx := -1
|
||||
for i := range parts {
|
||||
if parts[i].name == path {
|
||||
existingIdx = i
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
var body string
|
||||
if existingIdx >= 0 {
|
||||
body = string(parts[existingIdx].body)
|
||||
} else {
|
||||
body = `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>` +
|
||||
`<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"></Relationships>`
|
||||
}
|
||||
|
||||
var inserts strings.Builder
|
||||
for _, p := range pairs {
|
||||
rid := p[0]
|
||||
url := p[1]
|
||||
if strings.Contains(body, `Id="`+rid+`"`) {
|
||||
continue
|
||||
}
|
||||
inserts.WriteString(`<Relationship Id="`)
|
||||
inserts.WriteString(xmlAttrEscape(rid))
|
||||
inserts.WriteString(`" Type="`)
|
||||
inserts.WriteString(hyperlinkType)
|
||||
inserts.WriteString(`" Target="`)
|
||||
inserts.WriteString(xmlAttrEscape(url))
|
||||
inserts.WriteString(`" TargetMode="External"/>`)
|
||||
}
|
||||
|
||||
if inserts.Len() == 0 {
|
||||
return parts, nil
|
||||
}
|
||||
|
||||
closeIdx := strings.LastIndex(body, "</Relationships>")
|
||||
if closeIdx < 0 {
|
||||
return parts, fmt.Errorf("submission compose: malformed document.xml.rels (no closing tag)")
|
||||
}
|
||||
patched := body[:closeIdx] + inserts.String() + body[closeIdx:]
|
||||
|
||||
if existingIdx >= 0 {
|
||||
parts[existingIdx].body = []byte(patched)
|
||||
return parts, nil
|
||||
}
|
||||
parts = append(parts, baseZipPart{
|
||||
name: path,
|
||||
method: zip.Deflate,
|
||||
modTime: time.Now().Unix(),
|
||||
body: []byte(patched),
|
||||
})
|
||||
return parts, nil
|
||||
}
|
||||
|
||||
@@ -58,27 +58,32 @@ func minimalBaseBytes(t *testing.T, body string) []byte {
|
||||
// extractDocumentXML pulls word/document.xml out of a .docx zip for
|
||||
// assertions.
|
||||
func extractDocumentXML(t *testing.T, data []byte) string {
|
||||
return extractZipEntry(t, data, "word/document.xml")
|
||||
}
|
||||
|
||||
// extractZipEntry pulls any named entry out of a .docx zip.
|
||||
func extractZipEntry(t *testing.T, data []byte, name string) string {
|
||||
t.Helper()
|
||||
zr, err := zip.NewReader(bytes.NewReader(data), int64(len(data)))
|
||||
if err != nil {
|
||||
t.Fatalf("open zip: %v", err)
|
||||
}
|
||||
for _, f := range zr.File {
|
||||
if f.Name != "word/document.xml" {
|
||||
if f.Name != name {
|
||||
continue
|
||||
}
|
||||
rc, err := f.Open()
|
||||
if err != nil {
|
||||
t.Fatalf("open document.xml: %v", err)
|
||||
t.Fatalf("open %s: %v", name, err)
|
||||
}
|
||||
defer rc.Close()
|
||||
var buf bytes.Buffer
|
||||
if _, err := buf.ReadFrom(rc); err != nil {
|
||||
t.Fatalf("read document.xml: %v", err)
|
||||
t.Fatalf("read %s: %v", name, err)
|
||||
}
|
||||
return buf.String()
|
||||
}
|
||||
t.Fatal("document.xml not found in zip")
|
||||
t.Fatalf("%s not found in zip", name)
|
||||
return ""
|
||||
}
|
||||
|
||||
@@ -249,6 +254,120 @@ func TestComposer_LangPicksColumn(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// Slice D — rich-prose end-to-end through the composer.
|
||||
|
||||
func TestComposer_HeadingsAndLists(t *testing.T) {
|
||||
base := composerBase()
|
||||
// Extend the stylemap so the walker has named styles to apply.
|
||||
base.SectionSpec.Stylemap["heading_1"] = "Heading1"
|
||||
base.SectionSpec.Stylemap["list_bullet"] = "ListBullet"
|
||||
base.SectionSpec.Stylemap["list_numbered"] = "ListNumber"
|
||||
base.SectionSpec.Stylemap["blockquote"] = "Quote"
|
||||
|
||||
body := `<w:p><w:r><w:t>{{#section:body}}</w:t></w:r></w:p><w:p><w:r><w:t>{{/section:body}}</w:t></w:r></w:p>`
|
||||
baseBytes := minimalBaseBytes(t, body)
|
||||
composer := NewSubmissionComposer(NewSubmissionRenderer())
|
||||
|
||||
md := "# Heading line\n\n- bullet a\n- bullet b\n\n1. first\n2. second\n\n> quoted"
|
||||
sections := []SubmissionSection{
|
||||
{ID: uuid.New(), SectionKey: "body", OrderIndex: 1, Kind: "prose", Included: true, ContentMDDE: md},
|
||||
}
|
||||
out, err := composer.Compose(context.Background(), ComposeOptions{
|
||||
Sections: sections, Base: base, BaseBytes: baseBytes, Lang: "de",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Compose: %v", err)
|
||||
}
|
||||
docXML := extractDocumentXML(t, out)
|
||||
|
||||
for _, want := range []string{
|
||||
`<w:pStyle w:val="Heading1"/>`,
|
||||
`<w:pStyle w:val="ListBullet"/>`,
|
||||
`<w:pStyle w:val="ListNumber"/>`,
|
||||
`<w:pStyle w:val="Quote"/>`,
|
||||
"Heading line",
|
||||
"bullet a",
|
||||
"bullet b",
|
||||
`<w:t xml:space="preserve">1. </w:t>`,
|
||||
`<w:t xml:space="preserve">2. </w:t>`,
|
||||
"first",
|
||||
"second",
|
||||
"quoted",
|
||||
} {
|
||||
if !strings.Contains(docXML, want) {
|
||||
t.Errorf("expected %q in composed body; got: %s", want, docXML)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestComposer_HyperlinkWiresRels(t *testing.T) {
|
||||
base := composerBase()
|
||||
body := `<w:p><w:r><w:t>{{#section:facts}}</w:t></w:r></w:p><w:p><w:r><w:t>{{/section:facts}}</w:t></w:r></w:p>`
|
||||
baseBytes := minimalBaseBytes(t, body)
|
||||
composer := NewSubmissionComposer(NewSubmissionRenderer())
|
||||
|
||||
sections := []SubmissionSection{
|
||||
{ID: uuid.New(), SectionKey: "facts", OrderIndex: 1, Kind: "prose", Included: true,
|
||||
ContentMDDE: "See [BGH](https://bgh.bund.de) and [EuGH](https://curia.europa.eu)."},
|
||||
}
|
||||
out, err := composer.Compose(context.Background(), ComposeOptions{
|
||||
Sections: sections, Base: base, BaseBytes: baseBytes, Lang: "de",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Compose: %v", err)
|
||||
}
|
||||
|
||||
// Body: hyperlink elements with composer rIds.
|
||||
docXML := extractDocumentXML(t, out)
|
||||
if !strings.Contains(docXML, `<w:hyperlink r:id="rIdComposer1">`) ||
|
||||
!strings.Contains(docXML, `<w:hyperlink r:id="rIdComposer2">`) {
|
||||
t.Errorf("hyperlink rIds missing in body: %q", docXML)
|
||||
}
|
||||
if !strings.Contains(docXML, "BGH") || !strings.Contains(docXML, "EuGH") {
|
||||
t.Errorf("hyperlink labels missing: %q", docXML)
|
||||
}
|
||||
|
||||
// Rels: the matching <Relationship> rows must be in
|
||||
// word/_rels/document.xml.rels with the URL targets + External mode.
|
||||
rels := extractZipEntry(t, out, "word/_rels/document.xml.rels")
|
||||
for _, want := range []string{
|
||||
`Id="rIdComposer1"`,
|
||||
`Id="rIdComposer2"`,
|
||||
`Target="https://bgh.bund.de"`,
|
||||
`Target="https://curia.europa.eu"`,
|
||||
`TargetMode="External"`,
|
||||
"hyperlink", // the Type URL contains "hyperlink"
|
||||
} {
|
||||
if !strings.Contains(rels, want) {
|
||||
t.Errorf("expected %q in document.xml.rels: %s", want, rels)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestComposer_HyperlinkDedupesByURL(t *testing.T) {
|
||||
base := composerBase()
|
||||
body := `<w:p><w:r><w:t>{{#section:facts}}</w:t></w:r></w:p><w:p><w:r><w:t>{{/section:facts}}</w:t></w:r></w:p>`
|
||||
baseBytes := minimalBaseBytes(t, body)
|
||||
composer := NewSubmissionComposer(NewSubmissionRenderer())
|
||||
|
||||
// Same URL referenced twice — should produce one rId, two
|
||||
// <w:hyperlink> elements both pointing at it.
|
||||
sections := []SubmissionSection{
|
||||
{ID: uuid.New(), SectionKey: "facts", OrderIndex: 1, Kind: "prose", Included: true,
|
||||
ContentMDDE: "First [BGH](https://bgh.bund.de) and again [Bundesgerichtshof](https://bgh.bund.de)."},
|
||||
}
|
||||
out, _ := composer.Compose(context.Background(), ComposeOptions{
|
||||
Sections: sections, Base: base, BaseBytes: baseBytes, Lang: "de",
|
||||
})
|
||||
docXML := extractDocumentXML(t, out)
|
||||
if strings.Count(docXML, `<w:hyperlink r:id="rIdComposer1">`) != 2 {
|
||||
t.Errorf("expected 2 hyperlinks sharing rIdComposer1; got: %s", docXML)
|
||||
}
|
||||
if strings.Contains(docXML, `<w:hyperlink r:id="rIdComposer2">`) {
|
||||
t.Errorf("dedupe failed — second rId allocated for same URL: %s", docXML)
|
||||
}
|
||||
}
|
||||
|
||||
func TestComposer_OrderIndexAscending(t *testing.T) {
|
||||
base := composerBase()
|
||||
// No anchors → both sections append in order_index ASC order
|
||||
|
||||
@@ -27,79 +27,223 @@ package services
|
||||
// - Otherwise → plain text run
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// HyperlinkAllocator hands the walker a `rId` for each external URL
|
||||
// it encounters in `[label](url)` inline links. The composer's
|
||||
// post-pass uses these allocations to mutate
|
||||
// `word/_rels/document.xml.rels` so the emitted `<w:hyperlink
|
||||
// r:id="…">` elements resolve correctly. Pass nil to drop links to
|
||||
// plain text (the label survives, the URL doesn't render).
|
||||
//
|
||||
// t-paliad-316 Slice D.
|
||||
type HyperlinkAllocator func(url string) string
|
||||
|
||||
// RenderMarkdownToOOXML renders the given Markdown source into OOXML
|
||||
// paragraph elements (`<w:p>…</w:p>`), suitable for splicing into a
|
||||
// .docx body. Each paragraph carries `<w:pStyle w:val="<paragraphStyle>"/>`
|
||||
// when paragraphStyle is non-empty.
|
||||
//
|
||||
// Slice B shipped paragraphs + bold/italic. Slice D extends to
|
||||
// headings (h1/h2/h3), bullet/numbered lists, blockquote, and inline
|
||||
// hyperlinks via the optional HyperlinkAllocator.
|
||||
//
|
||||
// stylemap supplies the paragraph-style names for each kind:
|
||||
// stylemap["paragraph"] — default body
|
||||
// stylemap["heading_1/2/3"] — heading levels
|
||||
// stylemap["list_bullet"] — bullet list paragraph style
|
||||
// stylemap["list_numbered"] — numbered list paragraph style
|
||||
// stylemap["blockquote"] — blockquote
|
||||
// Missing entries fall back to the "paragraph" style.
|
||||
//
|
||||
// Empty input renders one empty paragraph so the splice site is
|
||||
// well-formed even when the lawyer hasn't typed anything in this
|
||||
// section.
|
||||
func RenderMarkdownToOOXML(md, paragraphStyle string) string {
|
||||
return RenderMarkdownToOOXMLWithStyles(md, map[string]string{"paragraph": paragraphStyle}, nil)
|
||||
}
|
||||
|
||||
// RenderMarkdownToOOXMLWithStyles is the full Slice-D-aware entry
|
||||
// point. Slice B's RenderMarkdownToOOXML is a wrapper for back-compat.
|
||||
func RenderMarkdownToOOXMLWithStyles(md string, stylemap map[string]string, links HyperlinkAllocator) string {
|
||||
defaultStyle := stylemap["paragraph"]
|
||||
if md == "" {
|
||||
return emptyParagraph(paragraphStyle)
|
||||
return emptyParagraph(defaultStyle)
|
||||
}
|
||||
paragraphs := splitMarkdownParagraphs(md)
|
||||
if len(paragraphs) == 0 {
|
||||
return emptyParagraph(paragraphStyle)
|
||||
blocks := splitMarkdownBlocks(md)
|
||||
if len(blocks) == 0 {
|
||||
return emptyParagraph(defaultStyle)
|
||||
}
|
||||
// Numbered-list counter resets on every non-numbered block so
|
||||
// "1. A\n2. B\n\n1. C" renders as 1./2./1. (the lawyer's input
|
||||
// determined the ordinal, the walker just renders).
|
||||
numberedCounter := 0
|
||||
var b strings.Builder
|
||||
for _, para := range paragraphs {
|
||||
b.WriteString(renderParagraph(para, paragraphStyle))
|
||||
for _, blk := range blocks {
|
||||
style := stylemap[blk.styleKey]
|
||||
if style == "" {
|
||||
style = defaultStyle
|
||||
}
|
||||
if blk.styleKey == "list_numbered" {
|
||||
numberedCounter++
|
||||
} else {
|
||||
numberedCounter = 0
|
||||
}
|
||||
b.WriteString(renderBlockParagraph(blk, style, links, numberedCounter))
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// splitMarkdownParagraphs splits the source into paragraphs. A
|
||||
// "paragraph" is a maximal run of non-blank lines. N consecutive blank
|
||||
// lines between two paragraphs produce (N-1) empty paragraphs in the
|
||||
// output so the lawyer's intentional vertical spacing survives.
|
||||
// mdBlock is one rendered paragraph: a kind (paragraph / heading_*
|
||||
// / list_bullet / list_numbered / blockquote) and the inline content
|
||||
// text. List markers, heading hashes, blockquote `> ` etc. are
|
||||
// stripped from the text before storage.
|
||||
type mdBlock struct {
|
||||
styleKey string // "paragraph" | "heading_1" | "heading_2" | "heading_3" | "list_bullet" | "list_numbered" | "blockquote"
|
||||
text string
|
||||
}
|
||||
|
||||
// splitMarkdownBlocks parses the source into a sequence of blocks,
|
||||
// detecting heading / list / blockquote prefixes line-by-line. Blank
|
||||
// lines split paragraph runs (same semantics as splitMarkdownParagraphs)
|
||||
// but each line is also tagged with its block kind.
|
||||
//
|
||||
// CRLF line endings normalise to LF before splitting.
|
||||
func splitMarkdownParagraphs(md string) []string {
|
||||
// Lines that look like block markers don't merge with their neighbours
|
||||
// even across blank lines — every list / heading / blockquote line is
|
||||
// its own block in the output. A run of unmarked lines collapses into
|
||||
// one "paragraph" block (so soft line breaks inside a paragraph still
|
||||
// concatenate).
|
||||
//
|
||||
// CRLF normalised to LF before parsing.
|
||||
func splitMarkdownBlocks(md string) []mdBlock {
|
||||
normalised := strings.ReplaceAll(md, "\r\n", "\n")
|
||||
lines := strings.Split(normalised, "\n")
|
||||
var paragraphs []string
|
||||
var current []string
|
||||
var blocks []mdBlock
|
||||
var pendingPara []string
|
||||
blankRun := 0
|
||||
flushParagraph := func() {
|
||||
if len(current) > 0 {
|
||||
paragraphs = append(paragraphs, strings.Join(current, "\n"))
|
||||
current = nil
|
||||
|
||||
flushPara := func() {
|
||||
if len(pendingPara) > 0 {
|
||||
blocks = append(blocks, mdBlock{styleKey: "paragraph", text: strings.Join(pendingPara, "\n")})
|
||||
pendingPara = nil
|
||||
}
|
||||
}
|
||||
for _, line := range lines {
|
||||
|
||||
for _, raw := range lines {
|
||||
line := raw
|
||||
if strings.TrimSpace(line) == "" {
|
||||
if len(current) > 0 {
|
||||
// End of a paragraph; the blank-counting starts now.
|
||||
flushParagraph()
|
||||
if len(pendingPara) > 0 {
|
||||
flushPara()
|
||||
blankRun = 1
|
||||
continue
|
||||
}
|
||||
// Already inside a blank run (or before the first paragraph).
|
||||
blankRun++
|
||||
continue
|
||||
}
|
||||
// Starting a new paragraph — emit (blankRun-1) empty paragraphs
|
||||
// in between if the lawyer used multiple blank lines as
|
||||
// vertical spacing.
|
||||
for i := 1; i < blankRun; i++ {
|
||||
paragraphs = append(paragraphs, "")
|
||||
// Detect heading / list / blockquote markers BEFORE we accumulate
|
||||
// into the paragraph buffer.
|
||||
kind, payload, ok := detectBlockMarker(line)
|
||||
if ok {
|
||||
flushPara()
|
||||
// Emit spacing paragraphs equivalent to (blankRun - 1) extra.
|
||||
for i := 1; i < blankRun; i++ {
|
||||
blocks = append(blocks, mdBlock{styleKey: "paragraph", text: ""})
|
||||
}
|
||||
blankRun = 0
|
||||
blocks = append(blocks, mdBlock{styleKey: kind, text: payload})
|
||||
continue
|
||||
}
|
||||
// Plain paragraph line.
|
||||
if len(pendingPara) == 0 {
|
||||
// Starting a new paragraph after a blank run — emit
|
||||
// (blankRun-1) extra empty paragraphs for vertical spacing.
|
||||
for i := 1; i < blankRun; i++ {
|
||||
blocks = append(blocks, mdBlock{styleKey: "paragraph", text: ""})
|
||||
}
|
||||
}
|
||||
blankRun = 0
|
||||
current = append(current, line)
|
||||
pendingPara = append(pendingPara, line)
|
||||
}
|
||||
flushParagraph()
|
||||
return paragraphs
|
||||
flushPara()
|
||||
return blocks
|
||||
}
|
||||
|
||||
// renderParagraph emits one `<w:p>` element for the given paragraph
|
||||
// text. Inline bold/italic spans become `<w:r>` runs with the
|
||||
// corresponding `<w:rPr>`.
|
||||
func renderParagraph(text, paragraphStyle string) string {
|
||||
// detectBlockMarker classifies a single line. Returns (styleKey,
|
||||
// payload-with-marker-stripped, true) for recognised markers; false
|
||||
// for plain paragraph lines.
|
||||
//
|
||||
// Recognised markers (Slice D):
|
||||
// # Heading → heading_1
|
||||
// ## Heading → heading_2
|
||||
// ### Heading → heading_3
|
||||
// - item / * item → list_bullet
|
||||
// 1. item / 2. item ... → list_numbered (any positive integer)
|
||||
// > quote → blockquote
|
||||
//
|
||||
// Leading whitespace inside the line is tolerated up to 3 spaces (per
|
||||
// CommonMark) so the lawyer's contentEditable indentation doesn't
|
||||
// hide the marker.
|
||||
func detectBlockMarker(line string) (string, string, bool) {
|
||||
trimmed := strings.TrimLeft(line, " ")
|
||||
// Cap to 3 spaces of leading indent — beyond that, treat as a
|
||||
// regular paragraph line (matches CommonMark).
|
||||
if len(line)-len(trimmed) > 3 {
|
||||
return "", "", false
|
||||
}
|
||||
if strings.HasPrefix(trimmed, "### ") {
|
||||
return "heading_3", strings.TrimSpace(trimmed[4:]), true
|
||||
}
|
||||
if strings.HasPrefix(trimmed, "## ") {
|
||||
return "heading_2", strings.TrimSpace(trimmed[3:]), true
|
||||
}
|
||||
if strings.HasPrefix(trimmed, "# ") {
|
||||
return "heading_1", strings.TrimSpace(trimmed[2:]), true
|
||||
}
|
||||
if strings.HasPrefix(trimmed, "> ") {
|
||||
return "blockquote", strings.TrimSpace(trimmed[2:]), true
|
||||
}
|
||||
if strings.HasPrefix(trimmed, "- ") || strings.HasPrefix(trimmed, "* ") {
|
||||
return "list_bullet", strings.TrimSpace(trimmed[2:]), true
|
||||
}
|
||||
// Numbered: "N. " where N is one or more digits.
|
||||
if i := indexOfNumberedMarker(trimmed); i > 0 {
|
||||
return "list_numbered", strings.TrimSpace(trimmed[i:]), true
|
||||
}
|
||||
return "", "", false
|
||||
}
|
||||
|
||||
// indexOfNumberedMarker checks for "N. " or "N) " at the start of the
|
||||
// trimmed line; returns the byte index just past the marker, or -1 if
|
||||
// no marker present.
|
||||
func indexOfNumberedMarker(s string) int {
|
||||
i := 0
|
||||
for i < len(s) && s[i] >= '0' && s[i] <= '9' {
|
||||
i++
|
||||
}
|
||||
if i == 0 {
|
||||
return -1
|
||||
}
|
||||
if i >= len(s) {
|
||||
return -1
|
||||
}
|
||||
if s[i] != '.' && s[i] != ')' {
|
||||
return -1
|
||||
}
|
||||
if i+1 >= len(s) || s[i+1] != ' ' {
|
||||
return -1
|
||||
}
|
||||
return i + 2
|
||||
}
|
||||
|
||||
// renderBlockParagraph emits one `<w:p>` for a block. List blocks
|
||||
// keep the same paragraph style as a default paragraph (the Slice D
|
||||
// design's contract — list styles come from the base's stylemap and
|
||||
// Word's numbering.xml is honoured by adding a leading bullet/number
|
||||
// prefix in the rendered text). This keeps the composer free of
|
||||
// numbering.xml mutations.
|
||||
func renderBlockParagraph(blk mdBlock, paragraphStyle string, links HyperlinkAllocator, numberedOrdinal int) string {
|
||||
var b strings.Builder
|
||||
b.WriteString(`<w:p>`)
|
||||
if paragraphStyle != "" {
|
||||
@@ -107,21 +251,124 @@ func renderParagraph(text, paragraphStyle string) string {
|
||||
b.WriteString(xmlAttrEscape(paragraphStyle))
|
||||
b.WriteString(`"/></w:pPr>`)
|
||||
}
|
||||
if text == "" {
|
||||
// Empty paragraph — emit a single empty run so Word renders the
|
||||
// paragraph as a blank line. Without the run, some Word
|
||||
// versions collapse the paragraph entirely.
|
||||
if blk.text == "" {
|
||||
b.WriteString(`<w:r><w:t xml:space="preserve"></w:t></w:r>`)
|
||||
b.WriteString(`</w:p>`)
|
||||
return b.String()
|
||||
}
|
||||
for _, span := range parseInlineSpans(text) {
|
||||
b.WriteString(renderRun(span))
|
||||
text := blk.text
|
||||
// List blocks emit a visible "• " / "N. " prefix run. The
|
||||
// stylemap entry handles paragraph indentation if the base
|
||||
// defines a list paragraph style; otherwise the prefix at least
|
||||
// surfaces the structure in plain Word. Lawyers who want Word's
|
||||
// auto-numbering reapply a list style post-export.
|
||||
switch blk.styleKey {
|
||||
case "list_bullet":
|
||||
b.WriteString(`<w:r><w:t xml:space="preserve">• </w:t></w:r>`)
|
||||
case "list_numbered":
|
||||
ordinal := numberedOrdinal
|
||||
if ordinal <= 0 {
|
||||
ordinal = 1
|
||||
}
|
||||
b.WriteString(`<w:r><w:t xml:space="preserve">`)
|
||||
b.WriteString(fmt.Sprintf("%d. ", ordinal))
|
||||
b.WriteString(`</w:t></w:r>`)
|
||||
}
|
||||
for _, run := range parseInlineRuns(text, links) {
|
||||
b.WriteString(run)
|
||||
}
|
||||
b.WriteString(`</w:p>`)
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// parseInlineRuns extracts inline spans + hyperlink runs and serialises
|
||||
// each to OOXML. Hyperlinks become `<w:hyperlink r:id="RID">…runs…</w:hyperlink>`
|
||||
// where RID comes from the HyperlinkAllocator.
|
||||
func parseInlineRuns(text string, links HyperlinkAllocator) []string {
|
||||
// Phase 1: find all hyperlink spans `[label](url)` and split the
|
||||
// text around them.
|
||||
type segment struct {
|
||||
text string
|
||||
isLink bool
|
||||
url string
|
||||
}
|
||||
var segs []segment
|
||||
rest := text
|
||||
for {
|
||||
idx := strings.Index(rest, "[")
|
||||
if idx < 0 {
|
||||
if rest != "" {
|
||||
segs = append(segs, segment{text: rest})
|
||||
}
|
||||
break
|
||||
}
|
||||
// Find matching closing bracket, then a "(" right after.
|
||||
closeBracket := strings.Index(rest[idx:], "](")
|
||||
if closeBracket < 0 {
|
||||
segs = append(segs, segment{text: rest})
|
||||
break
|
||||
}
|
||||
closeParen := strings.Index(rest[idx+closeBracket:], ")")
|
||||
if closeParen < 0 {
|
||||
segs = append(segs, segment{text: rest})
|
||||
break
|
||||
}
|
||||
// idx = start of "["
|
||||
// idx+closeBracket = position of "]"
|
||||
// idx+closeBracket+1 = position of "("
|
||||
// idx+closeBracket+closeParen = position of ")"
|
||||
label := rest[idx+1 : idx+closeBracket]
|
||||
url := rest[idx+closeBracket+2 : idx+closeBracket+closeParen]
|
||||
if idx > 0 {
|
||||
segs = append(segs, segment{text: rest[:idx]})
|
||||
}
|
||||
segs = append(segs, segment{text: label, isLink: true, url: url})
|
||||
rest = rest[idx+closeBracket+closeParen+1:]
|
||||
}
|
||||
|
||||
var runs []string
|
||||
for _, seg := range segs {
|
||||
if seg.isLink && links != nil {
|
||||
rid := links(seg.url)
|
||||
if rid != "" {
|
||||
var hb strings.Builder
|
||||
hb.WriteString(`<w:hyperlink r:id="`)
|
||||
hb.WriteString(xmlAttrEscape(rid))
|
||||
hb.WriteString(`">`)
|
||||
for _, span := range parseInlineSpans(seg.text) {
|
||||
hb.WriteString(renderRunWithLinkStyle(span))
|
||||
}
|
||||
hb.WriteString(`</w:hyperlink>`)
|
||||
runs = append(runs, hb.String())
|
||||
continue
|
||||
}
|
||||
}
|
||||
for _, span := range parseInlineSpans(seg.text) {
|
||||
runs = append(runs, renderRun(span))
|
||||
}
|
||||
}
|
||||
return runs
|
||||
}
|
||||
|
||||
// renderRunWithLinkStyle emits a hyperlink child run. Same B/I support
|
||||
// as renderRun, but additionally tags the run with the "Hyperlink"
|
||||
// character style (Word's built-in) so the link renders in the
|
||||
// document's hyperlink colour + underline.
|
||||
func renderRunWithLinkStyle(span inlineSpan) string {
|
||||
var b strings.Builder
|
||||
b.WriteString(`<w:r><w:rPr><w:rStyle w:val="Hyperlink"/>`)
|
||||
if span.Bold {
|
||||
b.WriteString(`<w:b/>`)
|
||||
}
|
||||
if span.Italic {
|
||||
b.WriteString(`<w:i/>`)
|
||||
}
|
||||
b.WriteString(`</w:rPr><w:t xml:space="preserve">`)
|
||||
b.WriteString(xmlTextEscape(span.Text))
|
||||
b.WriteString(`</w:t></w:r>`)
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// inlineSpan is one piece of inline content: a text payload plus
|
||||
// formatting flags. Bold and italic are independent — `***both***`
|
||||
// produces one span with both flags set.
|
||||
|
||||
@@ -144,3 +144,156 @@ func TestParseInlineSpans_UnderscoreBold(t *testing.T) {
|
||||
t.Errorf("expected one bold 'strong' span; got %+v", spans)
|
||||
}
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
// Slice D — rich-prose constructs
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
func slicedStylemap() map[string]string {
|
||||
return map[string]string{
|
||||
"paragraph": "Body",
|
||||
"heading_1": "H1",
|
||||
"heading_2": "H2",
|
||||
"heading_3": "H3",
|
||||
"list_bullet": "ListBullet",
|
||||
"list_numbered": "ListNumber",
|
||||
"blockquote": "Quote",
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenderMarkdownToOOXML_Heading1(t *testing.T) {
|
||||
out := RenderMarkdownToOOXMLWithStyles("# A heading", slicedStylemap(), nil)
|
||||
if !strings.Contains(out, `<w:pStyle w:val="H1"/>`) {
|
||||
t.Errorf("heading_1 missing H1 style: %q", out)
|
||||
}
|
||||
if !strings.Contains(out, "A heading") {
|
||||
t.Errorf("heading text missing: %q", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenderMarkdownToOOXML_Heading2And3(t *testing.T) {
|
||||
out := RenderMarkdownToOOXMLWithStyles("## H2 line\n### H3 line", slicedStylemap(), nil)
|
||||
if !strings.Contains(out, `<w:pStyle w:val="H2"/>`) || !strings.Contains(out, "H2 line") {
|
||||
t.Errorf("h2 not rendered: %q", out)
|
||||
}
|
||||
if !strings.Contains(out, `<w:pStyle w:val="H3"/>`) || !strings.Contains(out, "H3 line") {
|
||||
t.Errorf("h3 not rendered: %q", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenderMarkdownToOOXML_BulletList(t *testing.T) {
|
||||
out := RenderMarkdownToOOXMLWithStyles("- first\n- second\n* third", slicedStylemap(), nil)
|
||||
if !strings.Contains(out, `<w:pStyle w:val="ListBullet"/>`) {
|
||||
t.Errorf("bullet stylemap not applied: %q", out)
|
||||
}
|
||||
if strings.Count(out, "• ") != 3 {
|
||||
t.Errorf("expected 3 bullet prefixes; got %d in %q", strings.Count(out, "• "), out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenderMarkdownToOOXML_NumberedList(t *testing.T) {
|
||||
out := RenderMarkdownToOOXMLWithStyles("1. first\n2. second\n3. third", slicedStylemap(), nil)
|
||||
if !strings.Contains(out, `<w:pStyle w:val="ListNumber"/>`) {
|
||||
t.Errorf("numbered stylemap not applied: %q", out)
|
||||
}
|
||||
for _, want := range []string{"1. ", "2. ", "3. "} {
|
||||
if !strings.Contains(out, want) {
|
||||
t.Errorf("missing ordinal prefix %q in %q", want, out)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenderMarkdownToOOXML_NumberedListResetsOnNonList(t *testing.T) {
|
||||
// "1. A\n2. B\nplain\n1. C" → 1. A, 2. B, plain para, 1. C
|
||||
out := RenderMarkdownToOOXMLWithStyles("1. A\n2. B\nplain\n1. C", slicedStylemap(), nil)
|
||||
// The plain "plain" line breaks the list, so the next numbered
|
||||
// item restarts at 1.
|
||||
idxA := strings.Index(out, "1. ")
|
||||
if idxA < 0 {
|
||||
t.Fatalf("first 1. missing: %q", out)
|
||||
}
|
||||
idxB := strings.Index(out, "2. ")
|
||||
if idxB < 0 || idxB <= idxA {
|
||||
t.Fatalf("2. not after 1.: idxA=%d idxB=%d", idxA, idxB)
|
||||
}
|
||||
rest := out[idxB+1:]
|
||||
idxC := strings.Index(rest, "1. ")
|
||||
if idxC < 0 {
|
||||
t.Errorf("numbered counter didn't reset on non-list block: %q", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenderMarkdownToOOXML_Blockquote(t *testing.T) {
|
||||
out := RenderMarkdownToOOXMLWithStyles("> the quoted text", slicedStylemap(), nil)
|
||||
if !strings.Contains(out, `<w:pStyle w:val="Quote"/>`) {
|
||||
t.Errorf("blockquote stylemap not applied: %q", out)
|
||||
}
|
||||
if !strings.Contains(out, "the quoted text") {
|
||||
t.Errorf("blockquote text missing: %q", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenderMarkdownToOOXML_Hyperlink(t *testing.T) {
|
||||
allocated := map[string]string{}
|
||||
alloc := func(url string) string {
|
||||
rid := "rIdComposer" + url
|
||||
allocated[url] = rid
|
||||
return rid
|
||||
}
|
||||
out := RenderMarkdownToOOXMLWithStyles("See [Bundesgerichtshof](https://bgh.bund.de) for details.", slicedStylemap(), alloc)
|
||||
if _, ok := allocated["https://bgh.bund.de"]; !ok {
|
||||
t.Errorf("allocator never called for URL: %q", out)
|
||||
}
|
||||
if !strings.Contains(out, `<w:hyperlink r:id="rIdComposerhttps://bgh.bund.de">`) {
|
||||
t.Errorf("hyperlink tag missing or wrong rid: %q", out)
|
||||
}
|
||||
if !strings.Contains(out, "Bundesgerichtshof") {
|
||||
t.Errorf("link label missing: %q", out)
|
||||
}
|
||||
if !strings.Contains(out, `<w:rStyle w:val="Hyperlink"/>`) {
|
||||
t.Errorf("hyperlink character style missing: %q", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenderMarkdownToOOXML_HyperlinkNilAllocatorFallsBackToPlain(t *testing.T) {
|
||||
out := RenderMarkdownToOOXMLWithStyles("See [BGH](https://bgh.bund.de) here.", slicedStylemap(), nil)
|
||||
// Without an allocator, the label still renders as plain text.
|
||||
if !strings.Contains(out, "BGH") {
|
||||
t.Errorf("label dropped: %q", out)
|
||||
}
|
||||
if strings.Contains(out, "<w:hyperlink") {
|
||||
t.Errorf("hyperlink emitted without allocator: %q", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDetectBlockMarker(t *testing.T) {
|
||||
cases := []struct {
|
||||
in string
|
||||
kind string
|
||||
want string
|
||||
ok bool
|
||||
}{
|
||||
{"# A", "heading_1", "A", true},
|
||||
{"## B", "heading_2", "B", true},
|
||||
{"### C", "heading_3", "C", true},
|
||||
{" # indented", "heading_1", "indented", true}, // up to 3 spaces tolerated
|
||||
{" # too-deep", "", "", false}, // 4 spaces → not a heading
|
||||
{"- bullet", "list_bullet", "bullet", true},
|
||||
{"* star", "list_bullet", "star", true},
|
||||
{"1. one", "list_numbered", "one", true},
|
||||
{"42. forty-two", "list_numbered", "forty-two", true},
|
||||
{"1) paren", "list_numbered", "paren", true},
|
||||
{"1.no-space", "", "", false}, // ordinal needs trailing space
|
||||
{"> quote", "blockquote", "quote", true},
|
||||
{"plain", "", "", false},
|
||||
{"#nospace", "", "", false}, // heading needs space after hash
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.in, func(t *testing.T) {
|
||||
kind, payload, ok := detectBlockMarker(tc.in)
|
||||
if ok != tc.ok || kind != tc.kind || payload != tc.want {
|
||||
t.Errorf("detectBlockMarker(%q) = (%q,%q,%v); want (%q,%q,%v)", tc.in, kind, payload, ok, tc.kind, tc.want, tc.ok)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user