@@ -66,46 +66,35 @@ export function collectTocSources(doc: ProseMirrorNode, config: TocSwitchConfig)
6666 const attrs = node . attrs as Record < string , unknown > | undefined ;
6767 const paragraphProps = attrs ?. paragraphProperties as Record < string , unknown > | undefined ;
6868 const styleId = paragraphProps ?. styleId as string | undefined ;
69- // Pasted/new paragraphs intentionally have null paraId/sdBlockId (see
70- // InputRule.js SUPERDOC_SLICE_PASTE_IDENTITY_RESETS) to avoid public-id
71- // duplicates. Synthesize a deterministic position-based id so they still
72- // appear in the rebuilt TOC and round-trip via OOXML bookmarks.
69+ // Pasted/new paragraphs intentionally lose paraId/sdBlockId (see
70+ // InputRule.js SUPERDOC_SLICE_PASTE_IDENTITY_RESETS). Synthesize a
71+ // position-based id so they still appear in the rebuilt TOC.
7372 const sdBlockId =
7473 ( ( attrs ?. sdBlockId ?? attrs ?. paraId ) as string | undefined ) ?? buildFallbackBlockNodeId ( 'paragraph' , pos ) ;
75-
76- // Update paragraph context for TC field collection
7774 currentParagraphSdBlockId = sdBlockId ;
78-
7975 if ( ! sdBlockId ) return true ;
8076
8177 const text = flattenText ( node ) ;
82- // Word's TOC field skips paragraphs that are styled as headings but
83- // contain no visible text (page-break-only spacers, empty stubs).
84- // Including them produces ghost entries that look like a regression.
85- const hasVisibleText = text . trim ( ) . length > 0 ;
78+ // Word's TOC skips heading-styled paragraphs with no visible text
79+ // (page-break spacers, empty stubs).
80+ if ( text . trim ( ) . length === 0 ) return true ;
8681
87- // Check heading by style (\o switch)
82+ // \o switch — heading- style level
8883 if ( outlineLevels ) {
8984 const headingLevel = getHeadingLevel ( styleId ) ;
90- if (
91- headingLevel != null &&
92- headingLevel >= outlineLevels . from &&
93- headingLevel <= outlineLevels . to &&
94- hasVisibleText
95- ) {
85+ if ( headingLevel != null && headingLevel >= outlineLevels . from && headingLevel <= outlineLevels . to ) {
9686 sources . push ( { text, level : headingLevel , sdBlockId, kind : 'heading' } ) ;
97- // Continue descending to find TC fields within this paragraph
98- return true ;
87+ return true ; // descend so TC fields inside this paragraph are still collected
9988 }
10089 }
10190
102- // Check applied outline level (\u switch)
91+ // \u switch — applied paragraph outline level
10392 if ( useApplied ) {
10493 const effectiveLevels = outlineLevels ?? { from : 1 , to : 9 } ;
10594 const rawOutlineLevel = paragraphProps ?. outlineLevel as number | undefined ;
10695 if ( rawOutlineLevel != null ) {
10796 const tocLevel = rawOutlineLevel + 1 ;
108- if ( tocLevel >= effectiveLevels . from && tocLevel <= effectiveLevels . to && hasVisibleText ) {
97+ if ( tocLevel >= effectiveLevels . from && tocLevel <= effectiveLevels . to ) {
10998 sources . push ( { text, level : tocLevel , sdBlockId, kind : 'appliedOutline' } ) ;
11099 return true ;
111100 }
@@ -171,40 +160,38 @@ export interface EntryParagraphJson {
171160 content : Array < Record < string , unknown > > ;
172161}
173162
174- /**
175- * Builds ProseMirror-compatible paragraph JSON nodes for TOC entries.
176- *
177- * Each entry gets:
178- * - Paragraph style: TOC{level}
179- * - tocSourceId paragraph attribute (source heading/TC field's sdBlockId)
180- * - Link mark with anchor pointing to a `_Toc`-prefixed bookmark name (when \h is set)
181- * - Page number placeholder "0" with tocPageNumber mark
182- * - Separator: custom (\p switch) or default tab
183- */
184- /**
185- * Optional context that lets the entry builder produce final-looking output
186- * without a follow-up `mode: 'pageNumbers'` pass and without losing layout
187- * particulars from the existing TOC.
188- */
189- /** Mark JSON shape carried over from the existing TOC entry's text run. */
163+ /** A mark in JSON form, as carried on the rebuilt TOC entry's text runs. */
190164export interface EntryTextMark {
191165 type : string ;
192166 attrs ?: Record < string , unknown > ;
193167}
194168
169+ /**
170+ * Optional context that lets the entry builder produce final-looking output
171+ * (resolved page numbers, preserved tab spacing, sampled font/size marks)
172+ * without a follow-up `mode: 'pageNumbers'` pass.
173+ */
195174export interface BuildTocEntryOptions {
196175 /** sdBlockId → page number map from PresentationEditor's last layout cycle. */
197176 pageMap ?: Map < string , number > ;
198177 /** Right-tab stop position (twips) to mirror the existing TOC's spacing. */
199178 tabPos ?: number ;
179+ /** Marks sampled from the existing TOC entry text. `link` is filtered out and rebuilt. */
180+ entryTextMarks ?: EntryTextMark [ ] ;
200181 /**
201- * Marks (font, size, textStyle, bold/italic, etc.) sampled from the existing
202- * TOC entry's text run so a rebuild keeps the same visual styling. The link
203- * mark is excluded — the builder rebuilds it from the source's bookmark name.
182+ * Paragraph-level `<w:rPr>` overrides sampled from the existing entry. Word
183+ * stamps these on TOC entries (e.g. `bold: false`, `italic: false`) to
184+ * disable the TOC1 paragraph style's `<w:b/><w:i/>`. Preserving them keeps
185+ * the rebuilt entries visually identical to the imported ones.
204186 */
205- entryTextMarks ?: EntryTextMark [ ] ;
187+ paragraphRunProperties ?: Record < string , unknown > ;
206188}
207189
190+ /**
191+ * Build TOC entry paragraphs. Each paragraph carries `pStyle="TOC{level}"`,
192+ * a `tocSourceId` attr pointing back to the source heading, and three runs:
193+ * the (linked) entry title, the tab/separator, and the page number.
194+ */
208195export function buildTocEntryParagraphs (
209196 sources : TocSource [ ] ,
210197 config : TocSwitchConfig ,
@@ -224,82 +211,71 @@ const TAB_LEADER_MAP: Record<string, string> = {
224211 middleDot : 'middleDot' ,
225212} ;
226213
214+ /** Wrap inline children in a `run` node — the schema unit that `wrapTextInRunsPlugin` skips. */
215+ function asRun ( children : Array < Record < string , unknown > > ) : Record < string , unknown > {
216+ return { type : 'run' , content : children } ;
217+ }
218+
227219function buildEntryParagraph (
228220 source : TocSource ,
229221 config : TocSwitchConfig ,
230222 options : BuildTocEntryOptions = { } ,
231223) : EntryParagraphJson {
232224 const { display } = config ;
233225
234- // Entry text — preserves run formatting (font, size, bold, italic, textStyle…)
235- // sampled from the existing TOC. Link mark is rebuilt from the source's
236- // bookmark name and stacked on top of the preserved marks.
237- //
238- // We wrap the text in a `run` node because `wrapTextInRunsPlugin` would
239- // otherwise wrap the bare paragraph-child text on appendTransaction and, for
240- // the first child of a paragraph, *merge paragraph-style marks via addToSet*
241- // — which clobbers our sampled `textStyle` (TNR/Hyperlink) with the TOC1
242- // paragraph style's `textStyle` (Aptos). Pre-wrapping in a run keeps the
243- // marks we constructed.
244- const preservedMarks = ( options . entryTextMarks ?? [ ] ) . filter ( ( mark ) => mark ?. type && mark . type !== 'link' ) ;
245- const titleMarks : EntryTextMark [ ] = [ ...preservedMarks ] ;
226+ // Title text. Marks are stacked: sampled (font/size/textStyle/bold/italic)
227+ // first, link last. Wrapped in a `run` so `wrapTextInRunsPlugin` does not
228+ // re-wrap and merge the TOC1 paragraph style's run properties via addToSet,
229+ // which would clobber the sampled `textStyle` mark.
230+ const titleMarks : EntryTextMark [ ] = ( options . entryTextMarks ?? [ ] ) . filter (
231+ ( mark ) => mark ?. type && mark . type !== 'link' ,
232+ ) ;
246233 if ( display . hyperlinks ) {
247234 titleMarks . push ( {
248235 type : 'link' ,
249- attrs : {
250- anchor : generateTocBookmarkName ( source . sdBlockId ) ,
251- rId : null ,
252- history : true ,
253- } ,
236+ attrs : { anchor : generateTocBookmarkName ( source . sdBlockId ) , rId : null , history : true } ,
254237 } ) ;
255238 }
256239 const titleText : Record < string , unknown > = { type : 'text' , text : source . text || ' ' } ;
257240 if ( titleMarks . length > 0 ) titleText . marks = titleMarks ;
258241
259- const content : Array < Record < string , unknown > > = [ { type : 'run' , content : [ titleText ] } ] ;
242+ const content : Array < Record < string , unknown > > = [ asRun ( [ titleText ] ) ] ;
260243
261- // Determine whether to omit page number for this entry
244+ // Determine whether to omit page number for this entry.
262245 const omitRange = display . omitPageNumberLevels ;
263- const levelOmitted = omitRange && source . level >= omitRange . from && source . level <= omitRange . to ;
264- const entryOmitted = source . omitPageNumber ;
265- const omitPageNumber = levelOmitted || entryOmitted ;
246+ const omitPageNumber = Boolean (
247+ ( omitRange && source . level >= omitRange . from && source . level <= omitRange . to ) || source . omitPageNumber ,
248+ ) ;
266249
267250 if ( ! omitPageNumber ) {
268- const separatorRunChildren : Array < Record < string , unknown > > = [ ] ;
269- if ( display . separator ) {
270- separatorRunChildren . push ( { type : 'text' , text : display . separator } ) ;
271- } else {
272- separatorRunChildren . push ( { type : 'tab' } ) ;
273- }
274- content . push ( { type : 'run' , content : separatorRunChildren } ) ;
251+ // Separator: custom \p text or default tab.
252+ content . push ( asRun ( [ display . separator ? { type : 'text' , text : display . separator } : { type : 'tab' } ] ) ) ;
275253
276- // Page number — resolved from the page map when available so a single
277- // mode 'all' rebuild produces final numbers; falls back to '0' placeholder
278- // when the source paragraph is not yet in the page map (freshly pasted
279- // headings whose synthetic id has not been seen by a layout cycle).
254+ // Page number — resolved from the page map when available; '0' placeholder
255+ // otherwise (e.g. freshly-pasted heading whose synthetic id hasn't been
256+ // seen by a layout cycle yet).
280257 const resolvedPage = options . pageMap ?. get ( source . sdBlockId ) ;
281- content . push ( {
282- type : 'run' ,
283- content : [
258+ content . push (
259+ asRun ( [
284260 {
285261 type : 'text' ,
286262 text : resolvedPage != null ? String ( resolvedPage ) : '0' ,
287263 marks : [ { type : 'tocPageNumber' } ] ,
288264 } ,
289- ] ,
290- } ) ;
265+ ] ) ,
266+ ) ;
291267 }
292268
293- // Build paragraph properties — add right-aligned tab stop when enabled
294- const paragraphProperties : Record < string , unknown > = {
295- styleId : `TOC ${ source . level } ` ,
296- } ;
269+ const paragraphProperties : Record < string , unknown > = { styleId : `TOC ${ source . level } ` } ;
270+ if ( options . paragraphRunProperties && Object . keys ( options . paragraphRunProperties ) . length > 0 ) {
271+ paragraphProperties . runProperties = { ... options . paragraphRunProperties } ;
272+ }
297273
298274 const rightAlign = display . rightAlignPageNumbers !== false ; // default true
299275 if ( rightAlign && ! omitPageNumber ) {
300276 // Word's default TOC tab leader is dots. The \p switch is only emitted
301- // when a non-default separator is used , so an absent tabLeader means the
302- // user expects dots , not "no leader". Honor an explicit 'none' to opt out.
277+ // for a non-default separator, so an absent ` tabLeader` means "use the
278+ // default" , not "no leader". ` 'none'` is the explicit opt- out.
303279 const leader =
304280 display . tabLeader === 'none' ? undefined : ( display . tabLeader && TAB_LEADER_MAP [ display . tabLeader ] ) || 'dot' ;
305281 const pos = options . tabPos ?? DEFAULT_RIGHT_TAB_POS ;
0 commit comments