├── src ├── index.js ├── templates.js ├── bin.js └── convert.js ├── .prettierrc.yml ├── babel.config.js ├── jest.config.js ├── .travis.yml ├── .codeclimate.yml ├── .eslintrc.yml ├── test ├── test-cases │ ├── 2 │ │ └── index.html │ └── 3 │ │ ├── index.html │ │ └── output.tex └── unit │ └── convert.js ├── rollup.config.js ├── LICENSE ├── package.json ├── .gitignore └── README.md /src/index.js: -------------------------------------------------------------------------------- 1 | export { convertText, convertFile } from './convert'; 2 | -------------------------------------------------------------------------------- /.prettierrc.yml: -------------------------------------------------------------------------------- 1 | printWidth: 100 2 | singleQuote: true 3 | trailingComma: all 4 | tabWidth: 2 5 | useTabs: false 6 | -------------------------------------------------------------------------------- /babel.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | presets: [ 3 | [ 4 | '@babel/preset-env', 5 | { 6 | targets: { 7 | node: 'current', 8 | }, 9 | }, 10 | ], 11 | ], 12 | }; 13 | -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | testEnvironment: 'node', 3 | testRegex: 'test\\/.*\\.js$', 4 | testMatch: null, 5 | testURL: 'http://localhost/', 6 | testTimeout: 15000, 7 | 8 | coverageDirectory: 'coverage', 9 | collectCoverage: true, 10 | }; 11 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | version: ~> 1.0 2 | 3 | language: node_js 4 | 5 | node_js: 6 | - node 7 | 8 | jobs: 9 | include: 10 | - stage: lint 11 | script: 12 | - yarn lint 13 | 14 | - stage: build 15 | script: 16 | - yarn build 17 | 18 | - stage: test 19 | script: 20 | - yarn test && yarn codecov 21 | -------------------------------------------------------------------------------- /.codeclimate.yml: -------------------------------------------------------------------------------- 1 | engines: 2 | shellcheck: 3 | enabled: true 4 | duplication: 5 | enabled: true 6 | config: 7 | languages: 8 | javascript: 9 | mass_threshold: 70 10 | count_threshold: 3 11 | 12 | ratings: 13 | paths: 14 | - '**.js' 15 | 16 | exclude_paths: 17 | - node_modules 18 | - coverage 19 | - dist 20 | -------------------------------------------------------------------------------- /.eslintrc.yml: -------------------------------------------------------------------------------- 1 | env: 2 | node: true 3 | jest/globals: true 4 | 5 | extends: 6 | - airbnb-base 7 | - plugin:prettier/recommended 8 | - plugin:jest/recommended 9 | 10 | ignorePatterns: 11 | - node_modules/ 12 | - dist/ 13 | - '**/rollup.config.js' 14 | 15 | plugins: 16 | - import 17 | 18 | rules: 19 | import/prefer-default-export: off 20 | import/extensions: 21 | - error 22 | - never 23 | - json: always 24 | 25 | settings: 26 | import/resolver: 27 | node: 28 | extensions: 29 | - .js 30 | -------------------------------------------------------------------------------- /test/test-cases/2/index.html: -------------------------------------------------------------------------------- 1 |
12 |
13 |
14 | Some types of forces may be
15 | (i) Contact forces, (ii) Non-contact forces
16 | Contact forces involve physical contact between two objects.
17 |
18 |
19 |
2 |
3 | Net potential at \({\rm{B}}\) is due to superposition of plotential due to all shells.
4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 |
23 |
24 |
25 |
26 |
27 |
28 |
29 |
30 |
Styled Text
`; 47 | const tex = await convertText(html); 48 | 49 | console.log(tex) 50 | //\documentclass{article} 51 | // 52 | //\begin{document} 53 | // 54 | //Styled \textbf{Text} 55 | // 56 | //\end{document} 57 | ``` 58 | 59 | Converting html file: 60 | ```javascript 61 | import { convertFile } from 'html-to-latex'; 62 | 63 | const html = 'filePath.html'; 64 | 65 | await convertFile(html); 66 | ``` 67 | 68 | ### API 69 | 70 | #### convertText(htmlText, options?) 71 | 72 | Returns: `Promise`
302 |
--------------------------------------------------------------------------------
/src/convert.js:
--------------------------------------------------------------------------------
1 | import { parseFragment } from 'parse5';
2 | import { decodeHTML } from 'entities';
3 | import { outputFile, readFile, pathExists, ensureDir } from 'fs-extra';
4 | import { resolve, basename, join, dirname, extname } from 'path';
5 | import { stream } from 'got';
6 | import { pipeline as pipelineSync } from 'stream';
7 | import { promisify } from 'util';
8 | import { createWriteStream } from 'fs';
9 | import { generate as generateId } from 'shortid';
10 | import {
11 | docClass,
12 | usePackages,
13 | beginDocument,
14 | endDocument,
15 | section,
16 | subsection,
17 | subsubsection,
18 | bold,
19 | italic,
20 | underline,
21 | divider,
22 | itemize,
23 | enumerate,
24 | item,
25 | image,
26 | } from './templates';
27 |
28 | const pipeline = promisify(pipelineSync);
29 |
30 | function analyzeForPackageImports(HTMLText) {
31 | const pkgs = [];
32 |
33 | if (HTMLText.includes('\\cfrac')) pkgs.push('amsmath');
34 | if (HTMLText.includes(' name === 'src').value;
58 | const ext = extname(origPath) || '.jpg';
59 | const base = autoGenImageNames ? `${generateId()}${ext}` : basename(origPath);
60 | const localPath = resolve(imagesDir, base);
61 | const localLatexPath = join('images', base);
62 | const exists = await pathExists(localPath);
63 |
64 | if (!exists) {
65 | try {
66 | const url = new URL(origPath);
67 |
68 | await ensureDir(imagesDir);
69 |
70 | await pipeline(stream(url.href), createWriteStream(localPath));
71 | } catch (e) {
72 | if (debug) {
73 | console.debug(`URL: ${origPath}`);
74 | console.debug(e);
75 | }
76 | }
77 | }
78 |
79 | return image(localLatexPath, {
80 | width: imageWidth,
81 | height: imageHeight,
82 | keepRatio: keepImageAspectRatio,
83 | center: centerImages,
84 | });
85 | }
86 |
87 | function convertPlainText(value, opts) {
88 | const breakReplacement = opts.ignoreBreaks ? '' : '\n\n';
89 | const cleanText = value
90 | .replace(/(\n|\r)/g, breakReplacement) // Standardize line breaks or remove them
91 | .replace(/\t/g, '') // Remove tabs
92 | .replace(/(? bold(t));
105 | case 'i':
106 | return convertRichText(n, opts).then((t) => italic(t));
107 | case 'u':
108 | return convertRichText(n, opts).then((t) => underline(t));
109 | case 'br':
110 | return opts.ignoreBreaks ? ' ' : '\n\n';
111 | case 'span':
112 | return convertRichText(n, opts);
113 | case '#text':
114 | return convertPlainText(n.value, opts);
115 | default:
116 | return '';
117 | }
118 | }
119 |
120 | async function convertRichText(node, opts) {
121 | if (node.childNodes && node.childNodes.length > 0) {
122 | const converted = await Promise.all(node.childNodes.map((n) => convertRichTextSingle(n, opts)));
123 | return converted.join('');
124 | }
125 |
126 | return convertRichTextSingle(node, opts);
127 | }
128 |
129 | async function convertUnorderedLists({ childNodes }, opts) {
130 | const filtered = await childNodes.filter(({ nodeName }) => nodeName === 'li');
131 | const texts = await Promise.all(
132 | filtered.map((f) => convert([f], { ...opts, includeDocumentWrapper: false })),
133 | );
134 | const listItems = texts.map(item);
135 |
136 | return itemize(listItems.join('\n'));
137 | }
138 |
139 | async function convertOrderedLists({ childNodes }, opts) {
140 | const filtered = await childNodes.filter(({ nodeName }) => nodeName === 'li');
141 | const texts = await Promise.all(
142 | filtered.map((f) => convert([f], { ...opts, includeDocumentWrapper: false })),
143 | );
144 | const listItems = texts.map(item);
145 |
146 | return enumerate(listItems.join('\n'));
147 | }
148 |
149 | async function convertHeading(node, opts) {
150 | const text = await convertRichText(node, opts);
151 |
152 | switch (node.nodeName) {
153 | case 'h1':
154 | return section(text);
155 | case 'h2':
156 | return subsection(text);
157 | default:
158 | return subsubsection(text);
159 | }
160 | }
161 |
162 | export async function convert(
163 | nodes,
164 | {
165 | autoGenImageNames = true,
166 | includeDocumentWrapper = false,
167 | documentClass = 'article',
168 | includePackages = [],
169 | compilationDir = process.cwd(),
170 | ignoreBreaks = true,
171 | preferDollarInlineMath = false,
172 | skipWrappingEquations = false,
173 | debug = false,
174 | imageWidth,
175 | imageHeight,
176 | keepImageAspectRatio,
177 | centerImages,
178 | title,
179 | includeDate,
180 | author,
181 | } = {},
182 | ) {
183 | const blockedNodes = [
184 | 'h1',
185 | 'h2',
186 | 'h3',
187 | 'ul',
188 | 'ol',
189 | 'img',
190 | 'hr',
191 | 'div',
192 | 'section',
193 | 'body',
194 | 'html',
195 | 'header',
196 | 'footer',
197 | 'aside',
198 | 'p',
199 | ];
200 | const doc = [];
201 | const opts = {
202 | compilationDir,
203 | ignoreBreaks,
204 | preferDollarInlineMath,
205 | skipWrappingEquations,
206 | autoGenImageNames,
207 | debug,
208 | imageWidth,
209 | imageHeight,
210 | keepImageAspectRatio,
211 | centerImages,
212 | };
213 | let tempInlineDoc = [];
214 |
215 | if (includeDocumentWrapper) {
216 | doc.push(docClass(documentClass));
217 |
218 | if (includePackages.length > 0) doc.push(usePackages(includePackages));
219 |
220 | doc.push(beginDocument({ title, includeDate, author }));
221 | }
222 |
223 | nodes.forEach(async (n) => {
224 | if (!blockedNodes.includes(n.nodeName)) {
225 | tempInlineDoc.push(convertRichText(n, opts));
226 | return;
227 | }
228 |
229 | if (tempInlineDoc.length > 0) {
230 | doc.push(Promise.all(tempInlineDoc).then((t) => t.join('').trim()));
231 | tempInlineDoc = [];
232 | }
233 |
234 | switch (n.nodeName) {
235 | case 'h1':
236 | case 'h2':
237 | case 'h3':
238 | doc.push(convertHeading(n, opts));
239 | break;
240 | case 'ul':
241 | doc.push(convertUnorderedLists(n, opts));
242 | break;
243 | case 'ol':
244 | doc.push(convertOrderedLists(n, opts));
245 | break;
246 | case 'img':
247 | doc.push(convertImage(n, opts));
248 | break;
249 | case 'hr':
250 | doc.push(divider);
251 | break;
252 | case 'div':
253 | case 'section':
254 | case 'body':
255 | case 'html':
256 | case 'header':
257 | case 'footer':
258 | case 'aside':
259 | doc.push(
260 | convert(n.childNodes, {
261 | ...opts,
262 | includeDocumentWrapper: false,
263 | }),
264 | );
265 | break;
266 | case 'p':
267 | doc.push(
268 | convertRichText(n, opts).then((t) => {
269 | const trimmed = t.trim();
270 |
271 | // Check if text is only an equation. If so, switch \( \) & $ $, for \[ \]
272 | if (
273 | !opts.skipWrappingEquations &&
274 | trimmed.match(/^(\$|\\\()/) &&
275 | trimmed.match(/(\\\)|\$)$/)
276 | ) {
277 | const rewrapped = trimmed.replace(/^(\$|\\\()/, '\\[').replace(/(\\\)|\$)$/, '\\]');
278 |
279 | // TODO: Move all of this into the above regex check
280 | if (!rewrapped.includes('$')) return rewrapped;
281 | }
282 |
283 | return trimmed;
284 | }),
285 | );
286 | break;
287 | default:
288 | }
289 | });
290 |
291 | // Insert any left over inline nodes
292 | if (tempInlineDoc.length > 0) {
293 | doc.push(Promise.all(tempInlineDoc).then((t) => t.join('').trim()));
294 | }
295 |
296 | // Add document wrapper if configuration is set
297 | if (includeDocumentWrapper) doc.push(endDocument);
298 |
299 | const converted = await Promise.all(doc);
300 |
301 | return converted.filter(Boolean).join('\n\n');
302 | }
303 |
304 | export async function convertText(data, options = {}) {
305 | const root = await parseFragment(data);
306 |
307 | return convert(root.childNodes, {
308 | ...options,
309 | includePackages: options.includePackages || analyzeForPackageImports(data),
310 | });
311 | }
312 |
313 | export async function convertFile(filepath, { outputFilepath = filepath, ...options } = {}) {
314 | const data = await readFile(filepath, 'utf-8');
315 | const processed = await convertText(data, { includeDocumentWrapper: true, ...options });
316 |
317 | await exportFile(processed, outputFilepath, dirname(filepath));
318 | }
319 |
--------------------------------------------------------------------------------
/test/unit/convert.js:
--------------------------------------------------------------------------------
1 | import { directory } from 'tempy';
2 | import { pathExists, remove, readFile } from 'fs-extra';
3 | import { resolve } from 'path';
4 | import ShortId from 'shortid';
5 | import { convertText, exportFile, convertFile } from '../../src/convert';
6 |
7 | describe('exportFile', () => {
8 | let dir;
9 |
10 | beforeEach(() => {
11 | dir = directory();
12 | });
13 |
14 | afterEach(async () => {
15 | await remove(dir);
16 | });
17 |
18 | it('should export latex file', async () => {
19 | await exportFile('testing', 'test', dir);
20 |
21 | const exists = await pathExists(resolve(dir, 'test.tex'));
22 |
23 | expect(exists).toBeTruthy();
24 | });
25 | });
26 |
27 | describe('convertText', () => {
28 | describe('Document wrapper', () => {
29 | it('should insert the basic document wrapper and default document class of article', async () => {
30 | const html = `
Styled Text
`; 114 | const tex = await convertText(html); 115 | 116 | expect(tex).toBe('Styled \\textbf{Text}'); 117 | }); 118 | 119 | it('should convert simple text tag with bold `strong` styling', async () => { 120 | const html = `Styled Text
`; 121 | const tex = await convertText(html); 122 | 123 | expect(tex).toBe('Styled \\textbf{Text}'); 124 | }); 125 | 126 | it('should convert simple text tag with italics styling', async () => { 127 | const html = `Styled Text
`; 128 | const tex = await convertText(html); 129 | 130 | expect(tex).toBe('Styled \\textit{Text}'); 131 | }); 132 | 133 | it('should convert simple text tag with underline styling', async () => { 134 | const html = `Styled Text
`; 135 | const tex = await convertText(html); 136 | 137 | expect(tex).toBe('Styled \\underline{Text}'); 138 | }); 139 | 140 | it('should convert text tag with span nesting', async () => { 141 | const html = `Styled Text
`; 142 | const tex = await convertText(html); 143 | 144 | expect(tex).toBe('Styled Text'); 145 | }); 146 | 147 | it('should ignore `\t`', async () => { 148 | const html = `Styled\tText
`; 149 | const tex = await convertText(html); 150 | 151 | expect(tex).toBe('StyledText'); 152 | }); 153 | 154 | it('should escape `%`', async () => { 155 | const html = `Styled%Text
`; 156 | const tex = await convertText(html); 157 | 158 | expect(tex).toBe('Styled\\%Text'); 159 | }); 160 | 161 | it('should not escape `%` if its already escaped', async () => { 162 | const html = `Styled\\%Text
`; 163 | const tex = await convertText(html); 164 | 165 | expect(tex).toBe('Styled\\%Text'); 166 | }); 167 | }); 168 | 169 | describe('Converting text with different types of breaks', () => { 170 | it('should convert simple `p` tag text with `br` tags. These will be ignored by default', async () => { 171 | const html = `Styled
Text
Styled
Text
Styled\nText
`; 186 | const tex = await convertText(html, { ignoreBreaks: false }); 187 | 188 | expect(tex).toBe('Styled\n\nText'); 189 | }); 190 | 191 | it('should convert simple text with `\r` and the ignoreBreaks argument set to false', async () => { 192 | const html = `Styled\rText
`; 193 | const tex = await convertText(html, { ignoreBreaks: false }); 194 | 195 | expect(tex).toBe('Styled\n\nText'); 196 | }); 197 | }); 198 | 199 | describe('Unwrapped content', () => { 200 | it('should convert simple text with `br` tags and the ignoreBreaks argument set to false', async () => { 201 | const html = `StyledInner p tag
`; 209 | const tex = await convertText(html, { ignoreBreaks: false }); 210 | 211 | expect(tex).toBe('Three concentric metal shells\n\nMore text here.\n\nInner p tag'); 212 | }); 213 | }); 214 | 215 | describe('Converting text with equations', () => { 216 | it('should convert eq wrappers p tags with only an eq to use the \\[ wrapper instead of \\(', async () => { 217 | const html = `\\(x = 5\\Omega\\)
`; 218 | const tex = await convertText(html); 219 | 220 | expect(tex).toBe('\\[x = 5\\Omega\\]'); 221 | }); 222 | 223 | it('should convert p tags with only an eq to use the \\[ wrapper instead of $', async () => { 224 | const html = `$x = 5\\Omega$
`; 225 | const tex = await convertText(html); 226 | 227 | expect(tex).toBe('\\[x = 5\\Omega\\]'); 228 | }); 229 | 230 | it('should not convert p tags with only an eq to use the \\[ wrapper instead of \\( if skipWrappingEquations is true', async () => { 231 | const html = `\\(x = 5\\Omega\\)
`; 232 | const tex = await convertText(html, { skipWrappingEquations: true }); 233 | 234 | expect(tex).toBe('\\(x = 5\\Omega\\)'); 235 | }); 236 | 237 | it('should not convert p tags with only an eq to use the \\[ wrapper instead of $ if skipWrappingEquations is true', async () => { 238 | const html = `$x = 5\\Omega$
`; 239 | const tex = await convertText(html, { skipWrappingEquations: true }); 240 | 241 | expect(tex).toBe('$x = 5\\Omega$'); 242 | }); 243 | 244 | it('should not modify eq wrappers in p tags with an eq and other content', async () => { 245 | const html = `Some content $x = 5\\Omega$
`; 246 | const tex = await convertText(html); 247 | 248 | expect(tex).toBe('Some content $x = 5\\Omega$'); 249 | }); 250 | 251 | it('should prefer $ eq wrappers if configuration is given', async () => { 252 | const html = `Some content \\(x = 5\\Omega\\)
`; 253 | const tex = await convertText(html, { preferDollarInlineMath: true }); 254 | 255 | expect(tex).toBe('Some content $x = 5\\Omega$'); 256 | }); 257 | 258 | it('should handle eqs deep within text without tag wrapping', async () => { 259 | const html = 260 | 'This is some plain text \\(A,{\\rm{ }}B\\) and \\(C\\) with random equations \\(a,{\\rm{ }}b\\) and \\(c\\) \\((a < b < c)\\)'; 261 | const tex = await convertText(html, { preferDollarInlineMath: true }); 262 | 263 | expect(tex).toBe( 264 | 'This is some plain text $A,{\\rm{ }}B$ and $C$ with random equations $a,{\\rm{ }}b$ and $c$ $(a < b < c)$', 265 | ); 266 | }); 267 | }); 268 | 269 | describe('Converting H tags', () => { 270 | it('should convert simple h tag without special chars', async () => { 271 | const html = `Text
More Text
`; 317 | const tex = await convertText(html); 318 | 319 | expect(tex).toBe('Text\n\n\\hrule\n\n\nMore Text'); 320 | }); 321 | }); 322 | 323 | describe('Converting img tags', () => { 324 | it('should convert simple img tag', async () => { 325 | const html = `
`;
326 | const tex = await convertText(html, { autoGenImageNames: false });
327 |
328 | expect(tex).toBe('\\begin{center}\n\t\\includegraphics{images/image.png}\n\\end{center}');
329 | });
330 |
331 | it('should convert wrapped img tag', async () => {
332 | const spy = jest.spyOn(ShortId, 'generate');
333 | spy.mockImplementation(() => 'image2');
334 |
335 | const html = `
`;
357 | const tex = await convertText(html, { autoGenImageNames: false, imageWidth: '2cm' });
358 |
359 | expect(tex).toBe(
360 | '\\begin{center}\n\t\\includegraphics[width=2cm]{images/image.png}\n\\end{center}',
361 | );
362 | });
363 |
364 | it('should add height restrictions when given', async () => {
365 | const html = `
`;
366 | const tex = await convertText(html, { autoGenImageNames: false, imageHeight: '2cm' });
367 |
368 | expect(tex).toBe(
369 | '\\begin{center}\n\t\\includegraphics[height=2cm]{images/image.png}\n\\end{center}',
370 | );
371 | });
372 |
373 | it('should keep aspect ratio when given and width or height are restricted', async () => {
374 | const html = `
`;
375 | const tex = await convertText(html, {
376 | autoGenImageNames: false,
377 | imageHeight: '2cm',
378 | keepImageAspectRatio: true,
379 | });
380 |
381 | expect(tex).toBe(
382 | '\\begin{center}\n\t\\includegraphics[height=2cm,keepaspectratio]{images/image.png}\n\\end{center}',
383 | );
384 | });
385 |
386 | it('should ignore aspect ratio when given if width or height are not restricted', async () => {
387 | const html = `
`;
388 | const tex = await convertText(html, { autoGenImageNames: false, keepImageAspectRatio: true });
389 |
390 | expect(tex).toBe('\\begin{center}\n\t\\includegraphics{images/image.png}\n\\end{center}');
391 | });
392 |
393 | it('should not center the image', async () => {
394 | const html = `
`;
395 | const tex = await convertText(html, { autoGenImageNames: false, centerImages: false });
396 |
397 | expect(tex).toBe('\\includegraphics{images/image.png}');
398 | });
399 | });
400 |
401 | describe('Converting list tags', () => {
402 | it('should convert simple ul list tag', async () => {
403 | const html = `
`;
421 |
422 | await convertText(html, { autoGenImageNames: false, debug: true });
423 |
424 | expect(spy).toBeCalledTimes(2);
425 |
426 | spy.mockRestore();
427 | });
428 |
429 | it('should not display errors when converting img tag with an inaccessible source url without the debug flag', async () => {
430 | const spy = jest.spyOn(console, 'debug').mockImplementation();
431 | const html = `
`;
432 |
433 | await convertText(html, { autoGenImageNames: false });
434 |
435 | expect(spy).toBeCalledTimes(0);
436 |
437 | spy.mockRestore();
438 | });
439 | });
440 | });
441 |
442 | describe('convertFile', () => {
443 | describe('Converting mixed tags', () => {
444 | it('should convert text with a mixture of nested tags', async () => {
445 | await convertFile(resolve(__dirname, '../test-cases/2/index.html'), {
446 | includeDocumentWrapper: false,
447 | });
448 |
449 | const tex = await readFile(resolve(__dirname, '../test-cases/2/index.html.tex'), 'utf-8');
450 | const text = [
451 | "\\section*{\\centering{\\underline{\\textbf{Newton's Laws of Motion}}}}",
452 | '',
453 | '\\subsection*{\\textbf{Concept of Forces}}',
454 | '',
455 | 'Some types of forces may be (i) Contact forces, (ii) Non-contact forces \\textbf{Contact forces} involve physical contact between two objects.',
456 | ];
457 |
458 | expect(tex).toBe(text.join('\n'));
459 |
460 | await remove(resolve(__dirname, '../test-cases/2/index.html.tex'));
461 | });
462 | });
463 |
464 | it('should convert text without tag wrapper while ignoring break tags', async () => {
465 | const spy = jest.spyOn(ShortId, 'generate');
466 | spy.mockImplementation(() => 'image2');
467 |
468 | await convertFile(resolve(__dirname, '../test-cases/3/index.html'), { ignoreBreaks: false });
469 |
470 | const tex = await readFile(resolve(__dirname, '../test-cases/3/index.html.tex'), 'utf-8');
471 | const ref = await readFile(resolve(__dirname, '../test-cases/3/output.tex'), 'utf-8');
472 |
473 | expect(tex).toBe(ref);
474 |
475 | await remove(resolve(__dirname, '../test-cases/3/index.html.tex'));
476 |
477 | spy.mockClear();
478 | });
479 | });
480 |
--------------------------------------------------------------------------------