Skip to content

Commit aeded17

Browse files
committed
fix(feishu): preserve image layouts and captions
1 parent 7fcbc6a commit aeded17

12 files changed

Lines changed: 638 additions & 86 deletions

File tree

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
<template>
2+
<div class="feishu-grid">
3+
<slot />
4+
</div>
5+
</template>
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
<script setup lang="ts">
2+
import { computed, type CSSProperties } from 'vue'
3+
4+
const props = defineProps<{
5+
width?: string
6+
}>()
7+
8+
const columnStyle = computed<CSSProperties>(() => ({
9+
'--feishu-column-width': props.width || '1'
10+
}))
11+
</script>
12+
13+
<template>
14+
<div class="feishu-grid-column" :style="columnStyle">
15+
<slot />
16+
</div>
17+
</template>
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
<script setup lang="ts">
2+
import { computed } from 'vue'
3+
import { withBase } from 'vitepress'
4+
5+
const props = defineProps<{
6+
src: string
7+
caption?: string
8+
width?: string
9+
height?: string
10+
transparent?: boolean
11+
}>()
12+
13+
const resolvedSrc = computed(() =>
14+
props.src.startsWith('/') ? withBase(props.src) : props.src
15+
)
16+
</script>
17+
18+
<template>
19+
<figure class="feishu-figure">
20+
<div
21+
class="feishu-image-frame"
22+
:class="{ 'is-transparent': transparent }"
23+
>
24+
<img
25+
:src="resolvedSrc"
26+
:alt="caption || ''"
27+
:width="width"
28+
:height="height"
29+
loading="lazy"
30+
decoding="async"
31+
>
32+
</div>
33+
<figcaption v-if="caption">{{ caption }}</figcaption>
34+
</figure>
35+
</template>

docs/.vitepress/theme/index.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,9 @@
11
import DefaultTheme from 'vitepress/theme'
22
import type { Theme } from 'vitepress'
33
import AssignmentBoard from './components/AssignmentBoard.vue'
4+
import FeishuGrid from './components/FeishuGrid.vue'
5+
import FeishuGridColumn from './components/FeishuGridColumn.vue'
6+
import FeishuImage from './components/FeishuImage.vue'
47
import HomePage from './components/HomePage.vue'
58
import ScheduleBoard from './components/ScheduleBoard.vue'
69
import SessionHeader from './components/SessionHeader.vue'
@@ -11,6 +14,9 @@ export default {
1114
extends: DefaultTheme,
1215
enhanceApp({ app }) {
1316
app.component('AssignmentBoard', AssignmentBoard)
17+
app.component('FeishuGrid', FeishuGrid)
18+
app.component('FeishuGridColumn', FeishuGridColumn)
19+
app.component('FeishuImage', FeishuImage)
1420
app.component('HomePage', HomePage)
1521
app.component('ScheduleBoard', ScheduleBoard)
1622
app.component('SessionHeader', SessionHeader)

docs/.vitepress/theme/style.css

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2630,6 +2630,74 @@ body.has-calendar-dialog {
26302630
background: var(--site-brand-soft);
26312631
}
26322632

2633+
.vp-doc .feishu-grid {
2634+
display: flex;
2635+
width: 100%;
2636+
margin: 26px 0;
2637+
align-items: flex-start;
2638+
gap: 16px;
2639+
}
2640+
2641+
.vp-doc .feishu-grid-column {
2642+
min-width: 0;
2643+
flex-basis: 0;
2644+
flex-grow: var(--feishu-column-width, 1);
2645+
}
2646+
2647+
.vp-doc .feishu-grid-column > :first-child {
2648+
margin-top: 0;
2649+
}
2650+
2651+
.vp-doc .feishu-grid-column > :last-child {
2652+
margin-bottom: 0;
2653+
}
2654+
2655+
.vp-doc .feishu-figure {
2656+
width: fit-content;
2657+
max-width: 100%;
2658+
margin: 26px auto;
2659+
}
2660+
2661+
.vp-doc .feishu-image-frame {
2662+
display: flex;
2663+
max-width: 100%;
2664+
border-radius: 10px;
2665+
align-items: center;
2666+
justify-content: center;
2667+
overflow: hidden;
2668+
transition:
2669+
padding 160ms ease,
2670+
background-color 160ms ease;
2671+
}
2672+
2673+
.vp-doc .feishu-image-frame img {
2674+
display: block;
2675+
width: auto;
2676+
max-width: 100%;
2677+
height: auto;
2678+
margin: 0;
2679+
border-radius: 7px;
2680+
}
2681+
2682+
.vp-doc .feishu-figure figcaption {
2683+
margin-top: 9px;
2684+
color: var(--site-text-3);
2685+
font-size: 14px;
2686+
line-height: 1.55;
2687+
text-align: center;
2688+
}
2689+
2690+
.vp-doc .feishu-grid .feishu-figure {
2691+
width: 100%;
2692+
margin: 0;
2693+
}
2694+
2695+
.dark .vp-doc .feishu-image-frame.is-transparent {
2696+
padding: clamp(8px, 1.5vw, 16px);
2697+
border: 1px solid rgba(255, 255, 255, 0.22);
2698+
background: #ffffff;
2699+
}
2700+
26332701
.session-banner {
26342702
margin: 7px 0 36px;
26352703
padding: 30px;
@@ -2882,6 +2950,17 @@ body.has-calendar-dialog {
28822950
}
28832951

28842952
@media (max-width: 720px) {
2953+
.vp-doc .feishu-grid {
2954+
align-items: stretch;
2955+
flex-direction: column;
2956+
gap: 22px;
2957+
}
2958+
2959+
.vp-doc .feishu-grid-column {
2960+
width: 100%;
2961+
flex-basis: auto;
2962+
}
2963+
28852964
.calendar-timeline-heading-row {
28862965
margin-bottom: 16px;
28872966
align-items: flex-start;

scripts/feishu/client.mjs

Lines changed: 61 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -71,24 +71,27 @@ export async function fetchWikiMarkdown(client, wikiNodeToken) {
7171

7272
if (node.obj_type === 'docx') {
7373
const operation = `Export Wiki document ${wikiNodeToken}`
74-
const response = await callFeishu(operation, () =>
75-
client.request({
76-
method: 'POST',
77-
url: `/open-apis/docs_ai/v1/documents/${encodeURIComponent(node.obj_token)}/fetch`,
78-
data: {
79-
format: 'markdown',
80-
export_option: {
81-
export_block_id: false,
82-
export_style_attrs: false,
83-
export_cite_extra_data: false
84-
},
85-
extra_param: JSON.stringify({
86-
enable_user_cite_reference_map: true,
87-
return_html5_block_data: true
88-
})
89-
}
90-
})
91-
)
74+
const [response, blocks] = await Promise.all([
75+
callFeishu(operation, () =>
76+
client.request({
77+
method: 'POST',
78+
url: `/open-apis/docs_ai/v1/documents/${encodeURIComponent(node.obj_token)}/fetch`,
79+
data: {
80+
format: 'markdown',
81+
export_option: {
82+
export_block_id: false,
83+
export_style_attrs: false,
84+
export_cite_extra_data: false
85+
},
86+
extra_param: JSON.stringify({
87+
enable_user_cite_reference_map: true,
88+
return_html5_block_data: true
89+
})
90+
}
91+
})
92+
),
93+
listDocumentBlocks(client, node.obj_token)
94+
])
9295
const data = assertSuccess(response, operation)
9396
const document = data?.document
9497

@@ -100,6 +103,14 @@ export async function fetchWikiMarkdown(client, wikiNodeToken) {
100103
markdown: document.content,
101104
revisionId: document.revision_id ?? null,
102105
referenceMap: document.reference_map ?? {},
106+
imageMetadata: blocks
107+
.filter(({ image }) => image?.token)
108+
.map(({ image }) => ({
109+
token: image.token,
110+
caption: image.caption?.content?.trim() || undefined,
111+
width: positiveInteger(image.width),
112+
height: positiveInteger(image.height)
113+
})),
103114
node
104115
}
105116
}
@@ -125,6 +136,34 @@ export async function fetchWikiMarkdown(client, wikiNodeToken) {
125136
)
126137
}
127138

139+
export async function listDocumentBlocks(client, documentId) {
140+
const blocks = []
141+
let pageToken
142+
143+
do {
144+
const operation = `List blocks in document ${documentId}`
145+
const response = await callFeishu(operation, () =>
146+
client.docx.v1.documentBlock.list({
147+
path: { document_id: documentId },
148+
params: {
149+
page_size: 500,
150+
document_revision_id: -1,
151+
...(pageToken ? { page_token: pageToken } : {})
152+
}
153+
})
154+
)
155+
const data = assertSuccess(response, operation)
156+
blocks.push(...(data?.items ?? []))
157+
158+
if (data?.has_more && !data.page_token) {
159+
throw new Error(`${operation} returned no next page token`)
160+
}
161+
pageToken = data?.has_more ? data.page_token : undefined
162+
} while (pageToken)
163+
164+
return blocks
165+
}
166+
128167
export async function listWikiNodes(client, { spaceId, parentNodeToken }) {
129168
const nodes = []
130169
let pageToken
@@ -149,6 +188,10 @@ export async function listWikiNodes(client, { spaceId, parentNodeToken }) {
149188
return nodes
150189
}
151190

191+
function positiveInteger(value) {
192+
return Number.isInteger(value) && value > 0 ? value : undefined
193+
}
194+
152195
export async function downloadDocumentMedia(client, fileToken) {
153196
const response = await callFeishu(`Download document media ${fileToken}`, () =>
154197
client.drive.v1.media.download({

scripts/feishu/client.test.mjs

Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import assert from 'node:assert/strict'
22
import test from 'node:test'
33
import {
44
downloadFeishuMediaUrl,
5+
fetchWikiMarkdown,
56
listWikiNodes
67
} from './client.mjs'
78

@@ -72,3 +73,89 @@ test('downloads only allowlisted hosted Feishu media URLs', async () => {
7273
globalThis.fetch = originalFetch
7374
}
7475
})
76+
77+
test('combines Markdown export with paginated image block metadata', async () => {
78+
const blockRequests = []
79+
const client = {
80+
wiki: {
81+
v2: {
82+
space: {
83+
async getNode() {
84+
return {
85+
code: 0,
86+
data: {
87+
node: {
88+
obj_token: 'doc_01',
89+
obj_type: 'docx',
90+
title: 'Session 01'
91+
}
92+
}
93+
}
94+
}
95+
}
96+
}
97+
},
98+
async request() {
99+
return {
100+
code: 0,
101+
data: {
102+
document: {
103+
content: '# Session 01\n\n![](https://example.com/image.png)',
104+
revision_id: 7,
105+
reference_map: {}
106+
}
107+
}
108+
}
109+
},
110+
docx: {
111+
v1: {
112+
documentBlock: {
113+
async list(request) {
114+
blockRequests.push(request)
115+
if (!request.params.page_token) {
116+
return {
117+
code: 0,
118+
data: {
119+
has_more: true,
120+
page_token: 'next',
121+
items: [{ block_type: 2, text: { elements: [] } }]
122+
}
123+
}
124+
}
125+
return {
126+
code: 0,
127+
data: {
128+
has_more: false,
129+
items: [
130+
{
131+
block_type: 27,
132+
image: {
133+
token: 'img_01',
134+
caption: { content: ' 架构图 ' },
135+
width: 1280,
136+
height: 720
137+
}
138+
}
139+
]
140+
}
141+
}
142+
}
143+
}
144+
}
145+
}
146+
}
147+
148+
const document = await fetchWikiMarkdown(client, 'wiki_01')
149+
150+
assert.equal(blockRequests.length, 2)
151+
assert.equal(blockRequests[0].params.document_revision_id, -1)
152+
assert.equal(blockRequests[1].params.page_token, 'next')
153+
assert.deepEqual(document.imageMetadata, [
154+
{
155+
token: 'img_01',
156+
caption: '架构图',
157+
width: 1280,
158+
height: 720
159+
}
160+
])
161+
})

0 commit comments

Comments
 (0)