quicklook-pptx-renderer 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +266 -0
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +175 -0
- package/dist/diff/compare.d.ts +17 -0
- package/dist/diff/compare.js +71 -0
- package/dist/index.d.ts +29 -0
- package/dist/index.js +72 -0
- package/dist/lint.d.ts +27 -0
- package/dist/lint.js +328 -0
- package/dist/mapper/bleed-map.d.ts +6 -0
- package/dist/mapper/bleed-map.js +1 -0
- package/dist/mapper/constants.d.ts +2 -0
- package/dist/mapper/constants.js +4 -0
- package/dist/mapper/drawable-mapper.d.ts +16 -0
- package/dist/mapper/drawable-mapper.js +1464 -0
- package/dist/mapper/html-generator.d.ts +13 -0
- package/dist/mapper/html-generator.js +539 -0
- package/dist/mapper/image-mapper.d.ts +14 -0
- package/dist/mapper/image-mapper.js +70 -0
- package/dist/mapper/nano-malloc.d.ts +130 -0
- package/dist/mapper/nano-malloc.js +197 -0
- package/dist/mapper/ql-bleed.d.ts +35 -0
- package/dist/mapper/ql-bleed.js +254 -0
- package/dist/mapper/shape-mapper.d.ts +41 -0
- package/dist/mapper/shape-mapper.js +2384 -0
- package/dist/mapper/slide-mapper.d.ts +4 -0
- package/dist/mapper/slide-mapper.js +112 -0
- package/dist/mapper/style-builder.d.ts +12 -0
- package/dist/mapper/style-builder.js +30 -0
- package/dist/mapper/text-mapper.d.ts +14 -0
- package/dist/mapper/text-mapper.js +302 -0
- package/dist/model/enums.d.ts +25 -0
- package/dist/model/enums.js +2 -0
- package/dist/model/types.d.ts +482 -0
- package/dist/model/types.js +7 -0
- package/dist/package/content-types.d.ts +1 -0
- package/dist/package/content-types.js +4 -0
- package/dist/package/package.d.ts +10 -0
- package/dist/package/package.js +52 -0
- package/dist/package/relationships.d.ts +6 -0
- package/dist/package/relationships.js +25 -0
- package/dist/package/zip.d.ts +6 -0
- package/dist/package/zip.js +17 -0
- package/dist/reader/color.d.ts +3 -0
- package/dist/reader/color.js +79 -0
- package/dist/reader/drawing.d.ts +17 -0
- package/dist/reader/drawing.js +403 -0
- package/dist/reader/effects.d.ts +2 -0
- package/dist/reader/effects.js +83 -0
- package/dist/reader/fill.d.ts +2 -0
- package/dist/reader/fill.js +94 -0
- package/dist/reader/presentation.d.ts +5 -0
- package/dist/reader/presentation.js +127 -0
- package/dist/reader/slide-layout.d.ts +2 -0
- package/dist/reader/slide-layout.js +28 -0
- package/dist/reader/slide-master.d.ts +4 -0
- package/dist/reader/slide-master.js +49 -0
- package/dist/reader/slide.d.ts +2 -0
- package/dist/reader/slide.js +26 -0
- package/dist/reader/text-list-style.d.ts +2 -0
- package/dist/reader/text-list-style.js +9 -0
- package/dist/reader/text.d.ts +5 -0
- package/dist/reader/text.js +295 -0
- package/dist/reader/theme.d.ts +2 -0
- package/dist/reader/theme.js +109 -0
- package/dist/reader/transform.d.ts +2 -0
- package/dist/reader/transform.js +21 -0
- package/dist/render/image-renderer.d.ts +3 -0
- package/dist/render/image-renderer.js +33 -0
- package/dist/render/renderer.d.ts +9 -0
- package/dist/render/renderer.js +178 -0
- package/dist/render/shape-renderer.d.ts +3 -0
- package/dist/render/shape-renderer.js +175 -0
- package/dist/render/text-renderer.d.ts +3 -0
- package/dist/render/text-renderer.js +152 -0
- package/dist/resolve/color-resolver.d.ts +18 -0
- package/dist/resolve/color-resolver.js +321 -0
- package/dist/resolve/font-map.d.ts +2 -0
- package/dist/resolve/font-map.js +66 -0
- package/dist/resolve/inheritance.d.ts +5 -0
- package/dist/resolve/inheritance.js +106 -0
- package/package.json +74 -0
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Simulates Apple's nanov2 allocator for the 80-byte size class.
|
|
3
|
+
*
|
|
4
|
+
* OfficeImport uses [NSValue valueWithNonretainedObject:] to cache rendered
|
|
5
|
+
* elements by OADDrawable pointer. When a slide is freed and the next slide's
|
|
6
|
+
* drawables land at the same addresses (malloc reuse), the stale cache entry
|
|
7
|
+
* produces "bleed" — ghost PDF images from earlier slides.
|
|
8
|
+
*
|
|
9
|
+
* nanov2 blocks are 16 KB, each holding 204 fixed-size 80-byte slots.
|
|
10
|
+
* The free list is a per-block LIFO singly-linked stack (from libmalloc
|
|
11
|
+
* nanov2_malloc.c, confirmed via disassembly + malloc_logger tracing).
|
|
12
|
+
*
|
|
13
|
+
* Source: apple-oss-distributions/libmalloc, tag libmalloc-792.80.2
|
|
14
|
+
* - nanov2_malloc.c: nanov2_allocate_from_block_inline (line 2513)
|
|
15
|
+
* - nanov2_malloc.c: nanov2_free_to_block_inline (line 3101)
|
|
16
|
+
*
|
|
17
|
+
* RE findings on OfficeImport's PX parser allocation patterns:
|
|
18
|
+
*
|
|
19
|
+
* Size classes (from ObjC header ivar analysis):
|
|
20
|
+
* - OADShape: 72-80 bytes → 80-byte nanov2 class ✓
|
|
21
|
+
* - OADImage: 80 bytes → 80-byte class ✓
|
|
22
|
+
* - OADConnector: 72-80 bytes (subclass of OADShape) → 80-byte class ✓
|
|
23
|
+
* - OADShapeProperties: ~137 bytes → 144-byte class (NOT 80-byte)
|
|
24
|
+
* - OADGraphicProperties: ~136 bytes → NOT 80-byte
|
|
25
|
+
* - OADDrawableProperties (base): 80 bytes, but shapes use OADShapeProperties
|
|
26
|
+
* - OADOrientedBounds: ~60 bytes → 64-byte class
|
|
27
|
+
* - OADTextBody: ~32 bytes → 32/48-byte class
|
|
28
|
+
* - OADTextBodyProperties: ~157 bytes → NOT 80-byte
|
|
29
|
+
* - OADOuterShadowEffect: ~64 bytes → 64-byte class (NOT 80-byte)
|
|
30
|
+
* - OADPresetShapeGeometry: ~28 bytes → 32-byte class
|
|
31
|
+
* - OADGroup: ~88 bytes → 96-byte class (NOT 80-byte)
|
|
32
|
+
* - OADTable: 64 bytes → 64-byte class
|
|
33
|
+
*
|
|
34
|
+
* Conclusion: ONLY OADShape/OADImage/OADConnector objects reside in the
|
|
35
|
+
* 80-byte nanov2 pool. Each drawable = exactly 1 allocation in this pool.
|
|
36
|
+
* No sub-objects share the same size class.
|
|
37
|
+
*
|
|
38
|
+
* Progressive reading pipeline (from PMTop / OIProgressiveReaderDelegate):
|
|
39
|
+
* 1. PXReader reads slide N's XML → allocates OADDrawable objects
|
|
40
|
+
* 2. PMTop.readerDidReadElement: → maps slide N (cache check + store)
|
|
41
|
+
* 3. PDSlide.doneWithContent → frees slide N's drawables (mDrawables = nil)
|
|
42
|
+
* 4. Freed addresses go back to nanov2 LIFO pool
|
|
43
|
+
* 5. Repeat for slide N+1: new drawables reuse freed addresses
|
|
44
|
+
*
|
|
45
|
+
* LIFO correspondence verified empirically:
|
|
46
|
+
* For immediate-predecessor bleeds, targetAllocPos maps to
|
|
47
|
+
* srcAllocPos = prevSlideSize - 1 - targetAllocPos (pure LIFO).
|
|
48
|
+
* This was confirmed for slides 5←4, 6←5, 7←6, 9←8, 17←16.
|
|
49
|
+
*
|
|
50
|
+
* Cache mechanism (CMArchiveManager.mDrawableCache):
|
|
51
|
+
* - NSMutableDictionary keyed by [NSValue valueWithNonretainedObject:drawable]
|
|
52
|
+
* - cachedPathForDrawable: checked for EVERY drawable before rendering
|
|
53
|
+
* - addResourceForDrawable:withType:drawable: stores entry for PDF attachments
|
|
54
|
+
* - Only PDF-rendered shapes create cache entries (sources)
|
|
55
|
+
* - ALL drawable types can be cache hit targets (CSS rects included)
|
|
56
|
+
*
|
|
57
|
+
* Known limitations of this simulation:
|
|
58
|
+
* - Predicts ~167 bleeds vs 89 ground truth for the test file
|
|
59
|
+
* - False positives occur because we can't model every object in the 80-byte
|
|
60
|
+
* pool (system objects like NSMutableArray internals may interleave)
|
|
61
|
+
* - Cross-slide bleeds from older slides sometimes have wrong addresses
|
|
62
|
+
* - Use ql-bleed.ts extraction as verification/fallback
|
|
63
|
+
*/
|
|
64
|
+
/**
|
|
65
|
+
* Simulates the nanov2 80-byte allocator across an entire presentation.
|
|
66
|
+
*
|
|
67
|
+
* Each OADDrawable (OADShape/OADImage/OADConnector) = 1 allocation.
|
|
68
|
+
* Properties, geometry, effects, text body etc. are in different size classes.
|
|
69
|
+
*/
|
|
70
|
+
export declare class NanoAllocator {
|
|
71
|
+
private blocks;
|
|
72
|
+
private nextBlockBase;
|
|
73
|
+
/** Allocate one 80-byte slot, returns virtual address. */
|
|
74
|
+
alloc(): number;
|
|
75
|
+
/** Free one 80-byte slot. */
|
|
76
|
+
free(addr: number): void;
|
|
77
|
+
}
|
|
78
|
+
/** Tracks one drawable's allocation and metadata. */
|
|
79
|
+
export interface DrawableSlot {
|
|
80
|
+
/** Virtual address (from NanoAllocator). */
|
|
81
|
+
addr: number;
|
|
82
|
+
/** Whether this drawable was rendered as PDF (non-rect geometry). */
|
|
83
|
+
isPdf: boolean;
|
|
84
|
+
/** Index within the slide's drawable array. */
|
|
85
|
+
index: number;
|
|
86
|
+
}
|
|
87
|
+
/**
|
|
88
|
+
* Cache entry: the drawableElementCache stores rendered HTML keyed by
|
|
89
|
+
* [NSValue valueWithNonretainedObject:oadDrawable] — i.e. the raw pointer.
|
|
90
|
+
* After the drawable is freed, the address becomes stale but stays in the cache.
|
|
91
|
+
*/
|
|
92
|
+
export interface CacheEntry {
|
|
93
|
+
/** The stale virtual address. */
|
|
94
|
+
addr: number;
|
|
95
|
+
/** Source slide index (0-based). */
|
|
96
|
+
srcSlide: number;
|
|
97
|
+
/** Source drawable index within that slide. */
|
|
98
|
+
srcIndex: number;
|
|
99
|
+
/** Target drawable index on the slide receiving bleed (set during collision check). */
|
|
100
|
+
targetIndex?: number;
|
|
101
|
+
/** The cached HTML string — populated during rendering. */
|
|
102
|
+
html: string;
|
|
103
|
+
}
|
|
104
|
+
/**
|
|
105
|
+
* Per-drawable allocation info extracted from the PPTX.
|
|
106
|
+
*
|
|
107
|
+
* Only OADShape/OADImage/OADConnector objects are in the 80-byte nanov2 pool.
|
|
108
|
+
* Each drawable = exactly 1 allocation. Properties, geometry, effects, text body
|
|
109
|
+
* etc. are all in different size classes and do NOT interfere with the 80-byte pool.
|
|
110
|
+
*
|
|
111
|
+
* OADTable (graphicFrame) = 64 bytes → different nano size class, excluded.
|
|
112
|
+
*/
|
|
113
|
+
export interface DrawableAllocInfo {
|
|
114
|
+
isPdf: boolean;
|
|
115
|
+
index: number;
|
|
116
|
+
/** Drawable type for size class routing. */
|
|
117
|
+
type: "sp" | "pic" | "cxnSp" | "graphicFrame";
|
|
118
|
+
}
|
|
119
|
+
/**
|
|
120
|
+
* Simulates the full OfficeImport drawable lifecycle for bleed prediction.
|
|
121
|
+
*
|
|
122
|
+
* Progressive pipeline per slide:
|
|
123
|
+
* 1. Free previous slide's drawables (NSArray releases in forward index order;
|
|
124
|
+
* each OADDrawable.dealloc frees self → pushed to LIFO).
|
|
125
|
+
* 2. Allocate new slide's drawables (1 alloc each from 80-byte pool).
|
|
126
|
+
* 3. Check the element cache for address collisions (= bleed).
|
|
127
|
+
* ALL drawable types are checked (CSS rects included).
|
|
128
|
+
* 4. Cache PDF-rendered drawables' addresses.
|
|
129
|
+
*/
|
|
130
|
+
export declare function computeBleedMap(slides: Array<Array<DrawableAllocInfo>>): Map<number, CacheEntry[]>;
|
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Simulates Apple's nanov2 allocator for the 80-byte size class.
|
|
3
|
+
*
|
|
4
|
+
* OfficeImport uses [NSValue valueWithNonretainedObject:] to cache rendered
|
|
5
|
+
* elements by OADDrawable pointer. When a slide is freed and the next slide's
|
|
6
|
+
* drawables land at the same addresses (malloc reuse), the stale cache entry
|
|
7
|
+
* produces "bleed" — ghost PDF images from earlier slides.
|
|
8
|
+
*
|
|
9
|
+
* nanov2 blocks are 16 KB, each holding 204 fixed-size 80-byte slots.
|
|
10
|
+
* The free list is a per-block LIFO singly-linked stack (from libmalloc
|
|
11
|
+
* nanov2_malloc.c, confirmed via disassembly + malloc_logger tracing).
|
|
12
|
+
*
|
|
13
|
+
* Source: apple-oss-distributions/libmalloc, tag libmalloc-792.80.2
|
|
14
|
+
* - nanov2_malloc.c: nanov2_allocate_from_block_inline (line 2513)
|
|
15
|
+
* - nanov2_malloc.c: nanov2_free_to_block_inline (line 3101)
|
|
16
|
+
*
|
|
17
|
+
* RE findings on OfficeImport's PX parser allocation patterns:
|
|
18
|
+
*
|
|
19
|
+
* Size classes (from ObjC header ivar analysis):
|
|
20
|
+
* - OADShape: 72-80 bytes → 80-byte nanov2 class ✓
|
|
21
|
+
* - OADImage: 80 bytes → 80-byte class ✓
|
|
22
|
+
* - OADConnector: 72-80 bytes (subclass of OADShape) → 80-byte class ✓
|
|
23
|
+
* - OADShapeProperties: ~137 bytes → 144-byte class (NOT 80-byte)
|
|
24
|
+
* - OADGraphicProperties: ~136 bytes → NOT 80-byte
|
|
25
|
+
* - OADDrawableProperties (base): 80 bytes, but shapes use OADShapeProperties
|
|
26
|
+
* - OADOrientedBounds: ~60 bytes → 64-byte class
|
|
27
|
+
* - OADTextBody: ~32 bytes → 32/48-byte class
|
|
28
|
+
* - OADTextBodyProperties: ~157 bytes → NOT 80-byte
|
|
29
|
+
* - OADOuterShadowEffect: ~64 bytes → 64-byte class (NOT 80-byte)
|
|
30
|
+
* - OADPresetShapeGeometry: ~28 bytes → 32-byte class
|
|
31
|
+
* - OADGroup: ~88 bytes → 96-byte class (NOT 80-byte)
|
|
32
|
+
* - OADTable: 64 bytes → 64-byte class
|
|
33
|
+
*
|
|
34
|
+
* Conclusion: ONLY OADShape/OADImage/OADConnector objects reside in the
|
|
35
|
+
* 80-byte nanov2 pool. Each drawable = exactly 1 allocation in this pool.
|
|
36
|
+
* No sub-objects share the same size class.
|
|
37
|
+
*
|
|
38
|
+
* Progressive reading pipeline (from PMTop / OIProgressiveReaderDelegate):
|
|
39
|
+
* 1. PXReader reads slide N's XML → allocates OADDrawable objects
|
|
40
|
+
* 2. PMTop.readerDidReadElement: → maps slide N (cache check + store)
|
|
41
|
+
* 3. PDSlide.doneWithContent → frees slide N's drawables (mDrawables = nil)
|
|
42
|
+
* 4. Freed addresses go back to nanov2 LIFO pool
|
|
43
|
+
* 5. Repeat for slide N+1: new drawables reuse freed addresses
|
|
44
|
+
*
|
|
45
|
+
* LIFO correspondence verified empirically:
|
|
46
|
+
* For immediate-predecessor bleeds, targetAllocPos maps to
|
|
47
|
+
* srcAllocPos = prevSlideSize - 1 - targetAllocPos (pure LIFO).
|
|
48
|
+
* This was confirmed for slides 5←4, 6←5, 7←6, 9←8, 17←16.
|
|
49
|
+
*
|
|
50
|
+
* Cache mechanism (CMArchiveManager.mDrawableCache):
|
|
51
|
+
* - NSMutableDictionary keyed by [NSValue valueWithNonretainedObject:drawable]
|
|
52
|
+
* - cachedPathForDrawable: checked for EVERY drawable before rendering
|
|
53
|
+
* - addResourceForDrawable:withType:drawable: stores entry for PDF attachments
|
|
54
|
+
* - Only PDF-rendered shapes create cache entries (sources)
|
|
55
|
+
* - ALL drawable types can be cache hit targets (CSS rects included)
|
|
56
|
+
*
|
|
57
|
+
* Known limitations of this simulation:
|
|
58
|
+
* - Predicts ~167 bleeds vs 89 ground truth for the test file
|
|
59
|
+
* - False positives occur because we can't model every object in the 80-byte
|
|
60
|
+
* pool (system objects like NSMutableArray internals may interleave)
|
|
61
|
+
* - Cross-slide bleeds from older slides sometimes have wrong addresses
|
|
62
|
+
* - Use ql-bleed.ts extraction as verification/fallback
|
|
63
|
+
*/
|
|
64
|
+
const BLOCK_SLOTS = 204; // 16384 / 80
|
|
65
|
+
/** A single nanov2 block for the 80-byte size class. */
|
|
66
|
+
class NanoBlock {
|
|
67
|
+
/** LIFO free list: indices of free slots (top-of-stack = end of array). */
|
|
68
|
+
freeList = [];
|
|
69
|
+
/** Bump pointer for virgin space (next unallocated slot index). */
|
|
70
|
+
bump = 0;
|
|
71
|
+
/** Base "address" for this block (monotonically increasing virtual address). */
|
|
72
|
+
base;
|
|
73
|
+
constructor(base) {
|
|
74
|
+
this.base = base;
|
|
75
|
+
}
|
|
76
|
+
isFull() {
|
|
77
|
+
return this.freeList.length === 0 && this.bump >= BLOCK_SLOTS;
|
|
78
|
+
}
|
|
79
|
+
/** Allocate → returns virtual address. */
|
|
80
|
+
alloc() {
|
|
81
|
+
// Pop from LIFO free list if non-empty
|
|
82
|
+
if (this.freeList.length > 0) {
|
|
83
|
+
return this.base + this.freeList.pop() * 80;
|
|
84
|
+
}
|
|
85
|
+
// Bump-allocate from virgin space
|
|
86
|
+
if (this.bump < BLOCK_SLOTS) {
|
|
87
|
+
return this.base + this.bump++ * 80;
|
|
88
|
+
}
|
|
89
|
+
return -1; // block full
|
|
90
|
+
}
|
|
91
|
+
/** Free → push slot index onto LIFO stack. */
|
|
92
|
+
free(addr) {
|
|
93
|
+
const slot = (addr - this.base) / 80;
|
|
94
|
+
this.freeList.push(slot);
|
|
95
|
+
}
|
|
96
|
+
owns(addr) {
|
|
97
|
+
return addr >= this.base && addr < this.base + BLOCK_SLOTS * 80;
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
/**
|
|
101
|
+
* Simulates the nanov2 80-byte allocator across an entire presentation.
|
|
102
|
+
*
|
|
103
|
+
* Each OADDrawable (OADShape/OADImage/OADConnector) = 1 allocation.
|
|
104
|
+
* Properties, geometry, effects, text body etc. are in different size classes.
|
|
105
|
+
*/
|
|
106
|
+
export class NanoAllocator {
|
|
107
|
+
blocks = [];
|
|
108
|
+
nextBlockBase = 0x1000_0000; // arbitrary starting virtual address
|
|
109
|
+
/** Allocate one 80-byte slot, returns virtual address. */
|
|
110
|
+
alloc() {
|
|
111
|
+
// Try the last block first (hot path)
|
|
112
|
+
const last = this.blocks[this.blocks.length - 1];
|
|
113
|
+
if (last && !last.isFull()) {
|
|
114
|
+
return last.alloc();
|
|
115
|
+
}
|
|
116
|
+
// Need a new block
|
|
117
|
+
const block = new NanoBlock(this.nextBlockBase);
|
|
118
|
+
this.nextBlockBase += BLOCK_SLOTS * 80;
|
|
119
|
+
this.blocks.push(block);
|
|
120
|
+
return block.alloc();
|
|
121
|
+
}
|
|
122
|
+
/** Free one 80-byte slot. */
|
|
123
|
+
free(addr) {
|
|
124
|
+
for (let i = this.blocks.length - 1; i >= 0; i--) {
|
|
125
|
+
if (this.blocks[i].owns(addr)) {
|
|
126
|
+
this.blocks[i].free(addr);
|
|
127
|
+
return;
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
/**
|
|
133
|
+
* Simulates the full OfficeImport drawable lifecycle for bleed prediction.
|
|
134
|
+
*
|
|
135
|
+
* Progressive pipeline per slide:
|
|
136
|
+
* 1. Free previous slide's drawables (NSArray releases in forward index order;
|
|
137
|
+
* each OADDrawable.dealloc frees self → pushed to LIFO).
|
|
138
|
+
* 2. Allocate new slide's drawables (1 alloc each from 80-byte pool).
|
|
139
|
+
* 3. Check the element cache for address collisions (= bleed).
|
|
140
|
+
* ALL drawable types are checked (CSS rects included).
|
|
141
|
+
* 4. Cache PDF-rendered drawables' addresses.
|
|
142
|
+
*/
|
|
143
|
+
export function computeBleedMap(slides) {
|
|
144
|
+
const allocator = new NanoAllocator();
|
|
145
|
+
const cache = new Map(); // addr → CacheEntry
|
|
146
|
+
const bleedMap = new Map(); // slideIndex → bleeds
|
|
147
|
+
let prevSlots = [];
|
|
148
|
+
for (let s = 0; s < slides.length; s++) {
|
|
149
|
+
// 1. Free previous slide's drawables
|
|
150
|
+
// NSArray releases in forward order. Each OADDrawable.dealloc frees
|
|
151
|
+
// sub-objects first (different size classes), then free(self) in 80-byte pool.
|
|
152
|
+
for (const slot of prevSlots) {
|
|
153
|
+
if (slot.addr !== -1)
|
|
154
|
+
allocator.free(slot.addr);
|
|
155
|
+
}
|
|
156
|
+
// 2. Allocate new slide's drawables (1 alloc each)
|
|
157
|
+
const drawables = slides[s];
|
|
158
|
+
const currentSlots = [];
|
|
159
|
+
for (const d of drawables) {
|
|
160
|
+
if (d.type === "graphicFrame") {
|
|
161
|
+
// OADTable = 64 bytes, different nano size class — skip 80-byte pool
|
|
162
|
+
currentSlots.push({ addr: -1, isPdf: false, index: d.index });
|
|
163
|
+
continue;
|
|
164
|
+
}
|
|
165
|
+
// Alloc drawable itself (80 bytes): OADShape/OADImage/OADConnector
|
|
166
|
+
const addr = allocator.alloc();
|
|
167
|
+
currentSlots.push({ addr, isPdf: d.isPdf, index: d.index });
|
|
168
|
+
}
|
|
169
|
+
// 3. Check cache for bleed.
|
|
170
|
+
// In OfficeImport, ALL drawables are checked (CSS rects included).
|
|
171
|
+
const bleeds = [];
|
|
172
|
+
for (const slot of currentSlots) {
|
|
173
|
+
if (slot.addr === -1)
|
|
174
|
+
continue;
|
|
175
|
+
const cached = cache.get(slot.addr);
|
|
176
|
+
if (cached) {
|
|
177
|
+
bleeds.push({ ...cached, targetIndex: slot.index });
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
if (bleeds.length > 0) {
|
|
181
|
+
bleedMap.set(s, bleeds);
|
|
182
|
+
}
|
|
183
|
+
// 4. Cache PDF-rendered drawables (only non-rect shapes produce PDF attachments)
|
|
184
|
+
for (const slot of currentSlots) {
|
|
185
|
+
if (slot.isPdf && slot.addr !== -1) {
|
|
186
|
+
cache.set(slot.addr, {
|
|
187
|
+
addr: slot.addr,
|
|
188
|
+
srcSlide: s,
|
|
189
|
+
srcIndex: slot.index,
|
|
190
|
+
html: "",
|
|
191
|
+
});
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
prevSlots = currentSlots;
|
|
195
|
+
}
|
|
196
|
+
return bleedMap;
|
|
197
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* General bleed extraction via qlmanage diffing.
|
|
3
|
+
*
|
|
4
|
+
* Bleed = ghost PDF images from CMArchiveManager's use-after-free cache bug.
|
|
5
|
+
* OfficeImport's cache replaces invisible drawables with stale PDFs, ADDING
|
|
6
|
+
* extra <img> elements to slides.
|
|
7
|
+
*
|
|
8
|
+
* We extract these by diffing our clean HTML against qlmanage's HTML:
|
|
9
|
+
* any <img src="*.pdf"> in QL that has no position-match in ours is bleed.
|
|
10
|
+
*
|
|
11
|
+
* Works for ANY PPTX file — no hardcoding needed.
|
|
12
|
+
*/
|
|
13
|
+
import type { BleedEntry, BleedRemoval, QLBleedData } from "../model/types.js";
|
|
14
|
+
export type { BleedEntry, BleedRemoval, QLBleedData };
|
|
15
|
+
/**
|
|
16
|
+
* Extract bleed entries by diffing QL HTML against our HTML.
|
|
17
|
+
*
|
|
18
|
+
* Detects BOTH:
|
|
19
|
+
* - Additions: QL has <img> elements our output doesn't (bleed PDFs to inject)
|
|
20
|
+
* - Removals: Our output has elements QL doesn't (own shapes replaced by bleed)
|
|
21
|
+
*
|
|
22
|
+
* Uses set-based position matching with 2px tolerance for rounding differences.
|
|
23
|
+
*/
|
|
24
|
+
export declare function extractBleedEntries(qlHtml: string, ourHtml: string): QLBleedData;
|
|
25
|
+
/** Run qlmanage and return the .qlpreview directory path. */
|
|
26
|
+
export declare function runQLManage(pptxPath: string): string;
|
|
27
|
+
/**
|
|
28
|
+
* Full pipeline: extract bleed for any PPTX file.
|
|
29
|
+
*
|
|
30
|
+
* 1. Runs qlmanage to get ground-truth HTML
|
|
31
|
+
* 2. Diffs against our clean HTML
|
|
32
|
+
* 3. Reads bleed PDF attachments from QL output
|
|
33
|
+
* 4. Returns structured bleed data ready for injection
|
|
34
|
+
*/
|
|
35
|
+
export declare function extractQLBleed(ourHtml: string, pptxPath: string): QLBleedData;
|
|
@@ -0,0 +1,254 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* General bleed extraction via qlmanage diffing.
|
|
3
|
+
*
|
|
4
|
+
* Bleed = ghost PDF images from CMArchiveManager's use-after-free cache bug.
|
|
5
|
+
* OfficeImport's cache replaces invisible drawables with stale PDFs, ADDING
|
|
6
|
+
* extra <img> elements to slides.
|
|
7
|
+
*
|
|
8
|
+
* We extract these by diffing our clean HTML against qlmanage's HTML:
|
|
9
|
+
* any <img src="*.pdf"> in QL that has no position-match in ours is bleed.
|
|
10
|
+
*
|
|
11
|
+
* Works for ANY PPTX file — no hardcoding needed.
|
|
12
|
+
*/
|
|
13
|
+
import { execSync } from "node:child_process";
|
|
14
|
+
import { readFileSync, readdirSync, existsSync, rmSync, mkdirSync } from "node:fs";
|
|
15
|
+
import { join } from "node:path";
|
|
16
|
+
/** Parse position values from a style string. */
|
|
17
|
+
function parsePos(style) {
|
|
18
|
+
const t = style.match(/top:\s*(-?\d+)/);
|
|
19
|
+
const l = style.match(/left:\s*(-?\d+)/);
|
|
20
|
+
const w = style.match(/width:\s*(-?\d+)/);
|
|
21
|
+
const h = style.match(/height:\s*(-?\d+)/);
|
|
22
|
+
if (!t || !l || !w || !h)
|
|
23
|
+
return null;
|
|
24
|
+
return { top: +t[1], left: +l[1], width: +w[1], height: +h[1] };
|
|
25
|
+
}
|
|
26
|
+
/** Check if two positions match within a tolerance (handles 1px rounding diffs). */
|
|
27
|
+
function posMatch(a, b, tol = 2) {
|
|
28
|
+
return Math.abs(a.top - b.top) <= tol &&
|
|
29
|
+
Math.abs(a.left - b.left) <= tol &&
|
|
30
|
+
Math.abs(a.width - b.width) <= tol &&
|
|
31
|
+
Math.abs(a.height - b.height) <= tol;
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* Parse top-level elements from slide inner HTML.
|
|
35
|
+
* Tracks nesting depth to only capture direct children of <div class="slide">.
|
|
36
|
+
*/
|
|
37
|
+
function parseTopLevelElements(slideHtml) {
|
|
38
|
+
const elements = [];
|
|
39
|
+
let depth = 0;
|
|
40
|
+
let i = 0;
|
|
41
|
+
let elemStart = -1;
|
|
42
|
+
while (i < slideHtml.length) {
|
|
43
|
+
if (slideHtml[i] !== '<') {
|
|
44
|
+
i++;
|
|
45
|
+
continue;
|
|
46
|
+
}
|
|
47
|
+
// Closing tag
|
|
48
|
+
if (slideHtml[i + 1] === '/') {
|
|
49
|
+
const end = slideHtml.indexOf('>', i);
|
|
50
|
+
if (end === -1)
|
|
51
|
+
break;
|
|
52
|
+
depth--;
|
|
53
|
+
if (depth === 0 && elemStart >= 0) {
|
|
54
|
+
pushElement(slideHtml.substring(elemStart, end + 1));
|
|
55
|
+
elemStart = -1;
|
|
56
|
+
}
|
|
57
|
+
i = end + 1;
|
|
58
|
+
continue;
|
|
59
|
+
}
|
|
60
|
+
const tagEnd = slideHtml.indexOf('>', i);
|
|
61
|
+
if (tagEnd === -1)
|
|
62
|
+
break;
|
|
63
|
+
const tag = slideHtml.substring(i, tagEnd + 1);
|
|
64
|
+
const selfClose = tag.startsWith('<img ') || tag.startsWith('<col ') ||
|
|
65
|
+
tag.startsWith('<br') || tag.endsWith('/>');
|
|
66
|
+
if (depth === 0) {
|
|
67
|
+
if (selfClose) {
|
|
68
|
+
pushElement(tag);
|
|
69
|
+
i = tagEnd + 1;
|
|
70
|
+
continue;
|
|
71
|
+
}
|
|
72
|
+
elemStart = i;
|
|
73
|
+
}
|
|
74
|
+
if (!selfClose)
|
|
75
|
+
depth++;
|
|
76
|
+
i = tagEnd + 1;
|
|
77
|
+
}
|
|
78
|
+
function pushElement(html) {
|
|
79
|
+
const styleMatch = html.match(/style="([^"]+)"/);
|
|
80
|
+
const srcMatch = html.match(/src="([^"]+)"/);
|
|
81
|
+
const pos = styleMatch ? parsePos(styleMatch[1]) : null;
|
|
82
|
+
const type = html.startsWith('<img') ? 'img' :
|
|
83
|
+
html.startsWith('<table') ? 'table' :
|
|
84
|
+
html.startsWith('<div') ? 'div' : 'other';
|
|
85
|
+
elements.push({ type, html, pos, src: srcMatch?.[1], style: styleMatch?.[1] });
|
|
86
|
+
}
|
|
87
|
+
return elements;
|
|
88
|
+
}
|
|
89
|
+
/** Split full HTML into per-slide inner content strings. */
|
|
90
|
+
function splitSlides(html) {
|
|
91
|
+
const slides = [];
|
|
92
|
+
const marker = '<div class="slide" style="top:0; left:0;">';
|
|
93
|
+
let pos = 0;
|
|
94
|
+
while (true) {
|
|
95
|
+
const start = html.indexOf(marker, pos);
|
|
96
|
+
if (start === -1)
|
|
97
|
+
break;
|
|
98
|
+
const contentStart = start + marker.length;
|
|
99
|
+
// Find matching closing </div> — it's always the outermost one
|
|
100
|
+
// before the next <style> or </body>
|
|
101
|
+
let depth = 1;
|
|
102
|
+
let j = contentStart;
|
|
103
|
+
while (j < html.length && depth > 0) {
|
|
104
|
+
if (html[j] === '<') {
|
|
105
|
+
if (html.substring(j, j + 6) === '</div>') {
|
|
106
|
+
depth--;
|
|
107
|
+
if (depth === 0) {
|
|
108
|
+
slides.push(html.substring(contentStart, j));
|
|
109
|
+
break;
|
|
110
|
+
}
|
|
111
|
+
j += 6;
|
|
112
|
+
continue;
|
|
113
|
+
}
|
|
114
|
+
// Opening div
|
|
115
|
+
if (html.substring(j, j + 4) === '<div') {
|
|
116
|
+
depth++;
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
j++;
|
|
120
|
+
}
|
|
121
|
+
pos = j;
|
|
122
|
+
}
|
|
123
|
+
return slides;
|
|
124
|
+
}
|
|
125
|
+
/**
|
|
126
|
+
* Extract bleed entries by diffing QL HTML against our HTML.
|
|
127
|
+
*
|
|
128
|
+
* Detects BOTH:
|
|
129
|
+
* - Additions: QL has <img> elements our output doesn't (bleed PDFs to inject)
|
|
130
|
+
* - Removals: Our output has elements QL doesn't (own shapes replaced by bleed)
|
|
131
|
+
*
|
|
132
|
+
* Uses set-based position matching with 2px tolerance for rounding differences.
|
|
133
|
+
*/
|
|
134
|
+
export function extractBleedEntries(qlHtml, ourHtml) {
|
|
135
|
+
const qlSlides = splitSlides(qlHtml);
|
|
136
|
+
const ourSlides = splitSlides(ourHtml);
|
|
137
|
+
const entries = new Map();
|
|
138
|
+
const removals = new Map();
|
|
139
|
+
const pdfs = new Map(); // filled by caller
|
|
140
|
+
const count = Math.min(qlSlides.length, ourSlides.length);
|
|
141
|
+
for (let s = 0; s < count; s++) {
|
|
142
|
+
const qlElems = parseTopLevelElements(qlSlides[s]);
|
|
143
|
+
const ourElems = parseTopLevelElements(ourSlides[s]);
|
|
144
|
+
// Find matched pairs (QL element ↔ our element) by fuzzy position matching.
|
|
145
|
+
// Walk both lists sequentially since elements appear in similar order.
|
|
146
|
+
const qlMatched = new Set();
|
|
147
|
+
const ourMatched = new Set();
|
|
148
|
+
let ourPtr = 0;
|
|
149
|
+
for (let qi = 0; qi < qlElems.length; qi++) {
|
|
150
|
+
// Try to match against our[ourPtr]
|
|
151
|
+
while (ourPtr < ourElems.length && ourMatched.has(ourPtr))
|
|
152
|
+
ourPtr++;
|
|
153
|
+
if (ourPtr < ourElems.length && elementsMatch(qlElems[qi], ourElems[ourPtr])) {
|
|
154
|
+
qlMatched.add(qi);
|
|
155
|
+
ourMatched.add(ourPtr);
|
|
156
|
+
ourPtr++;
|
|
157
|
+
continue;
|
|
158
|
+
}
|
|
159
|
+
// Try a lookahead: maybe our[ourPtr] was removed and next one matches
|
|
160
|
+
if (ourPtr + 1 < ourElems.length && !ourMatched.has(ourPtr + 1) &&
|
|
161
|
+
elementsMatch(qlElems[qi], ourElems[ourPtr + 1])) {
|
|
162
|
+
qlMatched.add(qi);
|
|
163
|
+
ourMatched.add(ourPtr + 1);
|
|
164
|
+
ourPtr = ourPtr + 2;
|
|
165
|
+
continue;
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
// Bleed ADDITIONS: QL elements not matched → extra elements (bleed PDFs)
|
|
169
|
+
const bleeds = [];
|
|
170
|
+
let ownCount = 0;
|
|
171
|
+
for (let qi = 0; qi < qlElems.length; qi++) {
|
|
172
|
+
if (qlMatched.has(qi)) {
|
|
173
|
+
ownCount++;
|
|
174
|
+
continue;
|
|
175
|
+
}
|
|
176
|
+
const el = qlElems[qi];
|
|
177
|
+
if (el.type === 'img' && el.src?.endsWith('.pdf') && el.style) {
|
|
178
|
+
bleeds.push({ style: el.style, afterOwnElement: ownCount });
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
if (bleeds.length > 0)
|
|
182
|
+
entries.set(s, bleeds);
|
|
183
|
+
// Bleed REMOVALS: our elements not matched → replaced by bleed
|
|
184
|
+
const slideRemovals = [];
|
|
185
|
+
for (let oi = 0; oi < ourElems.length; oi++) {
|
|
186
|
+
if (!ourMatched.has(oi) && ourElems[oi].pos) {
|
|
187
|
+
slideRemovals.push({ pos: ourElems[oi].pos, type: ourElems[oi].type });
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
if (slideRemovals.length > 0)
|
|
191
|
+
removals.set(s, slideRemovals);
|
|
192
|
+
}
|
|
193
|
+
return { entries, removals, pdfs };
|
|
194
|
+
}
|
|
195
|
+
/**
|
|
196
|
+
* Check if two elements from QL and our output represent the same drawable.
|
|
197
|
+
* Matches by type and position (with 2px tolerance for rounding differences).
|
|
198
|
+
*/
|
|
199
|
+
function elementsMatch(ql, ours) {
|
|
200
|
+
if (ql.type !== ours.type)
|
|
201
|
+
return false;
|
|
202
|
+
if (ql.pos && ours.pos)
|
|
203
|
+
return posMatch(ql.pos, ours.pos);
|
|
204
|
+
if (!ql.pos && !ours.pos)
|
|
205
|
+
return true;
|
|
206
|
+
return false;
|
|
207
|
+
}
|
|
208
|
+
/** Run qlmanage and return the .qlpreview directory path. */
|
|
209
|
+
export function runQLManage(pptxPath) {
|
|
210
|
+
const tmpDir = "/tmp/ql-bleed-extract";
|
|
211
|
+
if (existsSync(tmpDir))
|
|
212
|
+
rmSync(tmpDir, { recursive: true });
|
|
213
|
+
mkdirSync(tmpDir, { recursive: true });
|
|
214
|
+
execSync(`qlmanage -p -o "${tmpDir}" "${pptxPath}"`, {
|
|
215
|
+
timeout: 30000,
|
|
216
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
217
|
+
});
|
|
218
|
+
const entries = readdirSync(tmpDir);
|
|
219
|
+
const qlpreview = entries.find(e => e.endsWith('.qlpreview'));
|
|
220
|
+
if (!qlpreview)
|
|
221
|
+
throw new Error('qlmanage did not produce .qlpreview output');
|
|
222
|
+
return join(tmpDir, qlpreview);
|
|
223
|
+
}
|
|
224
|
+
/**
|
|
225
|
+
* Full pipeline: extract bleed for any PPTX file.
|
|
226
|
+
*
|
|
227
|
+
* 1. Runs qlmanage to get ground-truth HTML
|
|
228
|
+
* 2. Diffs against our clean HTML
|
|
229
|
+
* 3. Reads bleed PDF attachments from QL output
|
|
230
|
+
* 4. Returns structured bleed data ready for injection
|
|
231
|
+
*/
|
|
232
|
+
export function extractQLBleed(ourHtml, pptxPath) {
|
|
233
|
+
const qlDir = runQLManage(pptxPath);
|
|
234
|
+
const qlHtml = readFileSync(join(qlDir, "Preview.html"), "utf8");
|
|
235
|
+
const data = extractBleedEntries(qlHtml, ourHtml);
|
|
236
|
+
// Read PDF buffers for bleed entries from QL output
|
|
237
|
+
// Map by style string since QL attachment names won't be reused
|
|
238
|
+
const qlSlides = splitSlides(qlHtml);
|
|
239
|
+
for (const [slideIdx, slideEntries] of data.entries) {
|
|
240
|
+
const qlElems = parseTopLevelElements(qlSlides[slideIdx]);
|
|
241
|
+
// Find the QL attachment name for each bleed entry by matching style
|
|
242
|
+
for (const entry of slideEntries) {
|
|
243
|
+
const qlElem = qlElems.find(e => e.type === 'img' && e.style === entry.style && e.src?.endsWith('.pdf'));
|
|
244
|
+
if (qlElem?.src) {
|
|
245
|
+
const pdfPath = join(qlDir, qlElem.src);
|
|
246
|
+
try {
|
|
247
|
+
data.pdfs.set(entry.style, readFileSync(pdfPath));
|
|
248
|
+
}
|
|
249
|
+
catch { /* missing PDF */ }
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
return data;
|
|
254
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import type { Shape, Connector, Fill, ColorMap, ColorScheme, FontScheme, StyleMatrix, Slide } from "../model/types.js";
|
|
2
|
+
import type { StyleBuilder } from "./style-builder.js";
|
|
3
|
+
export declare const PDF_PADDING = 20;
|
|
4
|
+
export declare const SUPPORTED_GEOMETRIES: Set<string>;
|
|
5
|
+
export interface ShapeMapperContext {
|
|
6
|
+
colorMap: ColorMap;
|
|
7
|
+
colorScheme: ColorScheme;
|
|
8
|
+
fontScheme: FontScheme;
|
|
9
|
+
styleMatrix?: StyleMatrix;
|
|
10
|
+
slide?: Slide;
|
|
11
|
+
}
|
|
12
|
+
export declare function mapShape(shape: Shape, styles: StyleBuilder, attachments: Map<string, Buffer>, ctx: ShapeMapperContext): string;
|
|
13
|
+
/**
|
|
14
|
+
* Generate PDF path drawing commands for a shape.
|
|
15
|
+
* pad = uniform offset from (0,0) where the shape starts in screen coords.
|
|
16
|
+
* totalH = total height of the coordinate space (needed for Y-flipping).
|
|
17
|
+
* For standalone shapes: pad=PDF_PADDING, totalH=h+2*pad.
|
|
18
|
+
* For group children: pad=0, totalH=h (shape draws at origin, caller uses cm to translate).
|
|
19
|
+
*/
|
|
20
|
+
export declare function getShapeDrawCmd(pad: number, w: number, h: number, totalH: number, preset: string, adjustValues?: Record<string, string>): string;
|
|
21
|
+
/** Wrap a PDF content stream into a minimal PDF 1.4 document. */
|
|
22
|
+
export declare function buildPdf(totalW: number, totalH: number, stream: string): Buffer;
|
|
23
|
+
export declare function mapConnector(conn: Connector, attachments: Map<string, Buffer>, ctx: ShapeMapperContext): string;
|
|
24
|
+
/**
|
|
25
|
+
* Compute the inscribed text frame for a geometry preset (shapeTextBoxRect in OfficeImport).
|
|
26
|
+
* OfficeImport computes inset using float px values for radius, then truncates dimensions.
|
|
27
|
+
* Input/output in EMU, but computation happens in pixel space to match QL's rounding.
|
|
28
|
+
*/
|
|
29
|
+
export declare function shapeTextBox(bounds: {
|
|
30
|
+
x: number;
|
|
31
|
+
y: number;
|
|
32
|
+
cx: number;
|
|
33
|
+
cy: number;
|
|
34
|
+
}, preset: string, adjustValues?: Record<string, string>): {
|
|
35
|
+
x: number;
|
|
36
|
+
y: number;
|
|
37
|
+
cx: number;
|
|
38
|
+
cy: number;
|
|
39
|
+
};
|
|
40
|
+
/** Returns just the color string (for PDF fill, which only needs a single color) */
|
|
41
|
+
export declare function fillToColor(fill: Fill | undefined, ctx: ShapeMapperContext): string | null;
|