Skip to content

Commit

Permalink
[feature] SVG Data URL (#47)
Browse files Browse the repository at this point in the history
Co-authored-by: Hussein Jafferjee <hussein.jafferjee@verygoodsecurity.com>
Co-authored-by: Hussein Jafferjee <hussein@jafferjee.ca>
  • Loading branch information
3 people authored Mar 4, 2024
1 parent d1a44c3 commit e5aff13
Show file tree
Hide file tree
Showing 3 changed files with 151 additions and 18 deletions.
73 changes: 57 additions & 16 deletions app/routes/dataurl.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@ import Dropdown from "~/components/dropdown";
import Utiliti from "~/components/utiliti";
import ReadFile from "~/components/read-file";
import { convertFileToDataUrl } from "~/utils/convert-image-file";
import { NativeTypes } from "react-dnd-html5-backend";
import { useDrop } from "react-dnd";

export const meta = metaHelper(
utilities.dataurl.name,
Expand All @@ -25,6 +27,47 @@ function isImage(dataUrl: string, fileType: string): Promise<boolean> {
});
}

function DroppableInput({
input,
setInput,
format,
quality,
}: {
readonly input: string;
readonly setInput: (v: string) => void;
readonly format: string;
readonly quality: string;
}) {
const [{ canDrop, isOver }, drop] = useDrop(() => ({
accept: [NativeTypes.FILE],
async drop(item: { files: File[] }) {
setInput(await convertFileToDataUrl(item.files[0], format, quality));
},
collect: (monitor) => ({
isOver: monitor.isOver(),
canDrop: monitor.canDrop(),
}),
}));

return (
<textarea
id="input"
rows={10}
className={
"block px-2 py-2 font-mono w-full lg:text-sm bg-zinc-800 focus:ring-0 text-white placeholder-zinc-400 " +
(isOver && canDrop
? "border-green-700 focus:border-green-700"
: "border-zinc-800 focus:border-zinc-800")
}
placeholder="Paste in your Data URL, drag and drop a file, or click on the attachment icon below and select a file."
required={true}
ref={drop}
value={input}
onChange={(e) => setInput(e.target.value)}
/>
);
}

export default function DataUrl() {
const [format, setFormat] = useState("jpg");
const [quality, setQuality] = useState("0");
Expand All @@ -39,8 +82,6 @@ export default function DataUrl() {
"image/jpeg",
"image/png",
"image/webp",
"image/avif",
"image/gif",
"image/svg+xml",
];

Expand All @@ -59,18 +100,17 @@ export default function DataUrl() {
);

const renderInput = useCallback(
(input: string, setInput: (v: string) => void) => (
<textarea
id="input"
rows={10}
className="block px-3 py-2 font-mono w-full lg:text-sm border-0 bg-zinc-800 focus:ring-0 text-white placeholder-zinc-400"
placeholder="Paste in your Data URL…"
required={true}
value={input}
onChange={(e) => setInput(e.target.value)}
/>
),
[],
(input: string, setInput: (v: string) => void) => {
return (
<DroppableInput
input={input}
setInput={setInput}
format={format}
quality={quality}
/>
);
},
[format, quality],
);

const renderOutput = useCallback(
Expand Down Expand Up @@ -100,11 +140,12 @@ export default function DataUrl() {
{ id: "jpg", label: "Jpeg" },
{ id: "png", label: "Png" },
{ id: "webp", label: "Webp" },
{ id: "svg", label: "Svg" },
]}
defaultValue={format}
/>

{format !== "png" ? (
{format !== "png" && format !== "svg" ? (
<Dropdown
onOptionChange={setQuality}
options={[
Expand All @@ -122,7 +163,7 @@ export default function DataUrl() {
) : null}

<ReadFile
accept="image/*"
accept={format === "svg" ? "image/svg+xml" : "image/*"}
onLoad={async (files) =>
setInput(await convertFileToDataUrl(files[0], format, quality))
}
Expand Down
19 changes: 17 additions & 2 deletions app/utils/convert-image-file.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,10 @@
import { cleanSvg, encodeSvg } from "~/utils/svg-utils";

const imageFormats: Record<string, string> = {
jpg: "image/jpeg",
png: "image/png",
webp: "image/webp",
svg: "image/svg+xml",
};

function extensionFromFormat(extension: string): string {
Expand All @@ -20,15 +23,27 @@ export function convertFileToDataUrl(
const reader = new FileReader();

reader.addEventListener("load", function (e) {
resolve((e.target?.result || "").toString());
let value = (e.target?.result || "").toString();

if (format === "svg") {
value = cleanSvg(value);
// Replace double quotes with single quotes
value = value.replace(/"/g, "'");
value = encodeSvg(value);
value = `data:image/svg+xml;utf8,${value}`;
}

resolve(value);
});

reader.addEventListener("error", function (e) {
reject((e.target?.error || "").toString());
});

const fileFormat = extensionFromFormat(file.type);
if (
if (format === "svg") {
reader.readAsText(file);
} else if (
(fileFormat && fileFormat !== format) ||
(quality != "0" && format !== "png")
) {
Expand Down
77 changes: 77 additions & 0 deletions app/utils/svg-utils.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
export function cleanSvg(svgString: string) {
// Parse the SVG string into a DOM document
const parser = new DOMParser();

const svgDoc = parser.parseFromString(
removeComments(svgString),
"image/svg+xml",
);
const svgRoot = svgDoc.documentElement;

removeNamespaces(svgRoot, svgString);
removeWhitespace(svgRoot);

// Serialize the cleaned SVG back to a string
const serializer = new XMLSerializer();

return serializer.serializeToString(svgDoc);
}

function removeComments(svgString: string): string {
return svgString.replace(/<!--.*?-->/g, "");
}

export function encodeSvg(svgString: string) {
return svgString
.replace(/"/g, "'")
.replace(/%/g, "%25")
.replace(/#/g, "%23")
.replace(/{/g, "%7B")
.replace(/}/g, "%7D")
.replace(/</g, "%3C")
.replace(/>/g, "%3E")
.replace(/\s+/g, " ");
}

function removeNamespaces(svgRoot: HTMLElement, svgString: string): void {
// Find all namespaces used in the SVG
const namespaces: Set<string> = new Set();

Array.from(svgRoot.attributes).forEach((attr) => {
if (attr.name.startsWith("xmlns:")) {
namespaces.add(attr.name.split(":")[1]);
}
});
// Remove namespaces that are not used
for (const namespace of namespaces) {
if (!svgString.match(new RegExp(`{namespace}:\\w+=".*?"`, "g"))?.length) {
svgRoot.removeAttribute(`xmlns:${namespace}`);
}
}
}

// Remove whitespace and normalize attributes
function removeWhitespace(node: HTMLElement): void {
for (let i = node.childNodes.length - 1; i >= 0; i--) {
const child = node.childNodes[i];
if (
child.nodeType === 3 &&
child.textContent &&
child.textContent.trim() === ""
) {
// Remove empty text nodes
node.removeChild(child);
} else if (child.nodeType === 1) {
// Recursively remove whitespace from child elements
removeWhitespace(child as HTMLElement);
}
}
// Normalize attributes by removing any unused ones
for (let i = node.attributes.length - 1; i >= 0; i--) {
const attr = node.attributes[i];
if (!attr.specified) {
// Remove unused attributes
node.removeAttributeNode(attr);
}
}
}

0 comments on commit e5aff13

Please sign in to comment.