|
1 | 1 | import type { |
| 2 | + DrawingBlock, |
2 | 3 | FlowBlock, |
| 4 | + ImageBlock, |
3 | 5 | ImageRun, |
| 6 | + ListBlock, |
4 | 7 | TableBlock, |
5 | 8 | ParagraphBlock, |
6 | 9 | ParagraphAttrs, |
@@ -78,7 +81,176 @@ const hashParagraphFrame = (frame: ParagraphFrame): string => { |
78 | 81 | }; |
79 | 82 |
|
80 | 83 | /** |
81 | | - * Generates a cache key hash from a block's runs, incorporating content and formatting. |
| 84 | + * Returns the content blocks stored in a table cell. |
| 85 | + * |
| 86 | + * Table cells support both the legacy `paragraph` field and the newer `blocks` |
| 87 | + * collection. The cache must normalize those shapes the same way the renderer |
| 88 | + * does so both sides respond to the same content changes. |
| 89 | + * |
| 90 | + * @param cell - The table cell to read blocks from |
| 91 | + * @returns The cell's content blocks in render order |
| 92 | + */ |
| 93 | +const getTableCellBlocks = (cell: TableBlock['rows'][number]['cells'][number]): FlowBlock[] => { |
| 94 | + return cell.blocks ?? (cell.paragraph ? [cell.paragraph] : []); |
| 95 | +}; |
| 96 | + |
| 97 | +/** |
| 98 | + * Reads a clip path from either the block itself or its attrs record. |
| 99 | + * |
| 100 | + * @param block - The image-like block |
| 101 | + * @returns The clip path string, or an empty string when not present |
| 102 | + */ |
| 103 | +const readBlockClipPath = (block: { clipPath?: string; attrs?: Record<string, unknown> }): string => { |
| 104 | + if (typeof block.clipPath === 'string') { |
| 105 | + return block.clipPath; |
| 106 | + } |
| 107 | + if (typeof block.attrs?.clipPath === 'string') { |
| 108 | + return block.attrs.clipPath; |
| 109 | + } |
| 110 | + return ''; |
| 111 | +}; |
| 112 | + |
| 113 | +/** |
| 114 | + * Creates a compact geometry key for drawing blocks. |
| 115 | + * |
| 116 | + * @param geometry - The drawing geometry |
| 117 | + * @returns A deterministic geometry signature |
| 118 | + */ |
| 119 | +const hashDrawingGeometry = (geometry: { |
| 120 | + width: number; |
| 121 | + height: number; |
| 122 | + rotation?: number; |
| 123 | + flipH?: boolean; |
| 124 | + flipV?: boolean; |
| 125 | +}): string => { |
| 126 | + return [geometry.width, geometry.height, geometry.rotation ?? 0, geometry.flipH ? 1 : 0, geometry.flipV ? 1 : 0].join( |
| 127 | + ':', |
| 128 | + ); |
| 129 | +}; |
| 130 | + |
| 131 | +/** |
| 132 | + * Hashes an image-like block using the visual properties that can affect |
| 133 | + * measurement and rendering. |
| 134 | + * |
| 135 | + * @param block - The image or image drawing block |
| 136 | + * @returns A deterministic hash fragment for the block |
| 137 | + */ |
| 138 | +const hashImageLikeBlock = ( |
| 139 | + block: Pick< |
| 140 | + ImageBlock, |
| 141 | + 'src' | 'width' | 'height' | 'alt' | 'title' | 'objectFit' | 'rotation' | 'flipH' | 'flipV' |
| 142 | + > & { |
| 143 | + clipPath?: string; |
| 144 | + attrs?: Record<string, unknown>; |
| 145 | + }, |
| 146 | +): string => { |
| 147 | + return [ |
| 148 | + block.src.slice(0, 50), |
| 149 | + block.width ?? '', |
| 150 | + block.height ?? '', |
| 151 | + block.alt ?? '', |
| 152 | + block.title ?? '', |
| 153 | + block.objectFit ?? '', |
| 154 | + readBlockClipPath(block), |
| 155 | + block.rotation ?? '', |
| 156 | + block.flipH ? 1 : 0, |
| 157 | + block.flipV ? 1 : 0, |
| 158 | + ].join(':'); |
| 159 | +}; |
| 160 | + |
| 161 | +/** |
| 162 | + * Hashes a list block by folding in each marker and paragraph item. |
| 163 | + * |
| 164 | + * @param block - The list block to hash |
| 165 | + * @returns A deterministic list hash fragment |
| 166 | + */ |
| 167 | +const hashListBlock = (block: ListBlock): string => { |
| 168 | + return block.items.map((item) => `${item.id}:${item.marker.text}:${hashRuns(item.paragraph)}`).join('|'); |
| 169 | +}; |
| 170 | + |
| 171 | +/** |
| 172 | + * Hashes a drawing block using the fields that affect its rendered footprint. |
| 173 | + * |
| 174 | + * @param block - The drawing block to hash |
| 175 | + * @returns A deterministic drawing hash fragment |
| 176 | + */ |
| 177 | +const hashDrawingBlock = (block: DrawingBlock): string => { |
| 178 | + if (block.drawingKind === 'image') { |
| 179 | + return `drawing:image:${hashImageLikeBlock(block)}`; |
| 180 | + } |
| 181 | + |
| 182 | + if (block.drawingKind === 'vectorShape') { |
| 183 | + return [ |
| 184 | + 'drawing:vector', |
| 185 | + hashDrawingGeometry(block.geometry), |
| 186 | + block.shapeKind ?? '', |
| 187 | + JSON.stringify(block.fillColor ?? null), |
| 188 | + JSON.stringify(block.strokeColor ?? null), |
| 189 | + block.strokeWidth ?? '', |
| 190 | + JSON.stringify(block.customGeometry ?? null), |
| 191 | + JSON.stringify(block.lineEnds ?? null), |
| 192 | + JSON.stringify(block.effectExtent ?? null), |
| 193 | + JSON.stringify(block.textContent ?? null), |
| 194 | + block.textAlign ?? '', |
| 195 | + block.textVerticalAlign ?? '', |
| 196 | + JSON.stringify(block.textInsets ?? null), |
| 197 | + ].join(':'); |
| 198 | + } |
| 199 | + |
| 200 | + if (block.drawingKind === 'shapeGroup') { |
| 201 | + return [ |
| 202 | + 'drawing:shapeGroup', |
| 203 | + hashDrawingGeometry(block.geometry), |
| 204 | + JSON.stringify(block.groupTransform ?? null), |
| 205 | + JSON.stringify(block.shapes), |
| 206 | + block.size?.width ?? '', |
| 207 | + block.size?.height ?? '', |
| 208 | + ].join(':'); |
| 209 | + } |
| 210 | + |
| 211 | + return [ |
| 212 | + 'drawing:chart', |
| 213 | + hashDrawingGeometry(block.geometry), |
| 214 | + block.chartData?.chartType ?? '', |
| 215 | + block.chartData?.subType ?? '', |
| 216 | + JSON.stringify(block.chartData?.series ?? []), |
| 217 | + block.chartRelId ?? '', |
| 218 | + ].join(':'); |
| 219 | +}; |
| 220 | + |
| 221 | +/** |
| 222 | + * Hashes a non-paragraph block embedded inside a table cell. |
| 223 | + * |
| 224 | + * The renderer versions these blocks through `deriveBlockVersion()`. The |
| 225 | + * measure cache must follow the same policy so parent-table repainting and |
| 226 | + * remeasurement stay aligned when nested tables, images, or drawings change. |
| 227 | + * |
| 228 | + * @param block - The non-paragraph cell block |
| 229 | + * @returns A deterministic hash fragment for the block |
| 230 | + */ |
| 231 | +const hashNonParagraphCellBlock = (block: Exclude<FlowBlock, ParagraphBlock>): string => { |
| 232 | + if (block.kind === 'table') { |
| 233 | + return `table:${hashRuns(block)}`; |
| 234 | + } |
| 235 | + |
| 236 | + if (block.kind === 'image') { |
| 237 | + return `image:${hashImageLikeBlock(block)}`; |
| 238 | + } |
| 239 | + |
| 240 | + if (block.kind === 'drawing') { |
| 241 | + return hashDrawingBlock(block); |
| 242 | + } |
| 243 | + |
| 244 | + if (block.kind === 'list') { |
| 245 | + return `list:${hashListBlock(block)}`; |
| 246 | + } |
| 247 | + |
| 248 | + return `${block.kind}:${block.id}`; |
| 249 | +}; |
| 250 | + |
| 251 | +/** |
| 252 | + * Generates a cache key hash from a block's content, incorporating text, |
| 253 | + * formatting, and embedded block data. |
82 | 254 | * |
83 | 255 | * Text content is preserved verbatim without whitespace normalization. Different |
84 | 256 | * whitespace (multiple spaces, tabs, leading/trailing spaces) produces different |
@@ -138,10 +310,15 @@ const hashRuns = (block: FlowBlock): string => { |
138 | 310 | } |
139 | 311 |
|
140 | 312 | // Support both new multi-block cells and legacy single paragraph cells |
141 | | - const cellBlocks = cell.blocks ?? (cell.paragraph ? [cell.paragraph] : []); |
| 313 | + const cellBlocks = getTableCellBlocks(cell); |
142 | 314 |
|
143 | 315 | for (const cellBlock of cellBlocks) { |
144 | | - const paragraphBlock = cellBlock as ParagraphBlock; |
| 316 | + if (cellBlock.kind !== 'paragraph') { |
| 317 | + cellHashes.push(`nb:${hashNonParagraphCellBlock(cellBlock)}`); |
| 318 | + continue; |
| 319 | + } |
| 320 | + |
| 321 | + const paragraphBlock = cellBlock; |
145 | 322 |
|
146 | 323 | // Safety: Check that runs array exists before iterating |
147 | 324 | if (!paragraphBlock.runs) { |
|
0 commit comments