Skip to content

Commit f1ad9f3

Browse files
feat: Add DXF import functionality
Implemented DXF file import capabilities: - Added a "DXF" button to the import section of the toolbar. - Integrated the 'dxf' npm library for parsing DXF files. - The 'dxf' library is lazy-loaded when the import functionality is first used to optimize initial load time. - Current implementation supports importing LINE and CIRCLE entities from DXF files. Coordinates are mapped directly; future work may involve more complex transformations if needed. - Basic error handling is included for file reading and parsing issues, with notifications for you via toasts. - Added comprehensive unit tests for the DXF import logic, covering various scenarios including valid entities (LINE, CIRCLE), empty files, files with unsupported entities, and invalid DXF content.
1 parent 9bd1f8b commit f1ad9f3

File tree

10 files changed

+451
-7
lines changed

10 files changed

+451
-7
lines changed

package-lock.json

Lines changed: 37 additions & 7 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@
3636
"@biomejs/biome": "^1.9.4",
3737
"@tailwindcss/postcss": "^4.1.3",
3838
"@types/blob-stream": "^0.1.33",
39+
"@types/dxf": "^4.6.10",
3940
"@types/file-saver": "^2.0.7",
4041
"@types/node": "^22.9.1",
4142
"@types/react": "^18.3.3",
@@ -46,6 +47,7 @@
4647
"@vitejs/plugin-react-swc": "^3.8.1",
4748
"autoprefixer": "^10.4.21",
4849
"biome": "^0.3.3",
50+
"dxf": "^5.2.0",
4951
"puppeteer": "^24.1.1",
5052
"tailwindcss": "^4.1.3",
5153
"typescript": "^5.2.2",

src/components/Toolbar.tsx

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import {exportEntitiesToSvgFile} from '../helpers/import-export-handlers/export-
1010
import {importEntitiesFromJsonFile} from '../helpers/import-export-handlers/import-entities-from-json';
1111
import {importEntitiesFromSvgFile} from '../helpers/import-export-handlers/import-entities-from-svg.ts';
1212
import {importImageFromFile} from '../helpers/import-export-handlers/import-image-from-file';
13+
import {importEntitiesFromDxfFile} from '../helpers/import-export-handlers/import-entities-from-dxf';
1314
import {times} from '../helpers/times';
1415
import {
1516
getActiveLayerId,
@@ -516,6 +517,24 @@ export const Toolbar: FC = () => {
516517
}}
517518
/>
518519
</Button>
520+
<Button
521+
className="relative w-full"
522+
title="Load from DXF file"
523+
dataId="dxf-open-button"
524+
iconName={IconName.VectorDocumentSolid}
525+
onClick={noopClickHandler}
526+
label="DXF"
527+
>
528+
<input
529+
className="absolute inset-0 opacity-0"
530+
type="file"
531+
accept="*.dxf"
532+
onChange={async (evt) => {
533+
await importEntitiesFromDxfFile(evt.target.files?.[0]);
534+
evt.target.files = null;
535+
}}
536+
/>
537+
</Button>
519538
<Button
520539
className="relative w-full"
521540
title="Load from JSON file"
Lines changed: 170 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,170 @@
1+
import fs from 'fs';
2+
import path from 'path';
3+
import { toast } from 'react-toastify';
4+
import { v4 as uuidv4 } from 'uuid';
5+
import { getEntities, setEntities, getActiveLayerId, getActiveLineColor, getActiveLineWidth, setActiveLayerId, setActiveLineColor, setActiveLineWidth } from '../../state';
6+
import { EntityType, type LineEntity, type CircleEntity } from '../../App.types';
7+
import { importEntitiesFromDxfFile } from './import-entities-from-dxf';
8+
9+
// Mock react-toastify
10+
jest.mock('react-toastify', () => ({
11+
toast: {
12+
success: jest.fn(),
13+
warn: jest.fn(),
14+
error: jest.fn(),
15+
info: jest.fn(),
16+
},
17+
}));
18+
19+
// Mock uuid to return predictable IDs
20+
let mockUuidCounter = 0;
21+
jest.mock('uuid', () => ({
22+
v4: jest.fn(() => `mock-uuid-${mockUuidCounter++}`),
23+
}));
24+
25+
26+
describe('importEntitiesFromDxfFile', () => {
27+
const mockFilesBasePath = path.join(__dirname, '../../../../test/mocks/dxf');
28+
let consoleLogSpy: jest.SpyInstance;
29+
let consoleWarnSpy: jest.SpyInstance;
30+
let consoleErrorSpy: jest.SpyInstance;
31+
32+
const defaultLayerId = 'layer-dxf-default';
33+
const defaultColor = '#FF00FF';
34+
const defaultWidth = 3;
35+
36+
beforeEach(() => {
37+
setEntities([]);
38+
mockUuidCounter = 0;
39+
(toast.success as jest.Mock).mockClear();
40+
(toast.warn as jest.Mock).mockClear();
41+
(toast.error as jest.Mock).mockClear();
42+
(toast.info as jest.Mock).mockClear();
43+
(uuidv4 as jest.Mock).mockClear(); // Clear mock usage counts
44+
45+
// Set default active states for predictability in tests
46+
setActiveLayerId(defaultLayerId);
47+
setActiveLineColor(defaultColor);
48+
setActiveLineWidth(defaultWidth);
49+
50+
// Spy on console methods
51+
consoleLogSpy = jest.spyOn(console, 'log').mockImplementation(() => {}); // Suppress log output during tests
52+
consoleWarnSpy = jest.spyOn(console, 'warn').mockImplementation(() => {});
53+
consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation(() => {});
54+
});
55+
56+
afterEach(() => {
57+
// Restore console spies
58+
consoleLogSpy.mockRestore();
59+
consoleWarnSpy.mockRestore();
60+
consoleErrorSpy.mockRestore();
61+
});
62+
63+
const loadMockDxfFile = (fileName: string): File => {
64+
const filePath = path.join(mockFilesBasePath, fileName);
65+
const fileContent = fs.readFileSync(filePath, 'utf-8');
66+
return new File([fileContent], fileName, { type: 'application/dxf' });
67+
};
68+
69+
it('should warn if no file is provided', async () => {
70+
await importEntitiesFromDxfFile(undefined);
71+
expect(getEntities()).toHaveLength(0);
72+
expect(toast.warn).toHaveBeenCalledWith('No DXF file selected.');
73+
expect(toast.success).not.toHaveBeenCalled();
74+
});
75+
76+
it('should import a single LINE entity from line.dxf', async () => {
77+
const file = loadMockDxfFile('line.dxf');
78+
await importEntitiesFromDxfFile(file);
79+
80+
const entities = getEntities();
81+
expect(entities).toHaveLength(1);
82+
expect(toast.success).toHaveBeenCalledWith('1 entities imported successfully from DXF!');
83+
84+
const line = entities[0] as LineEntity;
85+
expect(line.type).toBe(EntityType.LINE);
86+
expect(line.id).toBe('mock-uuid-0');
87+
expect(line.layerId).toBe(defaultLayerId);
88+
expect(line.start).toEqual({ x: 10.0, y: 10.0 });
89+
expect(line.end).toEqual({ x: 20.0, y: 20.0 });
90+
expect(line.color).toBe(defaultColor); // DXF color 256 (ByLayer) should use default
91+
expect(line.width).toBe(defaultWidth);
92+
});
93+
94+
it('should import a single CIRCLE entity from circle.dxf', async () => {
95+
const file = loadMockDxfFile('circle.dxf');
96+
await importEntitiesFromDxfFile(file);
97+
98+
const entities = getEntities();
99+
expect(entities).toHaveLength(1);
100+
expect(toast.success).toHaveBeenCalledWith('1 entities imported successfully from DXF!');
101+
102+
const circle = entities[0] as CircleEntity;
103+
expect(circle.type).toBe(EntityType.CIRCLE);
104+
expect(circle.id).toBe('mock-uuid-0');
105+
expect(circle.layerId).toBe(defaultLayerId);
106+
expect(circle.center).toEqual({ x: 30.0, y: 30.0 });
107+
expect(circle.radius).toBe(5.0);
108+
expect(circle.color).toBe(defaultColor);
109+
expect(circle.width).toBe(defaultWidth);
110+
});
111+
112+
it('should import one LINE and one CIRCLE entity from line-and-circle.dxf', async () => {
113+
const file = loadMockDxfFile('line-and-circle.dxf');
114+
await importEntitiesFromDxfFile(file);
115+
116+
const entities = getEntities();
117+
expect(entities).toHaveLength(2);
118+
expect(toast.success).toHaveBeenCalledWith('2 entities imported successfully from DXF!');
119+
120+
const line = entities.find(e => e.type === EntityType.LINE) as LineEntity;
121+
expect(line).toBeDefined();
122+
expect(line.id).toBe('mock-uuid-0'); // First entity parsed
123+
expect(line.layerId).toBe(defaultLayerId);
124+
expect(line.start).toEqual({ x: 10.0, y: 10.0 });
125+
expect(line.end).toEqual({ x: 20.0, y: 20.0 });
126+
127+
const circle = entities.find(e => e.type === EntityType.CIRCLE) as CircleEntity;
128+
expect(circle).toBeDefined();
129+
expect(circle.id).toBe('mock-uuid-1'); // Second entity parsed
130+
expect(circle.layerId).toBe(defaultLayerId);
131+
expect(circle.center).toEqual({ x: 30.0, y: 30.0 });
132+
expect(circle.radius).toBe(5.0);
133+
});
134+
135+
it('should import no entities from empty.dxf and show info toast', async () => {
136+
const file = loadMockDxfFile('empty.dxf');
137+
await importEntitiesFromDxfFile(file);
138+
139+
const entities = getEntities();
140+
expect(entities).toHaveLength(0);
141+
expect(toast.info).toHaveBeenCalledWith('No supported entities found in the DXF file.');
142+
expect(toast.success).not.toHaveBeenCalled();
143+
});
144+
145+
it('should log a message for unsupported entities in unsupported.dxf', async () => {
146+
const file = loadMockDxfFile('unsupported.dxf');
147+
await importEntitiesFromDxfFile(file);
148+
149+
const entities = getEntities();
150+
expect(entities).toHaveLength(0); // ARC is not supported
151+
expect(toast.info).toHaveBeenCalledWith('No supported entities found in the DXF file.');
152+
// Check console.log because that's what the implementation uses for unsupported types
153+
expect(consoleLogSpy).toHaveBeenCalledWith('Unsupported DXF entity type: ARC. Skipping.');
154+
});
155+
156+
it('should handle invalid DXF content and show error toast', async () => {
157+
const invalidFileContent = "this is not a dxf file";
158+
const file = new File([invalidFileContent], 'invalid.dxf', { type: 'application/dxf' });
159+
await importEntitiesFromDxfFile(file);
160+
161+
const entities = getEntities();
162+
expect(entities).toHaveLength(0);
163+
expect(toast.error).toHaveBeenCalledWith('An error occurred while parsing the DXF file. See console for details.');
164+
expect(consoleErrorSpy).toHaveBeenCalled(); // Check if console.error was called for the parsing error
165+
});
166+
167+
// TODO: Add a test case for DXF files with specific color numbers to check color mapping,
168+
// once the color mapping logic is more sophisticated than just defaultColor.
169+
// For example, a LINE with color index 1 (red) should be mapped to '#FF0000'.
170+
});

0 commit comments

Comments
 (0)