Skip to content
Open
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
Original file line number Diff line number Diff line change
Expand Up @@ -17,14 +17,15 @@ const isFragment = (nodeOrFragment) => {

/**
* Checks if a string looks like it contains HTML tags.
* Matches complete tag pairs (e.g., <div>...</div>) or self-closing tags (e.g., <br/>, <img ...>).
* Detects a tag (tag pairs or self-closing tags) anywhere in the string.
*
* @param {string} str
* @returns {boolean}
*/
const looksLikeHTML = (str) =>
/^\s*<[a-zA-Z][^>]*>.*<\/[a-zA-Z][^>]*>\s*$/s.test(str) || // Complete tag pair
/^\s*<[a-zA-Z][^>]*\/>\s*$/.test(str) || // Self-closing tag
/^\s*<(br|hr|img|input|meta|link|area|base|col|embed|param|source|track|wbr)\b[^>]*>\s*$/i.test(str); // Void elements
/<[a-zA-Z][^>]*>[\s\S]*?<\/[a-zA-Z][^>]*>/.test(str) || // Tag pair somewhere (incl. inline formatting inside text)
/<[a-zA-Z][^>]*\/>/.test(str) || // Self-closing tag somewhere
/<(br|hr|img|input|meta|link|area|base|col|embed|param|source|track|wbr)\b[^>]*>/i.test(str); // Void element somewhere

/**
* Inserts content at the specified position.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -240,4 +240,40 @@ describe('insertContentAt', () => {
// empty textblock wrapper replacement [from-1, to+1]
expect(tr.replaceWith).toHaveBeenCalledWith(9, 11, blockNode);
});

it('parses inline HTML mixed with text via the HTML path (not literal insertText)', () => {
const value = '☒ <strong>yes</strong> ☐ no';

createNodeFromContent.mockImplementation(() => [
{ isText: true, isBlock: false, marks: [], check: vi.fn() },
{ isText: true, isBlock: false, marks: [{ type: 'bold' }], check: vi.fn() },
{ isText: true, isBlock: false, marks: [], check: vi.fn() },
]);

const tr = makeTr();
const editor = makeEditor();

const cmd = insertContentAt(5, value, { updateSelection: true });
const result = cmd({ tr, dispatch: true, editor });

expect(result).toBe(true);
expect(createNodeFromContent).toHaveBeenCalled(); // took the HTML path
expect(tr.replaceWith).toHaveBeenCalled();
expect(tr.insertText).not.toHaveBeenCalledWith(value, 5, 5); // not inserted verbatim
});

it('treats prose with stray angle brackets as plain text', () => {
const value = 'For all x, 5 < 10 and 20 > 3';

const tr = makeTr();
const editor = makeEditor();

const cmd = insertContentAt(5, value, { updateSelection: true });
const result = cmd({ tr, dispatch: true, editor });

expect(result).toBe(true);
expect(createNodeFromContent).not.toHaveBeenCalled(); // fast path, no HTML parsing
expect(tr.insertText).toHaveBeenCalledWith(value, 5, 5);
expect(tr.replaceWith).not.toHaveBeenCalled();
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -64,8 +64,84 @@ export function resolveHyperlinkAttributes(instruction, docx) {
}

/**
* Processes a HYPERLINK instruction and creates a `w:hyperlink` node.
* @param {import('../../v2/types/index.js').OpenXmlNode[]} nodesToCombine The nodes to combine.
* Slips a `<w:hyperlink>` *inside* one paragraph, wrapping just its visible runs.
*
* @param {import('../../v2/types/index.js').OpenXmlNode} paragraph
* @param {Record<string, string | boolean>} linkAttributes
* @returns {import('../../v2/types/index.js').OpenXmlNode} A copy of the paragraph with its runs wrapped.
*/
function wrapParagraphRunsInHyperlink(paragraph, linkAttributes) {
const children = Array.isArray(paragraph.elements) ? paragraph.elements : [];
const wrappedChildren = [];
let pendingRuns = null;

const flushPendingRuns = () => {
if (!pendingRuns || pendingRuns.length === 0) {
pendingRuns = null;
return;
}
wrappedChildren.push({
name: 'w:hyperlink',
type: 'element',
attributes: { ...linkAttributes },
elements: pendingRuns,
});
pendingRuns = null;
};

for (const child of children) {
if (child?.name === 'w:r') {
if (!pendingRuns) pendingRuns = [];
pendingRuns.push(child);
Comment on lines +93 to +95

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Restrict hyperlinks to runs inside the field

When a HYPERLINK field begins after ordinary text in its first paragraph or ends before ordinary text in its last paragraph, preProcessNodesForFldChar passes the whole boundary paragraphs here, including those outside-field runs. This loop wraps every direct w:r, so the prefix and suffix unexpectedly become clickable; preserve the field boundaries when splitting the hyperlink across paragraphs rather than treating every run in each collected paragraph as field content.

Useful? React with 👍 / 👎.

continue;
}
// Paragraph properties (w:pPr) and any other structural child must stay a
// direct child of the paragraph, outside the inline hyperlink.
flushPendingRuns();
wrappedChildren.push(child);
Comment on lines +98 to +101

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Preserve links on runs nested in paragraph wrappers

When a cross-paragraph field's visible text is inside an inline wrapper such as w:sdt, this loop leaves the wrapper outside any hyperlink and therefore imports its text without a link mark. The changed preProcessNodesForFldChar test demonstrates this exact w:sdtContent shape and now expects the link to disappear; recurse into inline wrappers or otherwise apply the hyperlink to their visible runs while keeping the paragraph structure valid.

Useful? React with 👍 / 👎.

}
flushPendingRuns();

return { ...paragraph, elements: wrappedChildren };
}

/**
* Turns a HYPERLINK field code into real `<w:hyperlink>` tag(s).
*
* Old Word docs don't store a link as one tidy tag. They store "plumbing": a
* `begin` marker, the URL instruction, a `separate` marker, the visible text,
* then an `end` marker. By the time we get here that plumbing has been stripped
* and `nodesToCombine` holds just the visible content the link should cover. Our
* job is to wrap that content in a `<w:hyperlink>`.
*
* Common case — the whole link lives on one line, so `nodesToCombine` is just
* inline runs. One `<w:hyperlink>` around all of them is correct:
*
* <w:hyperlink><w:r>CSP - 1</w:r></w:hyperlink>
*
* Cross-paragraph case — the plumbing started on one line and finished on the
* next (very common when a link fills a table cell), so `nodesToCombine` holds
* whole `<w:p>` blocks, not loose runs. A `<w:hyperlink>` is *inline* (like
* `<b>`): it lives inside a line of text and cannot contain a `<w:p>`. Wrapping
* the paragraphs in one hyperlink...
*
* <w:hyperlink> <-- paragraphs can't live inside an inline tag
* <w:p>CSP - 1</w:p>
* <w:p>Data in transit</w:p>
* </w:hyperlink>
*
* ...makes the importer throw the paragraphs away (it only reads runs out of a
* hyperlink), which empties the table cell. An empty cell breaks the tableCell
* `block+` schema and aborts the whole document load.
*
* So for the cross-paragraph case we keep each paragraph and drop a *separate*
* `<w:hyperlink>` inside each one, all pointing at the same target — exactly how
* Word itself writes a link that spans more than one line:
*
* <w:p><w:hyperlink r:id="rId5"><w:r>CSP - 1</w:r></w:hyperlink></w:p>
* <w:p><w:hyperlink r:id="rId5"><w:r>Data in transit</w:r></w:hyperlink></w:p>
*
* @param {import('../../v2/types/index.js').OpenXmlNode[]} nodesToCombine The visible content the link should cover.
* @param {string} instruction The instruction text.
* @param {{ docx?: import('../../v2/docxHelper').ParsedDocx }} [options]
* @returns {import('../../v2/types/index.js').OpenXmlNode[]}
Expand All @@ -75,12 +151,30 @@ export function preProcessHyperlinkInstruction(nodesToCombine, instruction, opti
const docx = options.docx;
const linkAttributes = resolveHyperlinkAttributes(instruction, docx) ?? {};

return [
{
// A `<w:p>` in the gathered content means the link spans a paragraph break, so
// we can't use the simple "one hyperlink around everything" shape.
const spansParagraphBoundary = nodesToCombine.some((node) => node?.name === 'w:p');
if (!spansParagraphBoundary) {
return [
{
name: 'w:hyperlink',
type: 'element',
attributes: linkAttributes,
elements: nodesToCombine,
},
];
}

return nodesToCombine.map((node) => {
if (node?.name === 'w:p') {
return wrapParagraphRunsInHyperlink(node, linkAttributes);
}
// A loose run sitting between paragraphs still gets its own inline link.
return {
name: 'w:hyperlink',
type: 'element',
attributes: linkAttributes,
elements: nodesToCombine,
},
];
attributes: { ...linkAttributes },
elements: [node],
};
});
}
Original file line number Diff line number Diff line change
Expand Up @@ -181,4 +181,97 @@ describe('preProcessHyperlinkInstruction', () => {
},
]);
});

it('wraps runs per paragraph when the field spans a paragraph boundary', () => {
// A field whose collected content crosses a paragraph break yields whole
// <w:p> blocks. Because <w:hyperlink> is inline and cannot wrap a <w:p> or
// span the break, Word emits one <w:hyperlink> per paragraph sharing the
// same target. The paragraphs must survive as blocks so a table cell keeps
// its required block content.
const instruction = 'HYPERLINK "http://example.com"';
const mockDocx = {
'word/_rels/document.xml.rels': {
elements: [{ name: 'Relationships', elements: [] }],
},
};

const crossParagraphNodes = [
{
name: 'w:p',
elements: [{ name: 'w:r', elements: [{ name: 'w:t', elements: [{ type: 'text', text: 'first ' }] }] }],
},
{
name: 'w:p',
elements: [{ name: 'w:r', elements: [{ name: 'w:t', elements: [{ type: 'text', text: 'second' }] }] }],
},
];

const result = preProcessHyperlinkInstruction(crossParagraphNodes, instruction, { docx: mockDocx });

expect(result).toEqual([
{
name: 'w:p',
elements: [
{
name: 'w:hyperlink',
type: 'element',
attributes: { 'r:id': 'rIdabc12345' },
elements: [{ name: 'w:r', elements: [{ name: 'w:t', elements: [{ type: 'text', text: 'first ' }] }] }],
},
],
},
{
name: 'w:p',
elements: [
{
name: 'w:hyperlink',
type: 'element',
attributes: { 'r:id': 'rIdabc12345' },
elements: [{ name: 'w:r', elements: [{ name: 'w:t', elements: [{ type: 'text', text: 'second' }] }] }],
},
],
},
]);
});

it('keeps paragraph properties outside the per-paragraph hyperlink', () => {
// <w:pPr> is a paragraph-level property, not visible content, so it must stay
// a direct child of the paragraph rather than being pulled into the inline
// <w:hyperlink> alongside the runs.
const instruction = 'HYPERLINK "http://example.com"';
const mockDocx = {
'word/_rels/document.xml.rels': {
elements: [{ name: 'Relationships', elements: [] }],
},
};

const nodes = [
{
name: 'w:p',
elements: [
{ name: 'w:pPr', elements: [{ name: 'w:jc', attributes: { 'w:val': 'center' } }] },
{ name: 'w:r', elements: [{ name: 'w:t', elements: [{ type: 'text', text: 'only' }] }] },
],
},
{
name: 'w:p',
elements: [{ name: 'w:r', elements: [{ name: 'w:t', elements: [{ type: 'text', text: 'tail' }] }] }],
},
];

const result = preProcessHyperlinkInstruction(nodes, instruction, { docx: mockDocx });

expect(result[0]).toEqual({
name: 'w:p',
elements: [
{ name: 'w:pPr', elements: [{ name: 'w:jc', attributes: { 'w:val': 'center' } }] },
{
name: 'w:hyperlink',
type: 'element',
attributes: { 'r:id': 'rIdabc12345' },
elements: [{ name: 'w:r', elements: [{ name: 'w:t', elements: [{ type: 'text', text: 'only' }] }] }],
},
],
});
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -487,14 +487,17 @@ describe('preProcessNodesForFldChar', () => {

const { processedNodes } = preProcessNodesForFldChar(nodes, mockDocx);

// The field envelope crosses into a paragraph, so the collected content is a
// <w:p>. A <w:hyperlink> is inline and cannot wrap a <w:p>, so the link goes
// inside the paragraph around its runs — never around the paragraph itself.
expect(processedNodes).toEqual([
{
name: 'w:hyperlink',
type: 'element',
attributes: { 'r:id': 'rIdabc12345' },
name: 'w:p',
elements: [
{
name: 'w:p',
name: 'w:hyperlink',
type: 'element',
attributes: { 'r:id': 'rIdabc12345' },
elements: [{ name: 'w:r', elements: [{ name: 'w:t', elements: [{ type: 'text', text: 'link text' }] }] }],
},
],
Expand Down Expand Up @@ -531,24 +534,22 @@ describe('preProcessNodesForFldChar', () => {

const { processedNodes } = preProcessNodesForFldChar(nodes, mockDocx);

// The collected content is a <w:p>, so the paragraph is preserved as a block
// rather than being wrapped in an inline <w:hyperlink> (which would drop it).
// The visible run lives inside a <w:sdt> wrapper, not directly under the
// paragraph, so it is kept intact; the link is applied only to runs that are
// direct paragraph children.
expect(processedNodes).toEqual([
{
name: 'w:hyperlink',
type: 'element',
attributes: { 'r:id': 'rIdabc12345' },
name: 'w:p',
elements: [
{
name: 'w:p',
name: 'w:sdt',
elements: [
{
name: 'w:sdt',
name: 'w:sdtContent',
elements: [
{
name: 'w:sdtContent',
elements: [
{ name: 'w:r', elements: [{ name: 'w:t', elements: [{ type: 'text', text: 'link text' }] }] },
],
},
{ name: 'w:r', elements: [{ name: 'w:t', elements: [{ type: 'text', text: 'link text' }] }] },
],
},
],
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
/* @vitest-environment jsdom */

/**
* End-to-end coverage for `checkbox.setState`: it must update a block-scope
* checkbox's VISIBLE glyph, not just the stored `w14:checked` value.
*
* A block-scope checkbox is an SDT whose `sdtContent` wraps a `<w:p>` carrying
* the glyph (e.g. stacked Yes/No checkboxes in a table cell). The inline path
* swaps the glyph via `updateStructuredContentById`, but that builds inline text
* JSON the block schema rejects — so for block SDTs the glyph must be rewritten
* through the content range directly. The unit test in
* `content-controls-wrappers.test.ts` pins the wrapper-level behavior with a
* mock; this test drives the real import → mutate → read pipeline against the
* `block_checkbox_control.docx` fixture to confirm the glyph swaps end-to-end
* and to guard against regressions.
*/

import { describe, expect, it } from 'vitest';
import { initTestEditor, loadTestDataForEditorTests } from '@tests/helpers/helpers.js';

const UNCHECKED = '☐'; // U+2610
const CHECKED = '☒'; // U+2612

describe('checkbox.setState updates the visible glyph (block scope)', () => {
it('swaps the rendered glyph ☐ -> ☒ for a block-scope checkbox', async () => {
const docData = await loadTestDataForEditorTests('block_checkbox_control.docx');
const { editor } = initTestEditor({
content: docData.docx,
media: docData.media,
mediaFiles: docData.mediaFiles,
fonts: docData.fonts,
isHeadless: true,
user: { name: 'Test', email: 'test@example.com' },
});

// The fixture starts unchecked.
const before = await Promise.resolve(editor.doc.contentControls.list());
const checkboxBefore = before.items.find((item) => item.controlType === 'checkbox');
expect(checkboxBefore).toBeDefined();
expect(checkboxBefore?.text).toContain(UNCHECKED);

const result = await Promise.resolve(
editor.doc.contentControls.checkbox.setState(
{ target: checkboxBefore!.target, checked: true },
{ changeMode: 'direct' },
),
);
expect(result.success).toBe(true);

// Re-read: the rendered glyph should now be checked, not the empty box.
const after = await Promise.resolve(editor.doc.contentControls.list());
const checkboxAfter = after.items.find((item) => item.controlType === 'checkbox');
expect(checkboxAfter?.text).toContain(CHECKED);
expect(checkboxAfter?.text).not.toContain(UNCHECKED);
});
});
Loading