@link-assistant/web-capture 1.1.2 → 1.7.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +57 -13
- package/Dockerfile +26 -21
- package/README.md +290 -160
- package/bin/web-capture.js +622 -17
- package/package.json +15 -4
- package/src/animation.js +283 -0
- package/src/archive.js +225 -0
- package/src/batch.js +180 -0
- package/src/browser.js +150 -111
- package/src/docx.js +182 -0
- package/src/extract-images.js +120 -0
- package/src/figures.js +208 -0
- package/src/gdocs.js +335 -0
- package/src/image.js +65 -8
- package/src/index.js +15 -0
- package/src/latex.js +127 -0
- package/src/lib.js +281 -1
- package/src/localize-images.js +202 -0
- package/src/markdown.js +7 -2
- package/src/metadata.js +333 -0
- package/src/pdf.js +76 -0
- package/src/popups.js +171 -0
- package/src/postprocess.js +263 -0
- package/src/retry.js +48 -0
- package/src/themed-image.js +167 -0
- package/src/verify.js +312 -0
- package/tests/e2e/docker.test.js +51 -12
- package/tests/e2e/process.test.js +34 -10
- package/tests/fixtures/habr-articles.js +45 -0
- package/tests/integration/api-endpoints.test.js +231 -0
- package/tests/integration/habr-article.test.js +337 -0
- package/tests/mock/index.test.js +5 -3
- package/tests/unit/animation.test.js +33 -0
- package/tests/unit/batch.test.js +114 -0
- package/tests/unit/browser.test.js +38 -2
- package/tests/unit/extract-images.test.js +233 -0
- package/tests/unit/figures.test.js +74 -0
- package/tests/unit/gdocs.test.js +278 -0
- package/tests/unit/heading-image.test.js +49 -0
- package/tests/unit/latex.test.js +123 -0
- package/tests/unit/localize-images.test.js +78 -0
- package/tests/unit/metadata.test.js +140 -0
- package/tests/unit/postprocess.test.js +110 -0
- package/tests/unit/retry.test.js +111 -0
- package/tests/unit/verify.test.js +182 -0
- package/.github/workflows/release.yml +0 -294
- package/ARCHITECTURE.md +0 -982
- package/LICENSE +0 -24
- package/apply.sh +0 -166
- package/docs/market-analysis-2025-05-13.md +0 -64
- package/scripts/changeset-version.mjs +0 -38
- package/scripts/create-github-release.mjs +0 -93
- package/scripts/create-manual-changeset.mjs +0 -88
- package/scripts/format-github-release.mjs +0 -83
- package/scripts/format-release-notes.mjs +0 -218
- package/scripts/instant-version-bump.mjs +0 -121
- package/scripts/publish-to-npm.mjs +0 -128
- package/scripts/setup-npm.mjs +0 -37
- package/scripts/validate-changeset.mjs +0 -106
- package/scripts/version-and-commit.mjs +0 -237
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
import {
|
|
2
|
+
getArticle,
|
|
3
|
+
getAllVersions,
|
|
4
|
+
getAllArticles,
|
|
5
|
+
createConfigFromUrls,
|
|
6
|
+
validateConfig,
|
|
7
|
+
} from '../../src/batch.js';
|
|
8
|
+
|
|
9
|
+
describe('batch module', () => {
|
|
10
|
+
const sampleConfig = {
|
|
11
|
+
articles: {
|
|
12
|
+
'0.0.0': {
|
|
13
|
+
url: 'https://example.com/article-1',
|
|
14
|
+
title: 'First Article',
|
|
15
|
+
language: 'en',
|
|
16
|
+
},
|
|
17
|
+
'0.0.1': {
|
|
18
|
+
url: 'https://example.com/article-2',
|
|
19
|
+
title: 'Second Article',
|
|
20
|
+
language: 'ru',
|
|
21
|
+
},
|
|
22
|
+
},
|
|
23
|
+
defaults: {
|
|
24
|
+
archivePath: 'archive',
|
|
25
|
+
markdownFile: 'document.md',
|
|
26
|
+
imagesDir: 'images',
|
|
27
|
+
hasLocalImages: true,
|
|
28
|
+
},
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
describe('getArticle', () => {
|
|
32
|
+
it('returns article config by version', () => {
|
|
33
|
+
const article = getArticle(sampleConfig, '0.0.0');
|
|
34
|
+
expect(article.url).toBe('https://example.com/article-1');
|
|
35
|
+
expect(article.title).toBe('First Article');
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it('merges defaults into article config', () => {
|
|
39
|
+
const article = getArticle(sampleConfig, '0.0.0');
|
|
40
|
+
expect(article.markdownFile).toBe('document.md');
|
|
41
|
+
expect(article.hasLocalImages).toBe(true);
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it('throws for unknown version', () => {
|
|
45
|
+
expect(() => getArticle(sampleConfig, '9.9.9')).toThrow(
|
|
46
|
+
/Unknown article version/
|
|
47
|
+
);
|
|
48
|
+
});
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
describe('getAllVersions', () => {
|
|
52
|
+
it('returns all version keys', () => {
|
|
53
|
+
const versions = getAllVersions(sampleConfig);
|
|
54
|
+
expect(versions).toEqual(['0.0.0', '0.0.1']);
|
|
55
|
+
});
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
describe('getAllArticles', () => {
|
|
59
|
+
it('returns all articles with defaults merged', () => {
|
|
60
|
+
const articles = getAllArticles(sampleConfig);
|
|
61
|
+
expect(articles).toHaveLength(2);
|
|
62
|
+
expect(articles[0].markdownFile).toBe('document.md');
|
|
63
|
+
expect(articles[1].language).toBe('ru');
|
|
64
|
+
});
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
describe('createConfigFromUrls', () => {
|
|
68
|
+
it('creates config from URL list', () => {
|
|
69
|
+
const config = createConfigFromUrls([
|
|
70
|
+
'https://example.com/a',
|
|
71
|
+
'https://example.com/b',
|
|
72
|
+
]);
|
|
73
|
+
expect(Object.keys(config.articles)).toHaveLength(2);
|
|
74
|
+
expect(config.articles['1'].url).toBe('https://example.com/a');
|
|
75
|
+
expect(config.articles['2'].url).toBe('https://example.com/b');
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
it('sets default archive paths', () => {
|
|
79
|
+
const config = createConfigFromUrls(['https://habr.com/article/123']);
|
|
80
|
+
expect(config.articles['1'].archivePath).toContain('habr-com');
|
|
81
|
+
expect(config.articles['1'].markdownFile).toBe('document.md');
|
|
82
|
+
});
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
describe('validateConfig', () => {
|
|
86
|
+
it('validates correct config', () => {
|
|
87
|
+
const result = validateConfig(sampleConfig);
|
|
88
|
+
expect(result.valid).toBe(true);
|
|
89
|
+
expect(result.errors).toHaveLength(0);
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
it('rejects config without articles', () => {
|
|
93
|
+
const result = validateConfig({});
|
|
94
|
+
expect(result.valid).toBe(false);
|
|
95
|
+
expect(result.errors[0]).toContain('articles');
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
it('rejects article without url', () => {
|
|
99
|
+
const result = validateConfig({
|
|
100
|
+
articles: { 1: { title: 'No URL' } },
|
|
101
|
+
});
|
|
102
|
+
expect(result.valid).toBe(false);
|
|
103
|
+
expect(result.errors[0]).toContain('missing required "url"');
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
it('rejects article with invalid url', () => {
|
|
107
|
+
const result = validateConfig({
|
|
108
|
+
articles: { 1: { url: 'not-a-url' } },
|
|
109
|
+
});
|
|
110
|
+
expect(result.valid).toBe(false);
|
|
111
|
+
expect(result.errors[0]).toContain('invalid URL');
|
|
112
|
+
});
|
|
113
|
+
});
|
|
114
|
+
});
|
|
@@ -80,7 +80,7 @@ describe('Browser Abstraction Layer', () => {
|
|
|
80
80
|
expect(page.goto).toBeDefined();
|
|
81
81
|
expect(page.content).toBeDefined();
|
|
82
82
|
expect(page.screenshot).toBeDefined();
|
|
83
|
-
expect(page.
|
|
83
|
+
expect(page.type).toBe('puppeteer');
|
|
84
84
|
await browser.close();
|
|
85
85
|
});
|
|
86
86
|
});
|
|
@@ -101,7 +101,7 @@ describe('Browser Abstraction Layer', () => {
|
|
|
101
101
|
expect(page.goto).toBeDefined();
|
|
102
102
|
expect(page.content).toBeDefined();
|
|
103
103
|
expect(page.screenshot).toBeDefined();
|
|
104
|
-
expect(page.
|
|
104
|
+
expect(page.type).toBe('playwright');
|
|
105
105
|
await browser.close();
|
|
106
106
|
});
|
|
107
107
|
});
|
|
@@ -132,5 +132,41 @@ describe('Browser Abstraction Layer', () => {
|
|
|
132
132
|
await puppeteerBrowser.close();
|
|
133
133
|
await playwrightBrowser.close();
|
|
134
134
|
});
|
|
135
|
+
|
|
136
|
+
it('both adapters expose browser-commander v0.7.0+ APIs', async () => {
|
|
137
|
+
const puppeteerBrowser = await createBrowser('puppeteer');
|
|
138
|
+
const playwrightBrowser = await createBrowser('playwright');
|
|
139
|
+
|
|
140
|
+
const puppeteerPage = await puppeteerBrowser.newPage();
|
|
141
|
+
const playwrightPage = await playwrightBrowser.newPage();
|
|
142
|
+
|
|
143
|
+
// browser-commander v0.7.0: keyboard interaction
|
|
144
|
+
expect(puppeteerPage.keyboard).toBeDefined();
|
|
145
|
+
expect(puppeteerPage.keyboard.press).toBeDefined();
|
|
146
|
+
expect(puppeteerPage.keyboard.type).toBeDefined();
|
|
147
|
+
expect(playwrightPage.keyboard).toBeDefined();
|
|
148
|
+
expect(playwrightPage.keyboard.press).toBeDefined();
|
|
149
|
+
|
|
150
|
+
// browser-commander v0.7.0: dialog handling
|
|
151
|
+
expect(puppeteerPage.onDialog).toBeDefined();
|
|
152
|
+
expect(puppeteerPage.offDialog).toBeDefined();
|
|
153
|
+
expect(puppeteerPage.clearDialogHandlers).toBeDefined();
|
|
154
|
+
expect(playwrightPage.onDialog).toBeDefined();
|
|
155
|
+
|
|
156
|
+
// browser-commander v0.7.0: media emulation
|
|
157
|
+
expect(puppeteerPage.emulateMedia).toBeDefined();
|
|
158
|
+
expect(playwrightPage.emulateMedia).toBeDefined();
|
|
159
|
+
|
|
160
|
+
// browser-commander v0.8.0: PDF generation
|
|
161
|
+
expect(puppeteerPage.pdf).toBeDefined();
|
|
162
|
+
expect(playwrightPage.pdf).toBeDefined();
|
|
163
|
+
|
|
164
|
+
// browser-commander: evaluate
|
|
165
|
+
expect(puppeteerPage.evaluate).toBeDefined();
|
|
166
|
+
expect(playwrightPage.evaluate).toBeDefined();
|
|
167
|
+
|
|
168
|
+
await puppeteerBrowser.close();
|
|
169
|
+
await playwrightBrowser.close();
|
|
170
|
+
});
|
|
135
171
|
});
|
|
136
172
|
});
|
|
@@ -0,0 +1,233 @@
|
|
|
1
|
+
import { createHash } from 'crypto';
|
|
2
|
+
import fs from 'fs';
|
|
3
|
+
import path from 'path';
|
|
4
|
+
import os from 'os';
|
|
5
|
+
import {
|
|
6
|
+
extractAndSaveImages,
|
|
7
|
+
extractBase64ToBuffers,
|
|
8
|
+
stripBase64Images,
|
|
9
|
+
hasBase64Images,
|
|
10
|
+
} from '../../src/extract-images.js';
|
|
11
|
+
|
|
12
|
+
describe('extract-images module', () => {
|
|
13
|
+
let tmpDir;
|
|
14
|
+
|
|
15
|
+
beforeEach(() => {
|
|
16
|
+
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'extract-images-test-'));
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
afterEach(() => {
|
|
20
|
+
fs.rmSync(tmpDir, { recursive: true, force: true });
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
// 1x1 red PNG pixel as base64
|
|
24
|
+
const TINY_PNG =
|
|
25
|
+
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8/5+hHgAHggJ/PchI7wAAAABJRU5ErkJggg==';
|
|
26
|
+
// 1x1 white JPEG as base64
|
|
27
|
+
const TINY_JPEG =
|
|
28
|
+
'/9j/4AAQSkZJRgABAQAAAQABAAD/2wBDAP///wAAAf/bAEMA/9sAQwD/2wBDAf///wAAAf/bAEMA/9sAQwD/2Q==';
|
|
29
|
+
|
|
30
|
+
function contentHash(base64Data) {
|
|
31
|
+
const buf = Buffer.from(base64Data, 'base64');
|
|
32
|
+
return createHash('sha256').update(buf).digest('hex').slice(0, 8);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const PNG_HASH = contentHash(TINY_PNG);
|
|
36
|
+
const JPEG_HASH = contentHash(TINY_JPEG);
|
|
37
|
+
|
|
38
|
+
describe('extractAndSaveImages', () => {
|
|
39
|
+
it('extracts a single PNG image from markdown', () => {
|
|
40
|
+
const md = `# Hello\n\n\n\nEnd.`;
|
|
41
|
+
const result = extractAndSaveImages(md, tmpDir);
|
|
42
|
+
|
|
43
|
+
expect(result.extracted).toBe(1);
|
|
44
|
+
expect(result.markdown).toContain(
|
|
45
|
+
``
|
|
46
|
+
);
|
|
47
|
+
expect(result.markdown).not.toContain('data:image');
|
|
48
|
+
|
|
49
|
+
const imgPath = path.join(tmpDir, 'images', `image-${PNG_HASH}.png`);
|
|
50
|
+
expect(fs.existsSync(imgPath)).toBe(true);
|
|
51
|
+
const buf = fs.readFileSync(imgPath);
|
|
52
|
+
expect(buf.length).toBeGreaterThan(0);
|
|
53
|
+
// Verify PNG magic bytes
|
|
54
|
+
expect(buf[0]).toBe(0x89);
|
|
55
|
+
expect(buf[1]).toBe(0x50); // P
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it('extracts multiple images with content-hash filenames', () => {
|
|
59
|
+
const md = [
|
|
60
|
+
``,
|
|
61
|
+
``,
|
|
62
|
+
].join('\n');
|
|
63
|
+
|
|
64
|
+
const result = extractAndSaveImages(md, tmpDir);
|
|
65
|
+
expect(result.extracted).toBe(2);
|
|
66
|
+
expect(result.markdown).toContain(`images/image-${PNG_HASH}.png`);
|
|
67
|
+
expect(result.markdown).toContain(`images/image-${JPEG_HASH}.jpg`);
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it('produces stable filenames for duplicate images', () => {
|
|
71
|
+
const md = [
|
|
72
|
+
``,
|
|
73
|
+
``,
|
|
74
|
+
].join('\n');
|
|
75
|
+
|
|
76
|
+
const result = extractAndSaveImages(md, tmpDir);
|
|
77
|
+
expect(result.extracted).toBe(2);
|
|
78
|
+
// Both should produce the same hash-based filename
|
|
79
|
+
const matches = result.markdown.match(
|
|
80
|
+
new RegExp(`image-${PNG_HASH}\\.png`, 'g')
|
|
81
|
+
);
|
|
82
|
+
expect(matches).toHaveLength(2);
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
it('handles JPEG mime type correctly', () => {
|
|
86
|
+
const md = ``;
|
|
87
|
+
const result = extractAndSaveImages(md, tmpDir);
|
|
88
|
+
|
|
89
|
+
expect(result.extracted).toBe(1);
|
|
90
|
+
expect(result.markdown).toContain(`images/image-${JPEG_HASH}.jpg`);
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
it('uses custom imagesDir', () => {
|
|
94
|
+
const md = ``;
|
|
95
|
+
const result = extractAndSaveImages(md, tmpDir, {
|
|
96
|
+
imagesDir: 'my-images',
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
expect(result.extracted).toBe(1);
|
|
100
|
+
expect(result.markdown).toContain(`my-images/image-${PNG_HASH}.png`);
|
|
101
|
+
expect(
|
|
102
|
+
fs.existsSync(path.join(tmpDir, 'my-images', `image-${PNG_HASH}.png`))
|
|
103
|
+
).toBe(true);
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
it('returns markdown unchanged when no base64 images', () => {
|
|
107
|
+
const md = '# No images\n\nJust text.';
|
|
108
|
+
const result = extractAndSaveImages(md, tmpDir);
|
|
109
|
+
|
|
110
|
+
expect(result.extracted).toBe(0);
|
|
111
|
+
expect(result.markdown).toBe(md);
|
|
112
|
+
expect(fs.existsSync(path.join(tmpDir, 'images'))).toBe(false);
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
it('preserves non-base64 image references', () => {
|
|
116
|
+
const md =
|
|
117
|
+
'\n';
|
|
118
|
+
const result = extractAndSaveImages(md, tmpDir);
|
|
119
|
+
|
|
120
|
+
expect(result.extracted).toBe(0);
|
|
121
|
+
expect(result.markdown).toBe(md);
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
it('preserves alt text during extraction', () => {
|
|
125
|
+
const md = ``;
|
|
126
|
+
const result = extractAndSaveImages(md, tmpDir);
|
|
127
|
+
|
|
128
|
+
expect(result.markdown).toContain(
|
|
129
|
+
``
|
|
130
|
+
);
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
it('handles SVG data URIs', () => {
|
|
134
|
+
const svgContent =
|
|
135
|
+
'<svg xmlns="http://www.w3.org/2000/svg" width="1" height="1"><rect fill="red" width="1" height="1"/></svg>';
|
|
136
|
+
const svgBase64 = Buffer.from(svgContent).toString('base64');
|
|
137
|
+
const svgHash = contentHash(svgBase64);
|
|
138
|
+
const md = ``;
|
|
139
|
+
const result = extractAndSaveImages(md, tmpDir);
|
|
140
|
+
|
|
141
|
+
expect(result.extracted).toBe(1);
|
|
142
|
+
expect(result.markdown).toContain(`images/image-${svgHash}.svg`);
|
|
143
|
+
|
|
144
|
+
const imgPath = path.join(tmpDir, 'images', `image-${svgHash}.svg`);
|
|
145
|
+
expect(fs.existsSync(imgPath)).toBe(true);
|
|
146
|
+
const content = fs.readFileSync(imgPath, 'utf-8');
|
|
147
|
+
expect(content).toContain('<svg');
|
|
148
|
+
});
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
describe('extractBase64ToBuffers', () => {
|
|
152
|
+
it('extracts base64 images to in-memory buffers', () => {
|
|
153
|
+
const md = `# Hello\n\n\n\nEnd.`;
|
|
154
|
+
const result = extractBase64ToBuffers(md);
|
|
155
|
+
|
|
156
|
+
expect(result.images).toHaveLength(1);
|
|
157
|
+
expect(result.images[0].filename).toMatch(/^image-[0-9a-f]{8}\.png$/);
|
|
158
|
+
expect(result.images[0].buffer.length).toBeGreaterThan(0);
|
|
159
|
+
expect(result.markdown).toContain(`images/${result.images[0].filename}`);
|
|
160
|
+
expect(result.markdown).not.toContain('data:image');
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
it('uses custom imagesDir prefix', () => {
|
|
164
|
+
const md = ``;
|
|
165
|
+
const result = extractBase64ToBuffers(md, 'assets');
|
|
166
|
+
|
|
167
|
+
expect(result.markdown).toContain('assets/image-');
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
it('does not write files to disk', () => {
|
|
171
|
+
const md = ``;
|
|
172
|
+
extractBase64ToBuffers(md);
|
|
173
|
+
expect(fs.existsSync(path.join(tmpDir, 'images'))).toBe(false);
|
|
174
|
+
});
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
describe('stripBase64Images', () => {
|
|
178
|
+
it('strips base64 images and leaves alt text placeholder', () => {
|
|
179
|
+
const md = ``;
|
|
180
|
+
const result = stripBase64Images(md);
|
|
181
|
+
|
|
182
|
+
expect(result.stripped).toBe(1);
|
|
183
|
+
expect(result.markdown).toBe('*[image: my image]*');
|
|
184
|
+
expect(result.markdown).not.toContain('data:image');
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
it('strips base64 images with empty alt text', () => {
|
|
188
|
+
const md = ``;
|
|
189
|
+
const result = stripBase64Images(md);
|
|
190
|
+
|
|
191
|
+
expect(result.stripped).toBe(1);
|
|
192
|
+
expect(result.markdown).toBe('');
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
it('preserves remote image URLs', () => {
|
|
196
|
+
const md = '';
|
|
197
|
+
const result = stripBase64Images(md);
|
|
198
|
+
|
|
199
|
+
expect(result.stripped).toBe(0);
|
|
200
|
+
expect(result.markdown).toBe(md);
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
it('strips multiple base64 images', () => {
|
|
204
|
+
const md = [
|
|
205
|
+
``,
|
|
206
|
+
``,
|
|
207
|
+
'',
|
|
208
|
+
].join('\n');
|
|
209
|
+
const result = stripBase64Images(md);
|
|
210
|
+
|
|
211
|
+
expect(result.stripped).toBe(2);
|
|
212
|
+
expect(result.markdown).toContain('*[image: a]*');
|
|
213
|
+
expect(result.markdown).toContain('*[image: b]*');
|
|
214
|
+
expect(result.markdown).toContain('https://example.com/img.png');
|
|
215
|
+
});
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
describe('hasBase64Images', () => {
|
|
219
|
+
it('returns true when markdown has base64 images', () => {
|
|
220
|
+
expect(hasBase64Images(``)).toBe(
|
|
221
|
+
true
|
|
222
|
+
);
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
it('returns false when no base64 images', () => {
|
|
226
|
+
expect(hasBase64Images('')).toBe(false);
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
it('returns false for empty string', () => {
|
|
230
|
+
expect(hasBase64Images('')).toBe(false);
|
|
231
|
+
});
|
|
232
|
+
});
|
|
233
|
+
});
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import { extractFigures } from '../../src/figures.js';
|
|
2
|
+
|
|
3
|
+
describe('figures module', () => {
|
|
4
|
+
describe('extractFigures', () => {
|
|
5
|
+
it('extracts figures with captions', () => {
|
|
6
|
+
const html = `
|
|
7
|
+
<figure>
|
|
8
|
+
<img src="https://example.com/fig1.png" alt="Graph">
|
|
9
|
+
<figcaption>Figure 1. The relationship graph</figcaption>
|
|
10
|
+
</figure>
|
|
11
|
+
`;
|
|
12
|
+
const figures = extractFigures(html, 'https://example.com');
|
|
13
|
+
expect(figures).toHaveLength(1);
|
|
14
|
+
expect(figures[0].figureNum).toBe(1);
|
|
15
|
+
expect(figures[0].src).toBe('https://example.com/fig1.png');
|
|
16
|
+
expect(figures[0].caption).toContain('Figure 1');
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
it('handles Russian figure captions', () => {
|
|
20
|
+
const html = `
|
|
21
|
+
<figure>
|
|
22
|
+
<img src="https://example.com/fig.png" alt="">
|
|
23
|
+
<figcaption>Рис. 5. Описание</figcaption>
|
|
24
|
+
</figure>
|
|
25
|
+
`;
|
|
26
|
+
const figures = extractFigures(html, 'https://example.com');
|
|
27
|
+
expect(figures[0].figureNum).toBe(5);
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it('uses sequential numbering when no caption number', () => {
|
|
31
|
+
const html = `
|
|
32
|
+
<figure>
|
|
33
|
+
<img src="https://example.com/a.png" alt="">
|
|
34
|
+
<figcaption>Some caption without number</figcaption>
|
|
35
|
+
</figure>
|
|
36
|
+
<figure>
|
|
37
|
+
<img src="https://example.com/b.png" alt="">
|
|
38
|
+
</figure>
|
|
39
|
+
`;
|
|
40
|
+
const figures = extractFigures(html, 'https://example.com');
|
|
41
|
+
expect(figures).toHaveLength(2);
|
|
42
|
+
expect(figures[0].figureNum).toBe(1);
|
|
43
|
+
expect(figures[1].figureNum).toBe(2);
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it('skips SVG images', () => {
|
|
47
|
+
const html = `
|
|
48
|
+
<figure>
|
|
49
|
+
<img src="https://example.com/icon.svg" alt="">
|
|
50
|
+
</figure>
|
|
51
|
+
`;
|
|
52
|
+
const figures = extractFigures(html, 'https://example.com');
|
|
53
|
+
expect(figures).toHaveLength(0);
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it('resolves relative URLs', () => {
|
|
57
|
+
const html = `
|
|
58
|
+
<figure>
|
|
59
|
+
<img src="/images/fig.png" alt="">
|
|
60
|
+
</figure>
|
|
61
|
+
`;
|
|
62
|
+
const figures = extractFigures(html, 'https://example.com');
|
|
63
|
+
expect(figures[0].src).toBe('https://example.com/images/fig.png');
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
it('returns empty array for no figures', () => {
|
|
67
|
+
const figures = extractFigures(
|
|
68
|
+
'<div>no figures</div>',
|
|
69
|
+
'https://example.com'
|
|
70
|
+
);
|
|
71
|
+
expect(figures).toHaveLength(0);
|
|
72
|
+
});
|
|
73
|
+
});
|
|
74
|
+
});
|