dsh-zen-remote 1.1.13 → 1.1.14
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/README.md +3 -2
- package/README.zh-CN.md +3 -2
- package/docs/interface.md +46 -3
- package/lib/client.js +3421 -103
- package/lib/index.js +13 -0
- package/lib/index.js.map +1 -1
- package/lib/share-export.js +347 -0
- package/lib/share-export.js.map +1 -0
- package/lib/types/client/MobileAttachButton.d.ts +8 -1
- package/lib/types/client/MobileAttachButton.d.ts.map +1 -1
- package/lib/types/client/MobileAttachChips.d.ts.map +1 -1
- package/lib/types/client/MobileSessionHeader.d.ts +7 -8
- package/lib/types/client/MobileSessionHeader.d.ts.map +1 -1
- package/lib/types/client/MobileSessionInfo.d.ts.map +1 -1
- package/lib/types/client/effects/keyboard-avoid.d.ts.map +1 -1
- package/lib/types/client/effects/keyboard-guard.d.ts +16 -11
- package/lib/types/client/effects/keyboard-guard.d.ts.map +1 -1
- package/lib/types/client/host-attach.d.ts +38 -0
- package/lib/types/client/host-attach.d.ts.map +1 -0
- package/lib/types/client/index.d.ts.map +1 -1
- package/lib/types/client/locales.d.ts +23 -0
- package/lib/types/client/locales.d.ts.map +1 -1
- package/lib/types/client/share/fetch-share.d.ts +77 -0
- package/lib/types/client/share/fetch-share.d.ts.map +1 -0
- package/lib/types/client/share/markdown.d.ts +97 -0
- package/lib/types/client/share/markdown.d.ts.map +1 -0
- package/lib/types/client/share/png-stitch.d.ts +96 -0
- package/lib/types/client/share/png-stitch.d.ts.map +1 -0
- package/lib/types/client/share/rasterize.d.ts +317 -0
- package/lib/types/client/share/rasterize.d.ts.map +1 -0
- package/lib/types/client/share/share-card.d.ts +93 -0
- package/lib/types/client/share/share-card.d.ts.map +1 -0
- package/lib/types/client/share/share-constants.d.ts +27 -0
- package/lib/types/client/share/share-constants.d.ts.map +1 -0
- package/lib/types/client/share/share-flow.d.ts +76 -0
- package/lib/types/client/share/share-flow.d.ts.map +1 -0
- package/lib/types/client/share/share-preview.d.ts +11 -0
- package/lib/types/client/share/share-preview.d.ts.map +1 -0
- package/lib/types/client/share/share-sheet.d.ts +17 -0
- package/lib/types/client/share/share-sheet.d.ts.map +1 -0
- package/lib/types/client/share/text-layout.d.ts +48 -0
- package/lib/types/client/share/text-layout.d.ts.map +1 -0
- package/lib/types/client/styles/header.css.d.ts +1 -1
- package/lib/types/client/styles/header.css.d.ts.map +1 -1
- package/lib/types/client/styles/layout.css.d.ts +1 -1
- package/lib/types/client/styles/layout.css.d.ts.map +1 -1
- package/lib/types/index.d.ts.map +1 -1
- package/lib/types/share-export.d.ts +66 -0
- package/lib/types/share-export.d.ts.map +1 -0
- package/package.json +7 -2
- package/scripts/check-host-shadow.mjs +41 -9
- package/scripts/check-keyboard-avoid.mjs +4 -2
- package/scripts/check-share-image.mjs +970 -0
- package/scripts/check-upload-endpoint.mjs +10 -3
- package/src/client/MobileAttachButton.tsx +13 -0
- package/src/client/MobileAttachChips.tsx +8 -1
- package/src/client/MobileSessionHeader.tsx +90 -30
- package/src/client/MobileSessionInfo.tsx +234 -1
- package/src/client/effects/keyboard-avoid.ts +14 -7
- package/src/client/effects/keyboard-guard.ts +28 -18
- package/src/client/host-attach.ts +54 -0
- package/src/client/index.tsx +16 -0
- package/src/client/locales.ts +46 -0
- package/src/client/share/fetch-share.ts +161 -0
- package/src/client/share/markdown.ts +609 -0
- package/src/client/share/png-stitch.ts +281 -0
- package/src/client/share/rasterize.ts +1078 -0
- package/src/client/share/share-card.tsx +637 -0
- package/src/client/share/share-constants.ts +30 -0
- package/src/client/share/share-flow.ts +166 -0
- package/src/client/share/share-preview.tsx +337 -0
- package/src/client/share/share-sheet.tsx +222 -0
- package/src/client/share/text-layout.ts +147 -0
- package/src/client/styles/header.css.ts +30 -0
- package/src/client/styles/layout.css.ts +5 -2
- package/src/index.ts +13 -0
- package/src/share-export.ts +431 -0
|
@@ -0,0 +1,970 @@
|
|
|
1
|
+
// Self-check for the share-image slicing/rasterizing math (ticket 04, PLAN §5.2;
|
|
2
|
+
// ticket 07 adds the single-PNG stitch layer and the unified planning beneath it).
|
|
3
|
+
//
|
|
4
|
+
// The interesting decisions of the share-image export — slice grouping, width
|
|
5
|
+
// lifting for wide content, scale-to-fit for ultra-wide content, the pixel
|
|
6
|
+
// ratio fallback ladder, head-dropping truncation (the tail is kept), and the
|
|
7
|
+
// physical raster geometry of the SVG document — all live in pure functions
|
|
8
|
+
// inside src/client/share/rasterize.ts, so they are asserted numerically here
|
|
9
|
+
// without any DOM. The DOM half of rasterize.ts only measures and executes
|
|
10
|
+
// these decisions; that split is itself load-bearing and pinned at the bottom
|
|
11
|
+
// of this script (rasterize.ts may only import the pure constants module of
|
|
12
|
+
// its directory so type stripping can load it, and the template constants the
|
|
13
|
+
// width formulas invert live in share-constants.ts, imported by both the
|
|
14
|
+
// template and the rasterizer — asserted directly, no source-regex locking).
|
|
15
|
+
//
|
|
16
|
+
// Run: node scripts/check-share-image.mjs (needs Node >= 23.6 type stripping)
|
|
17
|
+
import assert from 'node:assert/strict'
|
|
18
|
+
import { randomBytes } from 'node:crypto'
|
|
19
|
+
import { readFileSync } from 'node:fs'
|
|
20
|
+
import { inflateSync } from 'node:zlib'
|
|
21
|
+
import {
|
|
22
|
+
MAX_SLICES,
|
|
23
|
+
PIXEL_RATIO_STEPS,
|
|
24
|
+
SLICE_PIXEL_BUDGET,
|
|
25
|
+
ShareRasterizeError,
|
|
26
|
+
buildSvgDocument,
|
|
27
|
+
bucketTurnWidth,
|
|
28
|
+
cardWidthForContent,
|
|
29
|
+
cardWidthForUserBubble,
|
|
30
|
+
planShareSlices,
|
|
31
|
+
planShareSlicesUnified,
|
|
32
|
+
rasterGeometry,
|
|
33
|
+
rasterGeometryStitch,
|
|
34
|
+
ratioChain,
|
|
35
|
+
requiredCardWidth,
|
|
36
|
+
slicePixelCount,
|
|
37
|
+
stitchPixelWidth,
|
|
38
|
+
ultraSliceWidth,
|
|
39
|
+
unifiedCardWidth,
|
|
40
|
+
} from '../src/client/share/rasterize.ts'
|
|
41
|
+
import {
|
|
42
|
+
IEND_CHUNK,
|
|
43
|
+
MAX_IDAT_CHUNK_BYTES,
|
|
44
|
+
PngStitchWriter,
|
|
45
|
+
crc32,
|
|
46
|
+
ihdrData,
|
|
47
|
+
pngChunk,
|
|
48
|
+
splitIdatBytes,
|
|
49
|
+
} from '../src/client/share/png-stitch.ts'
|
|
50
|
+
import {
|
|
51
|
+
BASE_CARD_WIDTH,
|
|
52
|
+
USER_BUBBLE_MAX_RATIO,
|
|
53
|
+
USER_BUBBLE_PAD_X,
|
|
54
|
+
WIDE_SLICE_WIDTH,
|
|
55
|
+
} from '../src/client/share/share-constants.ts'
|
|
56
|
+
import {
|
|
57
|
+
SHARE_EXPORT_ROUTE,
|
|
58
|
+
SHARE_TURNS_PRESETS,
|
|
59
|
+
ShareFetchError,
|
|
60
|
+
buildShareExportQuery,
|
|
61
|
+
} from '../src/client/share/fetch-share.ts'
|
|
62
|
+
import {
|
|
63
|
+
FALLBACK_SHARE_BASE_NAME,
|
|
64
|
+
SHARE_NAME_MAX,
|
|
65
|
+
sanitizeShareFileName,
|
|
66
|
+
shareImageFileName,
|
|
67
|
+
shareSliceFileName,
|
|
68
|
+
} from '../src/client/share/share-flow.ts'
|
|
69
|
+
import { parseMarkdown } from '../src/client/share/markdown.ts'
|
|
70
|
+
|
|
71
|
+
// ---- shipped constants (PLAN §5.2) ------------------------------------------
|
|
72
|
+
// The four template constants come from share-constants.ts — the one module
|
|
73
|
+
// both share-card.tsx (renders them) and rasterize.ts (inverts them in its
|
|
74
|
+
// width formulas) import, so these asserts pin the single source of truth
|
|
75
|
+
// that used to be regex-locked against the template's literals.
|
|
76
|
+
assert.equal(BASE_CARD_WIDTH, 390)
|
|
77
|
+
assert.equal(WIDE_SLICE_WIDTH, 780)
|
|
78
|
+
assert.equal(USER_BUBBLE_MAX_RATIO, 0.82)
|
|
79
|
+
assert.equal(USER_BUBBLE_PAD_X, 14)
|
|
80
|
+
assert.equal(SLICE_PIXEL_BUDGET, 12_000_000, '12M budget = iOS 16.7M canvas cap minus safety margin')
|
|
81
|
+
assert.equal(MAX_SLICES, 24)
|
|
82
|
+
assert.deepEqual(PIXEL_RATIO_STEPS, [2, 1.5, 1])
|
|
83
|
+
|
|
84
|
+
// ---- the pixel-ratio ladder --------------------------------------------------
|
|
85
|
+
// min(devicePixelRatio, 2) decides where the descending chain starts; the
|
|
86
|
+
// chain never comes back empty.
|
|
87
|
+
assert.deepEqual(ratioChain(2), [2, 1.5, 1])
|
|
88
|
+
assert.deepEqual(ratioChain(3), [2, 1.5, 1])
|
|
89
|
+
assert.deepEqual(ratioChain(1.5), [1.5, 1])
|
|
90
|
+
assert.deepEqual(ratioChain(1.2), [1])
|
|
91
|
+
assert.deepEqual(ratioChain(1), [1])
|
|
92
|
+
assert.deepEqual(ratioChain(0.5), [1], 'a nonsensical cap still yields a usable ratio')
|
|
93
|
+
|
|
94
|
+
// ---- physical pixel arithmetic ------------------------------------------------
|
|
95
|
+
// Canvas dimensions round up per side, so 7692 logical px at ratio 2 on a
|
|
96
|
+
// 390px card is exactly inside the budget and 7693 is out.
|
|
97
|
+
assert.equal(slicePixelCount(390, 7692, 2), 780 * 15384)
|
|
98
|
+
assert.ok(slicePixelCount(390, 7692, 2) <= SLICE_PIXEL_BUDGET)
|
|
99
|
+
assert.ok(slicePixelCount(390, 7693, 2) > SLICE_PIXEL_BUDGET)
|
|
100
|
+
assert.equal(slicePixelCount(780, 4000, 1), 3_120_000)
|
|
101
|
+
assert.equal(slicePixelCount(390.5, 100, 2), 781 * 200, 'fractional logical sizes still ceil per side')
|
|
102
|
+
|
|
103
|
+
// ---- physical raster geometry (P1: svg intrinsic = canvas size) ---------------
|
|
104
|
+
// The svg/foreignObject attrs must carry the PHYSICAL target (ceil of logical
|
|
105
|
+
// × scale × ratio): logical attrs make WebKit rasterize the SVG-as-image at
|
|
106
|
+
// its small intrinsic size and the later drawImage upscale it (blurry on 2x),
|
|
107
|
+
// and an unbounded ultra renderWidth would decode an unbounded bitmap before
|
|
108
|
+
// any canvas exists.
|
|
109
|
+
{
|
|
110
|
+
const g = rasterGeometry(390, 714, 1, 2, SLICE_PIXEL_BUDGET)
|
|
111
|
+
assert.equal(g.width, 780)
|
|
112
|
+
assert.equal(g.height, 1428)
|
|
113
|
+
assert.equal(g.transformScale, 2)
|
|
114
|
+
}
|
|
115
|
+
{
|
|
116
|
+
// Ultra geometry: composite transform, physical width capped by the wide width.
|
|
117
|
+
const g = rasterGeometry(938, 714, 780 / 938, 2, SLICE_PIXEL_BUDGET)
|
|
118
|
+
assert.equal(g.width, 1560)
|
|
119
|
+
assert.equal(g.height, 1188)
|
|
120
|
+
assert.ok(Math.abs(g.transformScale - (2 * 780) / 938) < 1e-12)
|
|
121
|
+
}
|
|
122
|
+
{
|
|
123
|
+
// A slice whose fresh-measured size busts the budget clamps back inside it
|
|
124
|
+
// (floor per side, so the product provably fits) — the ultra raster-size
|
|
125
|
+
// guarantee, independent of the planner's estimates.
|
|
126
|
+
const g = rasterGeometry(780, 5000, 1, 2, SLICE_PIXEL_BUDGET)
|
|
127
|
+
assert.equal(g.width, 1368)
|
|
128
|
+
assert.equal(g.height, 8770)
|
|
129
|
+
assert.ok(g.width * g.height <= SLICE_PIXEL_BUDGET)
|
|
130
|
+
assert.ok(g.transformScale < 2)
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// ---- SVG document: physical intrinsic + scaled logical wrapper ----------------
|
|
134
|
+
{
|
|
135
|
+
const svg = buildSvgDocument('<div xmlns="http://www.w3.org/1999/xhtml">hi</div>', {
|
|
136
|
+
renderWidth: 390, logicalHeight: 714, width: 780, height: 1428, transformScale: 2,
|
|
137
|
+
})
|
|
138
|
+
assert.ok(svg.startsWith('<svg xmlns="http://www.w3.org/2000/svg" width="780" height="1428"><foreignObject width="780" height="1428">'), 'svg and foreignObject carry the physical target')
|
|
139
|
+
assert.ok(svg.includes('<div xmlns="http://www.w3.org/1999/xhtml" style="width:390px;height:714px;transform:scale(2);transform-origin:0 0">'), 'the xhtml wrapper carries the logical size and the scale transform')
|
|
140
|
+
assert.ok(svg.includes('>hi<'), 'the body rides inside the wrapper')
|
|
141
|
+
assert.ok(svg.endsWith('</foreignObject></svg>'))
|
|
142
|
+
// XML-invalid control characters never reach the parser.
|
|
143
|
+
const scrubbed = buildSvgDocument('a\u0000b', { renderWidth: 10, logicalHeight: 10, width: 20, height: 20, transformScale: 2 })
|
|
144
|
+
assert.ok(!scrubbed.includes('\u0000'))
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// ---- width lifting formulas ----------------------------------------------------
|
|
148
|
+
// Assistant rows span the card content box: content + both card paddings.
|
|
149
|
+
assert.equal(cardWidthForContent(900, 18), 938)
|
|
150
|
+
assert.equal(requiredCardWidth('assistant', 900, 18), 938)
|
|
151
|
+
// User rows sit in a bubble capped at 82% of the row plus its own 14px pads —
|
|
152
|
+
// the percentage inverts into a division.
|
|
153
|
+
assert.equal(cardWidthForUserBubble(900, 18), Math.ceil(928 / 0.82) + 38)
|
|
154
|
+
assert.equal(cardWidthForUserBubble(900, 18), 1170)
|
|
155
|
+
assert.equal(requiredCardWidth('user', 900, 18), 1170)
|
|
156
|
+
|
|
157
|
+
// Bucketing rides on the REQUIRED card width (per role), not the raw content
|
|
158
|
+
// width — a user pre needs ~1.22x its width in card to clear the bubble cap.
|
|
159
|
+
assert.equal(bucketTurnWidth('assistant', 352, 18, 390, 780), 'base')
|
|
160
|
+
assert.equal(bucketTurnWidth('assistant', 353, 18, 390, 780), 'wide')
|
|
161
|
+
assert.equal(bucketTurnWidth('assistant', 742, 18, 390, 780), 'wide')
|
|
162
|
+
assert.equal(bucketTurnWidth('assistant', 743, 18, 390, 780), 'ultra')
|
|
163
|
+
assert.equal(bucketTurnWidth('user', 260, 18, 390, 780), 'base')
|
|
164
|
+
assert.equal(bucketTurnWidth('user', 261, 18, 390, 780), 'wide')
|
|
165
|
+
assert.equal(bucketTurnWidth('user', 580, 18, 390, 780), 'wide')
|
|
166
|
+
assert.equal(bucketTurnWidth('user', 581, 18, 390, 780), 'ultra')
|
|
167
|
+
assert.equal(bucketTurnWidth('assistant', 0, 18, 390, 780), 'base', 'no overflow stays base')
|
|
168
|
+
|
|
169
|
+
// Ultra turns render at natural width and shrink geometrically back to the
|
|
170
|
+
// wide width on output.
|
|
171
|
+
{
|
|
172
|
+
const a = ultraSliceWidth('assistant', 900, 18, 780)
|
|
173
|
+
assert.equal(a.renderWidth, 938)
|
|
174
|
+
assert.ok(Math.abs(a.scale - 780 / 938) < 1e-12)
|
|
175
|
+
assert.ok(a.scale < 1)
|
|
176
|
+
const u = ultraSliceWidth('user', 900, 18, 780)
|
|
177
|
+
assert.equal(u.renderWidth, 1170)
|
|
178
|
+
assert.ok(Math.abs(u.scale - 780 / 1170) < 1e-12)
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
// ---- slice planning -------------------------------------------------------------
|
|
182
|
+
// Fixed chrome geometry the planner must reserve for.
|
|
183
|
+
const EXTRAS = { gap: 14, paddingY: 36, headerHeight: 90, footerHeight: 60, noteHeight: 40 }
|
|
184
|
+
const OPTS = { budgetPx: SLICE_PIXEL_BUDGET, maxSlices: MAX_SLICES, ratios: [2, 1.5, 1] }
|
|
185
|
+
const turn = (height, renderWidth = 390, scale = 1) => ({ height, renderWidth, scale })
|
|
186
|
+
|
|
187
|
+
// Empty card: no slices, no truncation, lowest ratio as the nominal one.
|
|
188
|
+
assert.deepEqual(planShareSlices([], EXTRAS, OPTS), { pixelRatio: 1, slices: [], truncated: false, droppedTurns: 0 })
|
|
189
|
+
|
|
190
|
+
// Short card: one slice at the best ratio, header + footer + gaps accounted.
|
|
191
|
+
{
|
|
192
|
+
const plan = planShareSlices([turn(500)], EXTRAS, OPTS)
|
|
193
|
+
assert.equal(plan.pixelRatio, 2)
|
|
194
|
+
assert.equal(plan.slices.length, 1)
|
|
195
|
+
const [s] = plan.slices
|
|
196
|
+
// 36 padding + (90 header + 14 gap) + 500 turn + (14 gap + 60 footer)
|
|
197
|
+
assert.equal(s.height, 714)
|
|
198
|
+
assert.equal(s.outWidth, 390)
|
|
199
|
+
assert.equal(s.outHeight, 714)
|
|
200
|
+
assert.equal(s.lever, 1)
|
|
201
|
+
assert.ok(s.first && s.last && !s.truncated)
|
|
202
|
+
assert.equal(plan.truncated, false)
|
|
203
|
+
assert.equal(plan.droppedTurns, 0)
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
// Two tall turns split at the budget line; header rides slice 0, footer rides
|
|
207
|
+
// the last, and neither slice crosses 12M physical pixels.
|
|
208
|
+
{
|
|
209
|
+
const plan = planShareSlices([turn(5000), turn(5000)], EXTRAS, OPTS)
|
|
210
|
+
assert.equal(plan.pixelRatio, 2)
|
|
211
|
+
assert.equal(plan.slices.length, 2)
|
|
212
|
+
const [a, b] = plan.slices
|
|
213
|
+
assert.equal(`${a.from}-${a.to}`, '0-0')
|
|
214
|
+
assert.equal(`${b.from}-${b.to}`, '1-1')
|
|
215
|
+
assert.equal(a.height, 36 + 90 + 14 + 5000) // 5140
|
|
216
|
+
assert.equal(b.height, 36 + 5000 + 14 + 60) // 5110
|
|
217
|
+
for (const s of plan.slices) {
|
|
218
|
+
assert.ok(slicePixelCount(s.outWidth, s.outHeight, plan.pixelRatio) <= SLICE_PIXEL_BUDGET)
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
// Degradation ladder. 30 turns of 7600px: at ratio 2 one slice already busts
|
|
223
|
+
// the budget with the header attached, at 1.5 still one-per-slice (30 > 24),
|
|
224
|
+
// at ratio 1 four turns fit per slice -> 8 slices, no truncation.
|
|
225
|
+
{
|
|
226
|
+
const plan = planShareSlices(Array.from({ length: 30 }, () => turn(7600)), EXTRAS, OPTS)
|
|
227
|
+
assert.equal(plan.pixelRatio, 1)
|
|
228
|
+
assert.equal(plan.slices.length, 8)
|
|
229
|
+
assert.equal(plan.truncated, false)
|
|
230
|
+
assert.equal(plan.droppedTurns, 0)
|
|
231
|
+
assert.deepEqual(plan.slices.map((s) => `${s.from}-${s.to}`), [
|
|
232
|
+
'0-3', '4-7', '8-11', '12-15', '16-19', '20-23', '24-27', '28-29',
|
|
233
|
+
])
|
|
234
|
+
assert.ok(slicePixelCount(plan.slices[0].outWidth, plan.slices[0].outHeight, 1) <= SLICE_PIXEL_BUDGET)
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
// The ladder stops at the FIRST ratio that works: 30 turns of 6500px pair up
|
|
238
|
+
// at 1.5 (15 slices) before ratio 1 is ever needed.
|
|
239
|
+
{
|
|
240
|
+
const plan = planShareSlices(Array.from({ length: 30 }, () => turn(6500)), EXTRAS, OPTS)
|
|
241
|
+
assert.equal(plan.pixelRatio, 1.5)
|
|
242
|
+
assert.equal(plan.slices.length, 15)
|
|
243
|
+
assert.ok(plan.slices.every((s) => s.lever === 1))
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
// Truncation keeps the TAIL: 120 tall turns still need 30 slices at ratio 1
|
|
247
|
+
// -> keep the LAST 24, drop the head, mark the FIRST slice — with range=last
|
|
248
|
+
// the user explicitly named the newest turns, which head-keeping would
|
|
249
|
+
// discard exactly.
|
|
250
|
+
{
|
|
251
|
+
const plan = planShareSlices(Array.from({ length: 120 }, () => turn(7600)), EXTRAS, OPTS)
|
|
252
|
+
assert.equal(plan.pixelRatio, 1)
|
|
253
|
+
assert.equal(plan.slices.length, MAX_SLICES)
|
|
254
|
+
assert.equal(plan.truncated, true)
|
|
255
|
+
assert.equal(plan.droppedTurns, 24)
|
|
256
|
+
assert.equal(plan.slices[0].from, 24, 'the first kept slice starts right after the dropped head')
|
|
257
|
+
assert.equal(plan.slices.at(-1).to, 119, 'the tail runs to the end of the conversation')
|
|
258
|
+
assert.equal(plan.slices[0].truncated, true, 'the marker rides the first kept slice')
|
|
259
|
+
assert.equal(plan.slices[1].truncated, false)
|
|
260
|
+
assert.equal(plan.slices.at(-1).truncated, false)
|
|
261
|
+
// First slice: header + note + 4 turns; last slice: footer + the final turns.
|
|
262
|
+
const first = plan.slices[0]
|
|
263
|
+
assert.equal(first.height, 36 + (90 + 14) + (40 + 14) + 4 * 7600 + 3 * 14)
|
|
264
|
+
const last = plan.slices.at(-1)
|
|
265
|
+
assert.equal(last.height, 36 + 4 * 7600 + 3 * 14 + (14 + 60))
|
|
266
|
+
assert.ok(slicePixelCount(first.outWidth, first.outHeight, 1) <= SLICE_PIXEL_BUDGET)
|
|
267
|
+
assert.ok(slicePixelCount(last.outWidth, last.outHeight, 1) <= SLICE_PIXEL_BUDGET)
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
// Wide-turn isolation: a 780px turn between 390px turns becomes its own slice
|
|
271
|
+
// (a slice never mixes widths), with exact per-slice heights.
|
|
272
|
+
{
|
|
273
|
+
const plan = planShareSlices([turn(300), turn(250, 780), turn(300)], EXTRAS, OPTS)
|
|
274
|
+
assert.equal(plan.pixelRatio, 2)
|
|
275
|
+
assert.deepEqual(plan.slices.map((s) => s.renderWidth), [390, 780, 390])
|
|
276
|
+
assert.deepEqual(plan.slices.map((s) => s.height), [36 + 104 + 300, 36 + 250, 36 + 74 + 300])
|
|
277
|
+
assert.deepEqual(plan.slices.map((s) => s.first), [true, false, false])
|
|
278
|
+
assert.deepEqual(plan.slices.map((s) => s.last), [false, false, true])
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
// Ultra turn: renders at natural width, shrinks back to ~780 logical on
|
|
282
|
+
// output, no extra lever needed.
|
|
283
|
+
{
|
|
284
|
+
const plan = planShareSlices([turn(400, 938, 780 / 938)], EXTRAS, OPTS)
|
|
285
|
+
assert.equal(plan.pixelRatio, 2)
|
|
286
|
+
const [s] = plan.slices
|
|
287
|
+
assert.equal(s.renderWidth, 938)
|
|
288
|
+
assert.ok(Math.abs(s.outWidth - 780) < 0.01)
|
|
289
|
+
assert.ok(s.scale < 1 && s.scale > 0.8)
|
|
290
|
+
assert.equal(s.lever, 1)
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
// An un-splittable turn taller than the budget at EVERY ratio (even ratio 1)
|
|
294
|
+
// keeps completeness over sharpness: one slice, uniformly shrunk (the lever),
|
|
295
|
+
// and the ceiling rounding of the canvas is accounted for — the final physical
|
|
296
|
+
// count is genuinely within budget.
|
|
297
|
+
{
|
|
298
|
+
const plan = planShareSlices([turn(40000)], EXTRAS, OPTS)
|
|
299
|
+
assert.equal(plan.pixelRatio, 1)
|
|
300
|
+
assert.equal(plan.slices.length, 1)
|
|
301
|
+
assert.equal(plan.truncated, false)
|
|
302
|
+
const [s] = plan.slices
|
|
303
|
+
assert.ok(s.lever < 1)
|
|
304
|
+
assert.ok(s.scale < 0.88)
|
|
305
|
+
assert.ok(slicePixelCount(s.outWidth, s.outHeight, 1) <= SLICE_PIXEL_BUDGET, 'post-lever pixel count fits')
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
// Slices are contiguous and ordered, covering [droppedTurns, n-1] — the tail
|
|
309
|
+
// always runs to the very end of the conversation.
|
|
310
|
+
{
|
|
311
|
+
const inputs = Array.from({ length: 120 }, () => turn(7600))
|
|
312
|
+
const plan = planShareSlices(inputs, EXTRAS, OPTS)
|
|
313
|
+
let expected = plan.droppedTurns
|
|
314
|
+
for (const s of plan.slices) {
|
|
315
|
+
assert.equal(s.from, expected)
|
|
316
|
+
assert.ok(s.to >= s.from)
|
|
317
|
+
expected = s.to + 1
|
|
318
|
+
}
|
|
319
|
+
assert.equal(expected, inputs.length)
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
// 780-wide slices ride the ratio ladder too: turns of 5000px at width 780
|
|
323
|
+
// bust the budget at ratio 2 (1560 × 10072 > 12M) but fit at 1.5 — width
|
|
324
|
+
// lifting and ratio degradation compounded in one plan.
|
|
325
|
+
{
|
|
326
|
+
const plan = planShareSlices(Array.from({ length: 6 }, () => turn(5000, 780)), EXTRAS, OPTS)
|
|
327
|
+
assert.equal(plan.pixelRatio, 1.5)
|
|
328
|
+
assert.equal(plan.slices.length, 6)
|
|
329
|
+
assert.ok(plan.slices.every((s) => s.renderWidth === 780 && s.lever === 1))
|
|
330
|
+
assert.equal(plan.slices[0].height, 36 + 90 + 14 + 5000)
|
|
331
|
+
assert.equal(plan.slices.at(-1).height, 36 + 5000 + 14 + 60)
|
|
332
|
+
for (const s of plan.slices) {
|
|
333
|
+
assert.ok(slicePixelCount(s.outWidth, s.outHeight, plan.pixelRatio) <= SLICE_PIXEL_BUDGET)
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
// Ultra scale × budget lever compound: a turn whose ultra output still busts
|
|
338
|
+
// the budget at EVERY ratio gets the lever stacked on top of the geometric
|
|
339
|
+
// downscale (completeness kept; pinch-zoom restores size).
|
|
340
|
+
{
|
|
341
|
+
const plan = planShareSlices([turn(45000, 2000, 0.39)], EXTRAS, OPTS)
|
|
342
|
+
assert.equal(plan.pixelRatio, 1)
|
|
343
|
+
assert.equal(plan.slices.length, 1)
|
|
344
|
+
assert.equal(plan.truncated, false)
|
|
345
|
+
const [s] = plan.slices
|
|
346
|
+
assert.ok(s.lever < 1, 'the budget lever stacks onto the ultra scale')
|
|
347
|
+
assert.ok(s.scale < 0.39)
|
|
348
|
+
assert.ok(s.outWidth < 780)
|
|
349
|
+
assert.ok(slicePixelCount(s.outWidth, s.outHeight, 1) <= SLICE_PIXEL_BUDGET)
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
// ---- error surface -----------------------------------------------------------
|
|
353
|
+
{
|
|
354
|
+
const err = new ShareRasterizeError('probe-failed', 'blank')
|
|
355
|
+
assert.ok(err instanceof Error)
|
|
356
|
+
assert.equal(err.code, 'probe-failed')
|
|
357
|
+
assert.equal(err.detail, 'blank')
|
|
358
|
+
assert.match(err.message, /probe-failed/)
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
// ---- ticket 05: share fetch query builder + error taxonomy -------------------
|
|
362
|
+
assert.equal(SHARE_EXPORT_ROUTE, '/_dsh/mobile-nav/share-export')
|
|
363
|
+
assert.deepEqual(SHARE_TURNS_PRESETS, [3, 5, 10], 'PLAN §6: presets only, no free input')
|
|
364
|
+
assert.equal(
|
|
365
|
+
buildShareExportQuery('session-0f1e2d3c-4b5a-6978-8796-a5b4c3d2e1f0', { kind: 'all' }),
|
|
366
|
+
'/_dsh/mobile-nav/share-export?session=session-0f1e2d3c-4b5a-6978-8796-a5b4c3d2e1f0&range=all',
|
|
367
|
+
)
|
|
368
|
+
assert.equal(
|
|
369
|
+
buildShareExportQuery('session-abc', { kind: 'last', turns: 5 }),
|
|
370
|
+
'/_dsh/mobile-nav/share-export?session=session-abc&range=last&turns=5',
|
|
371
|
+
)
|
|
372
|
+
// The session id is URL-encoded: a stray '/' or '?' must not smuggle params.
|
|
373
|
+
assert.equal(
|
|
374
|
+
buildShareExportQuery('session-a/b?c=d', { kind: 'all' }),
|
|
375
|
+
`/_dsh/mobile-nav/share-export?session=${encodeURIComponent('session-a/b?c=d')}&range=all`,
|
|
376
|
+
)
|
|
377
|
+
// range=all never carries turns — the host 400s that pair (rejectStrayTurns).
|
|
378
|
+
assert.ok(!buildShareExportQuery('session-x', { kind: 'all' }).includes('turns='))
|
|
379
|
+
// The route constant must stay glued to the host module it mirrors.
|
|
380
|
+
const hostRouteSrc = readFileSync(new URL('../src/share-export.ts', import.meta.url), 'utf8')
|
|
381
|
+
assert.match(hostRouteSrc, new RegExp(`'${SHARE_EXPORT_ROUTE.replace(/\//g, '\\/')}'`), 'client and host must agree on the share-export route')
|
|
382
|
+
{
|
|
383
|
+
const err = new ShareFetchError('route-missing', 404, 'no envelope')
|
|
384
|
+
assert.ok(err instanceof Error)
|
|
385
|
+
assert.equal(err.code, 'route-missing')
|
|
386
|
+
assert.equal(err.status, 404)
|
|
387
|
+
assert.match(err.message, /route-missing/)
|
|
388
|
+
const net = new ShareFetchError('network', undefined, 'offline')
|
|
389
|
+
assert.equal(net.status, undefined)
|
|
390
|
+
assert.ok(net instanceof ShareFetchError)
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
// ---- ticket 05: portable filename sanitizer ----------------------------------
|
|
394
|
+
assert.equal(sanitizeShareFileName('网关错误率报表'), '网关错误率报表')
|
|
395
|
+
assert.equal(sanitizeShareFileName('a/b\\c|d'), 'a b c|d', 'path separators fold into spaces; other punctuation stays')
|
|
396
|
+
assert.equal(sanitizeShareFileName('two\n\n spaces\tand tab'), 'two spaces and tab')
|
|
397
|
+
assert.equal(sanitizeShareFileName('\u0000control\u001F\u007Fchars'), 'controlchars', 'C0 controls and DEL are stripped')
|
|
398
|
+
// Bidi format characters: zero-width, and they visually reorder the name —
|
|
399
|
+
// `photo<LRM|RLM|LRE|RLE|PDF|LRO>…` must not survive into a filename.
|
|
400
|
+
assert.equal(sanitizeShareFileName('photo\u202Egpj.png'), 'photogpj.png', 'an RLO (right-to-left override) extension spoof is stripped')
|
|
401
|
+
assert.equal(sanitizeShareFileName('a\u200Eb\u200Fc\u202Ad\u202Be\u202Cf\u202Dg'), 'abcdefg', 'LRM/RLM/LRE/RLE/PDF/LRO are stripped too')
|
|
402
|
+
assert.equal(sanitizeShareFileName(' '), FALLBACK_SHARE_BASE_NAME)
|
|
403
|
+
assert.equal(sanitizeShareFileName('///\\\\'), FALLBACK_SHARE_BASE_NAME, 'separator-only titles fall back')
|
|
404
|
+
assert.equal(sanitizeShareFileName(' leading and trailing '), 'leading and trailing')
|
|
405
|
+
// The cap counts CODE POINTS (a UTF-16 slice could split a surrogate pair).
|
|
406
|
+
{
|
|
407
|
+
const emoji = '😀'.repeat(SHARE_NAME_MAX + 5)
|
|
408
|
+
const capped = sanitizeShareFileName(emoji)
|
|
409
|
+
assert.equal(Array.from(capped).length, SHARE_NAME_MAX)
|
|
410
|
+
assert.ok(!capped.includes('\uFFFD'), 'no lone surrogate may survive the cap')
|
|
411
|
+
const cjk = '汉'.repeat(SHARE_NAME_MAX + 10)
|
|
412
|
+
assert.equal(Array.from(sanitizeShareFileName(cjk)).length, SHARE_NAME_MAX)
|
|
413
|
+
}
|
|
414
|
+
assert.equal(shareSliceFileName('share-card', 0), 'share-card-01.png')
|
|
415
|
+
assert.equal(shareSliceFileName('报表', 11), '报表-12.png')
|
|
416
|
+
// Ticket 07: the stitched deliverable is ONE file — plain `<base>.png`, no slice suffix.
|
|
417
|
+
assert.equal(shareImageFileName('报表'), '报表.png')
|
|
418
|
+
assert.equal(shareImageFileName('share-card'), 'share-card.png')
|
|
419
|
+
assert.ok(shareImageFileName(FALLBACK_SHARE_BASE_NAME).endsWith('.png'))
|
|
420
|
+
|
|
421
|
+
// ---- ticket 07: PNG byte layer — CRC32 + chunk framing -------------------------
|
|
422
|
+
// Read a big-endian u32 out of a byte view (length fields and CRCs are both BE).
|
|
423
|
+
const be32 = (bytes, off) => ((bytes[off] << 24) | (bytes[off + 1] << 16) | (bytes[off + 2] << 8) | bytes[off + 3]) >>> 0
|
|
424
|
+
const ENC = new TextEncoder()
|
|
425
|
+
assert.equal(crc32(new Uint8Array(0)), 0)
|
|
426
|
+
assert.equal(crc32(ENC.encode('123456789')), 0xcbf43926, 'the canonical CRC-32 check value')
|
|
427
|
+
assert.equal(crc32(ENC.encode('IEND')), 0xae426082, 'the IEND type CRC — a fixed constant of the PNG ecosystem')
|
|
428
|
+
{
|
|
429
|
+
const data = ENC.encode('hello world')
|
|
430
|
+
const chunk = pngChunk('IDAT', data)
|
|
431
|
+
assert.equal(chunk.length, 12 + data.length)
|
|
432
|
+
assert.equal(be32(chunk, 0), data.length, 'data length, big-endian')
|
|
433
|
+
assert.equal(String.fromCharCode(...chunk.slice(4, 8)), 'IDAT')
|
|
434
|
+
const crcInput = new Uint8Array(4 + data.length)
|
|
435
|
+
crcInput.set(chunk.slice(4, 8))
|
|
436
|
+
crcInput.set(data, 4)
|
|
437
|
+
assert.equal(be32(chunk, 8 + data.length), crc32(crcInput), 'CRC covers type+data, never the length field')
|
|
438
|
+
// Multi-byte lengths really are big-endian, not little.
|
|
439
|
+
const wide = pngChunk('odAT', new Uint8Array(0x1234))
|
|
440
|
+
assert.equal(be32(wide, 0), 0x1234)
|
|
441
|
+
assert.equal(wide[2], 0x12, 'high byte first')
|
|
442
|
+
assert.equal(wide[3], 0x34)
|
|
443
|
+
assert.throws(() => pngChunk('TOOLONG', data), /4 ASCII letters/)
|
|
444
|
+
}
|
|
445
|
+
{
|
|
446
|
+
const d = ihdrData(1560, 12345)
|
|
447
|
+
assert.equal(d.length, 13)
|
|
448
|
+
assert.equal(be32(d, 0), 1560)
|
|
449
|
+
assert.equal(be32(d, 4), 12345)
|
|
450
|
+
assert.deepEqual([...d.slice(8)], [8, 6, 0, 0, 0], '8-bit RGBA (color type 6), deflate, adaptive filter, no interlace')
|
|
451
|
+
}
|
|
452
|
+
{
|
|
453
|
+
assert.equal(IEND_CHUNK.length, 12)
|
|
454
|
+
assert.equal(be32(IEND_CHUNK, 0), 0)
|
|
455
|
+
assert.equal(String.fromCharCode(...IEND_CHUNK.slice(4, 8)), 'IEND')
|
|
456
|
+
assert.equal(be32(IEND_CHUNK, 8), 0xae426082)
|
|
457
|
+
}
|
|
458
|
+
{
|
|
459
|
+
const bytes = ENC.encode('x'.repeat(10))
|
|
460
|
+
assert.deepEqual(splitIdatBytes(bytes, 4).map((p) => p.length), [4, 4, 2], 'IDAT payloads split at the ceiling')
|
|
461
|
+
assert.equal(Buffer.concat(splitIdatBytes(bytes, 4).map(Buffer.from)).toString(), 'x'.repeat(10), 'concatenation is the identity')
|
|
462
|
+
assert.deepEqual(splitIdatBytes(new Uint8Array(0), 4).map((p) => p.length), [0], 'empty input still yields one (empty) payload')
|
|
463
|
+
assert.throws(() => splitIdatBytes(bytes, 0), /invalid IDAT chunk size/)
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
// ---- ticket 07: writer emits a spec-shaped PNG (node:zlib round-trip) ------------
|
|
467
|
+
// Node 22 has CompressionStream + Blob as globals, so the writer runs for real
|
|
468
|
+
// here: every chunk CRC is re-verified and the IDAT stream is inflated back to
|
|
469
|
+
// the exact scanline bytes (filter 0 + the rows that were fed).
|
|
470
|
+
{
|
|
471
|
+
const W = 3
|
|
472
|
+
const H = 5
|
|
473
|
+
const rows = Array.from({ length: H }, (_, y) => Uint8Array.from({ length: W * 4 }, (_, i) => (y * W * 4 + i) % 251))
|
|
474
|
+
const writer = new PngStitchWriter(W, H)
|
|
475
|
+
for (const row of rows) await writer.writeRow(row)
|
|
476
|
+
const png = new Uint8Array(await (await writer.finish()).arrayBuffer())
|
|
477
|
+
assert.deepEqual([...png.slice(0, 8)], [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a], 'PNG signature')
|
|
478
|
+
const chunks = []
|
|
479
|
+
for (let off = 8; off < png.length;) {
|
|
480
|
+
const len = be32(png, off)
|
|
481
|
+
const type = String.fromCharCode(...png.slice(off + 4, off + 8))
|
|
482
|
+
const crcInput = new Uint8Array(4 + len)
|
|
483
|
+
crcInput.set(png.slice(off + 4, off + 8))
|
|
484
|
+
crcInput.set(png.slice(off + 8, off + 8 + len), 4)
|
|
485
|
+
assert.equal(be32(png, off + 8 + len), crc32(crcInput), `${type} chunk CRC`)
|
|
486
|
+
chunks.push({ type, data: png.slice(off + 8, off + 8 + len) })
|
|
487
|
+
off += 12 + len
|
|
488
|
+
}
|
|
489
|
+
assert.deepEqual(chunks.map((c) => c.type), ['IHDR', 'IDAT', 'IEND'], 'small image = exactly one IDAT; IHDR first, IEND last')
|
|
490
|
+
assert.equal(be32(chunks[0].data, 0), W)
|
|
491
|
+
assert.equal(be32(chunks[0].data, 4), H, 'IHDR height = the row count fed (Σ slice heights)')
|
|
492
|
+
const raw = inflateSync(Buffer.from(chunks[1].data))
|
|
493
|
+
assert.equal(raw.length, H * (1 + W * 4))
|
|
494
|
+
for (let y = 0; y < H; y++) {
|
|
495
|
+
assert.equal(raw[y * (1 + W * 4)], 0, 'scanline filter byte 0 (None)')
|
|
496
|
+
assert.deepEqual([...raw.subarray(y * (1 + W * 4) + 1, (y + 1) * (1 + W * 4))], [...rows[y]])
|
|
497
|
+
}
|
|
498
|
+
// Row/height accounting guards: IHDR height must equal the rows actually fed.
|
|
499
|
+
const underfed = new PngStitchWriter(W, 2)
|
|
500
|
+
await underfed.writeRow(rows[0])
|
|
501
|
+
await assert.rejects(() => underfed.finish(), /declared 2 rows but 1/)
|
|
502
|
+
const badWidth = new PngStitchWriter(W, 1)
|
|
503
|
+
await assert.rejects(() => badWidth.writeRow(new Uint8Array(3)), /expected 12/)
|
|
504
|
+
const overfed = new PngStitchWriter(W, 1)
|
|
505
|
+
await overfed.writeRow(rows[0])
|
|
506
|
+
await assert.rejects(() => overfed.writeRow(rows[1]), /exceeds the declared height/)
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
// ---- ticket 07: multi-IDAT end-to-end — one zlib stream, many chunks (review 07+08) ----
|
|
510
|
+
// The small round-trip above always emits exactly ONE IDAT, so the split path
|
|
511
|
+
// was only pinned at the splitIdatBytes unit level. This block fixes the whole
|
|
512
|
+
// pipeline end to end: a big image of crypto-random rows (incompressible —
|
|
513
|
+
// the deflate stream alone crosses MAX_IDAT_CHUNK_BYTES, like a real
|
|
514
|
+
// long-transcript stitch) must emit ≥2 IDAT chunks, every chunk's CRC must
|
|
515
|
+
// verify, and — the property every real decoder relies on — the CONCATENATED
|
|
516
|
+
// payloads must inflate back to the exact scanline bytes: multiple IDATs are
|
|
517
|
+
// one zlib stream in order, never per-chunk streams.
|
|
518
|
+
{
|
|
519
|
+
const W = 97
|
|
520
|
+
const H = 8192 // H × (1 + W×4) ≈ 3.19 MB of noise → ≥ 3 compressed MiB → ≥ 4 chunks
|
|
521
|
+
const rowBytes = W * 4
|
|
522
|
+
const rows = Array.from({ length: H }, () => randomBytes(rowBytes))
|
|
523
|
+
const writer = new PngStitchWriter(W, H)
|
|
524
|
+
for (const row of rows) await writer.writeRow(row)
|
|
525
|
+
const png = new Uint8Array(await (await writer.finish()).arrayBuffer())
|
|
526
|
+
const idats = []
|
|
527
|
+
for (let off = 8; off < png.length;) {
|
|
528
|
+
const len = be32(png, off)
|
|
529
|
+
const type = String.fromCharCode(...png.slice(off + 4, off + 8))
|
|
530
|
+
assert.equal(be32(png, off + 8 + len), crc32(png.slice(off + 4, off + 8 + len)), `${type} chunk CRC (type+data, not the length field)`)
|
|
531
|
+
if (type === 'IDAT') idats.push(png.slice(off + 8, off + 8 + len))
|
|
532
|
+
off += 12 + len
|
|
533
|
+
}
|
|
534
|
+
assert.ok(idats.length >= 2, `a ~3 MiB random stream must split into multiple IDATs, got ${idats.length}`)
|
|
535
|
+
for (const [index, payload] of idats.entries()) {
|
|
536
|
+
assert.ok(payload.length <= MAX_IDAT_CHUNK_BYTES, `IDAT ${index} respects the split ceiling`)
|
|
537
|
+
assert.ok(payload.length > 0, `IDAT ${index} is never empty (an empty tail would shift the stream)`)
|
|
538
|
+
}
|
|
539
|
+
const raw = inflateSync(Buffer.concat(idats.map(Buffer.from)))
|
|
540
|
+
assert.equal(raw.length, H * (1 + rowBytes), 'the concatenated IDAT payloads inflate to exactly the scanline byte count')
|
|
541
|
+
const expected = Buffer.alloc(H * (1 + rowBytes))
|
|
542
|
+
for (let y = 0; y < H; y++) {
|
|
543
|
+
expected.writeUint8(0, y * (1 + rowBytes)) // filter byte 0 (None)
|
|
544
|
+
rows[y].copy(expected, y * (1 + rowBytes) + 1)
|
|
545
|
+
}
|
|
546
|
+
assert.ok(raw.equals(expected), 'every byte round-trips: filter 0 + the exact rows fed, in order')
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
// ---- ticket 07: unified planning — one global width, ratio and lever -------------
|
|
550
|
+
assert.equal(unifiedCardWidth(false), BASE_CARD_WIDTH)
|
|
551
|
+
assert.equal(unifiedCardWidth(true), WIDE_SLICE_WIDTH)
|
|
552
|
+
// Short card measured at the global width: one slice at the best ratio.
|
|
553
|
+
{
|
|
554
|
+
const plan = planShareSlicesUnified([turn(500, 780)], EXTRAS, OPTS)
|
|
555
|
+
assert.equal(plan.pixelRatio, 2)
|
|
556
|
+
assert.equal(plan.slices.length, 1)
|
|
557
|
+
assert.equal(plan.slices[0].outWidth, 780)
|
|
558
|
+
assert.equal(stitchPixelWidth(plan), 1560)
|
|
559
|
+
}
|
|
560
|
+
// Degradation ladder + tail-keeping truncation keep their semantics at the
|
|
561
|
+
// global width (same inputs the DOM half would produce for a 390 card).
|
|
562
|
+
{
|
|
563
|
+
const plan = planShareSlicesUnified(Array.from({ length: 120 }, () => turn(7600)), EXTRAS, OPTS)
|
|
564
|
+
assert.equal(plan.pixelRatio, 1)
|
|
565
|
+
assert.equal(plan.slices.length, MAX_SLICES)
|
|
566
|
+
assert.equal(plan.truncated, true)
|
|
567
|
+
assert.ok(plan.droppedTurns > 0)
|
|
568
|
+
assert.equal(plan.slices[0].from, plan.droppedTurns, 'the first kept slice starts right after the dropped head')
|
|
569
|
+
assert.equal(plan.slices.at(-1).to, 119, 'the tail runs to the end of the conversation')
|
|
570
|
+
assert.equal(plan.slices[0].truncated, true)
|
|
571
|
+
}
|
|
572
|
+
// GLOBAL lever: one un-splittable giant turn shrinks EVERY slice by the same
|
|
573
|
+
// multiplier — uniform output width is the stitch invariant. The legacy planner
|
|
574
|
+
// shrinks only the offending slice, which is exactly the per-slice width
|
|
575
|
+
// divergence stitching cannot tolerate.
|
|
576
|
+
{
|
|
577
|
+
const plan = planShareSlicesUnified([turn(40000), turn(500)], EXTRAS, OPTS)
|
|
578
|
+
assert.equal(plan.pixelRatio, 1)
|
|
579
|
+
assert.equal(plan.slices.length, 2)
|
|
580
|
+
const [giant, normal] = plan.slices
|
|
581
|
+
assert.ok(giant.lever < 1)
|
|
582
|
+
assert.equal(normal.lever, giant.lever, 'the lever is one global multiplier')
|
|
583
|
+
assert.ok(Math.abs(normal.outWidth - giant.outWidth) < 1e-9, 'every slice lands on the same output width')
|
|
584
|
+
assert.ok(slicePixelCount(giant.outWidth, giant.outHeight, 1) <= SLICE_PIXEL_BUDGET)
|
|
585
|
+
const legacy = planShareSlices([turn(40000), turn(500)], EXTRAS, OPTS)
|
|
586
|
+
assert.equal(legacy.slices[1].lever, 1, 'legacy keeps well-behaved slices native — the contrast the unified planner removes')
|
|
587
|
+
}
|
|
588
|
+
// stitchPixelWidth is THE authoritative width: each slice's own ceil'd arithmetic agrees.
|
|
589
|
+
{
|
|
590
|
+
const plan = planShareSlicesUnified([turn(5000, 780), turn(5000, 780)], EXTRAS, OPTS)
|
|
591
|
+
const width = stitchPixelWidth(plan)
|
|
592
|
+
assert.equal(width, 1170, '5000px turns at 780 fit the budget at ratio 1.5 first')
|
|
593
|
+
for (const s of plan.slices) assert.equal(Math.ceil(s.outWidth * plan.pixelRatio), width)
|
|
594
|
+
assert.equal(stitchPixelWidth({ ...plan, slices: [] }), 0, 'empty plans carry no width')
|
|
595
|
+
}
|
|
596
|
+
// An ultra turn inside a unified plan lands on the same output width by
|
|
597
|
+
// construction (renderWidth × W/renderWidth) — no isolation needed for width.
|
|
598
|
+
{
|
|
599
|
+
const plan = planShareSlicesUnified([turn(400, 938, 780 / 938), turn(500, 780)], EXTRAS, OPTS)
|
|
600
|
+
assert.equal(plan.pixelRatio, 2)
|
|
601
|
+
assert.equal(stitchPixelWidth(plan), 1560)
|
|
602
|
+
for (const s of plan.slices) assert.ok(Math.abs(s.outWidth - 780) < 1e-9)
|
|
603
|
+
}
|
|
604
|
+
// rasterGeometryStitch: the width is pinned to the global one; heights ceil;
|
|
605
|
+
// the budget clamp floors the height and NEVER moves the width.
|
|
606
|
+
{
|
|
607
|
+
const geo = rasterGeometryStitch(780, 714, 1560, SLICE_PIXEL_BUDGET)
|
|
608
|
+
assert.equal(geo.width, 1560)
|
|
609
|
+
assert.equal(geo.height, 1428)
|
|
610
|
+
assert.ok(Math.abs(geo.transformScale - 2) < 1e-12)
|
|
611
|
+
assert.equal(geo.clamped, false)
|
|
612
|
+
}
|
|
613
|
+
{
|
|
614
|
+
const geo = rasterGeometryStitch(780, 7693, 1560, SLICE_PIXEL_BUDGET)
|
|
615
|
+
assert.equal(geo.width, 1560, 'the stitch width is untouchable')
|
|
616
|
+
assert.equal(geo.height, Math.floor(SLICE_PIXEL_BUDGET / 1560))
|
|
617
|
+
assert.ok(geo.width * geo.height <= SLICE_PIXEL_BUDGET)
|
|
618
|
+
assert.equal(geo.clamped, true)
|
|
619
|
+
const ultra = rasterGeometryStitch(938, 714, 1560, SLICE_PIXEL_BUDGET)
|
|
620
|
+
assert.equal(ultra.width, 1560)
|
|
621
|
+
assert.ok(Math.abs(ultra.transformScale - 1560 / 938) < 1e-12, 'the transform folds ultra fit, lever and ratio into globalWidth/renderWidth')
|
|
622
|
+
assert.equal(ultra.height, Math.ceil(714 * (1560 / 938)))
|
|
623
|
+
assert.throws(() => rasterGeometryStitch(780, 714, 0, SLICE_PIXEL_BUDGET), /invalid stitch width/)
|
|
624
|
+
}
|
|
625
|
+
|
|
626
|
+
// ---- ticket 09: markdown parser — leniency beats strictness ---------------------
|
|
627
|
+
// The assistant text renderer feeds off parseMarkdown; every construct below
|
|
628
|
+
// pins its block/span shape, and the fallback rule is the point of the design:
|
|
629
|
+
// an unclosed/implausible marker must come back as PLAIN TEXT, never as a
|
|
630
|
+
// half-parsed span that leaks asterisks into the exported image.
|
|
631
|
+
{
|
|
632
|
+
// Empty and blank-only input yields no blocks (renderer renders nothing).
|
|
633
|
+
assert.deepEqual(parseMarkdown(''), [])
|
|
634
|
+
assert.deepEqual(parseMarkdown('\n \n\t\n'), [])
|
|
635
|
+
|
|
636
|
+
// Headings: six levels, inline content parsed, text trimmed; 7 hashes and a
|
|
637
|
+
// hash without a space are paragraphs.
|
|
638
|
+
assert.deepEqual(parseMarkdown('# 标题'), [{ kind: 'heading', level: 1, spans: [{ kind: 'text', text: '标题' }] }])
|
|
639
|
+
assert.deepEqual(parseMarkdown('###### 六级 **粗**'), [{
|
|
640
|
+
kind: 'heading',
|
|
641
|
+
level: 6,
|
|
642
|
+
spans: [{ kind: 'text', text: '六级 ' }, { kind: 'bold', spans: [{ kind: 'text', text: '粗' }] }],
|
|
643
|
+
}])
|
|
644
|
+
assert.deepEqual(parseMarkdown('####### 七个'), [{ kind: 'para', spans: [{ kind: 'text', text: '####### 七个' }] }])
|
|
645
|
+
assert.deepEqual(parseMarkdown('#nospace'), [{ kind: 'para', spans: [{ kind: 'text', text: '#nospace' }] }])
|
|
646
|
+
|
|
647
|
+
// Paragraphs keep soft line breaks inside one text span (pre-wrap renders them).
|
|
648
|
+
assert.deepEqual(parseMarkdown('第一行\n第二行'), [{ kind: 'para', spans: [{ kind: 'text', text: '第一行\n第二行' }] }])
|
|
649
|
+
|
|
650
|
+
// Fenced code: language tag recorded, tilde fences too, unterminated fences
|
|
651
|
+
// still yield their body, empty fences yield an empty block.
|
|
652
|
+
assert.deepEqual(parseMarkdown('```js\nconst x = 1\n```'), [{ kind: 'code', lang: 'js', lines: ['const x = 1'] }])
|
|
653
|
+
assert.deepEqual(parseMarkdown('~~~\nplain\nbody\n~~~'), [{ kind: 'code', lang: undefined, lines: ['plain', 'body'] }])
|
|
654
|
+
assert.deepEqual(parseMarkdown('```ts\nunterminated'), [{ kind: 'code', lang: 'ts', lines: ['unterminated'] }])
|
|
655
|
+
assert.deepEqual(parseMarkdown('```\n```'), [{ kind: 'code', lang: undefined, lines: [] }])
|
|
656
|
+
|
|
657
|
+
// Indented code (4 spaces / tab): one indent unit stripped, blank lines
|
|
658
|
+
// between indented lines stay inside, and a paragraph is never interrupted.
|
|
659
|
+
assert.deepEqual(parseMarkdown(' indented\n more'), [{ kind: 'code', lang: undefined, lines: ['indented', 'more'] }])
|
|
660
|
+
assert.deepEqual(parseMarkdown(' a\n\n b'), [{ kind: 'code', lang: undefined, lines: ['a', '', 'b'] }])
|
|
661
|
+
assert.deepEqual(parseMarkdown('正文\n 缩进'), [{ kind: 'para', spans: [{ kind: 'text', text: '正文\n 缩进' }] }])
|
|
662
|
+
|
|
663
|
+
// Blockquotes: consecutive > lines, prefix stripped, body block-parsed.
|
|
664
|
+
assert.deepEqual(parseMarkdown('> 引用\n> 第二行'), [{
|
|
665
|
+
kind: 'quote',
|
|
666
|
+
children: [{ kind: 'para', spans: [{ kind: 'text', text: '引用\n第二行' }] }],
|
|
667
|
+
}])
|
|
668
|
+
assert.deepEqual(parseMarkdown('> # 标\n> - 项'), [{
|
|
669
|
+
kind: 'quote',
|
|
670
|
+
children: [
|
|
671
|
+
{ kind: 'heading', level: 1, spans: [{ kind: 'text', text: '标' }] },
|
|
672
|
+
{ kind: 'list', ordered: false, items: [{ spans: [{ kind: 'text', text: '项' }], number: undefined, children: [] }] },
|
|
673
|
+
],
|
|
674
|
+
}])
|
|
675
|
+
|
|
676
|
+
// Lists: mixed bullet markers stay one list; ordered numbers are the ones
|
|
677
|
+
// written; two indent levels (2-space multiples); deeper indents cap onto
|
|
678
|
+
// level two; indented non-marker lines lazily continue the deepest item.
|
|
679
|
+
const li = (text, { number = undefined, children = [] } = {}) => ({ spans: [{ kind: 'text', text }], number, children })
|
|
680
|
+
assert.deepEqual(parseMarkdown('- 甲\n- 乙\n+ 丙'), [{ kind: 'list', ordered: false, items: [li('甲'), li('乙'), li('丙')] }])
|
|
681
|
+
assert.deepEqual(parseMarkdown('1. 甲\n2. 乙'), [{ kind: 'list', ordered: true, items: [li('甲', { number: 1 }), li('乙', { number: 2 })] }])
|
|
682
|
+
assert.deepEqual(parseMarkdown('- 甲\n - 甲一\n - 甲二\n- 乙'), [{
|
|
683
|
+
kind: 'list',
|
|
684
|
+
ordered: false,
|
|
685
|
+
items: [
|
|
686
|
+
li('甲', { children: [li('甲一'), li('甲二')] }),
|
|
687
|
+
li('乙'),
|
|
688
|
+
],
|
|
689
|
+
}])
|
|
690
|
+
assert.deepEqual(parseMarkdown('- 甲\n 续行'), [{ kind: 'list', ordered: false, items: [li('甲\n续行')] }])
|
|
691
|
+
// Loose list: a blank line between same-kind items keeps ONE list; a
|
|
692
|
+
// different marker kind after the blank starts a new one.
|
|
693
|
+
assert.deepEqual(parseMarkdown('- 甲\n\n- 乙'), [{ kind: 'list', ordered: false, items: [li('甲'), li('乙')] }])
|
|
694
|
+
{
|
|
695
|
+
const blocks = parseMarkdown('- 甲\n\n1. 乙')
|
|
696
|
+
assert.equal(blocks.length, 2)
|
|
697
|
+
assert.deepEqual(blocks.map((b) => b.kind), ['list', 'list'])
|
|
698
|
+
}
|
|
699
|
+
// The marker's trailing space is load-bearing: "3.14" is prose.
|
|
700
|
+
assert.deepEqual(parseMarkdown('3.14 是圆周率'), [{ kind: 'para', spans: [{ kind: 'text', text: '3.14 是圆周率' }] }])
|
|
701
|
+
|
|
702
|
+
// Tables: alignment row parsed per column, cells inline-parsed, short rows
|
|
703
|
+
// pad to the header width, extra cells drop, and a pipe line WITHOUT a
|
|
704
|
+
// delimiter row stays a paragraph.
|
|
705
|
+
{
|
|
706
|
+
const [table] = parseMarkdown('| A | B | C |\n| :- | :-: | --: |\n| a | b |\n| a | b | c | d |')
|
|
707
|
+
assert.equal(table.kind, 'table')
|
|
708
|
+
assert.deepEqual(table.align, ['left', 'center', 'right'])
|
|
709
|
+
assert.deepEqual(table.header, [
|
|
710
|
+
[{ kind: 'text', text: 'A' }],
|
|
711
|
+
[{ kind: 'text', text: 'B' }],
|
|
712
|
+
[{ kind: 'text', text: 'C' }],
|
|
713
|
+
])
|
|
714
|
+
assert.deepEqual(table.rows[0], [[{ kind: 'text', text: 'a' }], [{ kind: 'text', text: 'b' }], []])
|
|
715
|
+
assert.deepEqual(table.rows[1].map((cell) => cell.length), [1, 1, 1])
|
|
716
|
+
}
|
|
717
|
+
{
|
|
718
|
+
const [table] = parseMarkdown('| **粗** | `x` |\n| --- | --- |')
|
|
719
|
+
assert.equal(table.kind, 'table')
|
|
720
|
+
assert.deepEqual(table.header, [
|
|
721
|
+
[{ kind: 'bold', spans: [{ kind: 'text', text: '粗' }] }],
|
|
722
|
+
[{ kind: 'code', text: 'x' }],
|
|
723
|
+
])
|
|
724
|
+
assert.deepEqual(table.align, [null, null], 'plain --- means unspecified alignment')
|
|
725
|
+
}
|
|
726
|
+
assert.deepEqual(parseMarkdown('a | b'), [{ kind: 'para', spans: [{ kind: 'text', text: 'a | b' }] }])
|
|
727
|
+
|
|
728
|
+
// Thematic breaks: 3+ of one char (- * _) with optional spaces; '** **' is
|
|
729
|
+
// one too (stars + spaces only), so the whitespace-emphasis fallback below
|
|
730
|
+
// uses it inside a sentence instead.
|
|
731
|
+
for (const src of ['---', '***', '___', '- - -', ' ----']) {
|
|
732
|
+
assert.deepEqual(parseMarkdown(src), [{ kind: 'hr' }], `hr: ${src.trim()}`)
|
|
733
|
+
}
|
|
734
|
+
|
|
735
|
+
// Own-line markdown image becomes a placeholder block (alt/src recorded for
|
|
736
|
+
// a future v2); inline inside a sentence it degrades to '!' + a link span.
|
|
737
|
+
assert.deepEqual(parseMarkdown(''), [{ kind: 'image', alt: '架构', src: 'https://e.x/a.png' }])
|
|
738
|
+
assert.deepEqual(parseMarkdown('看  完'), [{
|
|
739
|
+
kind: 'para',
|
|
740
|
+
spans: [
|
|
741
|
+
{ kind: 'text', text: '看 !' },
|
|
742
|
+
{ kind: 'link', spans: [{ kind: 'text', text: '图' }], href: 'u' },
|
|
743
|
+
{ kind: 'text', text: ' 完' },
|
|
744
|
+
],
|
|
745
|
+
}])
|
|
746
|
+
|
|
747
|
+
// Inline spans of every kind, plus nesting (emphasis inside emphasis, code
|
|
748
|
+
// inside paragraphs protecting its markers, bold link text).
|
|
749
|
+
const inlineOf = (src) => {
|
|
750
|
+
const [block] = parseMarkdown(src)
|
|
751
|
+
assert.equal(block.kind, 'para')
|
|
752
|
+
return block.spans
|
|
753
|
+
}
|
|
754
|
+
assert.deepEqual(inlineOf('**粗**'), [{ kind: 'bold', spans: [{ kind: 'text', text: '粗' }] }])
|
|
755
|
+
assert.deepEqual(inlineOf('*斜*'), [{ kind: 'italic', spans: [{ kind: 'text', text: '斜' }] }])
|
|
756
|
+
assert.deepEqual(inlineOf('***粗斜***'), [{ kind: 'bold', spans: [{ kind: 'italic', spans: [{ kind: 'text', text: '粗斜' }] }] }])
|
|
757
|
+
assert.deepEqual(inlineOf('~~删~~'), [{ kind: 'strike', spans: [{ kind: 'text', text: '删' }] }])
|
|
758
|
+
assert.deepEqual(inlineOf('`码`'), [{ kind: 'code', text: '码' }])
|
|
759
|
+
assert.deepEqual(inlineOf('`*x*`'), [{ kind: 'code', text: '*x*' }])
|
|
760
|
+
assert.deepEqual(inlineOf('[标签](https://e.x)'), [{ kind: 'link', spans: [{ kind: 'text', text: '标签' }], href: 'https://e.x' }])
|
|
761
|
+
assert.deepEqual(inlineOf('[](https://e.x)'), [{ kind: 'link', spans: [{ kind: 'text', text: 'https://e.x' }], href: 'https://e.x' }], 'empty link text shows the URL itself')
|
|
762
|
+
assert.deepEqual(inlineOf('**a *i* c**'), [{
|
|
763
|
+
kind: 'bold',
|
|
764
|
+
spans: [{ kind: 'text', text: 'a ' }, { kind: 'italic', spans: [{ kind: 'text', text: 'i' }] }, { kind: 'text', text: ' c' }],
|
|
765
|
+
}])
|
|
766
|
+
assert.deepEqual(inlineOf('[**粗链**](u)'), [{ kind: 'link', spans: [{ kind: 'bold', spans: [{ kind: 'text', text: '粗链' }] }], href: 'u' }])
|
|
767
|
+
|
|
768
|
+
// Escapes: backslash + ASCII punctuation yields the literal char; a
|
|
769
|
+
// backslash before anything else stays a backslash.
|
|
770
|
+
assert.deepEqual(inlineOf('a \\*not em\\* b'), [{ kind: 'text', text: 'a *not em* b' }])
|
|
771
|
+
assert.deepEqual(inlineOf('\\`码\\`'), [{ kind: 'text', text: '`码`' }])
|
|
772
|
+
assert.deepEqual(inlineOf('\\[not a link\\](x)'), [{ kind: 'text', text: '[not a link](x)' }])
|
|
773
|
+
assert.deepEqual(inlineOf('a\\\\b'), [{ kind: 'text', text: 'a\\b' }])
|
|
774
|
+
|
|
775
|
+
// THE fallback rule: unclosed or implausible markers are literal text.
|
|
776
|
+
assert.deepEqual(inlineOf('**没闭'), [{ kind: 'text', text: '**没闭' }])
|
|
777
|
+
assert.deepEqual(inlineOf('*没闭'), [{ kind: 'text', text: '*没闭' }])
|
|
778
|
+
assert.deepEqual(inlineOf('`没闭'), [{ kind: 'text', text: '`没闭' }])
|
|
779
|
+
assert.deepEqual(inlineOf('~~没闭'), [{ kind: 'text', text: '~~没闭' }])
|
|
780
|
+
assert.deepEqual(inlineOf('[没闭](u'), [{ kind: 'text', text: '[没闭](u' }])
|
|
781
|
+
assert.deepEqual(inlineOf('[没括号] 后面'), [{ kind: 'text', text: '[没括号] 后面' }])
|
|
782
|
+
assert.deepEqual(inlineOf('a ** ** b'), [{ kind: 'text', text: 'a ** ** b' }], 'whitespace-only emphasis falls back')
|
|
783
|
+
// Flanking: an opener must touch non-space on both ends, so arithmetic and
|
|
784
|
+
// spaced asterisks never italicize.
|
|
785
|
+
assert.deepEqual(inlineOf('5 * 3 = 15'), [{ kind: 'text', text: '5 * 3 = 15' }])
|
|
786
|
+
assert.deepEqual(inlineOf('a * b * c'), [{ kind: 'text', text: 'a * b * c' }])
|
|
787
|
+
|
|
788
|
+
// Exact-run closers (review 09, P2): a longer run must NOT close a shorter
|
|
789
|
+
// marker. `*a **b** c*` parses as nested emphasis (the inner bold pair is
|
|
790
|
+
// skipped whole while hunting the outer single-star closer); the adjacent
|
|
791
|
+
// closer families that cannot close cleanly round-trip as literals —
|
|
792
|
+
// leaking mid-content stars into the share image is the one failure this
|
|
793
|
+
// parser must never produce.
|
|
794
|
+
assert.deepEqual(inlineOf('*a **b** c*'), [{
|
|
795
|
+
kind: 'italic',
|
|
796
|
+
spans: [{ kind: 'text', text: 'a ' }, { kind: 'bold', spans: [{ kind: 'text', text: 'b' }] }, { kind: 'text', text: ' c' }],
|
|
797
|
+
}])
|
|
798
|
+
assert.deepEqual(inlineOf('**a *b***'), [{ kind: 'text', text: '**a *b***' }])
|
|
799
|
+
assert.deepEqual(inlineOf('*****x*****'), [{ kind: 'text', text: '*****x*****' }])
|
|
800
|
+
assert.deepEqual(inlineOf('~~a ~~~ b~~'), [{ kind: 'strike', spans: [{ kind: 'text', text: 'a ~~~ b' }] }], 'a ~~~ triple is not a ~~ closer')
|
|
801
|
+
|
|
802
|
+
// Totality guards (review 09, P2/P3): nesting carries a depth ceiling and a
|
|
803
|
+
// pathological single paragraph past the inline-length ceiling parses as
|
|
804
|
+
// plain text — both return in milliseconds instead of throwing / scanning
|
|
805
|
+
// quadratic.
|
|
806
|
+
{
|
|
807
|
+
const deep = parseMarkdown('> '.repeat(10_000) + 'x')
|
|
808
|
+
assert.ok(Array.isArray(deep) && deep.length >= 1, 'deep quote chain must not throw')
|
|
809
|
+
const pathological = parseMarkdown(('*a ').repeat(20_000))
|
|
810
|
+
assert.deepEqual(pathological, [{ kind: 'para', spans: [{ kind: 'text', text: ('*a ').repeat(20_000) }] }], 'over-length paragraph downgrades to plain text')
|
|
811
|
+
}
|
|
812
|
+
}
|
|
813
|
+
|
|
814
|
+
// ---- ticket 09: parser output shape snapshot -----------------------------------
|
|
815
|
+
// One comprehensive document pinned in full: the renderer switches over this
|
|
816
|
+
// tree, so any parser change that reshapes it must be conscious (snapshot and
|
|
817
|
+
// renderer move together). This is the drift tripwire the ticket asked for.
|
|
818
|
+
assert.deepEqual(parseMarkdown([
|
|
819
|
+
'# 标题一',
|
|
820
|
+
'',
|
|
821
|
+
'段落 **粗** *斜* `码` ~~删~~ [链](https://e.x/a) 转义 \\*星\\*。',
|
|
822
|
+
'',
|
|
823
|
+
'## 标题二',
|
|
824
|
+
'',
|
|
825
|
+
'- 甲',
|
|
826
|
+
' - 甲一',
|
|
827
|
+
'- 乙',
|
|
828
|
+
'',
|
|
829
|
+
'1. 步一',
|
|
830
|
+
'2. 步二',
|
|
831
|
+
'',
|
|
832
|
+
'> 引用行',
|
|
833
|
+
'> 第二行',
|
|
834
|
+
'',
|
|
835
|
+
'| A | B |',
|
|
836
|
+
'| :- | --: |',
|
|
837
|
+
'| a | b |',
|
|
838
|
+
'',
|
|
839
|
+
'```ts',
|
|
840
|
+
'const x: number = 1',
|
|
841
|
+
'```',
|
|
842
|
+
'',
|
|
843
|
+
'---',
|
|
844
|
+
'',
|
|
845
|
+
'',
|
|
846
|
+
].join('\n')), [
|
|
847
|
+
{ kind: 'heading', level: 1, spans: [{ kind: 'text', text: '标题一' }] },
|
|
848
|
+
{
|
|
849
|
+
kind: 'para',
|
|
850
|
+
spans: [
|
|
851
|
+
{ kind: 'text', text: '段落 ' },
|
|
852
|
+
{ kind: 'bold', spans: [{ kind: 'text', text: '粗' }] },
|
|
853
|
+
{ kind: 'text', text: ' ' },
|
|
854
|
+
{ kind: 'italic', spans: [{ kind: 'text', text: '斜' }] },
|
|
855
|
+
{ kind: 'text', text: ' ' },
|
|
856
|
+
{ kind: 'code', text: '码' },
|
|
857
|
+
{ kind: 'text', text: ' ' },
|
|
858
|
+
{ kind: 'strike', spans: [{ kind: 'text', text: '删' }] },
|
|
859
|
+
{ kind: 'text', text: ' ' },
|
|
860
|
+
{ kind: 'link', spans: [{ kind: 'text', text: '链' }], href: 'https://e.x/a' },
|
|
861
|
+
{ kind: 'text', text: ' 转义 *星*。' },
|
|
862
|
+
],
|
|
863
|
+
},
|
|
864
|
+
{ kind: 'heading', level: 2, spans: [{ kind: 'text', text: '标题二' }] },
|
|
865
|
+
{
|
|
866
|
+
kind: 'list',
|
|
867
|
+
ordered: false,
|
|
868
|
+
items: [
|
|
869
|
+
{ spans: [{ kind: 'text', text: '甲' }], number: undefined, children: [{ spans: [{ kind: 'text', text: '甲一' }], number: undefined, children: [] }] },
|
|
870
|
+
{ spans: [{ kind: 'text', text: '乙' }], number: undefined, children: [] },
|
|
871
|
+
],
|
|
872
|
+
},
|
|
873
|
+
{
|
|
874
|
+
kind: 'list',
|
|
875
|
+
ordered: true,
|
|
876
|
+
items: [
|
|
877
|
+
{ spans: [{ kind: 'text', text: '步一' }], number: 1, children: [] },
|
|
878
|
+
{ spans: [{ kind: 'text', text: '步二' }], number: 2, children: [] },
|
|
879
|
+
],
|
|
880
|
+
},
|
|
881
|
+
{ kind: 'quote', children: [{ kind: 'para', spans: [{ kind: 'text', text: '引用行\n第二行' }] }] },
|
|
882
|
+
{
|
|
883
|
+
kind: 'table',
|
|
884
|
+
align: ['left', 'right'],
|
|
885
|
+
header: [[{ kind: 'text', text: 'A' }], [{ kind: 'text', text: 'B' }]],
|
|
886
|
+
rows: [[[{ kind: 'text', text: 'a' }], [{ kind: 'text', text: 'b' }]]],
|
|
887
|
+
},
|
|
888
|
+
{ kind: 'code', lang: 'ts', lines: ['const x: number = 1'] },
|
|
889
|
+
{ kind: 'hr' },
|
|
890
|
+
{ kind: 'image', alt: '图', src: 'https://e.x/i.png' },
|
|
891
|
+
])
|
|
892
|
+
|
|
893
|
+
// ---- structural contracts the math depends on ---------------------------------
|
|
894
|
+
// rasterize.ts may only import the PURE constants module of its directory:
|
|
895
|
+
// this script loads it via Node type stripping, which EXECUTES every value
|
|
896
|
+
// import (JSX or DOM code would break the check), and the client bundler
|
|
897
|
+
// inlines it next to share-preview without new externals. share-constants.ts
|
|
898
|
+
// is the single source both the template and the rasterizer consume, so the
|
|
899
|
+
// width formulas can never drift from the rendered bubble again.
|
|
900
|
+
const rasterizeSrc = readFileSync(new URL('../src/client/share/rasterize.ts', import.meta.url), 'utf8')
|
|
901
|
+
const rasterizeImportSpecifiers = [...rasterizeSrc.matchAll(/^\s*import[^;\n]*from\s*['"]([^'"]+)['"]/gm)].map((m) => m[1])
|
|
902
|
+
assert.deepEqual(
|
|
903
|
+
rasterizeImportSpecifiers, ['./share-constants.ts'],
|
|
904
|
+
'rasterize.ts may only import this directory\'s pure constants module (type stripping + externals budget)',
|
|
905
|
+
)
|
|
906
|
+
assert.match(rasterizeSrc, /export (async )?function (rasterizeShareCard|ensureProbe|planShareSlices)/)
|
|
907
|
+
assert.match(rasterizeSrc, /drawImage\(img, 0, 0\)/, 'paint must be a 1:1 copy — the SVG intrinsic size already IS the physical target')
|
|
908
|
+
assert.match(rasterizeSrc, /已省略前/, 'the default truncation note must count the dropped head turns')
|
|
909
|
+
|
|
910
|
+
// The constants module itself must stay a pure leaf: an import here would
|
|
911
|
+
// execute in this check, and JSX/DOM module scope would break type stripping.
|
|
912
|
+
const constantsSrc = readFileSync(new URL('../src/client/share/share-constants.ts', import.meta.url), 'utf8')
|
|
913
|
+
assert.doesNotMatch(constantsSrc, /^\s*import/m, 'share-constants.ts must stay import-free (pure leaf both sides consume)')
|
|
914
|
+
|
|
915
|
+
// png-stitch.ts is held to the same pure-leaf contract — this script imports
|
|
916
|
+
// it BY VALUE through type stripping (module scope executes), so it may carry
|
|
917
|
+
// no imports at all, and its browser globals (CompressionStream /
|
|
918
|
+
// createImageBitmap / document) must stay inside function/class bodies. The
|
|
919
|
+
// writer tests above prove the Node-loadable half runs for real.
|
|
920
|
+
const stitchSrc = readFileSync(new URL('../src/client/share/png-stitch.ts', import.meta.url), 'utf8')
|
|
921
|
+
assert.doesNotMatch(stitchSrc, /^\s*import/m, 'png-stitch.ts must stay import-free (pure leaf loadable by type stripping anywhere)')
|
|
922
|
+
assert.match(stitchSrc, /typeof CompressionStream === 'function'/, 'the stitch feature-detect gates on CompressionStream (old browsers fall back to multi-file)')
|
|
923
|
+
|
|
924
|
+
// The ticket-05 modules ride the same type-stripping constraint: a VALUE
|
|
925
|
+
// import (of share-card.tsx's JSX, or of anything touching the DOM at module
|
|
926
|
+
// scope) would fail this script's own import above. `import type` is erased
|
|
927
|
+
// by both tsc and Node's stripper, so only those are allowed. Constructing
|
|
928
|
+
// the error classes above additionally proves the constructor-parameter-
|
|
929
|
+
// property syntax (which the stripper rejects) is absent.
|
|
930
|
+
for (const mod of ['fetch-share.ts', 'share-flow.ts']) {
|
|
931
|
+
const src = readFileSync(new URL(`../src/client/share/${mod}`, import.meta.url), 'utf8')
|
|
932
|
+
assert.doesNotMatch(src, /^\s*import\s(?!type )/m, `${mod} must only import types (type stripping keeps it loadable here)`)
|
|
933
|
+
}
|
|
934
|
+
|
|
935
|
+
// share-card.tsx must CONSUME the shared constants module — that import is
|
|
936
|
+
// what makes the constants above the single source of truth for the bubble
|
|
937
|
+
// geometry (the retired regexes used to pin '82%' / '9px 14px' literals; the
|
|
938
|
+
// card root's padding/gap need no pin at all, the rasterizer measures them
|
|
939
|
+
// off the rendered clone).
|
|
940
|
+
const cardSrc = readFileSync(new URL('../src/client/share/share-card.tsx', import.meta.url), 'utf8')
|
|
941
|
+
assert.match(cardSrc, /from '\.\/share-constants\.ts'/, 'share-card must import the shared template constants (rasterize inverts them)')
|
|
942
|
+
assert.match(cardSrc, /data-share-role=\{turn\.role\}/, 'turn rows must carry data-share-role for role-aware width lifting')
|
|
943
|
+
assert.match(cardSrc, /whiteSpace: 'pre'/, 'code-block lines must keep natural width (the width probe depends on it)')
|
|
944
|
+
assert.match(cardSrc, /whiteSpace: 'nowrap'/, 'table cells must keep natural width (the width probe depends on it)')
|
|
945
|
+
// Ticket 09: assistant text renders through the markdown parser, user text
|
|
946
|
+
// keeps the ticket-03 plain-text fold (Chat-view parity), and the card stays
|
|
947
|
+
// className-free — the SVG-image render loads no stylesheets.
|
|
948
|
+
assert.match(cardSrc, /from '\.\/markdown\.ts'/, 'assistant text must render through the markdown parser')
|
|
949
|
+
assert.match(cardSrc, /from '\.\/text-layout\.ts'/, 'user text keeps the ticket-03 plain-text fold')
|
|
950
|
+
assert.ok(!/className[=:]/.test(cardSrc), 'share-card must stay className-free (foreignObject rasterization red line)')
|
|
951
|
+
// Inheritable-text drift guards: the shadow-root measurement inherits the
|
|
952
|
+
// host page's values while the SVG-image render starts from engine defaults,
|
|
953
|
+
// so every unpinned inheritable text property is a width-measurement drift
|
|
954
|
+
// channel (all pinned values are CSS initial values).
|
|
955
|
+
assert.match(cardSrc, /letterSpacing: 'normal'/, 'card root must pin letterSpacing so page and SVG renders agree')
|
|
956
|
+
assert.match(cardSrc, /wordBreak: 'normal'/, 'card root must pin wordBreak')
|
|
957
|
+
assert.match(cardSrc, /overflowWrap: 'normal'/, 'card root must pin overflowWrap')
|
|
958
|
+
assert.match(cardSrc, /hyphens: 'manual'/, 'card root must pin hyphens (initial value)')
|
|
959
|
+
assert.match(cardSrc, /textAlign: 'start'/, 'card root must pin textAlign')
|
|
960
|
+
assert.match(cardSrc, /textTransform: 'none'/, 'card root must pin textTransform')
|
|
961
|
+
assert.match(cardSrc, /tabSize: 8/, 'card root must pin tabSize (engine default 8)')
|
|
962
|
+
assert.match(cardSrc, /fontVariantLigatures: 'normal'/, 'card root must pin fontVariantLigatures')
|
|
963
|
+
|
|
964
|
+
// markdown.ts (ticket 09) rides the same pure-leaf contract as the constants
|
|
965
|
+
// module: import-free so this script's own type-stripped import above can load
|
|
966
|
+
// it, and the client bundler inlines it with no new externals.
|
|
967
|
+
const mdSrc = readFileSync(new URL('../src/client/share/markdown.ts', import.meta.url), 'utf8')
|
|
968
|
+
assert.doesNotMatch(mdSrc, /^\s*import/m, 'markdown.ts must stay import-free (pure leaf loadable by type stripping)')
|
|
969
|
+
|
|
970
|
+
console.log('SHARE IMAGE CHECK OK')
|