// ==UserScript==
// @name 学习通一键导出
// @namespace local.xxt.userscript
// @version 1.5.0
// @description 学习通作业/考试页一键导出 Word(.docx):默认保留原图(与原插件一致),OCR 可选
// @author 祁桉
// @match *://*.chaoxing.com/*
// @match *://*.cldisk.com/*
// @grant GM_xmlhttpRequest
// @grant GM.xmlHttpRequest
// @connect chaoxing.com
// @connect cldisk.com
// @connect cdn.jsdelivr.net
// @connect tessdata.projectnaptha.com
// @connect unpkg.com
// @connect *
// @require https://cdn.jsdelivr.net/npm/tesseract.js@5.1.1/dist/tesseract.min.js
// @run-at document-idle
// ==/UserScript==
//
// v1.5:OCR 默认关闭 -> 默认行为和原插件一致(图片一律保留原图)。
// 想用 OCR 时手动勾选;OCR 仅对"质量达标"的纯文字图生效,其余自动保留原图。
// 附带「诊断」按钮:一键导出报告,便于定位抓不到图/丢图的问题。
(() => {
if (window.__xxtExporterInjected) return;
window.__xxtExporterInjected = true;
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
const TYPES = '单选题|多选题|判断题|填空题|简答题|名词解释|论述题|案例分析';
async function scrollLoad() {
let lastH = 0;
for (let i = 0; i < 12; i++) {
window.scrollTo(0, document.documentElement.scrollHeight || document.body.scrollHeight);
await sleep(400);
const h = document.documentElement.scrollHeight || document.body.scrollHeight;
if (h === lastH) break;
lastH = h;
}
window.scrollTo(0, 0);
await sleep(300);
}
function parseQuestionsFromText(fullText) {
const normalized = (fullText || '').replace(/\u00a0/g, ' ').replace(/\r\n?/g, '\n');
const headerRe = new RegExp('\\n(?=\\s*\\d{1,3}\\s*[.、.::](?!\\d)\\s*(?:\\(?(?:' + TYPES + ')\\)?)?)', 'g');
const parts = normalized.split(headerRe).map((s) => s.trim()).filter(Boolean);
const questions = [];
for (const part of parts) {
const q = parseQuestionPart(part);
if (!q) continue;
const hasType = new RegExp('^(?:' + TYPES + ')$').test(q.type);
if (q.options.length || hasType || q.answer || q.myAnswer) {
questions.push(q);
}
}
return questions;
}
function extractTextAnswer(part, labels) {
const m = part.match(new RegExp(labels + '[::]'));
if (!m) return '';
const segment = part.slice(m.index + m[0].length);
const lines = segment.split(/\n+/).map((s) => s.trim());
for (const line of lines) {
if (!line) continue;
if (/^\d+分$/.test(line)) continue;
if (/^(我的答案|正确答案|参考答案|标准答案|AI讲解|知识点|排除法)/.test(line)) break;
return line;
}
return '';
}
function parseQuestionPart(part) {
const lines = part.split(/\n+/).map((s) => s.trim());
if (!lines.length) return null;
const header = lines[0] || '';
const hm = header.match(/^\s*(\d{1,3})\s*[.、.::](?!\d)\s*(?:\(([^)]*)\)\s*)?(.*)$/);
if (!hm) return null;
const number = parseInt(hm[1], 10);
let type = (hm[2] || '').trim();
let headerRest = (hm[3] || '').trim();
if (type) {
const tm = type.match(new RegExp('(' + TYPES + ')'));
type = tm ? tm[1] : type;
}
if (!type) {
const tm = headerRest.match(new RegExp('^(' + TYPES + ')'));
if (tm) {
type = tm[1];
headerRest = headerRest.slice(tm[0].length).trim();
}
}
if (!type) type = '题目';
const options = [];
const stemLines = headerRest ? [headerRest] : [];
let myAnswer = '';
let myText = '';
let correctAnswer = '';
let correctText = '';
let state = 'stem';
let currentOption = null;
const stopRe = /^\d+分$|^AI讲解$|^知识点[::]?|^排除法$|^[一二三四五六七八九十]+[.、]/;
for (let i = 1; i < lines.length; i++) {
const line = lines[i];
if (stopRe.test(line) || /^(答题卡|上一题|下一题|收藏|标记|纠错|展开|收起|题目列表|试卷列表)$/.test(line)) {
break;
}
const myM = line.match(/^我的答案[::]\s*([A-H])?/i);
if (myM) {
if (myM[1]) myAnswer = myM[1].toUpperCase();
state = 'my';
let j = i + 1;
while (j < lines.length && lines[j].startsWith(':')) {
if (!myText) myText = lines[j].slice(1).replace(/;+$/g, '').trim();
j++;
}
i = j - 1;
continue;
}
const correctM = line.match(/^(正确答案|参考答案|标准答案)[::]\s*([A-H])?/i);
if (correctM) {
if (correctM[2]) correctAnswer = correctM[2].toUpperCase();
state = 'correct';
let j = i + 1;
while (j < lines.length && lines[j].startsWith(':')) {
if (!correctText) correctText = lines[j].slice(1).replace(/;+$/g, '').trim();
j++;
}
i = j - 1;
continue;
}
if (/^(答案解析|解析)[::]/.test(line)) {
continue;
}
const optionM = line.match(/^([A-H])[.、.::)]\s*(.*)$/);
if (optionM) {
const letter = optionM[1].toUpperCase();
const optText = (optionM[2] || '').trim();
currentOption = optText ? `${letter}. ${optText}` : `${letter}. `;
options.push(currentOption);
state = 'options';
continue;
}
if (state === 'options' && currentOption) {
const appended = (options[options.length - 1].endsWith(' ') ? '' : ' ') + line;
options[options.length - 1] = options[options.length - 1] + appended;
continue;
}
if (line) {
stemLines.push(line);
}
}
let stem = stemLines.join(' ').trim();
if (!myAnswer) {
myAnswer = extractTextAnswer(part, '我的答案');
}
if (!correctAnswer) {
correctAnswer = extractTextAnswer(part, '正确答案|参考答案|标准答案');
}
return {
number,
type,
stem,
options,
answer: correctAnswer || '',
myAnswer: myAnswer || '',
analysis: ''
};
}
function buildStemItems(h) {
const qt = h.querySelector('.qtContent') || h.querySelector('.qtContent.workTextWrap');
if (!qt) return null;
const items = [];
let buf = '';
const flush = () => {
const t = buf.replace(/\s+/g, ' ').trim();
if (t) items.push({ type: 'text', text: t });
buf = '';
};
const walk = (el) => {
for (const child of el.childNodes) {
if (child.nodeType === Node.TEXT_NODE) {
buf += child.textContent;
} else if (child.nodeType === Node.ELEMENT_NODE) {
if (child.tagName === 'IMG') {
flush();
const src = child.getAttribute('data-original') || child.currentSrc || child.src || '';
if (src) items.push({ type: 'image', url: src, data_url: '' });
} else if (child.tagName === 'BR') {
buf += '\n';
} else {
walk(child);
}
}
}
};
walk(qt);
flush();
return items.length ? items : null;
}
function collectImagesByNumber() {
const map = {};
const heads = document.querySelectorAll('h3.mark_name');
for (const h of heads) {
const text = (h.innerText || '').trim();
const m = text.match(/^\s*(\d{1,3})\s*[.、.::]/);
if (!m) continue;
const num = parseInt(m[1], 10);
const questionImages = [];
h.querySelectorAll('img').forEach((img) => {
const src = img.getAttribute('data-original') || img.currentSrc || img.src || '';
if (src) questionImages.push({ url: src, data_url: '' });
});
const answerImages = [];
const optionImagesByLetter = {};
const seenAnswer = new Set();
const seenOption = new Set();
const answerSel = 'dd.rightAnswerContent.workTextWrap, dl.mark_fill.colorGreen';
const myAnswerSel = 'dd.textwrap.stuAnswerContent.reserve-newline, dl.mark_fill.colorDeep';
let node = h.nextElementSibling;
while (node && !(node.tagName === 'H3' && node.classList.contains('mark_name'))) {
const answerContainers = node.querySelectorAll(answerSel);
answerContainers.forEach((container) => {
container.querySelectorAll('img').forEach((img) => {
const src = img.getAttribute('data-original') || img.currentSrc || img.src || '';
if (src && !seenAnswer.has(src)) {
seenAnswer.add(src);
answerImages.push({ url: src, data_url: '' });
}
});
});
const optionLis = node.querySelectorAll('li.workTextWrap');
optionLis.forEach((li) => {
const t = (li.innerText || '').trim();
const lm = t.match(/^\s*([A-H])[.、.::)]/);
if (!lm) return;
const letter = lm[1].toUpperCase();
li.querySelectorAll('img').forEach((img) => {
const src = img.getAttribute('data-original') || img.currentSrc || img.src || '';
if (!src || seenOption.has(src)) return;
seenOption.add(src);
if (!optionImagesByLetter[letter]) optionImagesByLetter[letter] = [];
optionImagesByLetter[letter].push({ url: src, data_url: '' });
});
});
node = node.nextElementSibling;
}
const stemItems = buildStemItems(h);
if (questionImages.length || answerImages.length || Object.keys(optionImagesByLetter).length || stemItems) {
map[num] = { questionImages, answerImages, optionImagesByLetter, stemItems };
}
}
return map;
}
async function blobToDataURL(blob) {
return new Promise((resolve, reject) => {
const fr = new FileReader();
fr.onload = () => resolve(fr.result);
fr.onerror = reject;
fr.readAsDataURL(blob);
});
}
async function enrichImageData(img) {
if (img.data_url) return;
let src = img.url || '';
if (!src || src.startsWith('data:')) return;
if (src.startsWith('//')) src = location.protocol + src;
try {
const resp = await fetch(src, { credentials: 'include' });
if (resp.ok) {
const blob = await resp.blob();
img.data_url = await blobToDataURL(blob);
}
} catch (e) {
img.data_url = '';
}
}
async function attachImages(questions) {
const imageMap = collectImagesByNumber();
if (!Object.keys(imageMap).length) return questions;
for (const q of questions) {
const entry = imageMap[q.number];
if (!entry) continue;
const qImgs = entry.questionImages || [];
const aImgs = entry.answerImages || [];
const oMap = entry.optionImagesByLetter || {};
const oImgs = Object.values(oMap).flat();
for (const img of qImgs.concat(aImgs, oImgs)) {
await enrichImageData(img);
}
q.images = qImgs;
q.answer_images = aImgs;
q.option_images_by_letter = oMap;
if (entry.stemItems) q.stem_items = entry.stemItems;
}
return questions.filter((q) => q.stem || q.options.length || q.answer || (q.images && q.images.length) || (q.answer_images && q.answer_images.length) || (q.option_images_by_letter && Object.keys(q.option_images_by_letter).length));
}
function guessCourse() {
const selectors = ['.breadcrumb', '.crumbs', '.catalog_title', '.chapter', 'h1', '.title'];
for (const s of selectors) {
const el = document.querySelector(s);
const t = el && el.innerText ? el.innerText.trim() : '';
if (t && t.length < 80) return t;
}
const m = (document.title || '').match(/^(.+?)[-_—]\s*学习通/);
if (m) return m[1].trim();
return '';
}
function guessAssignmentName(fullText) {
const m = (fullText || '').match(/(?:考试详情|作业详情)\s*\n\s*(.+?)\s*\n\s*题量/);
if (m && m[1]) return m[1].trim();
return guessCourse();
}
function guessAssignmentKind(fullText) {
const t = fullText || '';
if (/考试详情|考试时间/.test(t)) return '考试';
if (/作业详情|作答时间/.test(t)) return '作业';
return '作业';
}
async function extract() {
await scrollLoad();
const fullText = document.body.innerText || '';
let questions = parseQuestionsFromText(fullText);
questions = await attachImages(questions);
if (!questions.length) {
return { ok: false, error: '没有解析到题目,请确认已打开学习通作业/考试页面并完全加载。' };
}
const assignmentName = guessAssignmentName(fullText);
const assignmentKind = guessAssignmentKind(fullText);
return {
ok: true,
title: document.title || '',
course: guessCourse(),
assignment_name: assignmentName,
assignment_kind: assignmentKind,
page_url: location.href,
questions,
};
}
// ================================================================
// 【补丁 1】图片获取:绕过 CORS
// 原插件真正靠的是 native_host.py 里带 Referer 的 urllib 下载;
// 油猴里 fetch 跨域会被拦,导致图片只剩一个网址。这里用 GM_xmlhttpRequest。
// ================================================================
async function enrichImageData(img) {
if (!img || img.data_url) return;
let src = img.url || '';
if (!src || src.startsWith('data:')) return;
if (src.startsWith('//')) src = location.protocol + src;
try { src = new URL(src, location.href).href; } catch (e) {}
const gm = (typeof GM_xmlhttpRequest !== 'undefined') ? GM_xmlhttpRequest
: (typeof GM !== 'undefined' && GM && GM.xmlHttpRequest) ? GM.xmlHttpRequest : null;
if (gm) {
const got = await new Promise((resolve) => {
try {
gm({
method: 'GET', url: src, responseType: 'blob',
headers: { Referer: location.href },
withCredentials: true, timeout: 30000,
onload: async (res) => {
try {
if (res && res.status >= 200 && res.status < 300 && res.response) {
if (res.response instanceof Blob) return resolve(await blobToDataURL(res.response));
if (res.response instanceof ArrayBuffer) return resolve(await blobToDataURL(new Blob([res.response])));
}
resolve('');
} catch (e) { resolve(''); }
},
onerror: () => resolve(''), ontimeout: () => resolve('')
});
} catch (e) { resolve(''); }
});
if (got) { img.data_url = got; return; }
}
try {
const resp = await fetch(src, { credentials: 'include' });
if (resp && resp.ok) img.data_url = await blobToDataURL(await resp.blob());
} catch (e) {}
}
// ================================================================
// 【补丁 2】图片文字识别 OCR(带质量闸门)
// 只有「像正常文字」时才用 OCR 结果;公式 / 图形 / 化学式 / 表格 等
// 会被闸门判定为不合格,自动改为放原图(和原版"直接留原图"的结果一致)。
// ================================================================
const OCR_MIN_CONF = 70; // 平均置信度低于此值 -> 判为图形/公式,用原图
const OCR_MIN_CHARS = 4; // 有效字符太少 -> 用原图
const OCR_MAX_SYMBOL_RATIO = 0.4; // 符号占比过高(公式/化学式/图形)-> 用原图
let _ocrWorker = null, _ocrLoading = null;
async function getOcrWorker() {
if (_ocrWorker) return _ocrWorker;
if (!_ocrLoading) {
_ocrLoading = (async () => {
if (typeof Tesseract === 'undefined') throw new Error('OCR 组件未加载(可能没联网)');
const w = await Tesseract.createWorker('chi_sim+eng');
_ocrWorker = w;
return w;
})().catch((e) => { _ocrLoading = null; throw e; });
}
return _ocrLoading;
}
function collectOcrTargets(res) {
const items = [], seen = new Set();
const push = (im) => {
if (!im) return;
const k = im.data_url || '';
if (!k.startsWith('data:') || seen.has(k)) return;
seen.add(k); items.push(im);
};
(res.questions || []).forEach((q) => {
(q.stem_items || []).forEach((it) => { if (it.type === 'image') push(it); });
(q.images || []).forEach(push);
const om = q.option_images_by_letter || {};
Object.keys(om).forEach((k) => (om[k] || []).forEach(push));
(q.answer_images || []).forEach(push);
});
return items;
}
function ocrQualityOk(out) {
const d = (out && out.data) || {};
const raw = (((d.text == null ? '' : d.text)) + '').trim();
const compact = raw.replace(/\s+/g, '');
const alnum = compact.replace(/[^\u4e00-\u9fa5A-Za-z0-9]/g, '');
const symbolRatio = compact.length ? (1 - (alnum.length / compact.length)) : 1;
const conf = (typeof d.confidence === 'number') ? d.confidence : 0;
const ok = !!raw && conf >= OCR_MIN_CONF && alnum.length >= OCR_MIN_CHARS && symbolRatio <= OCR_MAX_SYMBOL_RATIO;
return { ok: ok, text: raw.replace(/\s+/g, ' '), conf: Math.round(conf), symbolRatio: symbolRatio };
}
async function runOcrOnImages(res, onProgress) {
const targets = collectOcrTargets(res);
if (!targets.length) return { total: 0, ok: 0, skipped: 0, failed: false };
let worker;
try { worker = await getOcrWorker(); }
catch (e) { return { total: targets.length, ok: 0, skipped: 0, failed: true, error: (e && e.message) || String(e) }; }
let ok = 0;
for (let i = 0; i < targets.length; i++) {
try {
const out = await worker.recognize(targets[i].data_url);
const q = ocrQualityOk(out);
if (q.ok) { targets[i].ocr_text = q.text; targets[i].ocr_conf = q.conf; ok++; }
} catch (e) {}
if (onProgress) onProgress(i + 1, targets.length);
}
return { total: targets.length, ok: ok, skipped: targets.length - ok, failed: false };
}
// ================================================================
// userscript 版:界面 + 浏览器内导出
// 导出 Word 使用「自建最小 OOXML(.docx) 打包器」,图片按标准内嵌,
// 不依赖 nativeMessaging / 内置 Python / python-docx / RapidOCR。
// ================================================================
const CFG = { includeOptions: true, includeAnswer: false, includeImages: true, includeOcr: false };
let FAB = null, PANEL = null, TIP = null;
const esc = (s) => String(s == null ? '' : s)
.replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"');
const imgSrc = (im) => (im && (im.data_url || im.url)) || '';
const nap = (ms) => new Promise((r) => setTimeout(r, ms));
const letterOf = (s) => { const t = (s || '').trim(); return t ? t[0].toUpperCase() : ''; };
function stamp() {
const d = new Date(); const p = (n) => String(n).padStart(2, '0');
return `${d.getFullYear()}${p(d.getMonth() + 1)}${p(d.getDate())}_${p(d.getHours())}${p(d.getMinutes())}`;
}
function safeName(s) {
return (s || '').replace(/[\\/:*?"<>|\r\n\t]/g, '_').replace(/^[\s._]+|[\s._]+$/g, '').slice(0, 80);
}
function meta(res) {
const kind = res.assignment_kind || '作业';
const name = res.assignment_name || res.course || ('学习通' + kind);
return { kind, name, title: name + '(' + kind + ')' };
}
// ---------------- 通用下载 ----------------
function downloadBlob(filename, blob) {
const a = document.createElement('a');
a.href = URL.createObjectURL(blob);
a.download = filename;
document.body.appendChild(a); a.click();
setTimeout(() => { URL.revokeObjectURL(a.href); a.remove(); }, 1500);
}
function downloadText(filename, content, mime) {
downloadBlob(filename, new Blob(['\ufeff' + content], { type: mime + ';charset=utf-8' }));
}
// ---------------- Markdown / 文本 ----------------
function buildMarkdown(res) {
const qs = res.questions || []; const m = meta(res);
const out = ['# ' + m.title, '', '> 共 ' + qs.length + ' 题 · ' + new Date().toLocaleString(), ''];
qs.forEach((q, i) => {
out.push('## ' + (i + 1) + '. ' + (q.type || '题目'));
out.push(q.stem || '(图片题)');
if (CFG.includeImages) (q.images || []).forEach((im) => { const s = imgSrc(im); if (s) out.push(''); });
if (CFG.includeOptions) (q.options || []).forEach((o) => {
out.push('- ' + o);
if (CFG.includeImages) (((q.option_images_by_letter || {})[letterOf(o)]) || []).forEach((im) => { const s = imgSrc(im); if (s) out.push(' '); });
});
if (CFG.includeAnswer) out.push('> 答案:' + ((q.answer || '').trim() || '未显示答案'));
out.push('');
});
return out.join('\n');
}
function buildText(res) {
const qs = res.questions || [];
const out = [meta(res).title, '共 ' + qs.length + ' 题', ''];
qs.forEach((q, i) => {
out.push((i + 1) + '.【' + (q.type || '题目') + '】' + (q.stem || ''));
if (CFG.includeOptions) (q.options || []).forEach((o) => out.push(' ' + o));
if (CFG.includeAnswer) out.push(' 答案:' + ((q.answer || '').trim() || '未显示答案'));
out.push('');
});
return out.join('\n');
}
// ---------------- 最小 ZIP(store,无压缩) ----------------
let _crcTable = null;
function crc32(u8) {
if (!_crcTable) {
_crcTable = new Uint32Array(256);
for (let n = 0; n < 256; n++) { let c = n; for (let k = 0; k < 8; k++) c = (c & 1) ? (0xEDB88320 ^ (c >>> 1)) : (c >>> 1); _crcTable[n] = c >>> 0; }
}
let crc = 0xFFFFFFFF;
for (let i = 0; i < u8.length; i++) crc = (crc >>> 8) ^ _crcTable[(crc ^ u8[i]) & 0xFF];
return (crc ^ 0xFFFFFFFF) >>> 0;
}
function zipStore(entries) {
const enc = new TextEncoder();
const u16 = (n) => [n & 0xFF, (n >>> 8) & 0xFF];
const u32 = (n) => [n & 0xFF, (n >>> 8) & 0xFF, (n >>> 16) & 0xFF, (n >>> 24) & 0xFF];
const d = new Date();
const dt = ((d.getHours() << 11) | (d.getMinutes() << 5) | (d.getSeconds() >> 1)) & 0xFFFF;
const dd = (((d.getFullYear() - 1980) << 9) | ((d.getMonth() + 1) << 5) | d.getDate()) & 0xFFFF;
const parts = [], central = [];
let offset = 0;
for (const e of entries) {
const nb = enc.encode(e.name), data = e.data, crc = crc32(data);
const lh = new Uint8Array([].concat(
u32(0x04034b50), u16(20), u16(0x0800), u16(0), u16(dt), u16(dd),
u32(crc), u32(data.length), u32(data.length), u16(nb.length), u16(0)));
parts.push(lh, nb, data);
central.push({ nb, crc, size: data.length, offset });
offset += lh.length + nb.length + data.length;
}
const cdParts = [];
let cdSize = 0;
for (const c of central) {
const cd = new Uint8Array([].concat(
u32(0x02014b50), u16(20), u16(20), u16(0x0800), u16(0), u16(dt), u16(dd),
u32(c.crc), u32(c.size), u32(c.size), u16(c.nb.length), u16(0), u16(0), u16(0), u16(0), u32(0), u32(c.offset)));
cdParts.push(cd, c.nb);
cdSize += cd.length + c.nb.length;
}
const eocd = new Uint8Array([].concat(
u32(0x06054b50), u16(0), u16(0), u16(central.length), u16(central.length), u32(cdSize), u32(offset), u16(0)));
let total = eocd.length;
for (const p of parts) total += p.length;
for (const p of cdParts) total += p.length;
const out = new Uint8Array(total);
let pos = 0;
for (const p of parts.concat(cdParts, [eocd])) { out.set(p, pos); pos += p.length; }
return out;
}
// ---------------- 图片工具 ----------------
function b64ToU8(b64) {
const bin = atob(b64);
const u8 = new Uint8Array(bin.length);
for (let i = 0; i < bin.length; i++) u8[i] = bin.charCodeAt(i);
return u8;
}
function parseDataUrl(url) {
const m = /^data:image\/(png|jpe?g|gif|webp|bmp);base64,(.*)$/i.exec(url || '');
if (!m) return null;
const ext = m[1].toLowerCase().replace('jpeg', 'jpg');
try { return { ext: ext, u8: b64ToU8(m[2]) }; } catch (e) { return null; }
}
function imageSize(u8, ext) {
try {
if (ext === 'png') {
if (u8.length < 24) return null;
const w = (u8[16] << 24) | (u8[17] << 16) | (u8[18] << 8) | u8[19];
const h = (u8[20] << 24) | (u8[21] << 16) | (u8[22] << 8) | u8[23];
return (w > 0 && h > 0) ? { w: w, h: h } : null;
}
if (ext === 'gif') {
if (u8.length < 10) return null;
const w = u8[6] | (u8[7] << 8), h = u8[8] | (u8[9] << 8);
return (w > 0 && h > 0) ? { w: w, h: h } : null;
}
if (ext === 'jpg') {
let i = 2;
while (i + 9 < u8.length) {
if (u8[i] !== 0xFF) { i++; continue; }
const mk = u8[i + 1];
if (mk === 0xD8 || mk === 0x01 || (mk >= 0xD0 && mk <= 0xD7)) { i += 2; continue; }
const len = (u8[i + 2] << 8) | u8[i + 3];
if (mk >= 0xC0 && mk <= 0xCF && mk !== 0xC4 && mk !== 0xC8 && mk !== 0xCC) {
const h = (u8[i + 5] << 8) | u8[i + 6], w = (u8[i + 7] << 8) | u8[i + 8];
return (w > 0 && h > 0) ? { w: w, h: h } : null;
}
i += 2 + len;
}
}
if (ext === 'webp') {
if (u8.length < 30) return null;
if (u8[12] === 0x56 && u8[13] === 0x50 && u8[14] === 0x38 && u8[15] === 0x58) {
const w = 1 + (u8[24] | (u8[25] << 8) | (u8[26] << 16));
const h = 1 + (u8[27] | (u8[28] << 8) | (u8[29] << 16));
return { w: w, h: h };
}
}
} catch (e) {}
return null;
}
// ---------------- OOXML 片段 ----------------
const PTSZ = { title: 28, stem: 22, body: 20 };
function runXml(text, o) {
o = o || {};
const font = o.font || '宋体';
return '