|
1 | 1 | import { describe, expect, it, beforeEach, afterEach } from 'vitest'; |
2 | 2 | import { createDomPainter, sanitizeUrl, linkMetrics, applyRunDataAttributes } from './index.js'; |
| 3 | +import { resolveListMarkerGeometry } from '../../../../../shared/common/list-marker-utils.js'; |
3 | 4 | import type { |
4 | 5 | FlowBlock, |
5 | 6 | Measure, |
@@ -888,13 +889,11 @@ describe('DomPainter', () => { |
888 | 889 | painter.paint(listParaLayout, mount); |
889 | 890 |
|
890 | 891 | const firstLine = mount.querySelector('.superdoc-line') as HTMLElement; |
891 | | - // Word-spacing is calculated based on available width AFTER accounting for marker position + inline width. |
892 | | - // Fragment has indent: { left: 48, hanging: 24 }, so markerStartPos = 48 - 24 = 24 |
893 | | - // fragment.markerTextWidth is 12 |
894 | | - // Text starts at: markerStartPos (24) + markerTextWidth (12) + space (4px) = 40px |
895 | | - // availableWidth = 400 - 40 = 360 |
896 | | - // slack = 360 - 180 = 180, wordSpacing = 180 / 5 = 36px |
897 | | - expect(firstLine.style.wordSpacing).toBe('36px'); |
| 892 | + // Inline list first lines without explicit segment positioning keep the measured width contract. |
| 893 | + // The painter caps line.maxWidth by fragment width minus positive paragraph indents. |
| 894 | + // availableWidth = 400 - leftIndent(48) = 352 |
| 895 | + // slack = 352 - 180 = 172, wordSpacing = 172 / 5 = 34.4px |
| 896 | + expect(firstLine.style.wordSpacing).toBe('34.4px'); |
898 | 897 |
|
899 | 898 | const suffix = firstLine.querySelector('.superdoc-marker-suffix-space') as HTMLElement; |
900 | 899 | expect(suffix).toBeTruthy(); |
@@ -2336,6 +2335,213 @@ describe('DomPainter', () => { |
2336 | 2335 | expect(textSpan?.style.left).toBe('48px'); |
2337 | 2336 | }); |
2338 | 2337 |
|
| 2338 | + it('positions first-line list text from the resolved tab stop instead of stale wordLayout.textStartPx', () => { |
| 2339 | + const block: FlowBlock = { |
| 2340 | + kind: 'paragraph', |
| 2341 | + id: 'list-tab-stop-block', |
| 2342 | + runs: [{ text: 'Closing.', fontFamily: 'Arial', fontSize: 16 }], |
| 2343 | + attrs: { |
| 2344 | + indent: { left: 48, hanging: 24 }, |
| 2345 | + numberingProperties: { numId: 1, ilvl: 0 }, |
| 2346 | + wordLayout: { |
| 2347 | + firstLineIndentMode: true, |
| 2348 | + indentLeftPx: 48, |
| 2349 | + textStartPx: 48, |
| 2350 | + tabsPx: [144], |
| 2351 | + marker: { |
| 2352 | + markerText: '2.1', |
| 2353 | + glyphWidthPx: 20, |
| 2354 | + markerBoxWidthPx: 20, |
| 2355 | + markerX: 0, |
| 2356 | + justification: 'left', |
| 2357 | + suffix: 'tab', |
| 2358 | + run: { fontFamily: 'Arial', fontSize: 16 }, |
| 2359 | + }, |
| 2360 | + }, |
| 2361 | + }, |
| 2362 | + }; |
| 2363 | + |
| 2364 | + const measure: ParagraphMeasure = { |
| 2365 | + kind: 'paragraph', |
| 2366 | + lines: [ |
| 2367 | + { |
| 2368 | + fromRun: 0, |
| 2369 | + fromChar: 0, |
| 2370 | + toRun: 0, |
| 2371 | + toChar: 8, |
| 2372 | + width: 64, |
| 2373 | + ascent: 12, |
| 2374 | + descent: 4, |
| 2375 | + lineHeight: 20, |
| 2376 | + segments: [{ runIndex: 0, fromChar: 0, toChar: 8, width: 64, x: 0 }], |
| 2377 | + }, |
| 2378 | + ], |
| 2379 | + totalHeight: 20, |
| 2380 | + marker: { |
| 2381 | + markerWidth: 20, |
| 2382 | + markerTextWidth: 20, |
| 2383 | + indentLeft: 48, |
| 2384 | + }, |
| 2385 | + }; |
| 2386 | + |
| 2387 | + const listLayout: Layout = { |
| 2388 | + pageSize: layout.pageSize, |
| 2389 | + pages: [ |
| 2390 | + { |
| 2391 | + number: 1, |
| 2392 | + fragments: [ |
| 2393 | + { |
| 2394 | + kind: 'para', |
| 2395 | + blockId: 'list-tab-stop-block', |
| 2396 | + fromLine: 0, |
| 2397 | + toLine: 1, |
| 2398 | + x: 0, |
| 2399 | + y: 0, |
| 2400 | + width: 240, |
| 2401 | + markerWidth: 20, |
| 2402 | + }, |
| 2403 | + ], |
| 2404 | + }, |
| 2405 | + ], |
| 2406 | + }; |
| 2407 | + |
| 2408 | + const painter = createDomPainter({ blocks: [block], measures: [measure] }); |
| 2409 | + painter.paint(listLayout, mount); |
| 2410 | + |
| 2411 | + const lineEl = mount.querySelector('.superdoc-line') as HTMLElement; |
| 2412 | + expect(lineEl).toBeTruthy(); |
| 2413 | + |
| 2414 | + const textSpan = Array.from(lineEl.querySelectorAll('span')).find((el) => el.textContent === 'Closing.') as |
| 2415 | + | HTMLElement |
| 2416 | + | undefined; |
| 2417 | + expect(textSpan).toBeTruthy(); |
| 2418 | + expect(textSpan?.style.left).toBe('144px'); |
| 2419 | + }); |
| 2420 | + |
| 2421 | + it('preserves measured justification width for inline list first lines without explicit segments', () => { |
| 2422 | + const block: FlowBlock = { |
| 2423 | + kind: 'paragraph', |
| 2424 | + id: 'inline-justify-list-block', |
| 2425 | + runs: [ |
| 2426 | + { |
| 2427 | + text: 'Subject to the terms of this Agreement, Company will use', |
| 2428 | + fontFamily: 'Times New Roman', |
| 2429 | + fontSize: 13.333333333333332, |
| 2430 | + }, |
| 2431 | + ], |
| 2432 | + attrs: { |
| 2433 | + alignment: 'justify', |
| 2434 | + numberingProperties: { numId: 1, ilvl: 3 }, |
| 2435 | + wordLayout: { |
| 2436 | + indentLeftPx: 0, |
| 2437 | + hangingPx: 18, |
| 2438 | + firstLinePx: 0, |
| 2439 | + tabsPx: [], |
| 2440 | + textStartPx: 0, |
| 2441 | + marker: { |
| 2442 | + markerText: '1.1', |
| 2443 | + glyphWidthPx: 16.6669921875, |
| 2444 | + markerBoxWidthPx: 24.6669921875, |
| 2445 | + justification: 'left', |
| 2446 | + suffix: 'tab', |
| 2447 | + run: { |
| 2448 | + fontFamily: 'Times New Roman', |
| 2449 | + fontSize: 13.333333333333332, |
| 2450 | + }, |
| 2451 | + }, |
| 2452 | + }, |
| 2453 | + }, |
| 2454 | + }; |
| 2455 | + |
| 2456 | + const measure: ParagraphMeasure = { |
| 2457 | + kind: 'paragraph', |
| 2458 | + lines: [ |
| 2459 | + { |
| 2460 | + fromRun: 0, |
| 2461 | + fromChar: 0, |
| 2462 | + toRun: 0, |
| 2463 | + toChar: 57, |
| 2464 | + width: 309.5732421875, |
| 2465 | + ascent: 11.69921875, |
| 2466 | + descent: 2.876953125, |
| 2467 | + lineHeight: 15.33333333333333, |
| 2468 | + maxWidth: 325.7330078125, |
| 2469 | + segments: [{ runIndex: 0, fromChar: 0, toChar: 57, width: 312.90625 }], |
| 2470 | + spaceCount: 9, |
| 2471 | + }, |
| 2472 | + { |
| 2473 | + fromRun: 0, |
| 2474 | + fromChar: 57, |
| 2475 | + toRun: 0, |
| 2476 | + toChar: 61, |
| 2477 | + width: 24, |
| 2478 | + ascent: 11.69921875, |
| 2479 | + descent: 2.876953125, |
| 2480 | + lineHeight: 15.33333333333333, |
| 2481 | + maxWidth: 350.4, |
| 2482 | + segments: [{ runIndex: 0, fromChar: 57, toChar: 61, width: 24 }], |
| 2483 | + spaceCount: 0, |
| 2484 | + }, |
| 2485 | + ], |
| 2486 | + totalHeight: 30.66666666666666, |
| 2487 | + marker: { |
| 2488 | + markerWidth: 24.6669921875, |
| 2489 | + markerTextWidth: 16.6669921875, |
| 2490 | + indentLeft: 0, |
| 2491 | + gutterWidth: 8, |
| 2492 | + }, |
| 2493 | + }; |
| 2494 | + |
| 2495 | + const listLayout: Layout = { |
| 2496 | + pageSize: layout.pageSize, |
| 2497 | + pages: [ |
| 2498 | + { |
| 2499 | + number: 1, |
| 2500 | + fragments: [ |
| 2501 | + { |
| 2502 | + kind: 'para', |
| 2503 | + blockId: 'inline-justify-list-block', |
| 2504 | + fromLine: 0, |
| 2505 | + toLine: 2, |
| 2506 | + x: 0, |
| 2507 | + y: 0, |
| 2508 | + width: 350.4, |
| 2509 | + markerWidth: 24.6669921875, |
| 2510 | + markerTextWidth: 16.6669921875, |
| 2511 | + }, |
| 2512 | + ], |
| 2513 | + }, |
| 2514 | + ], |
| 2515 | + }; |
| 2516 | + |
| 2517 | + const painter = createDomPainter({ blocks: [block], measures: [measure] }); |
| 2518 | + painter.paint(listLayout, mount); |
| 2519 | + |
| 2520 | + const lineEl = mount.querySelector('.superdoc-line') as HTMLElement; |
| 2521 | + expect(lineEl).toBeTruthy(); |
| 2522 | + const markerEl = mount.querySelector('.superdoc-paragraph-marker') as HTMLElement; |
| 2523 | + const tabEl = mount.querySelector('.superdoc-tab') as HTMLElement; |
| 2524 | + |
| 2525 | + const expectedMarkerGeometry = resolveListMarkerGeometry( |
| 2526 | + block.attrs?.wordLayout as Parameters<typeof resolveListMarkerGeometry>[0], |
| 2527 | + 0, |
| 2528 | + 0, |
| 2529 | + 0, |
| 2530 | + () => 16.6669921875, |
| 2531 | + ); |
| 2532 | + |
| 2533 | + const appliedWordSpacing = Number.parseFloat(lineEl.style.wordSpacing); |
| 2534 | + const expectedWordSpacing = (325.7330078125 - 309.5732421875) / 9; |
| 2535 | + |
| 2536 | + expect(markerEl).toBeTruthy(); |
| 2537 | + expect(tabEl).toBeTruthy(); |
| 2538 | + expect(expectedMarkerGeometry).toBeTruthy(); |
| 2539 | + expect(lineEl.style.paddingLeft).toBe(`${expectedMarkerGeometry!.markerStartPx}px`); |
| 2540 | + expect(Number.parseFloat(tabEl.style.width)).toBeCloseTo(expectedMarkerGeometry!.suffixWidthPx, 4); |
| 2541 | + expect(appliedWordSpacing).toBeGreaterThan(0); |
| 2542 | + expect(appliedWordSpacing).toBeCloseTo(expectedWordSpacing, 5); |
| 2543 | + }); |
| 2544 | + |
2339 | 2545 | it('reuses fragment DOM nodes when layout geometry changes', () => { |
2340 | 2546 | const painter = createDomPainter({ blocks: [block], measures: [measure] }); |
2341 | 2547 | painter.paint(layout, mount); |
|
0 commit comments