特调 HTML 原生编辑接入手册
从理解,到动手实现。步骤、协议与可复制的示例,都在这份文档中。
兄弟网站已经生成 OmniDoc 特调 HTML 时,推荐由宿主先读取 HTML,再在用户点击按钮的当下调用 UniDocHtmlHost.openHtml(html, { mode: 'auto' })。完整正文通过浏览器消息移交给 UniDoc,新窗口沿用本地“打开 HTML 文件”的特调识别链路,进入原生页、文本框、目录和阅读视图。
本指南针对已冻结排版的 OmniDoc 文档。它不把这类文档放进“自由编辑 HTML”容器,也不承诺继续运行原网站的全部脚本。
1. 公网地址与交付物
| 用途 | 公网地址 |
|---|---|
| UniDoc 生产编辑器 | 打开 UniDoc |
| 本接入手册 | 特调 HTML 原生编辑接入手册 |
| 本手册原生源文件 | 下载 SPECIALHTMLINTEGRATION.udoc |
| 浏览器宿主 SDK | html-host.js.udoc |
| 通用内嵌协议 | UniDoc 内嵌接入指南 |
| 文档与资源分享 | 云分享使用说明 |
SDK 的生产文件后缀为 .js.udoc,服务端以 JavaScript 响应类型提供,浏览器可以直接通过 <script src="…"> 加载。不要把这个 URL 当作用户文档下载地址。手册 .udoc 则是真实的原生文档包,用于下载、恢复和继续编辑。
2. 这类文件如何识别
以本机验收文件 o1.zh (1).html 的结构为例:它的体积是 655,969 字节,存在 4 个 .page-container、data-omni-frozen="1"、11 个 <script>,同时引用了相对路径的 katex.min.css 和 katex.min.js。这些只是结构信息,本手册不发布该文件的正文、图片、来源地址或附件。
验收不能只看文件名或 data-omni-frozen 这个标记。当前冻结 DOM 导入器主要检查 .page-container 和 .autoscale;当存在 OmniDoc publisher 标记或 page-node-N 正文页 ID 时,会优先按页 ID 排序,避免缩略图或流式副本被误认成正文页。data-omni-frozen="1" 可以帮助宿主预检,单独存在它不代表文档满足导入契约。
auto 按以下顺序识别:
- 含内嵌原生 UDoc 模型的 UniDoc 导出 HTML:按原生模型恢复。
OmniDoc_PDF_VIEWER特调 PDF 查看器:进入专用冻结转换。- OmniDoc 多模式特调文档:转换为原生文本框,生成原文/译文阅读视图。
- 带
.omnidoc-page等约定的多页容器:按相应分页协议处理。 .page-container加.autoscale的冻结 DOM:提取页面、背景和原生文本框。- 以上均未识别:回落为普通正文 HTML 导入。
最后一项说明 auto 不是严格的“必须是特调 HTML”断言。宿主应在移交前做结构检查,导入后再验收原生结构;不能仅凭收到成功回执就宣称转换质量合格。本例应重点检查 4 页正文是否正确恢复,而不是把 11 个脚本是否继续运行作为原生转换的通过条件。
3. 选择接入方式
| 场景 | 推荐方式 | 关键条件 |
|---|---|---|
| 兄弟网站已经拿到 HTML 字符串 | openHtml 新窗口移交 | 先准备正文;点击事件内同步调用;保留 opener |
| 用户应留在兄弟网站内编辑 | createFrame + setHtml | 使用独立空白嵌入编辑器;消息只绑定真实父窗口 |
| HTML 已部署在 UniDoc 自己的同源域名 | import_url + import_mode=auto | 协议、主机和端口全部相同 |
| 只有另一个网站的公开 HTML URL | 已登录 UniDoc 中“文件 → 网址 HTML” | 由受控抓取服务校验并读取;不是匿名 URL 代理 |
| 只有本地文件 | 用户在宿主选择文件后传正文,或在 UniDoc 中打开文件 | 不把 file:// 或本机路径交给公网抓取 |
从兄弟网站切换到公网编辑器,优先选第一种。只传 URL 时,容易把文档获取权限、跨域、相对资源和编辑器加载混成一个问题;宿主已经持有正文时,消息移交更直接。
4. 完整按钮示例:先预加载,再一次点击打开
下面是一份可直接放进宿主页面的完整示例。将 SOURCE_URL 改成宿主自己有权读取的、已经冻结排版的 HTML 地址;名称和 SDK 版本标记可以按发布流程修改。源文件应采用 UTF-8。
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>在 UniDoc 中继续编辑</title>
<style>
body { margin: 40px; color: #203b4c; font: 16px/1.6 system-ui; }
button { border: 0; border-radius: 12px; padding: 12px 20px;
background: #087f91; color: white; font: inherit; cursor: pointer; }
button:disabled { opacity: .5; cursor: wait; }
button:focus-visible { outline: 3px solid #8bd4df; outline-offset: 3px; }
#status { max-width: 650px; }
</style>
</head>
<body>
<button id="open-unidoc" type="button" disabled>
在 UniDoc 中编辑(新窗口)
</button>
<p id="status" role="status" aria-live="polite">正在准备文档…</p>
<script src="https://app.unidoc.top/js/html-host.js.udoc?v=special-html-20260918"></script>
<script>
const EDITOR_URL = 'https://app.unidoc.top/';
const SOURCE_URL = new URL('/reports/o1.zh.html', location.href).href;
const DOCUMENT_NAME = 'o1.zh.html';
const MAX_HTML_BYTES = 32 * 1024 * 1024;
const openButton = document.getElementById('open-unidoc');
const status = document.getElementById('status');
let preparedHtml = '';
let resolvedSourceUrl = SOURCE_URL;
async function preloadDocument() {
if (!window.UniDocHtmlHost) throw new Error('UniDoc SDK 未加载');
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), 30000);
try {
const response = await fetch(SOURCE_URL, {
credentials: 'same-origin',
cache: 'no-store',
signal: controller.signal
});
if (!response.ok) throw new Error('来源 HTTP ' + response.status);
const declared = Number(response.headers.get('Content-Length') || 0);
if (declared > MAX_HTML_BYTES) throw new Error('HTML 超过 32 MiB');
const html = await response.text();
if (!html.trim() || new Blob([html]).size > MAX_HTML_BYTES) {
throw new Error('HTML 为空或超过 32 MiB');
}
const document = new DOMParser().parseFromString(html, 'text/html');
if (!document.querySelector('.page-container') ||
!document.querySelector('.autoscale')) {
throw new Error('来源不是预期的 OmniDoc 冻结 DOM,请检查导出产物');
}
preparedHtml = html;
resolvedSourceUrl = response.url || SOURCE_URL;
openButton.disabled = false;
status.textContent = '文档已准备好,点击后将在新窗口进入原生编辑。';
} finally {
clearTimeout(timer);
}
}
// 此回调故意不是 async;openHtml 内部的 window.open 在点击当下执行。
openButton.addEventListener('click', () => {
if (!preparedHtml) return;
try {
const handoff = UniDocHtmlHost.openHtml(preparedHtml, {
editorUrl: EDITOR_URL,
mode: 'auto',
name: DOCUMENT_NAME,
baseUrl: resolvedSourceUrl
});
openButton.disabled = true;
status.textContent = 'UniDoc 已打开,正在移交并导入文档…';
handoff.done.then(() => {
status.textContent = '导入回执已收到,请在 UniDoc 检查页面和文本。';
}).catch(error => {
status.textContent = '移交未完成:' + error.message +
'。请检查新窗口中的提示后重试。';
}).finally(() => {
openButton.disabled = false;
});
} catch (error) {
status.textContent = '无法打开:' + error.message;
}
});
preloadDocument().catch(error => {
status.textContent = '文档准备失败:' + error.message +
'。修复来源地址或权限后刷新本页。';
});
</script>
</body>
</html>不要把 fetch、登录跳转或异步确认放在 openHtml 前面的点击回调里。浏览器通常要求新窗口直接响应用户操作;跨过异步等待后再打开可能被弹窗策略拦截。MDN:Window.open
示例的 fetch 属于宿主:同源时使用宿主自己的会话;若来源与宿主也跨源,需要来源服务按实际权限提供 CORS。UniDoc SDK 不携带或复制宿主 Cookie,也没有替宿主读取任意地址的能力。
5. openHtml 的实际契约
const handoff = UniDocHtmlHost.openHtml(html, options);
await handoff.done;| 参数/返回 | 实际行为 |
|---|---|
html | 必填、非空字符串;按 Blob 计算不得超过 32 MiB |
options.editorUrl | 可选,默认 https://app.unidoc.top/;必须是无账号密码的绝对 HTTP(S) URL |
options.mode | 可选,SDK 默认 auto;允许值是 auto、html、free-html,本特调接入明确固定为 auto |
options.name | 可选,默认 网页.html;编辑器移除路径部分,补齐 HTML 后缀 |
options.baseUrl | 可选,绝对 HTTP(S) 来源地址;SDK 删除已有 <base> 并注入自己的 <base href> |
handoff.window | window.open 返回的窗口引用;用于识别新窗口,不等于读取跨源 DOM 的权限 |
handoff.done | Promise;收到精确窗口、精确 UniDoc origin 和 token 匹配的成功回执后 resolve |
done 成功值 | 消息对象 { type: 'unidoc:html-transfer-complete', token };不包含文档、文件字节、云端 ID 或公开分享链接 |
| 同步错误 | HTML 为空/过大、URL/模式无效、浏览器阻止新窗口等会直接 throw |
| 异步错误 | 不支持 auto、60 秒内未收到完成回执等会 reject |
SDK 内部创建随机 import_transfer token,并将 import_mode=auto 放进新窗口地址。URL 不携带 HTML 正文。UniDoc 接收后会清除这些启动参数,在校验来源窗口和 token 后处理正文;完成后移除接收监听并断开 opener。
当前新窗口协议只返回成功回执,不把转换错误作为结构化失败消息回传给宿主。转换失败时,新窗口会显示错误,宿主可能最终收到通用超时。因此监控应同时记录宿主错误、编辑器提示和发生阶段,不能只记录 done 的耗时。
SDK 还接受普通正文模式以及另一种网页编辑模式,但它们不属于本特调接入合同。兄弟网站的特调入口应固定 auto,不要让终端用户误切换后仍当作同一验收路径。
6. baseUrl:地址解析与资源权限是两件事
baseUrl 应填来源 HTML 的最终完整地址,例如 https://reports.example.com/export/run-42/o1.zh.html。同一目录的 katex.min.css 在浏览器中会以该目录为基准解析;目录末尾斜杠也会影响 URL 的解析结果。MDN:HTML base 元素
SDK 的 prepareHtml 是字符串预处理:删除输入中的已有 <base>,把经过转义的新 <base> 放到 <head> 开头;没有 <head> 时放到内容前面。它不会下载、内联、授权或校验全部依赖。
这也是 SDK 第三个公开函数:UniDocHtmlHost.prepareHtml(html, baseUrl)。第一个位置参数是非空、最多 32 MiB 的 HTML 字符串;第二个位置参数可省略,提供时必须为无账号密码的绝对 HTTP(S) URL。它同步返回处理后的 HTML 字符串,校验失败同步抛错。不传 baseUrl 时返回输入字符串;openHtml 和 setHtml 内部已经会调用它,一般无需重复调用。
冻结 DOM 转成原生文本框时,提取的是页面背景、文本块、内联样式、原文模板、目录信息和内嵌 @font-face。当前路径不是把整个 <head> 放进一个永久运行的浏览器页面,因此不能假定原来的相对 <link>、外部 <script> 或 CSS 规则会全部迁移。对 o1.zh (1).html 的 KaTeX 依赖,应在源站生成阶段完成公式渲染与版式冻结,并检查公式在原生文档内的实际效果。
上线前建议让发布器输出自包含的冻结文档:必要图片与字体内嵌,必要布局样式落到原生提取节点上;保留的远程资源用稳定 HTTPS 绝对地址。对于未内嵌依赖,必须分别验证图片、字体、公式和重开后的可用性。baseUrl 不能保证跨路径复制后的资源仍可还原。
资源权限还受下列边界限制:
- 来源 HTML 由宿主读取成功,不代表 UniDoc 或访客也能读取其图片、字体和 API。
- 宿主的登录 Cookie、Authorization 请求头不会被消息移交授予 UniDoc。
- 需要认证的资源应由源站按自身权限提供可用内容或有期限的资源地址;不要把长期密钥写进 HTML、地址栏或本手册。
- 跨源字体、脚本数据请求、图片转字节和离线打包的可用性依赖浏览器安全规则与服务端响应,不能靠
<base>绕过。 - HTTPS 编辑器加载 HTTP 依赖可能遇到混合内容限制;生产应统一使用 HTTPS。
7. iframe 完整示例:嵌入、导入、取回原生交换数据
下面使用一个专门的空白编辑器 iframe。宿主不能读跨源 iframe 的 DOM;通过 SDK 交换文档内容。
<div>
<button id="load" disabled>载入特调文档</button>
<button id="get" disabled>取回原生 JSON</button>
<p id="embed-status" role="status" aria-live="polite">正在初始化…</p>
</div>
<iframe id="unidoc-editor" title="UniDoc 原生文档编辑器"
allow="fullscreen" style="width:100%;height:85vh;border:1px solid #dfe8ed">
</iframe>
<script src="https://app.unidoc.top/js/html-host.js.udoc?v=special-html-20260918"></script>
<script>
const iframe = document.getElementById('unidoc-editor');
const loadButton = document.getElementById('load');
const getButton = document.getElementById('get');
const status = document.getElementById('embed-status');
const sourceUrl = new URL('/reports/o1.zh.html', location.href).href;
const viewer = UniDocHtmlHost.createFrame(iframe, {
editorUrl: 'https://app.unidoc.top/?embedUi=full'
});
let sourceHtml = '';
Promise.all([
viewer.ready,
fetch(sourceUrl, { credentials: 'same-origin' }).then(response => {
if (!response.ok) throw new Error('来源 HTTP ' + response.status);
return response.text();
})
]).then(([info, html]) => {
if (!info.htmlImportModes?.includes('auto')) {
throw new Error('编辑器版本不支持 auto,请更新后重试');
}
sourceHtml = html;
loadButton.disabled = false;
status.textContent = '编辑器与来源文档已准备好。';
}).catch(error => { status.textContent = error.message; });
loadButton.addEventListener('click', async () => {
loadButton.disabled = true;
getButton.disabled = true;
status.textContent = '正在导入…';
try {
await viewer.setHtml(sourceHtml, {
mode: 'auto', name: 'o1.zh.html', baseUrl: sourceUrl
});
getButton.disabled = false;
status.textContent = '已载入,可编辑并取回原生交换数据。';
} catch (error) {
status.textContent = error.message;
} finally {
loadButton.disabled = false;
}
});
getButton.addEventListener('click', async () => {
try {
const result = await viewer.getDocument();
// 当前 SDK 返回的是 JSON 字符串,不是 UDOC3 二进制。
const doc = JSON.parse(result.udoc);
if (doc.format !== 'udoc') throw new Error('返回的数据不是原生文档');
const url = URL.createObjectURL(new Blob([result.udoc], {
type: 'application/json'
}));
const link = document.createElement('a');
link.href = url;
link.download = 'o1.zh.udoc.json';
link.click();
setTimeout(() => URL.revokeObjectURL(url), 60000);
status.textContent = '已导出 .udoc.json,可在 UniDoc 中重新打开。';
} catch (error) {
status.textContent = error.message;
}
});
window.addEventListener('pagehide', () => viewer.destroy(), { once: true });
</script>示例不为 iframe 添加自定义 sandbox,避免把编辑器必需权限意外裁掉。若宿主必须添加 sandbox,应单独做完整权限、登录、下载和全屏验收;不得用 allow-same-origin 之类配置解释成“宿主获得 UniDoc 账户权限”。来源 HTML 内部的可执行内容也不因此获得父页面权限。
iframe SDK 的全部公开成员
| 调用 | 参数、默认值与返回 |
|---|---|
createFrame(iframe, options) | 第一个参数是实际 iframe 元素;唯一配置 editorUrl,默认依次取配置值、iframe 当前 src、生产编辑器地址 |
| URL 行为 | SDK 会写入 embed=1 与 embedView=paged,再设置 iframe.src;editorUrl 的其他查询参数会保留 |
viewer.ready | Promise;60 秒就绪超时。成功值是 unidoc:ready 消息,包含 mode、view、formats、htmlImportModes、template |
viewer.setHtml(html, options) | html 非空且至多 32 MiB;配置有 mode(默认 auto)、name(默认 网页.html)、baseUrl;先等 ready,再请求导入 |
setHtml 成功值 | { type: 'unidoc:content-set', id };表示该导入命令已处理,不能代替视觉验收 |
viewer.getDocument() | 当前无参数;取回 { type: 'unidoc:udoc', udoc: JSON字符串, id }。必须用 .udoc.json 标明交换格式,不能改名成二进制 .udoc |
viewer.destroy() | 清除监听与计时器,拒绝所有待处理请求;不自动移除 DOM 中的 iframe,也不删除服务端文档 |
| 命令超时 | 每条 set/get 请求在 ready 之后最多等待 120 秒;没有调用方可配置的 timeout 参数 |
编辑器会按接收顺序串行处理内容命令;队列超过 32 条时返回错误。宿主仍应等待 setHtml 完成后再取回文档,不应靠高频重复发送来探测完成。
8. 保存成真正的 .udoc 并恢复
最直接的方式是在新窗口或完整 iframe 编辑器中使用 文件 → 保存/另存为,得到真正的 UDOC3 原生文件。随后用 文件 → 打开 重开,核对原生页、文本框、目录、公式和当前编辑结果。
如果宿主必须程序化取得二进制包,底层 iframe 协议另支持:
iframe.contentWindow.postMessage({
type: 'unidoc:get-udoc',
format: 'udoc3',
id: crypto.randomUUID()
}, 'https://app.unidoc.top');它返回 { type: 'unidoc:udoc', format: 'udoc3', udoc: 'udoc3:BASE64', id }。宿主必须验证 event.source === iframe.contentWindow、event.origin === 'https://app.unidoc.top' 和自己的请求 ID,再去掉 udoc3: 前缀并解码成字节。失败时是 { type: 'unidoc:error', error, id }。当前 SDK 的 getDocument() 没有暴露该 format 参数;不要传一个不存在的配置并误以为返回了二进制。
下面的完整辅助函数可与上面的 viewer 配合。它只负责取回真正的包,不增加账号或云端权限:
async function getNativePackage(viewer, iframe) {
await viewer.ready;
const origin = 'https://app.unidoc.top';
const id = crypto.randomUUID();
return new Promise((resolve, reject) => {
let timer;
function finish(error, bytes) {
clearTimeout(timer);
window.removeEventListener('message', receive);
if (error) reject(error); else resolve(bytes);
}
function receive(event) {
const data = event.data;
if (event.source !== iframe.contentWindow || event.origin !== origin ||
!data || data.id !== id) return;
if (data.type === 'unidoc:error') {
finish(new Error(data.error || '原生导出失败'));
return;
}
if (data.type !== 'unidoc:udoc') return;
try {
if (data.format !== 'udoc3' || typeof data.udoc !== 'string' ||
!data.udoc.startsWith('udoc3:')) {
throw new Error('没有收到 UDOC3 二进制包');
}
const encoded = data.udoc.slice(6);
if (encoded.length > Math.ceil(8 * 1024 * 1024 / 3) * 4) {
throw new Error('嵌入草稿超过 8 MiB');
}
const bytes = Uint8Array.from(atob(encoded), ch => ch.charCodeAt(0));
finish(null, bytes);
} catch (error) {
finish(error);
}
}
window.addEventListener('message', receive);
timer = setTimeout(() => finish(new Error('原生导出等待超时')), 120000);
iframe.contentWindow.postMessage({
type: 'unidoc:get-udoc', format: 'udoc3', id
}, origin);
});
}
async function downloadNativePackage(viewer, iframe) {
const bytes = await getNativePackage(viewer, iframe);
const url = URL.createObjectURL(new Blob([bytes], {
type: 'application/vnd.unidoc.udoc3'
}));
const link = document.createElement('a');
link.href = url;
link.download = 'o1.zh.udoc';
link.click();
setTimeout(() => URL.revokeObjectURL(url), 60000);
}调用下载辅助函数时,要捕获 Promise 错误并写入自己的状态区。该函数使用的固定 origin 必须与实际编辑器地址一致;迁移域名时同步修改。
底层嵌入草稿二进制导出当前限 8 MiB,导出时有未内嵌资源或正文发生变化会明确报错;这与宿主 HTML 字符串 32 MiB 的上限不同。较大文档优先使用编辑器正常保存流程。使用 udoc3 返回值应只解码一次,不能把 base64 文本直接保存为 .udoc。
接收消息时指定准确 targetOrigin、校验来源窗口和消息字段,可以避免把其他窗口的消息当成本次文档结果。MDN:Window.postMessage
9. iframe 的账户与权限边界
嵌入正文协议绑定真实的父窗口与其 HTTP(S) origin;普通页面即使被 iframe 包含,也不会自动开放正文桥,必须显式启用嵌入模式。宿主可读写自己这个嵌入编辑器的正文,不等于获得 UniDoc 登录会话、云空间列表、上传权限或 /source/fetch 抓取权限。
因此应创建专门的空白 iframe,不要把用户已有的私人云文档 URL 作为通用外部编辑器入口。跨站 iframe 里的登录体验还可能受第三方 Cookie 与浏览器策略影响。需要完整账户功能时,优先新窗口,让用户在 UniDoc 顶层站点内完成登录和云保存。
同源策略仍然存在:兄弟站不能通过 iframe.contentDocument 或 handoff.window.document 读取 UniDoc 跨源 DOM;内容往返使用受限的消息协议。MDN:同源策略
10. 只有 URL 时:同源启动与受控跨源导入
UniDoc 自有同源 URL
如果来源文件确实部署于 https://app.unidoc.top/,可构造:
const source = 'https://app.unidoc.top/reports/example.zh.html';
const target = new URL('https://app.unidoc.top/');
target.searchParams.set('import_url', source);
target.searchParams.set('import_mode', 'auto');
location.href = target.href;来源必须和当前编辑器完全同源:协议、主机、端口都相同。https://reports.unidoc.top/、另一端口或 HTTP 都不等价。URL 不得携带用户名密码,不能指向编辑器自身;启动参数会在获取前从地址栏移除。
这个入口不是跨站代理。来源 HTML 的相对资源仍需在发布前处理,不应把完整 HTML、base64 或账户令牌塞进 import_url。
外部公开 URL
用户已登录时,可使用 文件 → 网址 HTML,输入公开可访问的 HTTP(S) URL。UniDoc 通过受控的 /source/fetch 服务验证来源与响应类型,并按服务端确认的最终 URL 设置来源基准,然后调用同一特调识别流程。它不会把用户输入的网址直接交给浏览器做任意跨站 fetch。
这条 UI 导入路径需要用户登录,不能由兄弟网站通过一个匿名启动参数代替。只有一个外部 URL、无法读取正文时才使用它;宿主已经得到正文时仍推荐 openHtml。
本机路径和 localhost
不能把 C:\…\o1.zh (1).html、file:///…、http://localhost:… 或内网地址作为公网读取地址。localhost 指向请求方自己的机器,公网服务无法据此读取用户电脑上的文件;file: 也不属于支持的 HTTP(S) URL。
本地开发可以让宿主在自己的回环地址读取明确选择的文件,随后把 HTML 字符串移交给公网 UniDoc。需要联网的依赖必须另行内嵌或发布到浏览器可访问的 HTTPS 地址;不要把一次本地成功当作公网资源验收通过。
11. 脚本、样式和交互的准确边界
对本例冻结 DOM 特调文档,原生转换的事实来源是已定稿的 DOM 与样式,编辑事实来源随后变成 UniDoc 原生文本框。原文/译文阅读视图由这些原生数据重新生成,编辑后看到的是当前文本,而不是继续运行源站旧 reflow 脚本所得的另一份副本。
| 来源内容 | 原生特调转换中的处理原则 |
|---|---|
| 页面尺寸、正文页顺序 | 从页面容器与约定的页 ID 恢复 |
| 文本内容、字体度量、定位与旋转 | 转为原生文本框及其内联样式,逐项验收 |
| 背景图/SVG 与图表位置 | 作为页面背景或相关图形数据保留,并检查资源是否完整 |
| 原文、译文来源与段落元数据 | 用于特调原生阅读模式及后续编辑派生 |
| 目录、页内锚点与参考文献内链 | 转为原生导航数据与对应落点,必须实际点验 |
内嵌 @font-face | 提取为文档内嵌字体声明;字体字节是否已内嵌需单独确认 |
| 源站工具栏、菜单和 11 个原始脚本 | 不承诺迁移为 UniDoc 功能,也不以继续执行作为转换目标 |
| 外部 CSS/JS 与脚本运行时生成内容 | 需要源站提前冻结或转成原生可用数据,不能假定导入器执行后再猜出最终状态 |
不同识别分支有不同实现;例如 PDF 查看器分支会进行受控的渲染冻结,多页容器分支可能保留沙箱页面。不能把这些分支能力泛化为“任意 HTML 的全部 JavaScript 都无损保留并执行”。本指南接入的是特调原生编辑,不使用自由 HTML 模式。
12. 错误、超时、COOP 与缓存排查
| 现象 | 先检查什么 | 处理方式 |
|---|---|---|
| SDK 未定义 | <script> 是否 200、是否被 CSP 拦截、响应是否是 JavaScript | 放行可信 SDK 域名或随宿主发布 SDK;检查控制台 |
| 点击没有打开窗口 | 是否在 click 内直接调用;浏览器是否阻止弹窗 | 预加载完成后再启用按钮;明确告知会开新窗口 |
| 新窗口提示移交失效 | opener 是否被 COOP、noopener 或浏览器策略切断 | 由站点负责人评估专用接入页面策略,或改用 iframe |
提示不支持 auto | 编辑器/SDK/Service Worker 是否混用旧缓存 | 更新两端到同一发布版本,清理相关缓存后重新打开专用编辑器 |
| SDK 60 秒超时 | 编辑器冷启动、资源失败、COOP、转换错误或标签页被关闭 | 检查新窗口提示和网络阶段;手动重试,不循环弹新窗口 |
| iframe 60 秒 ready 超时 | iframe 被 frame-src/frame-ancestors/X-Frame-Options 拦截,或编辑器未加载 | 检查响应头与浏览器控制台;成功 load 不等于 ready |
| iframe 120 秒请求超时 | 大文档、依赖不可用、导入失败或编辑器已导航 | 显示可重试状态,检查当前编辑器;需要时 destroy 后重建 |
| 显示普通正文而非 4 页原生页 | 来源缺少特调结构,或发错 mode/文件 | 校验 .page-container、.autoscale 和发布器版本 |
| 公式/字体/背景缺失 | 相对依赖、权限、外链失效或未冻结样式 | 在源站修复自包含产物,再比较和重开验收 |
| 取回文件打不开 | 将 JSON 或 base64 文本误命名为 .udoc | JSON 用 .udoc.json;UDOC3 前缀数据必须解码为二进制 |
新窗口 SDK 的 60 秒是宿主等待成功回执的上限;编辑器接收端还有 30 秒的移交计时。它们不是“导入一定在 30/60 秒完成”的性能承诺。当前协议在错误时可能只由编辑器显示详细提示,宿主随后超时。
COOP 可能把跨源窗口分入不同浏览上下文组并切断窗口引用。不要为了接入盲目去掉站点现有安全策略;专用按钮页面可以由负责人评估兼容策略,否则使用 iframe 内容协议。MDN:Cross-Origin-Opener-Policy
生产 SDK 建议使用随发布固定的版本查询参数,升级时同时更新按钮代码与验收记录;不要每次用随机参数绕过缓存。实际是否支持 auto 仍以编辑器 ready 能力声明为准。站点若自行复制 SDK,应跟随 js/html-host.js 更新,不能长期保留一份旧代码并只更新编辑器。
13. 上线验收清单
本轮对 o1.zh (1).html 的回归记录:4 页正文、425 个原生文字块、3 个原生目录项,没有自由 HTML 容器;381 个扫描表格单元格的拟合字号与源数据一致,4 页表格网格保留。保存真实 UDOC3 后重开仍保留这些结构。验收还修复了扫描表格 CSS 覆盖字号未迁移导致的文字放大重叠。上述结果是此样本的结构与专项视觉检查,不代表任意 HTML 都能像素级无损转换。
13.1 本地图片与媒体的导出合同
浏览器中的 blob: 地址只在当前会话有效,不能直接写进下载的 HTML。UniDoc 导出分页、流式和原始 HTML 前,会把已登记的本地图片、视频海报、视频来源、CSS 背景、嵌套 srcdoc 和已加载的包内媒体读取为字节,并按原 MIME 类型写成 data: URL。这样关闭编辑器、撤销对象 URL 或断网后,导出的文件仍能显示图片;导出不会把本机路径或账户令牌写进 HTML。
如果本地媒体句柄已经失效,导出会明确提示“有本地图片或媒体已失效”,不会再生成带“本地媒体未随 HTML 导出”占位框的文件。回到原文档重新插入该媒体后再导出。外部 HTTPS 地址仍保留为外链,导出阶段不会擅自下载或改写;需要离线交付时,应先把资源放入 UniDoc 文档缓存并重新保存。
验收时至少做三步:导出分页、流式和可恢复 HTML 各一份;关闭页面或撤销原始 blob: 地址;在禁用网络的浏览器中打开三份文件,确认每个 img 的 naturalWidth 大于零。可恢复 HTML 还要重新导入并保存一次真正的 UDOC3,确认再次导出仍含相同媒体字节。媒体字节会随文件变大,仍受浏览器下载能力和 UDOC3 包大小限制。
13.2 导出页的沙盒 HTML 全屏
导出的分页、流式和可恢复 HTML 会为每个动态 HTML 沙盒生成一个 udoc-embed-shell。沙盒 iframe 继续使用 sandbox="allow-scripts",并带有 allow="fullscreen" 与 allowfullscreen;导出页右上角的“全屏”控件直接以用户手势调用 iframe 的 requestFullscreen(),因此本地 file:// 文件和跨源页面不需要给沙盒增加 allow-same-origin 就能全屏。退出全屏可按浏览器 Esc,或再次点击控件。
特调 HTML 如果希望自己的按钮请求宿主全屏,可在用户点击处理函数中发送以下消息。宿主只接受消息来源正好是对应沙盒 iframe 的窗口,不会把消息转成编辑器权限:
button.addEventListener('click', () => {
parent.postMessage({ type: 'unidoc:request-fullscreen' }, '*');
});消息桥是增强能力,浏览器仍可能因没有瞬时用户手势、浏览器设置或企业策略拒绝请求;这时使用导出页提供的“全屏”控件,它由顶层页面直接接收点击。导出文件必须重新生成才能带上控件;已经下载的旧 HTML 不会被远程代码自动修改。全屏只改变显示区域,不会改变 .udoc 中保存的脚本、样式或沙盒权限。
先用一份不含私人内容的合成文件验证接入,再由有权限的测试人员在自己的环境检查真实特调产物。结构元数据可以进入验收记录,正文和附件不必公开。
- 宿主准备:SDK 正常加载,HTML 获取失败有明确提示,只有准备完成后按钮才可点击。
- 来源检查:记录字节数、正文页容器数、冻结标记和发布器版本;本机样本应记录 655,969 字节和 4 页结构。
- 新窗口契约:一次用户点击只打开一个窗口;新窗口进入
auto路径,宿主收到成功回执。 - 原生结构:4 个正文页顺序正确,没有重复缩略图或流式副本;文本能作为原生文本框修改。
- 转换类型:没有把整份特调文档包进自由 HTML 编辑容器,也没有误落入普通单栏正文。
- 视觉对比:逐页对比背景、字体、公式、表格、旋转文本与页边界;大图和首尾页都要检查。
- 阅读视图:有原文/译文来源的文件,切换原版文本框、单语/双语/原文阅读模式,确认修改会进入派生视图。
- 目录与内链:点击目录、页内锚点和引用链接,确认仍在当前文档内定位,没有打开默认演示文档。
- 依赖验证:阻断源站资源或离线重开一次;若缺资源,修复打包或明确依赖,再验收。
- 保存恢复:保存真实
.udoc,关闭后重开;页面、修改、原文来源与导航仍可用。 - iframe 往返:
setHtml完成后getDocument;JSON 保存为.udoc.json并能恢复,二进制按 UDOC3 协议另测。 - 失败路径:弹窗被拦、来源 404、CSP、COOP、失效资源、只读状态和超时均有可理解的出口。
- 权限隔离:宿主没有获得 Cookie、云空间或任意抓取权限;私有资源不因一次移交变成公开资源。
- 移动与窄屏:按钮、状态文字和 iframe 均可操作;不因固定高度或遮罩挡住导入结果。
成功回执只证明命令完成;原生结构、逐页视觉、内链和保存重开都通过,才算这类文件接入验收完成。
14. 给双方开发者的交接信息
提交问题时附:宿主和编辑器地址、SDK 发布版本、浏览器版本、输入字节数/页数、调用方式、最后状态、错误文字、耗时阶段与脱敏网络记录。提供可复现的合成样本或经授权的最小样本,不要公开完整私有文件、Cookie、Authorization、临时签名 URL 或密钥。
实现依据是项目中的 js/html-host.js、js/app.js 的 embedBridge、importPreparedHtmlByMode、importFrozenDomHtml、同源启动校验与受控网址导入逻辑。浏览器机制按本页 MDN 链接核查;UniDoc 功能和上限以当前发布代码及实际验收为准。
本说明由真实 .udoc 原生文档无损导出,保留 原生源文件。分发给兄弟网站时请使用本页公网 HTML 地址,方便阅读、复制代码和回到原生文档继续维护。