From 3bb731519b05c34e4616209fbeb31c113198bf8a Mon Sep 17 00:00:00 2001 From: Yanlong Wang Date: Fri, 21 Mar 2025 22:43:19 +0800 Subject: [PATCH] fix: generated alt --- package-lock.json | 19 ++- package.json | 5 +- src/services/alt-text.ts | 17 ++- src/services/canvas.ts | 191 +++++++++++++++++++++++++++++ src/services/jsdom.ts | 13 +- src/services/puppeteer.ts | 24 ++-- src/services/snapshot-formatter.ts | 79 ++++++------ thinapps-shared | 2 +- 8 files changed, 283 insertions(+), 67 deletions(-) create mode 100644 src/services/canvas.ts diff --git a/package-lock.json b/package-lock.json index 8abad2f..af61e06 100644 --- a/package-lock.json +++ b/package-lock.json @@ -17,7 +17,7 @@ "axios": "^1.3.3", "bcrypt": "^5.1.0", "busboy": "^1.6.0", - "civkit": "^0.9.0-f7b0ca7", + "civkit": "^0.9.0-848ef4e", "core-js": "^3.37.1", "cors": "^2.8.5", "dayjs": "^1.11.9", @@ -26,6 +26,7 @@ "firebase-functions": "^6.1.1", "htmlparser2": "^9.0.0", "jose": "^5.1.0", + "koa": "^2.16.0", "langdetect": "^0.2.1", "linkedom": "^0.18.4", "lru-cache": "^11.0.2", @@ -41,6 +42,7 @@ "set-cookie-parser": "^2.6.0", "simple-zstd": "^1.4.2", "stripe": "^11.11.0", + "svg2png-wasm": "^1.4.1", "tiktoken": "^1.0.16", "tld-extract": "^2.1.0", "turndown": "^7.1.3", @@ -62,7 +64,6 @@ "eslint-config-google": "^0.14.0", "eslint-plugin-import": "^2.25.4", "firebase-functions-test": "^3.0.0", - "koa": "^2.16.0", "pino-pretty": "^13.0.0", "replicate": "^0.16.1", "typescript": "^5.5.4" @@ -4002,9 +4003,9 @@ } }, "node_modules/civkit": { - "version": "0.9.0-f7b0ca7", - "resolved": "https://registry.npmjs.org/civkit/-/civkit-0.9.0-f7b0ca7.tgz", - "integrity": "sha512-WjF0zRY83Ewvx4fGs1O0PQD2Oyc/RlKCVGiO/LHdwEFwfldTqDE3XWdWv+brZ2GvsIsVVKVa+bEGP0SwJfrRXA==", + "version": "0.9.0-848ef4e", + "resolved": "https://registry.npmjs.org/civkit/-/civkit-0.9.0-848ef4e.tgz", + "integrity": "sha512-yxk5AKaiZSN4ntlwybVHYgUer402CSw06KzN7wvfaYra9evZkZ7MiFHGULqMnY7657k3CH0WV4n6jGfRj1Vpvw==", "license": "AGPL", "dependencies": { "lodash": "^4.17.21", @@ -4022,7 +4023,7 @@ "iconv-lite": "^0.6.3", "js-yaml": "^4.1.0", "jschardet": "^3.0.0", - "koa": "^2.14.2", + "koa": "^2.15.4", "koa-bodyparser": "^4.4.0", "koa-compose": "^4.1.0", "libmagic-ffi": "^0.1.4", @@ -11931,6 +11932,12 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/svg2png-wasm": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/svg2png-wasm/-/svg2png-wasm-1.4.1.tgz", + "integrity": "sha512-ZFy1NtwZVAsslaTQoI+/QqX2sg0vjmgJ/jGAuLZZvYcRlndI54hLPiwLC9JzXlFBerfxN5JiS7kpEUG0mrXS3Q==", + "license": "MIT" + }, "node_modules/tar": { "version": "6.2.1", "resolved": "https://registry.npmjs.org/tar/-/tar-6.2.1.tgz", diff --git a/package.json b/package.json index 47f0ec6..a43500d 100644 --- a/package.json +++ b/package.json @@ -26,7 +26,7 @@ "axios": "^1.3.3", "bcrypt": "^5.1.0", "busboy": "^1.6.0", - "civkit": "^0.9.0-f7b0ca7", + "civkit": "^0.9.0-848ef4e", "core-js": "^3.37.1", "cors": "^2.8.5", "dayjs": "^1.11.9", @@ -35,6 +35,7 @@ "firebase-functions": "^6.1.1", "htmlparser2": "^9.0.0", "jose": "^5.1.0", + "koa": "^2.16.0", "langdetect": "^0.2.1", "linkedom": "^0.18.4", "lru-cache": "^11.0.2", @@ -50,6 +51,7 @@ "set-cookie-parser": "^2.6.0", "simple-zstd": "^1.4.2", "stripe": "^11.11.0", + "svg2png-wasm": "^1.4.1", "tiktoken": "^1.0.16", "tld-extract": "^2.1.0", "turndown": "^7.1.3", @@ -71,7 +73,6 @@ "eslint-config-google": "^0.14.0", "eslint-plugin-import": "^2.25.4", "firebase-functions-test": "^3.0.0", - "koa": "^2.16.0", "pino-pretty": "^13.0.0", "replicate": "^0.16.1", "typescript": "^5.5.4" diff --git a/src/services/alt-text.ts b/src/services/alt-text.ts index b206909..143cea9 100644 --- a/src/services/alt-text.ts +++ b/src/services/alt-text.ts @@ -1,7 +1,7 @@ import { AssertionFailureError, AsyncService, HashManager } from 'civkit'; import { singleton } from 'tsyringe'; import { GlobalLogger } from './logger'; -import { CanvasService } from '../shared/services/canvas'; +import { CanvasService } from './canvas'; import { ImageInterrogationManager } from '../shared/services/common-iminterrogate'; import { ImgBrief } from './puppeteer'; import { ImgAlt } from '../db/img-alt'; @@ -32,13 +32,20 @@ export class AltTextService extends AsyncService { async caption(url: string) { try { const img = await this.canvasService.loadImage(url); + const contentTypeHint = Reflect.get(img, 'contentType'); + if (Math.min(img.naturalHeight, img.naturalWidth) < 64) { + throw new AssertionFailureError({ message: `Image is too small to generate alt text for url ${url}` }); + } const resized = this.canvasService.fitImageToSquareBox(img, 1024); const exported = await this.canvasService.canvasToBuffer(resized, 'image/png'); - const r = await this.imageInterrogator.interrogate('vertex-gemini-1.5-flash-002', { + const svgHint = contentTypeHint.includes('svg') ? `Beware this image is a SVG rendered on a gray background, the gray background is not part of the image.\n\n` : ''; + const svgSystemHint = contentTypeHint.includes('svg') ? ` Sometimes the system renders SVG on a gray background. When this happens, you must not include the gray background in the description.` : ''; + + const r = await this.imageInterrogator.interrogate('vertex-gemini-2.0-flash', { image: exported, - prompt: `Yield a concise image caption sentence in third person.`, - system: 'You are BLIP2, an image caption model.', + prompt: `${svgHint}Give a concise image caption descriptive sentence in third person. Start directly with the description.`, + system: `You are BLIP2, an image caption model. You will generate Alt Text (in web pages) for any image for a11y purposes. You must not start with "This image is sth...", instead, start direly with "sth..."${svgSystemHint}`, }); return r.replaceAll(/[\n\"]|(\.\s*$)/g, '').trim(); @@ -73,7 +80,7 @@ export class AltTextService extends AsyncService { if (this.asyncLocalContext.ctx.DNT) { // Don't cache alt text if DNT is set - return; + return generatedCaption; } // Don't try again until the next day diff --git a/src/services/canvas.ts b/src/services/canvas.ts new file mode 100644 index 0000000..15db6e9 --- /dev/null +++ b/src/services/canvas.ts @@ -0,0 +1,191 @@ +import { singleton, container } from 'tsyringe'; +import { AsyncService, mimeOf, ParamValidationError, SubmittedDataMalformedError, /* downloadFile */ } from 'civkit'; +import { readFile } from 'fs/promises'; + +import type canvas from '@napi-rs/canvas'; +export type { Canvas, Image } from '@napi-rs/canvas'; + +import { GlobalLogger } from './logger'; +import { TempFileManager } from './temp-file'; + +import { isMainThread } from 'worker_threads'; +import type { svg2png } from 'svg2png-wasm' with { 'resolution-mode': 'import' }; +import path from 'path'; +import { Threaded } from './threaded'; + +const downloadFile = async (uri: string) => { + const resp = await fetch(uri); + if (!(resp.ok && resp.body)) { + throw new Error(`Unexpected response ${resp.statusText}`); + } + const contentLength = parseInt(resp.headers.get('content-length') || '0'); + if (contentLength > 1024 * 1024 * 100) { + throw new Error('File too large'); + } + const buff = await resp.arrayBuffer(); + + return { buff, contentType: resp.headers.get('content-type') }; +}; + +@singleton() +export class CanvasService extends AsyncService { + + logger = this.globalLogger.child({ service: this.constructor.name }); + svg2png!: typeof svg2png; + canvas!: typeof canvas; + + constructor( + protected temp: TempFileManager, + protected globalLogger: GlobalLogger, + ) { + super(...arguments); + } + + override async init() { + await this.dependencyReady(); + if (!isMainThread) { + const { createSvg2png, initialize } = require('svg2png-wasm'); + const wasmBuff = await readFile(path.resolve(path.dirname(require.resolve('svg2png-wasm')), '../svg2png_wasm_bg.wasm')); + const fontBuff = await readFile(path.resolve(__dirname, '../../licensed/SourceHanSansSC-Regular.otf')); + await initialize(wasmBuff); + this.svg2png = createSvg2png({ + fonts: [Uint8Array.from(fontBuff)], + defaultFontFamily: { + serifFamily: 'Source Han Sans SC', + sansSerifFamily: 'Source Han Sans SC', + cursiveFamily: 'Source Han Sans SC', + fantasyFamily: 'Source Han Sans SC', + monospaceFamily: 'Source Han Sans SC', + } + }); + } + this.canvas = require('@napi-rs/canvas'); + + this.emit('ready'); + } + + @Threaded() + async renderSvgToPng(svgContent: string,) { + return this.svg2png(svgContent, { backgroundColor: '#D3D3D3' }); + } + + protected async _loadImage(input: string | Buffer) { + let buff; + let contentType; + do { + if (typeof input === 'string') { + if (input.startsWith('data:')) { + const firstComma = input.indexOf(','); + const header = input.slice(0, firstComma); + const data = input.slice(firstComma + 1); + const encoding = header.split(';')[1]; + contentType = header.split(';')[0].split(':')[1]; + if (encoding?.startsWith('base64')) { + buff = Buffer.from(data, 'base64'); + } else { + buff = Buffer.from(decodeURIComponent(data), 'utf-8'); + } + break; + } + if (input.startsWith('http')) { + const r = await downloadFile(input); + buff = Buffer.from(r.buff); + contentType = r.contentType; + break; + } + } + if (Buffer.isBuffer(input)) { + buff = input; + const mime = await mimeOf(buff); + contentType = `${mime.mediaType}/${mime.subType}`; + break; + } + throw new ParamValidationError('Invalid input'); + } while (false); + + if (!buff) { + throw new ParamValidationError('Invalid input'); + } + + if (contentType?.includes('svg')) { + buff = await this.renderSvgToPng(buff.toString('utf-8')); + } + + const img = await this.canvas.loadImage(buff); + Reflect.set(img, 'contentType', contentType); + + return img; + } + + async loadImage(uri: string | Buffer) { + const t0 = Date.now(); + try { + const theImage = await this._loadImage(uri); + const t1 = Date.now(); + this.logger.debug(`Image loaded in ${t1 - t0}ms`); + + return theImage; + } catch (err: any) { + if (err?.message?.includes('Unsupported image type') || err?.message?.includes('unsupported')) { + this.logger.warn(`Failed to load image ${uri.slice(0, 128)}`, { err }); + throw new SubmittedDataMalformedError(`Unknown image format for ${uri.slice(0, 128)}`); + } + throw err; + } + } + + fitImageToSquareBox(image: canvas.Image | canvas.Canvas, size: number = 1024) { + // this.logger.debug(`Fitting image(${ image.width }x${ image.height }) to ${ size } box`); + // const t0 = Date.now(); + if (image.width <= size && image.height <= size) { + if (image instanceof this.canvas.Canvas) { + return image; + } + const canvasInstance = this.canvas.createCanvas(image.width, image.height); + const ctx = canvasInstance.getContext('2d'); + ctx.drawImage(image, 0, 0, image.width, image.height, 0, 0, canvasInstance.width, canvasInstance.height); + // this.logger.debug(`No need to resize, copied to canvas in ${ Date.now() - t0 } ms`); + + return canvasInstance; + } + + const aspectRatio = image.width / image.height; + + const resizedWidth = Math.round(aspectRatio > 1 ? size : size * aspectRatio); + const resizedHeight = Math.round(aspectRatio > 1 ? size / aspectRatio : size); + + const canvasInstance = this.canvas.createCanvas(resizedWidth, resizedHeight); + const ctx = canvasInstance.getContext('2d'); + ctx.drawImage(image, 0, 0, image.width, image.height, 0, 0, resizedWidth, resizedHeight); + // this.logger.debug(`Resized to ${ resizedWidth }x${ resizedHeight } in ${ Date.now() - t0 } ms`); + + return canvasInstance; + } + + corpImage(image: canvas.Image | canvas.Canvas, x: number, y: number, w: number, h: number) { + // this.logger.debug(`Cropping image(${ image.width }x${ image.height }) to ${ w }x${ h } at ${ x },${ y } `); + // const t0 = Date.now(); + const canvasInstance = this.canvas.createCanvas(w, h); + const ctx = canvasInstance.getContext('2d'); + ctx.drawImage(image, x, y, w, h, 0, 0, w, h); + // this.logger.debug(`Crop complete in ${ Date.now() - t0 } ms`); + + return canvasInstance; + } + + canvasToDataUrl(canvas: canvas.Canvas, mimeType?: 'image/png' | 'image/jpeg') { + // this.logger.debug(`Exporting canvas(${ canvas.width }x${ canvas.height })`); + // const t0 = Date.now(); + return canvas.toDataURLAsync((mimeType || 'image/png') as 'image/png'); + } + + async canvasToBuffer(canvas: canvas.Canvas, mimeType?: 'image/png' | 'image/jpeg') { + // this.logger.debug(`Exporting canvas(${ canvas.width }x${ canvas.height })`); + // const t0 = Date.now(); + return canvas.toBuffer((mimeType || 'image/png') as 'image/png'); + } + +} + +const instance = container.resolve(CanvasService); +export default instance; diff --git a/src/services/jsdom.ts b/src/services/jsdom.ts index 6bd960d..fa97125 100644 --- a/src/services/jsdom.ts +++ b/src/services/jsdom.ts @@ -169,10 +169,12 @@ export class JSDomControl extends AsyncService { Array.from(rootDoc.querySelectorAll('img[src],img[data-src]')) .map((x: any) => [x.getAttribute('src'), x.getAttribute('data-src'), x.getAttribute('alt')]) .forEach(([u1, u2, alt]) => { + let absUrl: string | undefined; if (u1) { try { const u1Txt = new URL(u1, snapshot.rebase || snapshot.href).toString(); imgSet.add(u1Txt); + absUrl = u1Txt; } catch (err) { // void 0; } @@ -181,14 +183,17 @@ export class JSDomControl extends AsyncService { try { const u2Txt = new URL(u2, snapshot.rebase || snapshot.href).toString(); imgSet.add(u2Txt); + absUrl = u2Txt; } catch (err) { // void 0; } } - rebuiltImgs.push({ - src: u1 || u2, - alt - }); + if (absUrl) { + rebuiltImgs.push({ + src: absUrl, + alt + }); + } }); const r = { diff --git a/src/services/puppeteer.ts b/src/services/puppeteer.ts index 71b434b..3b13f81 100644 --- a/src/services/puppeteer.ts +++ b/src/services/puppeteer.ts @@ -395,7 +395,7 @@ function giveSnapshot(stopActiveSnapshot, overrideDomAnalysis) { description: document.head?.querySelector('meta[name="description"]')?.getAttribute('content') ?? '', href: document.location.href, html: document.documentElement?.outerHTML, - htmlSignificantlyModifiedByJs: Boolean(Math.abs(thisElemCount - initialElemCount) / (initialElemCount + Number.EPSILON) > 0.1), + htmlSignificantlyModifiedByJs: Boolean(Math.abs(thisElemCount - initialElemCount) / (initialElemCount + Number.EPSILON) > 0.05), text: document.body?.innerText, shadowExpanded: shadowDomPresent() ? cloneAndExpandShadowRoots()?.outerHTML : undefined, parsed: parsed, @@ -407,16 +407,18 @@ function giveSnapshot(stopActiveSnapshot, overrideDomAnalysis) { if (document.baseURI !== r.href) { r.rebase = document.baseURI; } - if (parsed && parsed.content) { - const elem = document.createElement('div'); - elem.innerHTML = parsed.content; - r.imgs = briefImgs(elem); - } else { - const allImgs = briefImgs(); - if (allImgs.length === 1) { - r.imgs = allImgs; + r.imgs = briefImgs().filter((x)=> { + if (x.complete) { + if (Math.min(x.width, x.height, x.naturalWidth, x.naturalHeight) < 64) { + return false; + } } - } + const m = Math.min(x.width, x.height); + if (m && m < 64) { + return false; + } + return true; + }); return r; } @@ -756,7 +758,7 @@ export class PuppeteerControl extends AsyncService { dElem = delta /(previousElemCount + Number.EPSILON); } - if (dt < 1500 && dElem < 0.1) { + if (dt < 1200 && dElem < 0.05) { return; } diff --git a/src/services/snapshot-formatter.ts b/src/services/snapshot-formatter.ts index e236e8c..60c126b 100644 --- a/src/services/snapshot-formatter.ts +++ b/src/services/snapshot-formatter.ts @@ -213,6 +213,7 @@ export class SnapshotFormatter extends AsyncService { const imageSummary = {} as { [k: string]: string; }; const imageIdxTrack = new Map(); const uid = this.threadLocal.get('uid'); + do { if (pdfMode) { contentText = (snapshot.parsed?.content || snapshot.text || '').trim(); @@ -229,10 +230,10 @@ export class SnapshotFormatter extends AsyncService { break; } - const urlToAltMap: { [k: string]: string | undefined; } = {}; const noGFMOpts = this.threadLocal.get('noGfm'); const imageRetention = this.threadLocal.get('retainImages') as CrawlerOptions['retainImages']; let imgIdx = 0; + const urlToAltMap: { [k: string]: string | undefined; } = {}; const customRules: { [k: string]: Rule; } = { 'img-retention': { filter: 'img', @@ -267,41 +268,37 @@ export class SnapshotFormatter extends AsyncService { if (!src) { return ''; } - const mapped = urlToAltMap[originalSrc]; + + const keySrc = (originalSrc.startsWith('data:') ? this.dataUrlToBlobUrl(originalSrc, snapshot.rebase) : src).trim(); + const mapped = urlToAltMap[keySrc]; const imgSerial = ++imgIdx; - const idxArr = imageIdxTrack.has(src) ? imageIdxTrack.get(src)! : []; + const idxArr = imageIdxTrack.has(keySrc) ? imageIdxTrack.get(keySrc)! : []; idxArr.push(imgSerial); - imageIdxTrack.set(src, idxArr); + imageIdxTrack.set(keySrc, idxArr); if (mapped) { - imageSummary[src] = mapped || alt; + imageSummary[keySrc] = mapped || alt; if (imageRetention === 'alt_p') { - return `(Image ${imgIdx}: ${mapped || alt})`; + return `(Image ${imgSerial}: ${mapped || alt})`; } - if (src?.startsWith('data:') && imgDataUrlToObjectUrl) { - const mappedUrl = new URL(`blob:${nominalUrl?.origin || ''}/${md5Hasher.hash(src)}`); - mappedUrl.protocol = 'blob:'; - - return `![Image ${imgIdx}: ${mapped || alt}](${mappedUrl})`; + if (imgDataUrlToObjectUrl) { + return `![Image ${imgSerial}: ${mapped || alt}](${keySrc})`; } - return `![Image ${imgIdx}: ${mapped || alt}](${src})`; + return `![Image ${imgSerial}: ${mapped || alt}](${src})`; } else if (imageRetention === 'alt_p') { - return alt ? `(Image ${imgIdx}: ${alt})` : ''; + return alt ? `(Image ${imgSerial}: ${alt})` : ''; } - imageSummary[src] = alt || ''; + imageSummary[keySrc] = alt || ''; - if (src?.startsWith('data:') && imgDataUrlToObjectUrl) { - const mappedUrl = new URL(`blob:${nominalUrl?.origin || ''}/${md5Hasher.hash(src)}`); - mappedUrl.protocol = 'blob:'; - - return alt ? `![Image ${imgIdx}: ${alt}](${mappedUrl})` : `![Image ${imgIdx}](${mappedUrl})`; + if (imgDataUrlToObjectUrl) { + return alt ? `![Image ${imgSerial}: ${alt}](${keySrc})` : `![Image ${imgSerial}](${keySrc})`; } - return alt ? `![Image ${imgIdx}: ${alt}](${src})` : `![Image ${imgIdx}](${src})`; + return alt ? `![Image ${imgSerial}: ${alt}](${src})` : `![Image ${imgSerial}](${src})`; } } as Rule }; @@ -343,7 +340,9 @@ export class SnapshotFormatter extends AsyncService { return undefined; }); if (r && x.src) { - urlToAltMap[x.src.trim()] = r; + // note x.src here is already rebased to absolute url by browser/upstream. + const keySrc = (x.src.startsWith('data:') ? this.dataUrlToBlobUrl(x.src, snapshot.rebase) : x.src).trim(); + urlToAltMap[keySrc] = r; } }); @@ -416,13 +415,10 @@ export class SnapshotFormatter extends AsyncService { .toPairs() .map( ([url, alt], i) => { - if (imgDataUrlToObjectUrl && url.startsWith('data:')) { - const refUrl = new URL(formatted.url!); - const mappedUrl = new URL(`blob:${refUrl.origin}/${md5Hasher.hash(url)}`); + const idxTrack = imageIdxTrack.get(url); + const tag = idxTrack?.length ? `Image ${_.uniq(idxTrack).join(',')}` : `Hidden Image ${i + 1}`; - url = mappedUrl.toString(); - } - return [`Image ${(imageIdxTrack?.get(url) || [i + 1]).join(',')}${alt ? `: ${alt}` : ''}`, url]; + return [`${tag}${alt ? `: ${alt}` : ''}`, url]; } ).fromPairs() .value(); @@ -522,6 +518,13 @@ ${suffixMixins.length ? `\n${suffixMixins.join('\n\n')}\n` : ''}`; return f as FormattedPage; } + dataUrlToBlobUrl(dataUrl: string, baseUrl: string = 'http://localhost/') { + const refUrl = new URL(baseUrl); + const mappedUrl = new URL(`blob:${refUrl.origin || 'localhost'}/${md5Hasher.hash(dataUrl)}`); + + return mappedUrl.href; + } + async getGeneralSnapshotMixins(snapshot: PageSnapshot) { let inferred; const mixin: any = {}; @@ -534,10 +537,11 @@ ${suffixMixins.length ? `\n${suffixMixins.join('\n\n')}\n` : ''}`; for (const img of inferred.imgs) { const imgSerial = ++imgIdx; - const idxArr = imageIdxTrack.has(img.src) ? imageIdxTrack.get(img.src)! : []; + const keySrc = (img.src.startsWith('data:') ? this.dataUrlToBlobUrl(img.src, snapshot.rebase) : img.src).trim(); + const idxArr = imageIdxTrack.has(keySrc) ? imageIdxTrack.get(keySrc)! : []; idxArr.push(imgSerial); - imageIdxTrack.set(img.src, idxArr); - imageSummary[img.src] = img.alt || ''; + imageIdxTrack.set(keySrc, idxArr); + imageSummary[keySrc] = img.alt || ''; } mixin.images = @@ -545,7 +549,10 @@ ${suffixMixins.length ? `\n${suffixMixins.join('\n\n')}\n` : ''}`; .toPairs() .map( ([url, alt], i) => { - return [`Image ${(imageIdxTrack?.get(url) || [i + 1]).join(',')}${alt ? `: ${alt}` : ''}`, url]; + const idxTrack = imageIdxTrack.get(url); + const tag = idxTrack?.length ? `Image ${_.uniq(idxTrack).join(',')}` : `Hidden Image ${i + 1}`; + + return [`${tag}${alt ? `: ${alt}` : ''}`, url]; } ).fromPairs() .value(); @@ -611,14 +618,9 @@ ${suffixMixins.length ? `\n${suffixMixins.join('\n\n')}\n` : ''}`; const src = (node.getAttribute('src') || '').trim(); const alt = cleanAttribute(node.getAttribute('alt')) || ''; - if (options.url) { - const refUrl = new URL(options.url); - const mappedUrl = new URL(`blob:${refUrl.origin}/${md5Hasher.hash(src)}`); + const blobUrl = this.dataUrlToBlobUrl(src, options.url?.toString()); - return `![${alt}](${mappedUrl})`; - } - - return `![${alt}](blob:${md5Hasher.hash(src)})`; + return `![${alt}](${blobUrl})`; } }); } @@ -817,6 +819,7 @@ ${suffixMixins.length ? `\n${suffixMixins.join('\n\n')}\n` : ''}`; if (contentType.startsWith('image/')) { snapshot.html = `${fileName}`; snapshot.title = fileName; + snapshot.imgs = [{ src: url.href }]; return snapshot; } diff --git a/thinapps-shared b/thinapps-shared index 4b1061e..a2ebcb8 160000 --- a/thinapps-shared +++ b/thinapps-shared @@ -1 +1 @@ -Subproject commit 4b1061e6e9623bb98b82ac6f86004988c7211385 +Subproject commit a2ebcb882fa92644cc3dfd6b8d8e66f06dd940e9