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
8 changes: 8 additions & 0 deletions packages/layout-engine/painters/dom/src/renderer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5383,6 +5383,14 @@ export class DomPainter {
elem.style.zIndex = '1';
applyRunDataAttributes(elem as HTMLElement, (run as TextRun).dataAttrs);

// SD-2454: bookmark marker runs carry a data-bookmark-name attribute.
// Surface the bookmark name as a native `title` tooltip so hovering the
// opening bracket identifies which bookmark is being marked.
const bookmarkName = (run as TextRun).dataAttrs?.['data-bookmark-name'];
if (bookmarkName) {
(elem as HTMLElement).title = bookmarkName;
}

// Assert PM positions are present for cursor fallback
assertPmPositions(run, 'paragraph text run');

Expand Down
15 changes: 15 additions & 0 deletions packages/layout-engine/painters/dom/src/styles.ts
Original file line number Diff line number Diff line change
Expand Up @@ -197,6 +197,21 @@ const LINK_AND_TOC_STYLES = `
}
}

/* SD-2454: bookmark bracket indicators.
* When the showBookmarks layout option is enabled, the pm-adapter emits
* [ and ] marker TextRuns at bookmark start/end positions. Mirror Word's
* visual treatment: subtle gray, non-selectable so users can't accidentally
* include the brackets in copied text. The bookmark name is surfaced via
* the native title tooltip on the opening bracket. */
[data-bookmark-marker="start"],
[data-bookmark-marker="end"] {
color: #8b8b8b;
user-select: none;
cursor: default;
font-weight: normal;
}


/* Reduced motion support */
@media (prefers-reduced-motion: reduce) {
.superdoc-link {
Expand Down
17 changes: 17 additions & 0 deletions packages/layout-engine/pm-adapter/src/converter-context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,23 @@ export type ConverterContext = {
* Used by table creation paths to determine which style to apply to new tables.
*/
defaultTableStyleId?: string;
/**
* When true, emit visible gray `[` and `]` marker TextRuns at bookmarkStart
* and bookmarkEnd positions — matching Word's "Show bookmarks" feature
* (File > Options > Advanced). Off by default because bookmarks are a
* structural concept, not a visual one. SD-2454.
*/
showBookmarks?: boolean;

/**
* Populated by the bookmark-start inline converter during conversion: the
* set of bookmark numeric ids (as strings) that actually rendered a start
* marker. The bookmark-end converter reads this set to suppress emitting
* an orphan `]` for a start it also suppressed (e.g. `_Toc…` / `_Ref…`
* auto-generated bookmarks filtered out by the `showBookmarks` feature).
* SD-2454.
*/
renderedBookmarkIds?: Set<string>;
};

/**
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
import type { TextRun } from '@superdoc/contracts';
import type { PMNode } from '../../types.js';
import { textNodeToRun } from './text-run.js';
import { type InlineConverterParams } from './common.js';

/**
* Converts a `bookmarkEnd` PM node.
*
* SD-2454: when `converterContext.showBookmarks` is true, emit a visible gray
* `]` marker at the bookmark end. Matches Word's "Show bookmarks" rendering.
* Returns void (no visual output) when the option is off, preserving today's
* behavior where bookmarkEnd is an invisible structural marker.
*
* The PM schema does not store the bookmark name on bookmarkEnd — only the
* numeric `id` that matches the corresponding bookmarkStart. We therefore
* don't set a tooltip on the closing bracket (Word also omits the name on
* the closing bracket's hover). Styling and identification happen on the
* opening bracket.
*/
export function bookmarkEndNodeToRun(params: InlineConverterParams): TextRun | void {
const { node, converterContext } = params;
if (converterContext?.showBookmarks !== true) return;

const nodeAttrs =
typeof node.attrs === 'object' && node.attrs !== null ? (node.attrs as Record<string, unknown>) : {};
const bookmarkId = typeof nodeAttrs.id === 'string' || typeof nodeAttrs.id === 'number' ? String(nodeAttrs.id) : '';

// Only emit `]` if we emitted the matching `[`. Keeps brackets paired and
// prevents an orphan closing bracket for a suppressed auto-generated
// bookmark (`_Toc…`, `_Ref…`, `_GoBack`).
const rendered = converterContext?.renderedBookmarkIds;
if (rendered && bookmarkId && !rendered.has(bookmarkId)) return;

const run = textNodeToRun({
...params,
node: { type: 'text', text: ']', marks: [...(node.marks ?? [])] } as PMNode,
});
run.dataAttrs = {
...(run.dataAttrs ?? {}),
'data-bookmark-marker': 'end',
...(bookmarkId ? { 'data-bookmark-id': bookmarkId } : {}),
};
return run;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
import { describe, it, expect, vi } from 'vitest';
import type { TextRun } from '@superdoc/contracts';
import type { PMNode } from '../../types.js';
import type { InlineConverterParams } from './common.js';

vi.mock('./text-run.js', () => ({
textNodeToRun: vi.fn(
(params: InlineConverterParams): TextRun => ({
text: params.node.text || '',
fontFamily: params.defaultFont,
fontSize: params.defaultSize,
}),
),
}));

import { bookmarkStartNodeToBlocks } from './bookmark-start.js';
import { bookmarkEndNodeToRun } from './bookmark-end.js';

function makeParams(
node: PMNode,
opts: { showBookmarks?: boolean; bookmarks?: Map<string, number>; renderedBookmarkIds?: Set<string> } = {},
): InlineConverterParams {
return {
node,
positions: new WeakMap(),
defaultFont: 'Calibri',
defaultSize: 16,
inheritedMarks: [],
sdtMetadata: undefined,
hyperlinkConfig: { enableRichHyperlinks: false },
themeColors: undefined,
runProperties: undefined,
paragraphProperties: undefined,
converterContext: {
translatedNumbering: {},
translatedLinkedStyles: { docDefaults: {}, latentStyles: {}, styles: {} },
showBookmarks: opts.showBookmarks ?? false,
renderedBookmarkIds: opts.renderedBookmarkIds,
} as unknown as InlineConverterParams['converterContext'],
enableComments: false,
visitNode: vi.fn(),
bookmarks: opts.bookmarks,
tabOrdinal: 0,
paragraphAttrs: {},
nextBlockId: vi.fn(),
} as InlineConverterParams;
}

describe('bookmarkStartNodeToBlocks (SD-2454)', () => {
it('emits no visible run when showBookmarks is off (default)', () => {
const node: PMNode = { type: 'bookmarkStart', attrs: { name: 'chapter1', id: '1' } };
const result = bookmarkStartNodeToBlocks(makeParams(node, { showBookmarks: false }));
expect(result).toBeUndefined();
});

it('emits a `[` TextRun with bookmark-name data attr when showBookmarks is on', () => {
const node: PMNode = { type: 'bookmarkStart', attrs: { name: 'chapter1', id: '1' } };
const result = bookmarkStartNodeToBlocks(makeParams(node, { showBookmarks: true }));
expect(result).toBeDefined();
expect(result!.text).toBe('[');
expect(result!.dataAttrs).toEqual({
'data-bookmark-name': 'chapter1',
'data-bookmark-marker': 'start',
});
});

// Matches Word behavior: `_Toc…`, `_Ref…`, `_GoBack` etc. are hidden from
// Show Bookmarks because they are internally generated for headings,
// fields, or navigation — showing them would clutter the document.
it.each(['_Toc1234', '_Ref506192326', '_GoBack'])('suppresses marker for auto-generated bookmark "%s"', (name) => {
const node: PMNode = { type: 'bookmarkStart', attrs: { name, id: '1' } };
const result = bookmarkStartNodeToBlocks(makeParams(node, { showBookmarks: true }));
expect(result).toBeUndefined();
});

it('still records bookmark position for cross-reference resolution regardless of showBookmarks', () => {
const bookmarks = new Map<string, number>();
const node: PMNode = { type: 'bookmarkStart', attrs: { name: 'chapter1', id: '1' } };
const params = makeParams(node, { showBookmarks: false, bookmarks });
// Seed the position map
params.positions.set(node, { start: 42, end: 42 });
bookmarkStartNodeToBlocks(params);
expect(bookmarks.get('chapter1')).toBe(42);
});
});

describe('bookmarkEndNodeToRun (SD-2454)', () => {
it('emits no run when showBookmarks is off (default)', () => {
const node: PMNode = { type: 'bookmarkEnd', attrs: { id: '1' } };
const result = bookmarkEndNodeToRun(makeParams(node, { showBookmarks: false }));
expect(result).toBeUndefined();
});

it('emits a `]` TextRun when the matching start was rendered', () => {
const node: PMNode = { type: 'bookmarkEnd', attrs: { id: '1' } };
const result = bookmarkEndNodeToRun(makeParams(node, { showBookmarks: true, renderedBookmarkIds: new Set(['1']) }));
expect(result).toBeDefined();
expect(result!.text).toBe(']');
expect(result!.dataAttrs).toEqual({
'data-bookmark-marker': 'end',
'data-bookmark-id': '1',
});
});

it('suppresses `]` when the matching start was also suppressed (no orphan brackets)', () => {
const node: PMNode = { type: 'bookmarkEnd', attrs: { id: '42' } };
// Start with id 42 was suppressed — renderedBookmarkIds does not include it
const result = bookmarkEndNodeToRun(
makeParams(node, { showBookmarks: true, renderedBookmarkIds: new Set(['99']) }),
);
expect(result).toBeUndefined();
});
});
Original file line number Diff line number Diff line change
@@ -1,26 +1,68 @@
import { type InlineConverterParams } from './common';
import type { TextRun } from '@superdoc/contracts';
import type { PMNode } from '../../types.js';
import { textNodeToRun } from './text-run.js';
import { type InlineConverterParams } from './common.js';

export function bookmarkStartNodeToBlocks({
node,
positions,
bookmarks,
visitNode,
inheritedMarks,
sdtMetadata,
runProperties,
}: InlineConverterParams): void {
// Track bookmark position for cross-reference resolution
/**
* Converts a `bookmarkStart` PM node.
*
* Primary job: record the bookmark's PM position in the `bookmarks` Map so
* cross-reference navigation (goToAnchor) can resolve `#<bookmark-name>`
* hrefs to a document position.
*
* SD-2454: when `converterContext.showBookmarks` is true, also emit a visible
* gray `[` marker at the bookmark start, matching Word's opt-in "Show
* bookmarks" feature. The marker is a regular TextRun so it flows through
* pagination and line breaking like any other character; `dataAttrs` tag it
* so DomPainter can style it gray and set a tooltip with the bookmark name.
*
* When `showBookmarks` is false (the default), the converter still descends
* into any content inside the bookmark span but emits no visual output.
*/
export function bookmarkStartNodeToBlocks(params: InlineConverterParams): TextRun | void {
const { node, positions, bookmarks, visitNode, inheritedMarks, sdtMetadata, runProperties, converterContext } =
params;
const nodeAttrs =
typeof node.attrs === 'object' && node.attrs !== null ? (node.attrs as Record<string, unknown>) : {};
const bookmarkName = typeof nodeAttrs.name === 'string' ? nodeAttrs.name : undefined;

if (bookmarkName && bookmarks) {
const nodePos = positions.get(node);
if (nodePos) {
bookmarks.set(bookmarkName, nodePos.start);
}
}
// Process any content inside the bookmark (usually empty)

// Word hides `_Toc…` / `_Ref…` / other `_`-prefixed bookmarks from its Show
// Bookmarks rendering because they're autogenerated (headings, fields).
// Mirror that so opt-in markers don't pollute every heading and xref target.
const shouldRender =
converterContext?.showBookmarks === true && typeof bookmarkName === 'string' && !bookmarkName.startsWith('_');

let run: TextRun | undefined;
if (shouldRender) {
run = textNodeToRun({
...params,
node: { type: 'text', text: '[', marks: [...(node.marks ?? [])] } as PMNode,
});
run.dataAttrs = {
...(run.dataAttrs ?? {}),
'data-bookmark-name': bookmarkName!,
'data-bookmark-marker': 'start',
};
// Record the id so the matching bookmarkEnd converter knows to emit `]`.
// Without this, suppressing a `_`-prefixed start leaves an orphan `]`.
const bookmarkIdRaw = nodeAttrs.id;
const bookmarkId =
typeof bookmarkIdRaw === 'string' || typeof bookmarkIdRaw === 'number' ? String(bookmarkIdRaw) : '';
if (bookmarkId && converterContext?.renderedBookmarkIds) {
converterContext.renderedBookmarkIds.add(bookmarkId);
}
}

if (Array.isArray(node.content)) {
node.content.forEach((child) => visitNode(child, inheritedMarks, sdtMetadata, runProperties));
}

return run;
}
Loading
Loading