Skip to content

Commit

Permalink
feat: refactor compression utilities to support Base64 and URL-safe e…
Browse files Browse the repository at this point in the history
…ncoding
  • Loading branch information
MH4GF committed Dec 19, 2024
1 parent 8a2a9c4 commit 0838e34
Show file tree
Hide file tree
Showing 4 changed files with 95 additions and 55 deletions.
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import type { QueryParam } from '@/schemas/queryParam'
import { addHiddenNodeIds, updateActiveTableName } from '@/stores'
import { decompressFromUTF16 } from '@/utils'
import { decompressFromEncodedURIComponent } from '@/utils'
import { useNodesInitialized } from '@xyflow/react'
import { useEffect } from 'react'
import { useERDContentContext } from './ERDContentContext'
Expand All @@ -19,7 +19,7 @@ const getHiddenNodeIdsFromUrl = async (): Promise<string[]> => {
const hiddenQueryParam: QueryParam = 'hidden'
const compressed = urlParams.get(hiddenQueryParam)
const hiddenNodeIds = compressed
? await decompressFromUTF16(compressed).catch(() => undefined)
? await decompressFromEncodedURIComponent(compressed).catch(() => undefined)
: undefined

return hiddenNodeIds ? hiddenNodeIds.split(',') : []
Expand Down
4 changes: 2 additions & 2 deletions frontend/packages/erd-core/src/stores/userEditing/store.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import type { QueryParam } from '@/schemas/queryParam'
import type { ShowMode } from '@/schemas/showMode'
import { compressToUTF16 } from '@/utils'
import { compressToEncodedURIComponent } from '@/utils'
import { proxy, subscribe } from 'valtio'
import { proxySet } from 'valtio/utils'

Expand Down Expand Up @@ -41,7 +41,7 @@ subscribe(userEditingStore.hiddenNodeIds, async () => {

url.searchParams.delete(activeQueryParam)
if (hiddenNodeIds) {
const compressed = await compressToUTF16(hiddenNodeIds)
const compressed = await compressToEncodedURIComponent(hiddenNodeIds)
url.searchParams.set(activeQueryParam, compressed)
}

Expand Down
Binary file modified frontend/packages/erd-core/src/utils/compressionString.test.ts
Binary file not shown.
142 changes: 91 additions & 51 deletions frontend/packages/erd-core/src/utils/compressionString.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,66 +9,106 @@ function createReadableStreamFromBytes(
})
}

export async function compressToUTF16(input: string): Promise<string> {
const encoder = new TextEncoder()
const inputBytes = encoder.encode(input)

// Deflate compression
const compressionStream = new CompressionStream('deflate')
const compressedStream =
createReadableStreamFromBytes(inputBytes).pipeThrough(compressionStream)
const compressedBuffer = await new Response(compressedStream).arrayBuffer()
const compressedBytes = new Uint8Array(compressedBuffer)

// Store the length of the compressed bytes in the first 4 bytes
const length = compressedBytes.length
const totalLength = 4 + length // Store length in the first 4 bytes
// Align to 2-byte (UTF-16 code unit) boundary, add 1 byte padding if necessary
const paddedLength = totalLength % 2 === 0 ? totalLength : totalLength + 1

const resultBytes = new Uint8Array(paddedLength)
const dataView = new DataView(resultBytes.buffer)
/**
* Perform Deflate compression and return a Uint8Array
*/
async function deflateCompress(inputBytes: Uint8Array): Promise<Uint8Array> {
const compression = new CompressionStream('deflate')
const stream =
createReadableStreamFromBytes(inputBytes).pipeThrough(compression)
const compressedBuffer = await new Response(stream).arrayBuffer()
return new Uint8Array(compressedBuffer)
}

// Store length information in the first 4 bytes (Little Endian)
dataView.setUint32(0, length, true)
// Store the compressed data after the first 4 bytes
resultBytes.set(compressedBytes, 4)
/**
* Perform Deflate decompression and return a Uint8Array
*/
async function deflateDecompress(inputBytes: Uint8Array): Promise<Uint8Array> {
const decompression = new DecompressionStream('deflate')
const stream =
createReadableStreamFromBytes(inputBytes).pipeThrough(decompression)
const decompressedBuffer = await new Response(stream).arrayBuffer()
return new Uint8Array(decompressedBuffer)
}

// Convert to Uint16Array to interpret as UTF-16 code units
const uint16Array = new Uint16Array(resultBytes.buffer)
/**
* Encode byte array to Base64 string
*/
function bytesToBase64(bytes: Uint8Array): string {
let binaryString = ''
for (const b of Array.from(bytes)) {
binaryString += String.fromCharCode(b)
}
return btoa(binaryString)
}

// Convert each Uint16 element to a character
return String.fromCharCode(...Array.from(uint16Array))
/**
* Decode Base64 string to byte array
*/
function base64ToBytes(base64: string): Uint8Array {
const binaryString = atob(base64)
const length = binaryString.length
const bytes = new Uint8Array(length)
for (let i = 0; i < length; i++) {
bytes[i] = binaryString.charCodeAt(i)
}
return bytes
}

export async function decompressFromUTF16(input: string): Promise<string> {
const decoder = new TextDecoder()
/**
* Convert Base64 string to URL-safe string (`+` => `-`, `/` => `_`, remove `=`)
*/
function base64ToUrlSafe(base64: string): string {
return base64.replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '')
}

// Reconstruct Uint16Array from UTF-16 code units
const codeUnits = new Uint16Array(input.length)
for (let i = 0; i < input.length; i++) {
codeUnits[i] = input.charCodeAt(i)
/**
* Convert URL-safe string back to Base64
*/
function urlSafeToBase64(urlSafe: string): string {
let base64 = urlSafe.replace(/-/g, '+').replace(/_/g, '/')
while (base64.length % 4) {
base64 += '='
}
return base64
}

// Reference Uint8Array from Uint16Array
const bytes = new Uint8Array(codeUnits.buffer)
const dataView = new DataView(bytes.buffer)

// Get the actual length of the compressed data from the first 4 bytes
const length = dataView.getUint32(0, true)
/**
* Compress string using Deflate and return as Base64
*/
async function compressToBase64(input: string): Promise<string> {
const textEncoder = new TextEncoder()
const inputBytes = textEncoder.encode(input)
const compressedBytes = await deflateCompress(inputBytes)
return bytesToBase64(compressedBytes)
}

// Extract the compressed data part
const compressedBytes = bytes.subarray(4, 4 + length)
/**
* Decompress Base64 string and return the original string
*/
async function decompressFromBase64(input: string): Promise<string> {
const textDecoder = new TextDecoder()
const compressedBytes = base64ToBytes(input)
const decompressedBytes = await deflateDecompress(compressedBytes)
return textDecoder.decode(decompressedBytes)
}

// Decompress
const decompressionStream = new DecompressionStream('deflate')
const decompressedStream =
createReadableStreamFromBytes(compressedBytes).pipeThrough(
decompressionStream,
)
const decompressedBuffer = await new Response(
decompressedStream,
).arrayBuffer()
/**
* Compress string using Deflate and return as URL-safe encoded string (based on Base64)
*/
export async function compressToEncodedURIComponent(
input: string,
): Promise<string> {
const base64 = await compressToBase64(input)
return base64ToUrlSafe(base64)
}

return decoder.decode(decompressedBuffer)
/**
* Restore original string from URL-safe encoded string
*/
export async function decompressFromEncodedURIComponent(
input: string,
): Promise<string> {
const base64 = urlSafeToBase64(input)
return decompressFromBase64(base64)
}

0 comments on commit 0838e34

Please sign in to comment.