diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml
new file mode 100644
index 0000000..40c2d06
--- /dev/null
+++ b/.github/workflows/test.yml
@@ -0,0 +1,32 @@
+name: Tests
+
+on:
+ pull_request:
+ branches: [main]
+ push:
+ branches: [main]
+
+jobs:
+ test:
+ runs-on: ubuntu-latest
+
+ permissions:
+ contents: read
+
+ steps:
+ - name: Checkout code
+ uses: actions/checkout@v4
+
+ - name: Setup Deno
+ uses: denoland/setup-deno@v2
+ with:
+ deno-version: v2.1.x
+
+ - name: Run tests
+ run: deno task test
+
+ - name: Check formatting
+ run: deno fmt --check
+
+ - name: Run linter
+ run: deno lint
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..22aa5fc
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,22 @@
+# Deno
+.deno/
+deno.lock
+
+# Build outputs
+*.exe
+tile-maker
+
+# Logs
+*.log
+
+# OS files
+.DS_Store
+Thumbs.db
+
+# IDE
+.vscode/
+.idea/
+
+# Test artifacts
+coverage/
+*.lcov
diff --git a/README.md b/README.md
index 3471b24..3f1ab80 100644
--- a/README.md
+++ b/README.md
@@ -2,9 +2,15 @@
## Problem Statement
-When generating tiles for websites, designers and developers often encounter issues when rotating tiles. Common solutions involve less-than-ideal hacks, such as adding pseudoelements, scaling, and rotating them to achieve the desired effect. These workarounds can complicate code, reduce performance, and make maintenance harder.
+When generating tiles for websites, designers and developers often encounter
+issues when rotating tiles. Common solutions involve less-than-ideal hacks, such
+as adding pseudo-elements, scaling, and rotating them to achieve the desired
+effect. These workarounds can complicate code, reduce performance, and make
+maintenance harder.
-WTC Tile Maker aims to solve this by providing a robust, programmatic solution for generating and manipulating tiles, including rotation, without relying on CSS hacks.
+WTC Tile Maker aims to solve this by providing a robust, programmatic solution
+for generating and manipulating tiles, including rotation, without relying on
+CSS hacks.
---
@@ -24,10 +30,10 @@ deno run -A main.ts list
# Help
deno run -A main.ts help
-
```
-The output file will be saved in the same directory as the input with the angle appended to the filename (e.g., `input-tile-45.png`).
+The output file will be saved in the same directory as the input with the angle
+appended to the filename (e.g., `input-tile-45.png`).
---
@@ -50,6 +56,26 @@ The output file will be saved in the same directory as the input with the angle
deno run -A main.ts
```
+### Running Tests
+
+Run the test suite:
+
+```sh
+deno task test
+```
+
+Check code formatting:
+
+```sh
+deno fmt
+```
+
+Run the linter:
+
+```sh
+deno lint
+```
+
---
## CLI Usage
@@ -80,39 +106,40 @@ The output file will be saved in the same directory as the input with the angle
## Available Rational Angles
-These are the angles where `tan(θ) = m/n` for small integers m, n. These angles produce periodic tilings when used for rotation.
-
-| Index | Label | m | n |
-| ----- | ---------------------- | --- | --- |
-| 0 | 0° | 0 | 1 |
-| 1 | 90° | 1 | 0 |
-| 2 | -90° | -1 | 0 |
-| 3 | 45° | 1 | 1 |
-| 4 | -45° | -1 | 1 |
-| 5 | 26.565° (arctan 1/2) | 1 | 2 |
-| 6 | -26.565° (arctan -1/2) | -1 | 2 |
-| 7 | 63.435° (arctan 2) | 2 | 1 |
-| 8 | -63.435° (arctan -2) | -2 | 1 |
-| 9 | 18.435° (arctan 1/3) | 1 | 3 |
-| 10 | -18.435° (arctan -1/3) | -1 | 3 |
-| 11 | 71.565° (arctan 3) | 3 | 1 |
-| 12 | -71.565° (arctan -3) | -3 | 1 |
-| 13 | 14.036° (arctan 1/4) | 1 | 4 |
-| 14 | -14.036° (arctan -1/4) | -1 | 4 |
-| 15 | 75.964° (arctan 4) | 4 | 1 |
-| 16 | -75.964° (arctan -4) | -4 | 1 |
-| 17 | 33.690° (arctan 2/3) | 2 | 3 |
-| 18 | -33.690° (arctan -2/3) | -2 | 3 |
-| 19 | 56.310° (arctan 3/2) | 3 | 2 |
-| 20 | -56.310° (arctan -3/2) | -3 | 2 |
-| 21 | 36.870° (arctan 3/4) | 3 | 4 |
-| 22 | -36.870° (arctan -3/4) | -3 | 4 |
-| 23 | 53.130° (arctan 4/3) | 4 | 3 |
-| 24 | -53.130° (arctan -4/3) | -4 | 3 |
-| 25 | 11.310° (arctan 1/5) | 1 | 5 |
-| 26 | -11.310° (arctan -1/5) | -1 | 5 |
-| 27 | 78.690° (arctan 5) | 5 | 1 |
-| 28 | -78.690° (arctan -5) | -5 | 1 |
+These are the angles where `tan(θ) = m/n` for small integers m, n. These angles
+produce periodic tilings when used for rotation.
+
+| Index | Label | m | n |
+| ----- | ---------------------- | -- | - |
+| 0 | 0° | 0 | 1 |
+| 1 | 90° | 1 | 0 |
+| 2 | -90° | -1 | 0 |
+| 3 | 45° | 1 | 1 |
+| 4 | -45° | -1 | 1 |
+| 5 | 26.565° (arctan 1/2) | 1 | 2 |
+| 6 | -26.565° (arctan -1/2) | -1 | 2 |
+| 7 | 63.435° (arctan 2) | 2 | 1 |
+| 8 | -63.435° (arctan -2) | -2 | 1 |
+| 9 | 18.435° (arctan 1/3) | 1 | 3 |
+| 10 | -18.435° (arctan -1/3) | -1 | 3 |
+| 11 | 71.565° (arctan 3) | 3 | 1 |
+| 12 | -71.565° (arctan -3) | -3 | 1 |
+| 13 | 14.036° (arctan 1/4) | 1 | 4 |
+| 14 | -14.036° (arctan -1/4) | -1 | 4 |
+| 15 | 75.964° (arctan 4) | 4 | 1 |
+| 16 | -75.964° (arctan -4) | -4 | 1 |
+| 17 | 33.690° (arctan 2/3) | 2 | 3 |
+| 18 | -33.690° (arctan -2/3) | -2 | 3 |
+| 19 | 56.310° (arctan 3/2) | 3 | 2 |
+| 20 | -56.310° (arctan -3/2) | -3 | 2 |
+| 21 | 36.870° (arctan 3/4) | 3 | 4 |
+| 22 | -36.870° (arctan -3/4) | -3 | 4 |
+| 23 | 53.130° (arctan 4/3) | 4 | 3 |
+| 24 | -53.130° (arctan -4/3) | -4 | 3 |
+| 25 | 11.310° (arctan 1/5) | 1 | 5 |
+| 26 | -11.310° (arctan -1/5) | -1 | 5 |
+| 27 | 78.690° (arctan 5) | 5 | 1 |
+| 28 | -78.690° (arctan -5) | -5 | 1 |
You can also run `deno -A main.ts list` to see these angles.
@@ -120,7 +147,8 @@ You can also run `deno -A main.ts list` to see these angles.
## Recommended Input Aspect Ratios
-The output tile size depends on how well the input dimensions align with the m:n ratios of the angles. For best results, use these aspect ratios:
+The output tile size depends on how well the input dimensions align with the m:n
+ratios of the angles. For best results, use these aspect ratios:
### Best Aspect Ratios
@@ -136,7 +164,9 @@ The output tile size depends on how well the input dimensions align with the m:n
### Ratios to Avoid
-Aspect ratios with **prime numbers > 5** (like 7:3, 11:4, 13:8) or **irrational proportions** will produce larger outputs because the GCD calculations yield smaller divisors.
+Aspect ratios with **prime numbers > 5** (like 7:3, 11:4, 13:8) or **irrational
+proportions** will produce larger outputs because the GCD calculations yield
+smaller divisors.
---
@@ -156,7 +186,8 @@ deno -A main.ts list
### Generate a Tile
-Generate a rotated tile from `Checker.png` using angle index 27, and max size 6000:
+Generate a rotated tile from `Checker.png` using angle index 27, and max size
+6000:
| Step | Description | Example |
| ------- | ------------------------------------------------------------------------------------- | ------------------------------------------------- |
@@ -164,7 +195,8 @@ Generate a rotated tile from `Checker.png` using angle index 27, and max size 60
| Command | Command to generate a rotated tile using angle index 27, margin 3, and max size 6000. | `generate -lv -a 27 -s 6000 Checker.png` |
| Output | Resulting seamlessly tileable image after processing. |
|
-This will also work with images of different aspect ratios (like 3×2). For example. this enerates a rotated tile from `3x2-checker.png` using 45°:
+This will also work with images of different aspect ratios (like 3×2). For
+example, this generates a rotated tile from `3x2-checker.png` using 45°:
| Step | Description | Example |
| ------- | ------------------------------------------------ | ------------------------------------------------- |
@@ -172,11 +204,17 @@ This will also work with images of different aspect ratios (like 3×2). For exam
| Command | Command to generate a rotated tile at 45°. | `generate -d 45 3x2-checker.png` |
| Output | Seamlessly tileable image after rotating at 45°. |
|
-_N.B._ Notice, here, how there seems to be a piece missing! This is because the default tile margin is too small to make these rotated tiles cover the output dimensions. This command should be updated to `generate -m 2 -d 45 3x2-checker.png`.
+_N.B._ Notice, here, how there seems to be a piece missing! This is because the
+default tile margin is too small to make these rotated tiles cover the output
+dimensions. This command should be updated to
+`generate -m 2 -d 45 3x2-checker.png`.
#### A warning about sizes and appropriate aspect ratios
-Sometimes, a combination of input size and rotation will produce an output that is either too large or creating an appropriate output tile is just beyong the capabilities of this math. In this case you should likely fall back to hacky methods or get a tile produces in a more predictable aspect ratio.
+Sometimes, a combination of input size and rotation will produce an output that
+is either too large or creating an appropriate output tile is just beyond the
+capabilities of this math. In this case you should likely fall back to hacky
+methods or get a tile produced in a more predictable aspect ratio.
### Generate with a Specific Degree
@@ -192,8 +230,11 @@ The tool will find the closest rational angle and generate the tile accordingly.
## Common Issues
-- **allowLargeBuffers**: If you receive an error about `allowLargeBuffers`, use the `-l` flag. Warning: this may affect performance, but enables processing of narrow rotation / large image combinations.
-- **Missing pieces**: If the output appears to be missing pieces around the edges, try increasing the `-m` (tileMargin) value, e.g., `-m 3`.
+- **allowLargeBuffers**: If you receive an error about `allowLargeBuffers`, use
+ the `-l` flag. Warning: this may affect performance, but enables processing of
+ narrow rotation / large image combinations.
+- **Missing pieces**: If the output appears to be missing pieces around the
+ edges, try increasing the `-m` (tileMargin) value, e.g., `-m 3`.
---
@@ -201,11 +242,15 @@ The tool will find the closest rational angle and generate the tile accordingly.
### The Problem with Rotating Tiles
-When you rotate a regular tiling pattern (like a checkerboard) by an arbitrary angle, the result doesn't tile seamlessly in the x and y direction anymore.
+When you rotate a regular tiling pattern (like a checkerboard) by an arbitrary
+angle, the result doesn't tile seamlessly in the x and y direction anymore.
### Rational Angles
-The key insight is that **rational angles**—angles where `tan(θ) = m/n` for small integers m and n—produce periodic tilings when used for rotation. At these specific angles, the rotated grid aligns back to integer positions, allowing seamless repetition.
+The key insight is that **rational angles**—angles where `tan(θ) = m/n` for
+small integers m and n—produce periodic tilings when used for rotation. At these
+specific angles, the rotated grid aligns back to integer positions, allowing
+seamless repetition.
For example:
@@ -215,21 +260,28 @@ For example:
### Tile Dimension Calculation
-For a rational angle with `tan(θ) = m/n`, the rotated pattern repeats at intervals of `√(m² + n²)` times the original period.
+For a rational angle with `tan(θ) = m/n`, the rotated pattern repeats at
+intervals of `√(m² + n²)` times the original period.
To create a seamlessly tileable output:
-- The output dimensions are calculated as: `input dimensions × √(m² + n²) / gcd(m, n)`
+- The output dimensions are calculated as:
+ `input dimensions × √(m² + n²) / gcd(m, n)`
- This ensures the rotated grid aligns back to integer positions
### The Process
1. **Input**: A tileable source image (e.g., a checkerboard pattern)
-2. **Tile**: The source image is tiled into a larger canvas to provide enough material for the rotation
+2. **Tile**: The source image is tiled into a larger canvas to provide enough
+ material for the rotation
3. **Rotate**: The tiled canvas is rotated by the selected rational angle
-4. **Crop**: A precisely calculated region is extracted that will tile seamlessly
-5. **Output**: The resulting image can be used as a CSS background and will tile perfectly at the rotated angle
-
-This approach eliminates the need for CSS hacks like pseudoelements with `transform: rotate()` and `scale()`, resulting in cleaner code, better performance, and more predictable rendering across browsers.
+4. **Crop**: A precisely calculated region is extracted that will tile
+ seamlessly
+5. **Output**: The resulting image can be used as a CSS background and will tile
+ perfectly at the rotated angle
+
+This approach eliminates the need for CSS hacks like pseudo-elements with
+`transform: rotate()` and `scale()`, resulting in cleaner code, better
+performance, and more predictable rendering across browsers.
---
diff --git a/deno.json b/deno.json
index b61140f..568896e 100644
--- a/deno.json
+++ b/deno.json
@@ -1,12 +1,14 @@
{
"imports": {
+ "@std/assert": "jsr:@std/assert@^1.0.19",
"@std/cli": "jsr:@std/cli@^1.0.27",
"@std/fs": "jsr:@std/fs@^1.0.22",
"@std/path": "jsr:@std/path@^1.1.4",
"sharp": "npm:sharp@^0.34.5"
},
"tasks": {
- "compile": "deno compile --allow-all main.ts"
+ "compile": "deno compile --allow-all main.ts",
+ "test": "deno test --allow-read --allow-env --allow-ffi"
},
"version": "0.0.1"
}
diff --git a/deno.lock b/deno.lock
deleted file mode 100644
index 52e04a2..0000000
--- a/deno.lock
+++ /dev/null
@@ -1,326 +0,0 @@
-{
- "version": "5",
- "specifiers": {
- "jsr:@std/cli@*": "1.0.27",
- "jsr:@std/cli@^1.0.27": "1.0.27",
- "jsr:@std/fmt@^1.0.9": "1.0.9",
- "jsr:@std/fs@*": "1.0.22",
- "jsr:@std/fs@^1.0.22": "1.0.22",
- "jsr:@std/internal@^1.0.12": "1.0.12",
- "jsr:@std/path@^1.1.4": "1.1.4",
- "npm:@imagemagick/magick-wasm@0.0.31": "0.0.31",
- "npm:@types/node@*": "24.2.0",
- "npm:sharp@~0.34.5": "0.34.5"
- },
- "jsr": {
- "@std/cli@1.0.27": {
- "integrity": "eba97edd0891871a7410e835dd94b3c260c709cca5983df2689c25a71fbe04de",
- "dependencies": [
- "jsr:@std/fmt",
- "jsr:@std/internal"
- ]
- },
- "@std/fmt@1.0.9": {
- "integrity": "2487343e8899fb2be5d0e3d35013e54477ada198854e52dd05ed0422eddcabe0"
- },
- "@std/fs@1.0.22": {
- "integrity": "de0f277a58a867147a8a01bc1b181d0dfa80bfddba8c9cf2bacd6747bcec9308",
- "dependencies": [
- "jsr:@std/internal",
- "jsr:@std/path"
- ]
- },
- "@std/internal@1.0.12": {
- "integrity": "972a634fd5bc34b242024402972cd5143eac68d8dffaca5eaa4dba30ce17b027"
- },
- "@std/path@1.1.4": {
- "integrity": "1d2d43f39efb1b42f0b1882a25486647cb851481862dc7313390b2bb044314b5",
- "dependencies": [
- "jsr:@std/internal"
- ]
- }
- },
- "npm": {
- "@emnapi/runtime@1.8.1": {
- "integrity": "sha512-mehfKSMWjjNol8659Z8KxEMrdSJDDot5SXMq00dM8BN4o+CLNXQ0xH2V7EchNHV4RmbZLmmPdEaXZc5H2FXmDg==",
- "dependencies": [
- "tslib"
- ]
- },
- "@imagemagick/magick-wasm@0.0.31": {
- "integrity": "sha512-QNivAUxSaItuiY8ziI/vRy6TtoecD7TOsD1LGZCG3wv8lfbdGbIj2QiJk0FlGkGwAVR966NlD3mkxPNvQrvq0w=="
- },
- "@img/colour@1.0.0": {
- "integrity": "sha512-A5P/LfWGFSl6nsckYtjw9da+19jB8hkJ6ACTGcDfEJ0aE+l2n2El7dsVM7UVHZQ9s2lmYMWlrS21YLy2IR1LUw=="
- },
- "@img/sharp-darwin-arm64@0.34.5": {
- "integrity": "sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w==",
- "optionalDependencies": [
- "@img/sharp-libvips-darwin-arm64"
- ],
- "os": ["darwin"],
- "cpu": ["arm64"]
- },
- "@img/sharp-darwin-x64@0.34.5": {
- "integrity": "sha512-YNEFAF/4KQ/PeW0N+r+aVVsoIY0/qxxikF2SWdp+NRkmMB7y9LBZAVqQ4yhGCm/H3H270OSykqmQMKLBhBJDEw==",
- "optionalDependencies": [
- "@img/sharp-libvips-darwin-x64"
- ],
- "os": ["darwin"],
- "cpu": ["x64"]
- },
- "@img/sharp-libvips-darwin-arm64@1.2.4": {
- "integrity": "sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g==",
- "os": ["darwin"],
- "cpu": ["arm64"]
- },
- "@img/sharp-libvips-darwin-x64@1.2.4": {
- "integrity": "sha512-1IOd5xfVhlGwX+zXv2N93k0yMONvUlANylbJw1eTah8K/Jtpi15KC+WSiaX/nBmbm2HxRM1gZ0nSdjSsrZbGKg==",
- "os": ["darwin"],
- "cpu": ["x64"]
- },
- "@img/sharp-libvips-linux-arm64@1.2.4": {
- "integrity": "sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==",
- "os": ["linux"],
- "cpu": ["arm64"]
- },
- "@img/sharp-libvips-linux-arm@1.2.4": {
- "integrity": "sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==",
- "os": ["linux"],
- "cpu": ["arm"]
- },
- "@img/sharp-libvips-linux-ppc64@1.2.4": {
- "integrity": "sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA==",
- "os": ["linux"],
- "cpu": ["ppc64"]
- },
- "@img/sharp-libvips-linux-riscv64@1.2.4": {
- "integrity": "sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA==",
- "os": ["linux"],
- "cpu": ["riscv64"]
- },
- "@img/sharp-libvips-linux-s390x@1.2.4": {
- "integrity": "sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ==",
- "os": ["linux"],
- "cpu": ["s390x"]
- },
- "@img/sharp-libvips-linux-x64@1.2.4": {
- "integrity": "sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==",
- "os": ["linux"],
- "cpu": ["x64"]
- },
- "@img/sharp-libvips-linuxmusl-arm64@1.2.4": {
- "integrity": "sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==",
- "os": ["linux"],
- "cpu": ["arm64"]
- },
- "@img/sharp-libvips-linuxmusl-x64@1.2.4": {
- "integrity": "sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==",
- "os": ["linux"],
- "cpu": ["x64"]
- },
- "@img/sharp-linux-arm64@0.34.5": {
- "integrity": "sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg==",
- "optionalDependencies": [
- "@img/sharp-libvips-linux-arm64"
- ],
- "os": ["linux"],
- "cpu": ["arm64"]
- },
- "@img/sharp-linux-arm@0.34.5": {
- "integrity": "sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw==",
- "optionalDependencies": [
- "@img/sharp-libvips-linux-arm"
- ],
- "os": ["linux"],
- "cpu": ["arm"]
- },
- "@img/sharp-linux-ppc64@0.34.5": {
- "integrity": "sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA==",
- "optionalDependencies": [
- "@img/sharp-libvips-linux-ppc64"
- ],
- "os": ["linux"],
- "cpu": ["ppc64"]
- },
- "@img/sharp-linux-riscv64@0.34.5": {
- "integrity": "sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw==",
- "optionalDependencies": [
- "@img/sharp-libvips-linux-riscv64"
- ],
- "os": ["linux"],
- "cpu": ["riscv64"]
- },
- "@img/sharp-linux-s390x@0.34.5": {
- "integrity": "sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg==",
- "optionalDependencies": [
- "@img/sharp-libvips-linux-s390x"
- ],
- "os": ["linux"],
- "cpu": ["s390x"]
- },
- "@img/sharp-linux-x64@0.34.5": {
- "integrity": "sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==",
- "optionalDependencies": [
- "@img/sharp-libvips-linux-x64"
- ],
- "os": ["linux"],
- "cpu": ["x64"]
- },
- "@img/sharp-linuxmusl-arm64@0.34.5": {
- "integrity": "sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==",
- "optionalDependencies": [
- "@img/sharp-libvips-linuxmusl-arm64"
- ],
- "os": ["linux"],
- "cpu": ["arm64"]
- },
- "@img/sharp-linuxmusl-x64@0.34.5": {
- "integrity": "sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==",
- "optionalDependencies": [
- "@img/sharp-libvips-linuxmusl-x64"
- ],
- "os": ["linux"],
- "cpu": ["x64"]
- },
- "@img/sharp-wasm32@0.34.5": {
- "integrity": "sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw==",
- "dependencies": [
- "@emnapi/runtime"
- ],
- "cpu": ["wasm32"]
- },
- "@img/sharp-win32-arm64@0.34.5": {
- "integrity": "sha512-WQ3AgWCWYSb2yt+IG8mnC6Jdk9Whs7O0gxphblsLvdhSpSTtmu69ZG1Gkb6NuvxsNACwiPV6cNSZNzt0KPsw7g==",
- "os": ["win32"],
- "cpu": ["arm64"]
- },
- "@img/sharp-win32-ia32@0.34.5": {
- "integrity": "sha512-FV9m/7NmeCmSHDD5j4+4pNI8Cp3aW+JvLoXcTUo0IqyjSfAZJ8dIUmijx1qaJsIiU+Hosw6xM5KijAWRJCSgNg==",
- "os": ["win32"],
- "cpu": ["ia32"]
- },
- "@img/sharp-win32-x64@0.34.5": {
- "integrity": "sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw==",
- "os": ["win32"],
- "cpu": ["x64"]
- },
- "@types/node@24.2.0": {
- "integrity": "sha512-3xyG3pMCq3oYCNg7/ZP+E1ooTaGB4cG8JWRsqqOYQdbWNY4zbaV0Ennrd7stjiJEFZCaybcIgpTjJWHRfBSIDw==",
- "dependencies": [
- "undici-types"
- ]
- },
- "detect-libc@2.1.2": {
- "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="
- },
- "semver@7.7.4": {
- "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==",
- "bin": true
- },
- "sharp@0.34.5": {
- "integrity": "sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg==",
- "dependencies": [
- "@img/colour",
- "detect-libc",
- "semver"
- ],
- "optionalDependencies": [
- "@img/sharp-darwin-arm64",
- "@img/sharp-darwin-x64",
- "@img/sharp-libvips-darwin-arm64",
- "@img/sharp-libvips-darwin-x64",
- "@img/sharp-libvips-linux-arm",
- "@img/sharp-libvips-linux-arm64",
- "@img/sharp-libvips-linux-ppc64",
- "@img/sharp-libvips-linux-riscv64",
- "@img/sharp-libvips-linux-s390x",
- "@img/sharp-libvips-linux-x64",
- "@img/sharp-libvips-linuxmusl-arm64",
- "@img/sharp-libvips-linuxmusl-x64",
- "@img/sharp-linux-arm",
- "@img/sharp-linux-arm64",
- "@img/sharp-linux-ppc64",
- "@img/sharp-linux-riscv64",
- "@img/sharp-linux-s390x",
- "@img/sharp-linux-x64",
- "@img/sharp-linuxmusl-arm64",
- "@img/sharp-linuxmusl-x64",
- "@img/sharp-wasm32",
- "@img/sharp-win32-arm64",
- "@img/sharp-win32-ia32",
- "@img/sharp-win32-x64"
- ],
- "scripts": true
- },
- "tslib@2.8.1": {
- "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="
- },
- "undici-types@7.10.0": {
- "integrity": "sha512-t5Fy/nfn+14LuOc2KNYg75vZqClpAiqscVvMygNnlsHBFpSXdJaYtXMcdNLpl/Qvc3P2cB3s6lOV51nqsFq4ag=="
- }
- },
- "remote": {
- "https://deno.land/x/imagemagick_deno@0.0.14/mod.ts": "6b58c767d2308488597c3660e7ff399ede244198d7903900fa43a49cf93c7796",
- "https://deno.land/x/imagemagick_deno@0.0.14/src/alpha-option.ts": "749a9f3309e491ec09a1d6bc50ce95d9733887d9f57c6863c4ff1c7e9610227b",
- "https://deno.land/x/imagemagick_deno@0.0.14/src/auto-threshold-method.ts": "bb08a00046137e441930e56190b6db10c5fe657cb0a6142cd565a40b1c4250a2",
- "https://deno.land/x/imagemagick_deno@0.0.14/src/channels.ts": "a15c5f2d278ee7961b4b425c97cfc1fc62c1955c87706c74743fa6215fa482c9",
- "https://deno.land/x/imagemagick_deno@0.0.14/src/color-space.ts": "3d9a60f3a8bfefea8d9525572d7bd6214530c69688e8799dceb492b7797d1d0a",
- "https://deno.land/x/imagemagick_deno@0.0.14/src/composite-operator.ts": "f4b5046415c5965d53b17a9e441a42d87e8477b7c158704abd417d6ac10f3ea0",
- "https://deno.land/x/imagemagick_deno@0.0.14/src/defines/define.ts": "645fb3a06424ed750250212ac8762ba2ea97c4e4fdbda8aedf21734cbaf4833c",
- "https://deno.land/x/imagemagick_deno@0.0.14/src/defines/defines.ts": "fc8e12475e11a30f9f6f9c2b5e2fba94b01d65135654b97694da915d40fae2fe",
- "https://deno.land/x/imagemagick_deno@0.0.14/src/distort-method.ts": "13819e00ccb6a636af9ece5d11dfce9451e578d46c94e1f528b0ae5da7721985",
- "https://deno.land/x/imagemagick_deno@0.0.14/src/drawables/drawable.ts": "61b40233ea3c28664c2f8dfd8d794772d8a7a779f4228060efd41b0d44762521",
- "https://deno.land/x/imagemagick_deno@0.0.14/src/drawables/drawing-wand.ts": "3c495d8cf37eac2c3cc0e840a13aed24457d37414528077b06d3f8aa76cd4cde",
- "https://deno.land/x/imagemagick_deno@0.0.14/src/error-metric.ts": "fafe44d95312b0e9dd6e5d6d3efd536764468a4b80e3dc3d7d7efc33a40fb871",
- "https://deno.land/x/imagemagick_deno@0.0.14/src/evaluate-operator.ts": "c05d51cb193d95ce0432dee914465cbafc35026ea1102cc48f431571bfb67260",
- "https://deno.land/x/imagemagick_deno@0.0.14/src/filter-type.ts": "face0109ae9e56125b778a8842384031d6e0bd688dfcf96c0861f2fd8bb27225",
- "https://deno.land/x/imagemagick_deno@0.0.14/src/gravity.ts": "ed99d33e3775c510c0a29fb330ca5ac9445e41dd3644507186c25cc32eb8634a",
- "https://deno.land/x/imagemagick_deno@0.0.14/src/image-magick.ts": "1ae1c396bb9539b7918ac40c3710d13475f8e9d3d0a5a9216c087c1b95fab4ba",
- "https://deno.land/x/imagemagick_deno@0.0.14/src/internal/exception/exception.ts": "2c1e1d5f6df4fcaef50403ed18f5ebdf560a5e764944d569db406e97f76f2aae",
- "https://deno.land/x/imagemagick_deno@0.0.14/src/internal/geometry-flags.ts": "56bbc3f668db2e67f607cd1c08e07f51ded80a8c402efb0b6cd4ad98d0f69d19",
- "https://deno.land/x/imagemagick_deno@0.0.14/src/internal/magick-rectangle.ts": "ffffcd9ebffe20f871396af22c9f5acb332b5d503a5b21200a94e1e61e4e68b3",
- "https://deno.land/x/imagemagick_deno@0.0.14/src/internal/native-instance.ts": "25b42d5db19439ba7016821bf363d85cabe6fa0784e9ec0e84a975f9ca4a850c",
- "https://deno.land/x/imagemagick_deno@0.0.14/src/internal/native/array.ts": "bcfa4f33246feaf3e1cfd219f188819caf2ed84562f986f508b1ba8beecd28fb",
- "https://deno.land/x/imagemagick_deno@0.0.14/src/internal/native/string.ts": "a3985bf82a8c0e0507001ab1af72c817f6a9f3fffcba532c5504b75102107ce3",
- "https://deno.land/x/imagemagick_deno@0.0.14/src/internal/pointer/pointer.ts": "d866febf67a2d72678e6bd0fd70f751622348c3c2c4ad0aba42dbd750c4f8526",
- "https://deno.land/x/imagemagick_deno@0.0.14/src/internal/string-info.ts": "6121081f0382fdfe259bb6c95655b1626cc68af778ad91af437daa8c55965575",
- "https://deno.land/x/imagemagick_deno@0.0.14/src/magick-color.ts": "6e849e94f3183d86f44d55f4646af394d0d3573fbce8b26b6d6bfbda03dcaf5c",
- "https://deno.land/x/imagemagick_deno@0.0.14/src/magick-colors.ts": "c3a4cdbbca0ebce9386ae71f835118847d8770573efcb63a35c54242aa156f90",
- "https://deno.land/x/imagemagick_deno@0.0.14/src/magick-error-severity.ts": "160e5f07bad67542c9c95a8ec61e70f294333bf7f3c463419dc4fadfacdbdbf6",
- "https://deno.land/x/imagemagick_deno@0.0.14/src/magick-error.ts": "5a515e203373ef48903bda51635e04f232bf3144eaee48c66d65df1e705346d4",
- "https://deno.land/x/imagemagick_deno@0.0.14/src/magick-format-info.ts": "3c20c60a0eab8883cf7268c6993855718de5c3b53cab36097af611ac1a5219f9",
- "https://deno.land/x/imagemagick_deno@0.0.14/src/magick-format.ts": "b5fa87a4dcc9ccdc1465fbee8cc3a6999767c94d67ad0d86b7998af26bf8c309",
- "https://deno.land/x/imagemagick_deno@0.0.14/src/magick-geometry.ts": "c41ec925e2cba2f4a07ab278de87d533aac282f68b038d6ca7075fc09570f759",
- "https://deno.land/x/imagemagick_deno@0.0.14/src/magick-image-collection.ts": "7a1249264e27e9ae7d0e416d6dffe057750f212819c7973f5666f3a80e274e4a",
- "https://deno.land/x/imagemagick_deno@0.0.14/src/magick-image.ts": "e712e9f6d6f87a426e8a0cf6b467d100d10ee5ca70d81b145c370a1007ca8b27",
- "https://deno.land/x/imagemagick_deno@0.0.14/src/magick.ts": "990bbb125a908afd71bba8b9601704f64abfe68b861e906ec7495f87b2f4c776",
- "https://deno.land/x/imagemagick_deno@0.0.14/src/orientation-type.ts": "a5c48feec25d432e5c3ad3ed76c929a7960836d3ab1012525c0f7883e4f46c30",
- "https://deno.land/x/imagemagick_deno@0.0.14/src/paint-method.ts": "0178827b90549bf587e8ae9e2757cb96607b1fffa5c05d0534a8de136a346d29",
- "https://deno.land/x/imagemagick_deno@0.0.14/src/percentage.ts": "00240337512949c97e407b006cdd025af5fc6db600adce9ca6193ab61e326291",
- "https://deno.land/x/imagemagick_deno@0.0.14/src/pixel-channel.ts": "8039ee75caf150f4f817c49a12f025dd7ca01e263dfd3bd882a55ad0eb17086c",
- "https://deno.land/x/imagemagick_deno@0.0.14/src/pixel-interpolate-method.ts": "d2c62675acb5d8fffca3e2c91c9a35bfebec62f2424268e5e240f9f17f57d356",
- "https://deno.land/x/imagemagick_deno@0.0.14/src/pixels/pixel-collection.ts": "e21b9e3ecd31cd94f7a57939f565f9df3d3009e68fe8a03d8760780096d2e457",
- "https://deno.land/x/imagemagick_deno@0.0.14/src/point.ts": "f664938d0f39eadd41fe5eb8ca81c52b59a7f7138539afea3ddc863d25a4a935",
- "https://deno.land/x/imagemagick_deno@0.0.14/src/profiles/image-profile.ts": "ea1bb6406430a03cf9263a40260fcd8f99bcc14fa3629206fcbcd2679c94b4a2",
- "https://deno.land/x/imagemagick_deno@0.0.14/src/quantum.ts": "7e92f9cf73fc6ec89df48ab4c462339fad580f0b6a1e1009d3c5a5cb599dc3ed",
- "https://deno.land/x/imagemagick_deno@0.0.14/src/settings/distort-settings.ts": "cdb352260b90a140191c222bafde0740114062822400bdf89709bef1c2f40563",
- "https://deno.land/x/imagemagick_deno@0.0.14/src/settings/drawing-settings.ts": "40eb95416367982afd13de2138dd06527a937e1459d6e374f8d5f8e7fa0deb7e",
- "https://deno.land/x/imagemagick_deno@0.0.14/src/settings/magick-read-settings.ts": "95417d00701245c7c5bd202cd0d4f02546ae01a77ecc9d0523c97a84ddf862d5",
- "https://deno.land/x/imagemagick_deno@0.0.14/src/settings/magick-settings.ts": "8fb86c3bd354023d8026624bb4bca78501ae92adea6287f97e687b8b762095d1",
- "https://deno.land/x/imagemagick_deno@0.0.14/src/settings/native-drawing-settings.ts": "b6a04740bd9261a478ff44b631cb039c9f909ed2243cae4e6ae6b3ebcce6dc21",
- "https://deno.land/x/imagemagick_deno@0.0.14/src/settings/native-magick-settings.ts": "859787363161a2c6a693ab5b475859f9e9b02dfb7128215b0c03a68839892d1c",
- "https://deno.land/x/imagemagick_deno@0.0.14/src/virtual-pixel-method.ts": "ae2f0520e05b382299e4d41f4d7e2c67baf727ef7c816037e601c978948b1451",
- "https://deno.land/x/imagemagick_deno@0.0.14/src/wasm/magick.ts": "b5ec7d6c3c7379f8f9ba0c23238f7024aa35f3a15edb2d1cbca4ccc44a186ac9",
- "https://deno.land/x/imagemagick_deno@0.0.14/src/wasm/magick_native.js": "e7f2cfe41531d94286bf839783c08cc0b4b9c89e6ad7bbd0e2a4dcf70086ca75",
- "https://deno.land/x/imagemagick_deno@0.0.31/mod.ts": "124d7f045429f6e6c486b86e72d025410d09576bc0d8075e69f97118a1a33413"
- },
- "workspace": {
- "dependencies": [
- "jsr:@std/cli@^1.0.27",
- "jsr:@std/fs@^1.0.22",
- "jsr:@std/path@^1.1.4",
- "npm:sharp@~0.34.5"
- ]
- }
-}
diff --git a/main.ts b/main.ts
index ee01295..b10f918 100644
--- a/main.ts
+++ b/main.ts
@@ -3,14 +3,13 @@ import { relative } from "@std/path";
import { existsSync } from "@std/fs/exists";
import {
+ calculateTileDimensions,
findClosestRationalAngle,
+ generateTile,
getImageProperties,
- calculateTileDimensions,
getOutputPath,
- generateTile,
RATIONAL_ANGLES,
validateDimensions,
- GenerateTileOptions,
} from "./src/lib.ts";
// Define a proper interface for parsed arguments
@@ -241,19 +240,19 @@ type Command = typeof VALID_COMMANDS[number];
// Type guard for valid commands
function isValidCommand(cmd: unknown): cmd is Command {
- return typeof cmd === "string" &&
+ return typeof cmd === "string" &&
(VALID_COMMANDS as readonly string[]).includes(cmd);
}
async function main() {
const commandInput = args._[0] ?? "help";
-
+
if (!isValidCommand(commandInput)) {
- throw new Error(`Unknown command${args._[0] ? `: ${args._[0]}` : '.'}`);
+ throw new Error(`Unknown command${args._[0] ? `: ${args._[0]}` : "."}`);
}
-
+
const command = commandInput;
-
+
switch (command) {
case "help":
help();
@@ -266,7 +265,7 @@ async function main() {
if (typeof input !== "string") {
throw new Error("Input file must be a string");
}
-
+
await generate({
angleOption: args.angleOption,
degrees: args.degrees,
@@ -290,9 +289,9 @@ try {
await main();
} catch (error) {
if (error instanceof Error) {
- if (error.message == "Input image exceeds pixel limit")
+ if (error.message == "Input image exceeds pixel limit") {
console.log(`Error: ${error.message}. Try setting the -l flag.`);
- else console.error(`Error: ${error.message}`);
+ } else console.error(`Error: ${error.message}`);
} else {
console.error(error);
}
diff --git a/src/lib.ts b/src/lib.ts
index c91ba6e..8a7849b 100644
--- a/src/lib.ts
+++ b/src/lib.ts
@@ -1,4 +1,4 @@
-import { basename, dirname, extname } from "@std/path";
+import { basename, dirname, extname, join, SEPARATOR } from "@std/path";
import sharp from "sharp";
export interface AngleEntry {
@@ -48,15 +48,16 @@ export const RATIONAL_ANGLES: AngleEntry[] = [
* Find the closest rational angle to the given angle
*/
export function findClosestRationalAngle(angle: number): AngleEntry {
- // Normalize angle to 0-360 range for matching
+ // Normalize angle to -180 to 180 range for matching
let normalizedAngle = angle % 360;
- if (normalizedAngle < 0) normalizedAngle += 360;
+ if (normalizedAngle > 180) normalizedAngle -= 360;
+ if (normalizedAngle < -180) normalizedAngle += 360;
return RATIONAL_ANGLES.reduce((closest, ra) =>
Math.abs(normalizedAngle - ra.degrees) <
- Math.abs(normalizedAngle - closest.degrees)
+ Math.abs(normalizedAngle - closest.degrees)
? ra
- : closest,
+ : closest
);
}
@@ -157,6 +158,7 @@ export function validateDimensions({
if (!isFinite(width) || !isFinite(height)) {
errors.push("Tile dimensions resulted in infinity");
+ return errors; // Return early to avoid redundant size checks
}
if (width > validWidth) {
@@ -226,8 +228,12 @@ export function getOutputPath({
const inputExt = extname(input as string);
const inputBase = basename(input as string, inputExt);
const inputDir = dirname(input as string);
- const outputFileName = `${inputBase}-tile-${rationalAngle.degrees}${inputExt}`;
- return output ? (output as string) : `${inputDir}/${outputFileName}`;
+ const outputFileName =
+ `${inputBase}-tile-${rationalAngle.degrees}${inputExt}`;
+ if (output) return output as string;
+ const outputPath = join(inputDir, outputFileName);
+ // Preserve "./" or ".\" prefix for files in current directory for cross-platform compatibility
+ return inputDir === "." ? `.${SEPARATOR}${outputFileName}` : outputPath;
}
export interface GenerateTileOptions {
@@ -268,12 +274,13 @@ export async function generateTile({
height: metadata.height * tileRepeat.y,
};
- if (verbose)
+ if (verbose) {
console.log(
"Tiled dimensions and repeat counts:",
tiledDimensions,
tileRepeat,
);
+ }
console.log("Starting tile generation, this may take a moment.");
diff --git a/src/lib_test.ts b/src/lib_test.ts
new file mode 100644
index 0000000..8ad3324
--- /dev/null
+++ b/src/lib_test.ts
@@ -0,0 +1,317 @@
+import { assertEquals } from "@std/assert";
+import { join, SEPARATOR } from "@std/path";
+import {
+ calculateTileDimensions,
+ findClosestRationalAngle,
+ gcd,
+ getOutputPath,
+ lcm,
+ RATIONAL_ANGLES,
+ validateDimensions,
+} from "./lib.ts";
+
+// Test findClosestRationalAngle
+Deno.test("findClosestRationalAngle - finds exact match for 45 degrees", () => {
+ const result = findClosestRationalAngle(45);
+ assertEquals(result.degrees, 45);
+ assertEquals(result.m, 1);
+ assertEquals(result.n, 1);
+ assertEquals(result.label, "45°");
+});
+
+Deno.test("findClosestRationalAngle - finds closest angle for 44 degrees", () => {
+ const result = findClosestRationalAngle(44);
+ assertEquals(result.degrees, 45);
+ assertEquals(result.m, 1);
+ assertEquals(result.n, 1);
+});
+
+Deno.test("findClosestRationalAngle - finds closest angle for 46 degrees", () => {
+ const result = findClosestRationalAngle(46);
+ assertEquals(result.degrees, 45);
+ assertEquals(result.m, 1);
+ assertEquals(result.n, 1);
+});
+
+Deno.test("findClosestRationalAngle - finds exact match for 0 degrees", () => {
+ const result = findClosestRationalAngle(0);
+ assertEquals(result.degrees, 0);
+ assertEquals(result.m, 0);
+ assertEquals(result.n, 1);
+});
+
+Deno.test("findClosestRationalAngle - finds exact match for 90 degrees", () => {
+ const result = findClosestRationalAngle(90);
+ assertEquals(result.degrees, 90);
+ assertEquals(result.m, 1);
+ assertEquals(result.n, 0);
+});
+
+Deno.test("findClosestRationalAngle - normalizes negative angles", () => {
+ const result = findClosestRationalAngle(-45);
+ assertEquals(result.degrees, -45);
+ assertEquals(result.m, -1);
+ assertEquals(result.n, 1);
+});
+
+Deno.test("findClosestRationalAngle - normalizes angles > 360", () => {
+ const result = findClosestRationalAngle(405); // 405 - 360 = 45
+ assertEquals(result.degrees, 45);
+ assertEquals(result.m, 1);
+ assertEquals(result.n, 1);
+});
+
+// Test gcd function
+Deno.test("gcd - calculates GCD of 12 and 8", () => {
+ const result = gcd(12, 8);
+ assertEquals(result, 4);
+});
+
+Deno.test("gcd - calculates GCD of 100 and 50", () => {
+ const result = gcd(100, 50);
+ assertEquals(result, 50);
+});
+
+Deno.test("gcd - calculates GCD of coprime numbers", () => {
+ const result = gcd(17, 19);
+ assertEquals(result, 1);
+});
+
+Deno.test("gcd - handles zero", () => {
+ const result = gcd(0, 5);
+ assertEquals(result, 5);
+});
+
+Deno.test("gcd - handles both zeros", () => {
+ const result = gcd(0, 0);
+ assertEquals(result, 0);
+});
+
+Deno.test("gcd - handles negative numbers", () => {
+ const result = gcd(-12, 8);
+ assertEquals(result, 4);
+});
+
+Deno.test("gcd - handles both negative numbers", () => {
+ const result = gcd(-12, -8);
+ assertEquals(result, 4);
+});
+
+// Test lcm function
+Deno.test("lcm - calculates LCM of 12 and 8", () => {
+ const result = lcm(12, 8);
+ assertEquals(result, 24);
+});
+
+Deno.test("lcm - calculates LCM of 4 and 6", () => {
+ const result = lcm(4, 6);
+ assertEquals(result, 12);
+});
+
+Deno.test("lcm - handles zero", () => {
+ const result = lcm(0, 5);
+ assertEquals(result, 0);
+});
+
+Deno.test("lcm - handles coprime numbers", () => {
+ const result = lcm(7, 13);
+ assertEquals(result, 91);
+});
+
+Deno.test("lcm - handles negative numbers", () => {
+ const result = lcm(-12, 8);
+ assertEquals(result, 24);
+});
+
+// Test calculateTileDimensions
+Deno.test("calculateTileDimensions - 0 degrees returns original dimensions", () => {
+ const result = calculateTileDimensions(100, 100, { m: 0, n: 1 });
+ assertEquals(result.width, 100);
+ assertEquals(result.height, 100);
+});
+
+Deno.test("calculateTileDimensions - 90 degrees swaps dimensions", () => {
+ const result = calculateTileDimensions(100, 200, { m: 1, n: 0 });
+ assertEquals(result.width, 200);
+ assertEquals(result.height, 100);
+});
+
+Deno.test("calculateTileDimensions - 45 degrees on square image", () => {
+ const result = calculateTileDimensions(100, 100, { m: 1, n: 1 });
+ // For 45 degrees (m=1, n=1) on 100x100, expect 141x141
+ assertEquals(result.width, 141);
+ assertEquals(result.height, 141);
+});
+
+Deno.test("calculateTileDimensions - handles 26.565 degrees (1:2 ratio)", () => {
+ const result = calculateTileDimensions(100, 200, { m: 1, n: 2 });
+ // For 26.565 degrees (m=1, n=2) on 100x200, expect 447x224
+ assertEquals(result.width, 447);
+ assertEquals(result.height, 224);
+});
+
+Deno.test("calculateTileDimensions - negative m produces same dimensions as positive m", () => {
+ const result = calculateTileDimensions(100, 100, { m: -1, n: 1 });
+ // Negative m should give same dimensions as positive (absolute value used)
+ const positive = calculateTileDimensions(100, 100, { m: 1, n: 1 });
+ assertEquals(result.width, positive.width);
+ assertEquals(result.height, positive.height);
+});
+
+// Test validateDimensions
+Deno.test("validateDimensions - accepts valid dimensions", () => {
+ const errors = validateDimensions({
+ width: 500,
+ height: 500,
+ validWidth: 2000,
+ });
+ assertEquals(errors.length, 0);
+});
+
+Deno.test("validateDimensions - rejects width exceeding limit", () => {
+ const errors = validateDimensions({
+ width: 3000,
+ height: 500,
+ validWidth: 2000,
+ });
+ assertEquals(errors.length, 1);
+ assertEquals(errors[0].includes("width"), true);
+ assertEquals(errors[0].includes("3000"), true);
+});
+
+Deno.test("validateDimensions - rejects height exceeding limit", () => {
+ const errors = validateDimensions({
+ width: 500,
+ height: 3000,
+ validWidth: 2000,
+ validHeight: 2000,
+ });
+ assertEquals(errors.length, 1);
+ assertEquals(errors[0].includes("height"), true);
+ assertEquals(errors[0].includes("3000"), true);
+});
+
+Deno.test("validateDimensions - rejects zero width", () => {
+ const errors = validateDimensions({
+ width: 0,
+ height: 500,
+ validWidth: 2000,
+ });
+ assertEquals(errors.length, 1);
+ assertEquals(errors[0].includes("zero"), true);
+});
+
+Deno.test("validateDimensions - rejects zero height", () => {
+ const errors = validateDimensions({
+ width: 500,
+ height: 0,
+ validWidth: 2000,
+ });
+ assertEquals(errors.length, 1);
+ assertEquals(errors[0].includes("zero"), true);
+});
+
+Deno.test("validateDimensions - rejects infinite width", () => {
+ const errors = validateDimensions({
+ width: Infinity,
+ height: 500,
+ validWidth: 2000,
+ });
+ assertEquals(errors.length, 1);
+ assertEquals(errors[0].includes("infinity"), true);
+});
+
+Deno.test("validateDimensions - rejects infinite height", () => {
+ const errors = validateDimensions({
+ width: 500,
+ height: Infinity,
+ validWidth: 2000,
+ });
+ assertEquals(errors.length, 1);
+ assertEquals(errors[0].includes("infinity"), true);
+});
+
+Deno.test("validateDimensions - reports multiple errors", () => {
+ const errors = validateDimensions({
+ width: 3000,
+ height: 4000,
+ validWidth: 2000,
+ validHeight: 2000,
+ });
+ assertEquals(errors.length, 2);
+});
+
+// Test getOutputPath
+Deno.test("getOutputPath - generates default output path", () => {
+ const result = getOutputPath({
+ input: join("path", "to", "image.png"),
+ rationalAngle: { degrees: 45 },
+ });
+ assertEquals(result, join("path", "to", "image-tile-45.png"));
+});
+
+Deno.test("getOutputPath - handles custom output path", () => {
+ const result = getOutputPath({
+ input: join("path", "to", "image.png"),
+ rationalAngle: { degrees: 45 },
+ output: join("custom", "output.png"),
+ });
+ assertEquals(result, join("custom", "output.png"));
+});
+
+Deno.test("getOutputPath - handles different extensions", () => {
+ const result = getOutputPath({
+ input: join("path", "to", "image.jpg"),
+ rationalAngle: { degrees: 26.565 },
+ });
+ assertEquals(result, join("path", "to", "image-tile-26.565.jpg"));
+});
+
+Deno.test("getOutputPath - handles files in current directory", () => {
+ const result = getOutputPath({
+ input: "image.png",
+ rationalAngle: { degrees: 90 },
+ });
+ assertEquals(result, `.${SEPARATOR}image-tile-90.png`);
+});
+
+Deno.test("getOutputPath - handles negative angles with double dash", () => {
+ const result = getOutputPath({
+ input: join("path", "to", "image.png"),
+ rationalAngle: { degrees: -45 },
+ });
+ // Note: negative angles result in double dash in filename (e.g., -tile--45)
+ assertEquals(result, join("path", "to", "image-tile--45.png"));
+});
+
+// Test RATIONAL_ANGLES constant
+Deno.test("RATIONAL_ANGLES - contains expected number of angles", () => {
+ assertEquals(RATIONAL_ANGLES.length, 29);
+});
+
+Deno.test("RATIONAL_ANGLES - first angle is 0 degrees", () => {
+ assertEquals(RATIONAL_ANGLES[0].degrees, 0);
+ assertEquals(RATIONAL_ANGLES[0].m, 0);
+ assertEquals(RATIONAL_ANGLES[0].n, 1);
+});
+
+Deno.test("RATIONAL_ANGLES - contains 45 degree angle", () => {
+ const angle45 = RATIONAL_ANGLES.find((a) => a.degrees === 45);
+ assertEquals(angle45?.m, 1);
+ assertEquals(angle45?.n, 1);
+});
+
+Deno.test("RATIONAL_ANGLES - contains 90 degree angle", () => {
+ const angle90 = RATIONAL_ANGLES.find((a) => a.degrees === 90);
+ assertEquals(angle90?.m, 1);
+ assertEquals(angle90?.n, 0);
+});
+
+Deno.test("RATIONAL_ANGLES - all angles have required properties", () => {
+ RATIONAL_ANGLES.forEach((angle) => {
+ assertEquals(typeof angle.degrees, "number");
+ assertEquals(typeof angle.m, "number");
+ assertEquals(typeof angle.n, "number");
+ assertEquals(typeof angle.label, "string");
+ });
+});