转换过程中保留图像
您导入的一些文档可能包含您希望保留在转换文档中的图像。
注意
Tiptap 不提供图像上传服务。您需要实现自己的服务器来处理图像上传。
导入图像
如果您导入的 DOCX 文件包含图像,只有当您提供了图像上传配置时,转换服务才会将这些图像包含在生成的 Tiptap JSON 中。
使用 imageUploadConfig 选项来指定您服务器上的一个端点,转换服务将在导入过程中将图像上传到该端点。
import { Editor } from '@tiptap/core'
import { ImportDocx } from '@tiptap-pro/extension-import-docx'
const editor = new Editor({
// ... other editor options,
extensions: [
ImportDocx.configure({
token: '<your-jwt>',
imageUploadConfig: {
url: 'https://your-server.com/upload-image',
},
})
]
})在此配置中,imageUploadConfig.url 设置为您服务器上的一个端点,该端点将负责接收图像文件。如果未提供此项,导入器将从文档中剥离图像。
当触发导入时,转换服务将把每个嵌入的图像上传到您提供的 URL。
经过身份验证的图像上传
如果您的上传端点需要身份验证或自定义请求头,您可以直接进行配置:
ImportDocx.configure({
token: '<your-jwt>',
imageUploadConfig: {
url: 'https://your-server.com/upload-image',
headers: {
Authorization: 'Bearer your-upload-token',
},
method: 'PUT',
queryParams: {
bucket: 'my-bucket',
},
},
})请参阅图像上传配置参考,了解所有可用选项。
回调处理流程
- 接收文件: 读取原始请求体,并从
File-Name请求头获取文件名。请参阅请求内容。 - 存储图像: 将图像保存到可通过 URL 访问的位置。这可以是 AWS S3 存储桶、Cloudinary 等存储服务,或你服务器上的公共文件夹。为保存的文件生成公共 URL。
- 返回 URL: 返回
2xx响应,并在 JSON 请求体中包含图像的绝对 URL,例如{ "url": "https://my-cdn.com/uploads/unique-image-name.png" }。请参阅响应必须包含的内容。
请求内容
每张图片对应一个请求,图片本身作为原始请求体发送。
| 部分 | 值 |
|---|---|
| 方法 | POST,或你配置的 method |
| 请求体 | 原始图片字节。不是 multipart/form-data,也没有 file 字段 |
Content-Type | 图片的 MIME 类型,例如 image/png |
File-Name | 图片文件名,例如 image1.png |
因此,请将请求体读取为 blob 或 buffer,而不是表单数据。
有两点需要注意:
- 你的
headers和queryParams也会发送,因此可以用来验证请求。Content-Type和File-Name由转换服务设置,你不能用自己的值替换它们。 File-Name来自 DOCX 内部,Word 会将图片编号为image1、image2等,并在每个文档中从 1 重新开始。因此不同导入中会出现相同名称,它更像标签而不是唯一键。如果根据它构建存储键,请加入文档级唯一信息,以区分不同导入。
你的端点应在 10 秒内响应。响应较慢会被视为该图片上传失败。最多会同时运行三次上传,因此端点需要处理并发请求。
无论图片类型如何,文档中的每张图片都会被发送。对于 png、jpeg、gif、bmp、tiff、webp、svg、emf 和 wmf,Content-Type 会根据文件扩展名设置。其他类型会以 application/octet-stream 发送,请在自己的服务端决定保留哪些类型。单张图片的大小和文档中的图片数量均不设限制。
响应必须包含的内容
响应状态必须为 2xx,并且 JSON 请求体中包含字符串类型的 url:
{ "url": "https://my-cdn.com/uploads/unique-image-name.png" }url 必须是包含主机名的绝对 http 或 https URL。转换服务会严格使用你返回的值,除了去除值两侧的空白外,不会重新编码或规范化,因此签名 URL 仍能保持有效。
相对 URL 会被拒绝,不会被解析
类似 /uploads/image1.png 的路径会被拒绝。转换在我们的服务器上运行,而不是在你的页面中运行,因此没有可用于解析的 origin;猜测 origin 可能会生成悄悄指向错误位置的 URL。请自行返回完整 URL。出于同样原因,//my-cdn.com/image1.png 这样的协议相对值也会被拒绝。
URL 无法使用时
该图片会被跳过,文档的其余部分仍会正常导入。上传失败、超时或返回非 JSON 内容时也会如此。失败的上传不会重试,也不会导致整个导入失败。
无论哪种情况,导入的详细日志都会包含指明文件名的消息。当端点已响应但其 url 无法使用时,消息还会包含以下原因之一:
| 原因 | 常见原因 |
|---|---|
响应不包含 url 字段 | JSON 没有 url 键,使用了 location 或 src 等其他键,或将 url 设置为 null |
url 字段不是字符串 | url 保存的是数字或对象 |
url 字段为空 | url 为 "" 或只包含空白字符 |
| URL 包含空格或控制字符,无法按原样使用 | 值中存在未编码的空格或换行。返回前请进行百分号编码 |
| URL 是相对 URL,服务端运行时没有页面可供解析 | 类似 /uploads/image1.png 的路径,或协议相对的 //my-cdn.com/image1.png |
| URL 不是格式正确的绝对 http(s) URL | 缺少斜杠(例如 https:/my-cdn.com/image1.png),或完全没有主机名 |
| 只能使用 http 和 https URL | 使用了 s3:// 或 data: 等协议 |
报告的 URL 会在查询字符串之前截断,因此签名 URL 中的令牌不会写入日志。
重要注意事项
- 公共可访问性: 你提供的端点 URL 必须能够从互联网访问,因为 Tiptap 的云服务将调用它。它不能是 localhost 或位于防火墙后面。同样,返回的图像 URL 应该是公开可访问的(或者至少对需要查看文档的任何人可访问)。
- 正确的响应格式: 你的端点必须返回一个带有
url字段的 JSON 对象,其中包含绝对http或httpsURL。其他任何形式都会导致图片被跳过,原因会写入导入的详细日志。请参阅响应必须包含的内容。 - 安全性: Tiptap 不限制你使用哪个端点。你可以通过
imageUploadConfig选项传递自定义请求头(例如Authorization: Bearer ...)和查询参数进行身份验证。转换服务将在上传图像时转发这些请求头。在你的端实现任何必要的身份验证,例如验证请求头中的 Bearer 令牌或 API 密钥。 - 图像持久性: 你返回的 URL 将在编辑器内容中继续使用。例如,导入后编辑器会包含
src: "https://my-cdn.com/uploads/unique-image-name.png"的图像节点。之后任何导出或查看该内容的人都会尝试加载该 URL。请确保图像在这些 URL 上保持可用,不要立即删除它们。
服务器实现示例
此示例显示了一个简单的服务器实现,该实现接受图像上传并将其上传到由环境变量配置的 S3 存储桶。
import { serve } from '@hono/node-server'
import { Hono } from 'hono'
import { Upload } from '@aws-sdk/lib-storage'
import { S3Client } from '@aws-sdk/client-s3'
const {
AWS_ACCESS_KEY_ID,
AWS_SECRET_ACCESS_KEY,
AWS_REGION,
AWS_S3_BUCKET,
PORT = '3011',
AWS_ENDPOINT,
AWS_FORCE_STYLE,
} = process.env
if (!AWS_ACCESS_KEY_ID || !AWS_SECRET_ACCESS_KEY || !AWS_S3_BUCKET) {
console.error('请提供 AWS_ACCESS_KEY_ID、AWS_SECRET_ACCESS_KEY 和 AWS_S3_BUCKET')
process.exit(1)
}
const s3 = new S3Client({
credentials: {
accessKeyId: AWS_ACCESS_KEY_ID,
secretAccessKey: AWS_SECRET_ACCESS_KEY,
},
region: AWS_REGION,
endpoint: AWS_ENDPOINT,
forcePathStyle: AWS_FORCE_STYLE === 'true',
})
const app = new Hono() as Hono<any>
app.post('/upload', async (c) => {
const file = await c.req.blob()
const filename = c.req.header('File-Name')
const fileType = c.req.header('Content-Type')
// Word 会在每个文档中从 image1 重新开始编号,因此构建存储键时要加入唯一信息。
const key = `${crypto.randomUUID()}-${filename}`
if (!file) {
return c.json({ error: '未上传文件' }, 400)
}
try {
const data = await new Upload({
client: s3,
params: {
Bucket: AWS_S3_BUCKET,
Key: key,
Body: file,
ContentType: fileType,
},
}).done()
return c.json({ url: data.Location })
} catch (error) {
console.error(error)
return c.json({ error: '文件上传失败' }, 500)
}
})
serve({
fetch: app.fetch,
port: Number(PORT) || 3000,
})这是另一个使用 bun 的实现,没有任何依赖项:
const s3Client = new Bun.S3Client({
accessKeyId: process.env.AWS_ACCESS_KEY_ID,
secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY,
region: process.env.AWS_REGION,
bucket: process.env.AWS_BUCKET,
endpoint: process.env.AWS_ENDPOINT,
})
Bun.serve({
port: 8081,
async fetch(req) {
const url = new URL(req.url)
// 处理 /upload 端点上的文件上传
if (url.pathname === '/upload') {
const file = await req.blob()
const filename = req.headers.get('File-Name')!
const fileType = req.headers.get('Content-Type')!
// Word 会在每个文档中从 image1 重新开始编号,因此构建存储键时要加入唯一信息。
const key = `${crypto.randomUUID()}-${filename}`
if (!file) {
return new Response(JSON.stringify({ error: '未上传文件' }), {
status: 400,
headers: {
'content-type': 'application/json',
},
})
}
try {
// 使用自己的键存储,并使用请求中的类型
const s3File = s3Client.file(key, { type: fileType })
// 将文件写入 S3
await s3File.write(file)
return new Response(
JSON.stringify({
// 将上传文件的 URL 返回给客户端,以便插入到编辑器中
url: new Response(s3File).headers.get('location'),
}),
{
headers: {
'content-type': 'application/json',
},
},
)
} catch (error) {
return new Response(
JSON.stringify({
error: error instanceof Error ? error.message : '文件上传失败',
}),
{
status: 500,
headers: {
'content-type': 'application/json',
},
},
)
}
}
return new Response(JSON.stringify({ error: '未找到' }), {
status: 404,
})
},
})