fix(landing): derive conversion-preset copy from real format differences (#663)

The 83 X-to-Y converter pages were generated from one template with the
format names substituted in. Three of the four features and two of the
three FAQs were byte-identical across all 82 non-override presets, every
page came out at exactly 144 words, and pages within a family sat at
roughly 61% similarity with identical shingle counts. That is the
mass-produced fingerprint, and the same shape that got the docs tool
pages deduped in #662. All 83 are indexed today, so this is prevention
rather than repair.

Copy is now derived from what actually differs between the two formats:
alpha, animation, colour depth, lossy re-encode behaviour, container
reach, and where each format is genuinely accepted. png-to-jpg explains
that transparency gets flattened; gif-to-jpg warns you keep frame one
only; psd-to-svg says to export Photoshop's real vector layers instead of
tracing them.

Mean similarity across all 3,403 pairs drops from 34.8% to 6.7%, and the
uniform 144-word length is gone (206-256 words, varying). Within-family
max is roughly flat: on the built pages, image goes 62.8% to 66.1%, video
61.3% to 66.4%, audio 60.7% to 60.6%. Generated copy has a similarity
floor, and beating it inside a family would take hand-written pages. What
has gone is the identical shingle count per family, which was the
strongest tell.

Also drops the one-off eps-to-svg override: all five vectorize presets
now get honest tracing copy, not just that one.

Verified: landing builds 798 pages, zero verbatim repeats between a
page's blurb and its own features or FAQs, biome clean.
This commit is contained in:
SnapOtter
2026-07-28 17:08:53 +08:00
committed by GitHub
parent 42e1dc9799
commit a75a22dd3f
2 changed files with 2132 additions and 1203 deletions
+806 -41
View File
@@ -8,58 +8,821 @@ const SEO = join(__dirname, "../apps/landing/src/data/tool-seo.ts");
const BEGIN = " // BEGIN conversion-presets (generated by scripts/generate-conversion-seo.mjs)";
const END = " // END conversion-presets";
// Per-preset honest overrides for conversions whose result is an approximation
// rather than a faithful copy, so the SEO copy sets correct expectations.
const OVERRIDES = {
"eps-to-svg": (j) => ` ${j("eps-to-svg")}: {
searchTitle: ${j("EPS to SVG Converter Online Free")},
longDescription: ${j("Convert EPS files to SVG by tracing the artwork into vector paths. This works best for logos, icons, and line art. Complex EPS files with gradients, photographic content, or fine detail can lose fidelity, because the result is a traced approximation rather than a one-to-one copy of the original vectors. Runs on your own SnapOtter instance, never uploaded to a third party.")},
useCases: [
${j("Trace an EPS logo or icon into a clean, scalable SVG")},
${j("Convert simple EPS line art for the web without Illustrator")},
${j("Batch convert many EPS files to traced SVG at once")},
],
features: [
${j("Traces EPS artwork into SVG vector paths (not a 1:1 vector copy)")},
${j("Best results on logos, icons, and line art")},
${j("Batch processing for multiple files")},
${j("Private: runs on your own instance, no third-party upload")},
],
faqs: [
{ q: ${j("Does this preserve the original EPS vectors exactly?")}, a: ${j("No. It renders the EPS and traces the result into new SVG paths. Simple line art and logos convert cleanly, but complex EPS with gradients, effects, or embedded images can lose detail. For a faithful vector copy, keep the original EPS or use a dedicated vector editor.")} },
{ q: ${j("How do I convert EPS to SVG?")}, a: ${j("Upload your EPS file, then download the traced SVG. SnapOtter processes it on your instance.")} },
{ q: ${j("Are my files private?")}, a: ${j("Yes. Files are processed on your own server and are not sent to any third party.")} },
],
},`,
// Copy here used to be one template with the format names substituted in, which
// left 3 of the 4 features and 2 of the 3 FAQs byte-identical across all 82
// presets and put every page in a family at roughly 61% similarity. Google reads
// that as a duplicate cluster; it did exactly that to the docs tool pages in
// PR #662. So each entry is now derived from what actually differs between the
// two formats: alpha, animation, re-encode behaviour, colour depth, size, and
// where the format is genuinely accepted.
const F = {
JPG: {
full: "JPEG",
lossy: true,
alpha: false,
anim: false,
colors: "full",
home: "photographs, and it is the one raster format every piece of software opens",
},
PNG: {
full: "PNG",
lossy: false,
alpha: true,
anim: false,
colors: "full",
home: "screenshots, logos, and anything with flat colour or sharp edges",
},
WebP: {
full: "WebP",
lossy: true,
alpha: true,
anim: true,
colors: "full",
home: "the web, where it usually lands 25-35% smaller than an equivalent JPEG",
},
AVIF: {
full: "AVIF",
lossy: true,
alpha: true,
anim: true,
colors: "full",
home: "the web, where it generally beats both JPEG and WebP on size at matched quality",
},
HEIC: {
full: "HEIC",
lossy: true,
alpha: true,
anim: false,
colors: "full",
home: "iPhone camera rolls, and very little outside Apple's ecosystem",
},
GIF: {
full: "GIF",
lossy: false,
alpha: true,
anim: true,
colors: "256",
home: "short looping animation, paid for with a 256-colour palette",
},
TIFF: {
full: "TIFF",
lossy: false,
alpha: true,
anim: false,
colors: "full",
home: "print, scanning, and archival masters, at several times the size of a JPEG",
},
PSD: {
full: "Photoshop PSD",
lossy: false,
alpha: true,
anim: false,
colors: "full",
home: "layered Photoshop work, and is unreadable to most non-Adobe software",
},
EPS: {
full: "EPS",
lossy: false,
alpha: false,
anim: false,
colors: "full",
home: "print and prepress workflows built around PostScript",
},
SVG: { full: "SVG", alpha: true, home: "logos and icons that must stay sharp at any size" },
};
function entry(p) {
const F = p.from;
const T = p.to;
const j = (s) => JSON.stringify(s);
if (OVERRIDES[p.id]) return OVERRIDES[p.id](j);
return ` ${j(p.id)}: {
searchTitle: ${j(`${F} to ${T} Converter Online Free`)},
longDescription: ${j(`Convert ${F} files to ${T} online, free and private. Your files are processed on your own SnapOtter instance, never uploaded to a third party. Batch convert multiple ${F} files to ${T} at once.`)},
// Video: the container in the filename matters less than what plays it, so the
// copy leads with reach rather than with codec trivia.
const V = {
MP4: {
full: "MP4",
plays: "essentially everywhere: browsers, phones, TVs, and editors",
pick: "Pick MP4 unless you have a specific reason not to. It is the one container you can hand to a stranger and simply expect to work.",
},
MOV: {
full: "QuickTime MOV",
plays: "well on Apple hardware and in editing suites, patchily elsewhere",
pick: "Pick MOV when the file is heading into Final Cut or Premiere, or staying inside an Apple workflow. For anything you are sending outward, MP4 travels further.",
},
WEBM: {
full: "WebM",
plays: "in every modern browser, but not in most desktop players or on TVs",
pick: "Pick WebM for video you serve from your own pages, where you control playback and want smaller files with no licensing questions attached.",
},
MKV: {
full: "Matroska MKV",
plays: "in desktop players like VLC, but rarely in browsers or on phones and TVs",
pick: "Pick MKV when one file has to carry several audio tracks, subtitles, or chapters, and it will be played on a desktop rather than streamed.",
},
AVI: {
full: "AVI",
plays: "on older Windows software, and is long past being a sensible delivery format",
pick: "Pick AVI only because some legacy tool demands it. Nothing built this decade prefers it, and it cannot carry modern codecs cleanly.",
},
};
const A = {
MP3: {
full: "MP3",
lossy: true,
note: "the safe default: every player built in the last 25 years reads it",
pick: "Choose MP3 when the file has to simply work somewhere you do not control.",
tip: "MP3 cannot mark a gapless album boundary reliably, so expect a short silence between tracks unless the player understands the gapless tags.",
},
AAC: {
full: "AAC",
lossy: true,
note: "cleaner than MP3 at the same bitrate, and standard across Apple devices",
pick: "Choose AAC when you want better quality than MP3 at the same size and the destination is Apple hardware.",
tip: "A raw .aac stream has no container and no dependable duration header, which is why some players scrub through it badly. Converting fixes that even when the codec stays the same.",
},
M4A: {
full: "M4A",
lossy: true,
note: "AAC audio in an MP4 container, which some older players refuse to open",
pick: "Choose M4A when you are staying inside an Apple workflow that expects it.",
tip: "M4A and MP4 are the same container with a different extension. If a player rejects the .m4a, renaming it to .mp4 occasionally works without converting anything.",
},
OGG: {
full: "Ogg Vorbis",
lossy: true,
note: "royalty-free and strong at low bitrates, but thin on hardware support",
pick: "Choose Ogg when you serve audio from your own pages and want a royalty-free codec.",
tip: "Ogg's bitrate is variable by design, so a nominal 128kbps Vorbis file is not directly comparable to a constant 128kbps MP3, and usually sounds better.",
},
WAV: {
full: "WAV",
lossy: false,
note: "uncompressed PCM: perfect fidelity at roughly 10MB per minute",
pick: "Choose WAV when the audio is heading into an editor or a transcription pipeline that wants uncompressed input.",
tip: "WAV has no standard tag block, so artist and title fields most likely do not exist in the source to carry over.",
},
FLAC: {
full: "FLAC",
lossy: false,
note: "lossless compression, so bit-perfect audio at about half the size of WAV",
pick: "Choose FLAC when you want an exact archival copy at roughly half the size of WAV.",
tip: "FLAC keeps the full dynamic range. If the destination is a phone or a car stereo, consider normalising while you convert.",
},
};
// Where a family differs only by its source container (four video-to-GIF pages,
// five extract-audio pages, seven image-to-PDF pages), swapping one token still
// leaves the pages ~85% identical. These notes carry what is actually different
// about starting from each source, which is both the differentiator and the more
// useful page.
const VSRC = {
MP4: {
gif: "MP4 is usually H.264 at a sane bitrate, so it decodes fast and the frame timing is regular, which is the easy case for a clean loop.",
audio:
"MP4 almost always carries AAC, so an MP3 or WAV target means a real decode, while pulling the AAC out untouched is instant.",
convert:
"An MP4 source is already H.264 or H.265 in almost every case, so moving it into another container is frequently a straight stream copy that costs nothing in quality and finishes in seconds.",
},
MOV: {
gif: "MOV off a camera or an iPhone is often ProRes or high-bitrate H.264 at 4K, so expect to scale the width down hard before the GIF is a sane size.",
audio:
"MOV from cameras and editors frequently holds uncompressed PCM rather than a compressed track, so a WAV target can be a straight copy while MP3 is a genuine encode.",
convert:
"A MOV source out of a camera or an editor may hold ProRes or DNxHD, which no browser will play, so this is usually a genuine re-encode rather than a re-wrap.",
},
MKV: {
gif: "MKV files tend to be long, high-resolution, and carry several subtitle and audio tracks. Trim to the few seconds you actually want first; the rest is ignored.",
audio:
"MKV is the permissive one: the audio inside might be AC3, DTS, Opus, FLAC, or several tracks at once. Pick the track you want, then choose a target every player can read.",
convert:
"An MKV source very often already holds H.264 or H.265, in which case the video is copied across untouched and only the container changes.",
},
WEBM: {
gif: "WebM is VP8, VP9, or AV1, all of which decode more slowly than H.264, so conversion takes longer for the same clip length.",
audio:
"WebM carries Opus or Vorbis, neither of which older players reliably handle, which is usually the whole reason for pulling the audio into something else.",
convert:
"A WebM source is VP8, VP9, or AV1. None of those sit inside MP4, MOV, or AVI in any way players actually support, so expect a full re-encode.",
},
AVI: {
gif: "AVI is normally an older codec at modest resolution, which makes it a light source for GIF. Interlaced captures may show combing that survives into the frames.",
audio:
"AVI typically holds MP3 or raw PCM. If it is already MP3 the extraction is a copy, so there is no second lossy pass to worry about.",
convert:
"An AVI source is typically an older codec such as DivX, Xvid, or MJPEG, so a re-encode is unavoidable here, and it is also an upgrade.",
},
};
const IMG_PDF = {
JPG: "JPG is the usual output of phone cameras and scanners, so this is the common path for turning photographed paperwork into a filed document.",
PNG: "PNG suits screenshots and UI captures, where the sharp text stays crisp because PNG never compressed it lossily in the first place.",
HEIC: "HEIC comes straight off an iPhone, and PDF is often the easiest way to hand those photos to someone whose software cannot open HEIC at all.",
TIFF: "TIFF is the scanner format, and a single TIFF can already hold many pages. Multi-page TIFFs expand into multiple PDF pages rather than collapsing to one.",
WebP: "WebP is a web format rather than a document one, so this is typically about getting downloaded images into something printable and shareable.",
GIF: "GIF may be animated, and a PDF page cannot be. Animated GIFs contribute their first frame only.",
EPS: "EPS is already PostScript, so it maps onto PDF pages more directly than a bitmap does and vector artwork stays sharp at any zoom.",
};
// Tracing quality depends almost entirely on what the source looks like, and the
// five vectorize presets differ only in that. Each source has a genuinely
// different failure mode worth warning about.
const VEC_SRC = {
PNG: "PNG is the best-case source: it was never lossily compressed, so edges are exactly where the artist put them and the tracer has clean boundaries to follow.",
JPG: "JPG is the hardest source. JPEG compression leaves ringing and blocking around every hard edge, and the tracer cannot tell those artifacts from real detail, so it faithfully traces the noise too. Start from a high-quality JPG and expect to simplify the result.",
TIFF: "TIFF is usually a scan, often 1-bit or greyscale line art at high resolution, which traces well. Scanner speckle becomes stray paths, so despeckle first if the original was a paper drawing.",
PSD: "A PSD is flattened before tracing, so layers, masks, and effects are baked in. If the PSD contains real vector shape or text layers, those are already vectors and exporting them from Photoshop directly will beat anything a tracer can reconstruct.",
EPS: "EPS is already vector, so tracing it is a round trip: the artwork is rendered to pixels and then redrawn as new paths. That is worth doing only when something downstream refuses EPS, because the original vectors are strictly better than the trace.",
};
// The three PDF-to-image targets are lossless, lossless, and lossy in that order,
// which the old copy collapsed into one boolean. What actually decides the choice
// is what the pages contain.
const PDF_TARGET = {
JPG: "JPG keeps the output small, which suits photo-heavy pages and previews. Text edges soften slightly, so push the DPI up if the pages are mostly type.",
PNG: "PNG is lossless, so screenshots, diagrams, and small text come out exactly as rendered. Files are larger than JPG, sometimes much larger on photographic pages.",
TIFF: "TIFF is the archival and prepress choice, and unlike the others it can hold every page in one multi-page file rather than a folder of separate images.",
};
// A second, different fact per format. The first goes in the blurb and this one
// in the FAQ, so both sections carry unique text instead of the page's whole
// distinctiveness sitting in one field.
const VEC_TIP = {
PNG: "If the PNG has anti-aliased edges, nudge the threshold up so the tracer does not emit a halo of thin intermediate shapes around every boundary.",
JPG: "Blur the JPG very slightly before tracing. It sounds backwards, but it smooths the compression noise the tracer would otherwise faithfully chase.",
TIFF: "Feed the tracer the full-size scan rather than a shrunk copy. High-resolution input traces markedly better, and downsampling first throws away the edge precision it needs.",
PSD: "Flatten deliberately in Photoshop first if you care which layers win. Left alone, the converter composites everything visible and you get whatever that produces.",
EPS: "Render at high resolution before tracing, since the trace can only ever be as accurate as the pixels handed to it.",
};
const GIF_TIP = {
MP4: "Its constant frame rate means you can halve to 12fps by taking every other frame, with no judder in the result.",
MOV: "Scale the width down before touching anything else. On 4K camera footage that single change dominates the final file size.",
MKV: "Check which video track you are trimming if the file carries more than one, or you may cut from the wrong stream.",
WEBM: "Trim before converting so the slower VP9 or AV1 decoder has far less to chew through.",
AVI: "Deinterlace first if the footage came off tape or a capture card, or the combing artifacts get baked into every frame.",
};
const PDF_TIP = {
JPG: "Quality around 85 is the usual sweet spot for scanned pages: visually clean, and a fraction of the lossless size.",
PNG: "Expect roughly 3-5x the file size of the same page rendered to JPG, which is the price of exact text edges.",
TIFF: "Ask for a single multi-page TIFF when the destination is an archive, rather than a folder of separate page images.",
};
const PRIVACY = "Runs on your own SnapOtter instance. Files never reach a third party.";
// The blurb already makes the privacy point, so the bullet says it about this
// specific input instead of reprinting the same sentence lower down the page.
const priv = (what) =>
`Your ${what} files are processed on your own server and never uploaded anywhere`;
const NO_LIMITS =
"No. Self-hosted SnapOtter has no watermarks or quotas beyond the limits you configure yourself.";
/** What to actually do about the tradeoff, phrased so it never restates the blurb. */
function rasterAdvice(from, to) {
const a = F[from];
const b = F[to];
if (a.anim && !b.anim) {
return `Only the first frame survives, so if the animation is the point, convert to WebP or MP4 instead and keep the motion.`;
}
if (a.alpha && !b.alpha) {
return `The visible pixels survive intact; it is the transparency that does not. Composite against the background you will actually display on, or the edges will fringe.`;
}
if (b.colors === "256") {
return `Dithering hides some of the banding at the cost of a noisier, larger file. Flat graphics need none of it; photographs need more than it can give.`;
}
if (!a.lossy && b.lossy) {
return `Judge it at the size the image is actually viewed, not zoomed to 100%. Most photographs survive far more compression than people expect.`;
}
if (a.lossy && !b.lossy) {
return `Nothing is lost in this direction, but nothing is regained either. It is worth doing before editing, and pointless purely as an archive upgrade.`;
}
if (a.lossy && b.lossy) {
return `Convert once from the best original you have. Chaining conversions is what visibly degrades an image, not any single hop.`;
}
return `Both formats carry full colour, so the encode quality you pick is the only thing that matters here.`;
}
/**
* The honest tradeoffs for a raster pair, most consequential first, or null when
* there are none. Losses that destroy information (frames, alpha) outrank the
* compression story, and more than one can apply at once: GIF to JPG drops the
* animation *and* the transparency, so returning only the first would be a lie
* by omission on exactly the pages this copy exists to differentiate.
*/
function rasterTradeoff(from, to) {
const a = F[from];
const b = F[to];
const notes = [];
if (a.anim && !b.anim) {
notes.push(`${to} holds a single frame, so an animated ${from} is reduced to its first frame.`);
}
if (a.alpha && !b.alpha) {
notes.push(`${to} has no alpha channel, so transparency is flattened onto a solid background.`);
}
if (b.colors === "256") {
notes.push(
`${to} allows 256 colours per frame, so photographic gradients will band. It suits flat graphics and short loops, not photos.`,
);
} else if (!a.lossy && b.lossy) {
notes.push(
`${to} is lossy, trading some detail for a far smaller file. Keep the ${from} if you still need the pristine original.`,
);
} else if (a.lossy && !b.lossy) {
notes.push(
`${to} is lossless, but it cannot restore what ${from} already discarded. Expect a larger file at the same visible quality, which pays off when the image faces more editing.`,
);
} else if (a.lossy && b.lossy) {
notes.push(
"Both formats are lossy, so this is a re-encode and a little detail goes each time. Start from the best source you have rather than chaining conversions.",
);
}
return notes.length ? notes.join(" ") : null;
}
function rasterEntry(p) {
const { from, to } = p;
const a = F[from];
const b = F[to];
const tradeoff = rasterTradeoff(from, to);
const flattens = a.alpha && !b.alpha;
// Both halves of the contrast vary, so pages that share a target (jpg-to-gif and
// webp-to-gif, say) still open on different sentences.
const longDescription = [
`Convert ${from} images to ${to}. ${a.full} is built for ${a.home}; ${b.full} for ${b.home}.`,
tradeoff,
PRIVACY,
]
.filter(Boolean)
.join(" ");
const firstUse =
from === "HEIC"
? "Open iPhone photos in software that has never heard of HEIC"
: from === "PSD"
? "Hand a Photoshop comp to someone who does not have Photoshop"
: a.lossy && !b.lossy
? `Move a ${from} into ${to} before editing, so repeated saves stop degrading it`
: `Cut page weight by serving ${to} instead of ${from}`;
const secondUse =
b.colors === "256"
? "Build a short loop or flat graphic that displays anywhere an image does"
: b.alpha && !a.alpha
? `Get a ${to} with a real alpha channel to cut a subject out of`
: `Batch a folder of ${from} files into ${to} in one pass`;
return {
searchTitle: `${from} to ${to} Converter Online Free`,
longDescription,
useCases: [
${j(`Convert a ${F} file to ${T} without installing software`)},
${j(`Batch convert many ${F} files to ${T} at once`)},
${j(`Prepare ${T} files for apps that do not accept ${F}`)},
firstUse,
secondUse,
`Satisfy an upload form or pipeline that takes ${to} and rejects ${from}`,
],
features: [
${j(`${F} to ${T} conversion in your browser`)},
${j("Batch processing for multiple files")},
${j("Private: runs on your own instance, no third-party upload")},
${j("Free and unlimited on self-hosted SnapOtter")},
flattens
? "Pick the background colour that transparency is flattened onto"
: `Quality control over the ${to} encode, so you choose the size-versus-detail point`,
`Batch conversion across a folder of ${from} files`,
`Metadata and colour profile carried over wherever ${to} can hold them`,
priv(from),
],
faqs: [
{ q: ${j(`How do I convert ${F} to ${T}?`)}, a: ${j(`Upload your ${F} file, then download the converted ${T}. SnapOtter processes it on your instance.`)} },
{ q: ${j(`Is ${F} to ${T} conversion free?`)}, a: ${j("Yes. Self-hosted SnapOtter has no limits or watermarks.")} },
{ q: ${j("Are my files private?")}, a: ${j("Yes. Files are processed on your own server and are not sent to any third party.")} },
{
q: `Will converting ${from} to ${to} lose quality?`,
a: rasterAdvice(from, to),
},
{
q: flattens ? "What happens to transparent areas?" : `Which quality setting should I use?`,
a: flattens
? `They are composited onto a background colour you choose, white by default. There is no way around it: ${to} has nowhere to store an alpha channel.`
: `Start at the default, then lower it until you can see the difference at the size the image is actually viewed. Most photographs hold up further down than people expect.`,
},
{
q: `Can I go back to ${from} afterwards?`,
a: a.lossy
? `You can convert back, but you will not recover the original. ${from} discarded detail when it was first written, and ${to} cannot invent it. Keep the source file if you might need it.`
: `You can, and if ${to} is lossless the round trip is clean. If ${to} is lossy, converting back gives you a ${from} wrapper around already-degraded pixels rather than the original.`,
},
],
};
}
function vectorizeEntry(p) {
const { from, to } = p;
return {
searchTitle: `${from} to ${to} Converter Online Free`,
longDescription: `Trace a ${from} image into ${to} vector paths. This reads the pixels and redraws them as shapes, so the result is a new drawing rather than a recovered original. Logos, icons, line art, and anything with flat colour and clean edges come out well; photographs, soft gradients, and fine texture do not. ${VEC_SRC[from]} ${PRIVACY}`,
useCases: [
`Rebuild a logo you only have as a ${from} into something that scales cleanly`,
`Turn scanned line art or a signature into editable ${to} paths`,
"Prepare artwork for cutting, engraving, or large-format print from a raster source",
],
features: [
`Traces ${from} pixels into ${to} paths, which is a redraw and not a 1:1 vector recovery`,
"Tuned for flat colour and hard edges; a poor fit for photographic content",
`Batch tracing across many ${from} files`,
priv(from),
],
faqs: [
{
q: "Does this recover the original vectors?",
a: `No, and nothing can. Once artwork has been rasterised the paths are gone. This traces the pixels into new ${to} paths that approximate them. If the true vector source exists anywhere, use that instead.`,
},
{
q: `Why does my photo look wrong as ${to}?`,
a: "Because tracing has to reduce continuous tone to a finite set of filled shapes, so a photograph becomes a posterised blob. Vectorisation is for logos and line art.",
},
{
q: `Any tips for getting a better trace from a ${from}?`,
a: VEC_TIP[from],
},
{
q: "Can I edit the result?",
a: `Yes. The output is ordinary ${to} that opens in Illustrator, Inkscape, Figma, or a text editor.`,
},
],
};
}
function svgToRasterEntry(p) {
const { from, to } = p;
const b = F[to];
return {
searchTitle: `${from} to ${to} Converter Online Free`,
longDescription: `Render an ${from} into a ${to} bitmap at whatever pixel size you need. ${b.full} suits ${b.home}. Because ${from} is vector, nothing is being upscaled: the artwork is redrawn at the target resolution, so a 4000px export is exactly as sharp as a 400px one.${b.alpha ? "" : ` ${to} has no alpha channel, so transparent areas are flattened onto a background colour you pick.`} ${PRIVACY}`,
useCases: [
"Export an icon or logo at the exact pixel dimensions a platform demands",
`Produce a ${to} fallback for somewhere ${from} is not accepted`,
`Render one ${from} at several sizes for app icons or social images`,
],
features: [
"Set output width and height directly; the vector is redrawn, never upscaled",
b.alpha
? `Transparency preserved in the ${to} output`
: "Choose the background colour behind transparent regions",
`Batch rendering across many ${from} files`,
priv(from),
],
faqs: [
{
q: "What resolution should I export at?",
a: "Whatever the destination needs, doubled if it will appear on a high-DPI screen. There is no quality penalty for going large from a vector source, only file size.",
},
{
q: `Are fonts in my ${from} rendered correctly?`,
a: `Text renders when the ${from} embeds its glyphs or uses a font the renderer has. If the result looks substituted, convert text to outlines in your editor first.`,
},
{ q: "Is there a size or batch limit?", a: NO_LIMITS },
],
};
}
function imageToPdfEntry(p) {
const { from } = p;
const a = F[from];
return {
searchTitle: `${from} to PDF Converter Online Free`,
longDescription: `Place ${from} images into a PDF, one page each, with page size and orientation under your control. Several ${from} files combine into a single document in the order you arrange them. ${a.alpha ? ` ${from} transparency is composited onto the page background, since PDF pages are opaque.` : ""} ${PRIVACY}`,
useCases: [
"Combine scanned or photographed pages into one document to email or file",
`Produce a printable PDF from ${from} artwork at a fixed paper size`,
`Bundle a set of ${from} images into one attachment instead of a zip`,
],
features: [
`Many ${from} files into one PDF, ordered how you arrange them`,
"Page size, orientation, margin, and fit control",
"Original image resolution preserved, with no silent downsampling",
priv(from),
],
faqs: [
{
q: "Can I control the page order?",
a: "Yes. Arrange the files before converting and each becomes a page in that order.",
},
{
q: `Will the PDF be much larger than the ${from} files?`,
a: `Roughly the sum of the images plus a little structure. PDF wraps the image data rather than re-compressing it, so a large ${from} makes a large page. Compress the images first if size matters.`,
},
{
q: `Anything to know about ${from} specifically?`,
a: IMG_PDF[from],
},
{
q: "Is the text in my scans searchable?",
a: "Not from this tool, which produces image pages. Run OCR PDF afterwards to add a searchable text layer.",
},
],
};
}
function pdfToImageEntry(p) {
const { to } = p;
return {
searchTitle: `PDF to ${to} Converter Online Free`,
longDescription: `Render each page of a PDF into a separate ${to} image at a DPI you choose. 150 DPI suits screen use and 300 DPI matches print. ${PDF_TARGET[to]} ${PRIVACY}`,
useCases: [
"Pull page images out of a PDF for a slide deck or a document",
"Generate thumbnails or previews of a PDF's pages",
`Get a flat ${to} of a page for somewhere PDF is not accepted`,
],
features: [
`Per-page ${to} output at a DPI you select`,
"Page range selection instead of the whole document",
"Multi-page PDFs returned as a zip of numbered images",
priv("PDF"),
],
faqs: [
{
q: "What DPI should I pick?",
a: "150 for screens, 300 for print, 600 only if you plan to OCR small type afterwards. Higher DPI multiplies render time and file size quickly.",
},
{
q: "Can I convert only some pages?",
a: "Yes. Give a page range and only those pages render.",
},
{
q: `Anything to watch with ${to} output?`,
a: PDF_TIP[to],
},
{
q: "Does this work on password-protected PDFs?",
a: "Not directly. Remove the password with Unlock PDF first, then convert.",
},
],
};
}
function videoEntry(p) {
const { from, to } = p;
const a = V[from];
const b = V[to];
return {
searchTitle: `${from} to ${to} Converter Online Free`,
longDescription: `Convert ${a.full} video to ${b.full}. ${b.full} plays ${b.plays}, while ${from} plays ${a.plays}. Container and codec are handled together, so what comes out is a file that genuinely opens rather than a renamed extension. ${PRIVACY}`,
useCases: [
`Make a ${from} recording playable on a device or site that rejects it`,
`Standardise mixed-format footage on ${to} before editing`,
`Re-wrap a ${from} for upload without re-shooting or re-exporting`,
],
features: [
`Reads what is actually inside the ${from}, rather than trusting the extension`,
"Bitrate and resolution control, or a stream copy when the codecs already line up",
"Audio carried across and re-encoded only when it has to be",
priv(from),
],
faqs: [
{
q: `Does converting ${from} to ${to} reduce quality?`,
a: `${VSRC[from].convert} Where a re-encode is needed it is a real one, so start from the best source you have.`,
},
{ q: `Why target ${to}?`, a: b.pick },
{
q: "How long does it take?",
a: "A stream copy takes seconds. A full re-encode is bound by your CPU or GPU and scales with length and resolution. Progress is reported live while it runs.",
},
],
};
}
function videoToGifEntry(p) {
const { from } = p;
return {
searchTitle: `${from} to GIF Converter Online Free`,
longDescription: `Turn a clip of ${from} video into an animated GIF. GIF displays anywhere an image does, including comment boxes and chat clients that block video, and it charges you for that: 256 colours per frame and a file often larger than the video it came from. ${VSRC[from].gif} ${PRIVACY}`,
useCases: [
"Make a short loop for a README, issue thread, or chat where video will not embed",
`Cut a reaction clip out of a longer ${from}`,
"Produce an autoplaying preview for somewhere that strips video tags",
],
features: [
"Trim to a start and end point before the GIF is built",
"Frame rate and width control, the two knobs that actually decide the size",
"Per-clip optimised palette to limit banding inside GIF's 256 colours",
`Handles what ${from} actually contains without a separate decode step`,
priv(from),
],
faqs: [
{
q: `Why is my GIF bigger than the ${from} it came from?`,
a: "Because GIF stores frames with no motion compensation, while modern video codecs encode only what changed. A 10-second GIF at full frame rate can easily beat the source. Cut the length, then the width, then the frame rate.",
},
{
q: "Why does the colour look banded?",
a: "GIF allows 256 colours per frame, and gradients and skin tones suffer most. If the destination takes video or WebP, both look far better at a fraction of the size.",
},
{
q: `Any tips when the source is ${from}?`,
a: GIF_TIP[from],
},
{
q: "What length works well?",
a: "Under about 5 seconds, at 10-15fps and around 480px wide, keeps most GIFs manageable.",
},
],
};
}
function gifToVideoEntry(p) {
const { to } = p;
const b = V[to];
return {
searchTitle: `GIF to ${to} Converter Online Free`,
longDescription: `Convert an animated GIF into ${b.full} video. This is usually a large win: real video codecs use motion compensation, so the same animation typically lands 5-20x smaller than the GIF and looks better, having escaped GIF's 256-colour ceiling. ${b.full} plays ${b.plays}. ${PRIVACY}`,
useCases: [
`Cut page weight by replacing a heavy GIF with a looping ${to}`,
"Get a GIF onto a platform that accepts video but not animated images",
"Recover a usable master from an animation that only survives as a GIF",
],
features: [
"Frame timing preserved, including variable per-frame delays",
"Loop-friendly output suited to autoplay and muted playback",
"Batch conversion across many GIFs",
priv("GIF"),
],
faqs: [
{
q: "Will it still loop?",
a: "The video carries the same frames and timing. Looping then becomes a playback setting: the loop attribute in HTML, or the platform's own toggle.",
},
{
q: "What happens to GIF transparency?",
a: "Video has no alpha channel, so transparent pixels are composited onto a solid background. Animated WebP is the better target when that transparency matters.",
},
{
q: `Why target ${to}?`,
a: b.pick,
},
{
q: "How much smaller will it be?",
a: "Usually 5-20x, and more on long or high-frame-rate animations. Flat graphics with little motion gain the least.",
},
],
};
}
function extractAudioEntry(p) {
const { from, to } = p;
const b = A[to];
return {
searchTitle: `${from} to ${to} Converter Online Free`,
longDescription: `Pull the audio track out of a ${from} video and save it as ${to}. ${b.full} is ${b.note}. ${VSRC[from].audio} ${PRIVACY}`,
useCases: [
`Get a podcast or interview out of a recorded ${from} for offline listening`,
`Lift music or a voiceover from ${from} footage for reuse`,
"Feed the audio to transcription or analysis that will not take video",
],
features: [
"Audio extracted directly, with no needless video decode",
b.lossy
? "Bitrate selectable, so you set the size-versus-fidelity point"
: "Bit-exact extraction with no re-encode where the source allows it",
`Track selection on ${from} files carrying more than one audio stream`,
priv(from),
],
faqs: [
{
q: "Does extracting audio re-encode it?",
a: b.lossy
? `Yes, unless the source track is already ${to}, in which case it is copied out untouched. Going from one lossy format to another loses a little, so pick the highest bitrate you are willing to store.`
: `No. ${to} is lossless, so the decoded audio is written out without a second lossy pass.`,
},
{
q: `My ${from} has several audio tracks. Which one do I get?`,
a: "The default track unless you choose another. Multi-track files list what they contain so you can pick.",
},
{
q: `Why pick ${to} here?`,
a: b.pick,
},
{
q: `How big will the ${to} be?`,
a: b.lossy
? "Roughly bitrate times duration. At 192kbps that works out around 1.4MB per minute."
: `${to} is uncompressed or losslessly compressed, so expect tens of megabytes per hour at minimum. Choose MP3 when size matters more than fidelity.`,
},
],
};
}
function audioEntry(p) {
const { from, to } = p;
const a = A[from];
const b = A[to];
const transcode =
a.lossy && b.lossy
? "Both formats are lossy, so this is a second compression pass over audio that was already compressed. Fine for listening, wrong for archiving."
: a.lossy && !b.lossy
? `${to} is lossless, but it cannot rebuild what ${from} already threw away. You get a larger file containing exactly what the ${from} held, which helps before editing and achieves nothing as an archive upgrade.`
: `${from} is lossless and ${to} is lossy, so this discards audio data in exchange for a much smaller file.`;
return {
searchTitle: `${from} to ${to} Converter Online Free`,
longDescription: `Convert ${a.full} audio to ${b.full}. You are starting from ${a.full}, which is ${a.note}. ${b.full} is ${b.note}. ${transcode} ${PRIVACY}`,
useCases: [
to === "MP3"
? `Get a ${from} file playing on a car stereo, an old phone, or anything stubborn`
: `Move a ${from} into ${to} for editing or archiving`,
"Standardise a mixed music or voice library on a single format",
`Meet an upload requirement that names ${to} specifically`,
],
features: [
b.lossy ? "Bitrate and sample rate control" : "Lossless encode, with no quality call to make",
`Tags, artwork, and track metadata carried across wherever ${to} supports them`,
"Batch conversion across a whole library",
priv(from),
],
faqs: [
{
q: `Will ${from} to ${to} lose quality?`,
a: b.lossy
? `Some, unavoidably. Pick a bitrate you are happy to live with, because the discarded detail is gone for good and re-encoding upward later recovers nothing.`
: `Nothing is lost on this hop. Whether that is useful depends on the source: from a lossy original you get a faithful copy of already-reduced audio, not a restored one.`,
},
{
q: "Are tags and cover art kept?",
a: `Yes, wherever the target has somewhere to put them. ${to} carries the common fields; anything exotic in the source may not survive.`,
},
{ q: `Anything peculiar about ${from} I should know?`, a: a.tip },
{
q: "Can I convert a whole folder at once?",
a: "Yes. Batch conversion applies the same settings to every file and returns them together.",
},
],
};
}
function spreadsheetEntry(p) {
const { from, to } = p;
return {
searchTitle: `${from} to ${to} Converter Online Free`,
longDescription: `Convert an ${from} workbook to ${to}. ${to} is plain text, which is what makes it portable and also what makes it lossy in a structural sense: formulas collapse to their computed values, and formatting, charts, and multiple sheets do not survive. ${PRIVACY}`,
useCases: [
"Feed spreadsheet data into a script, a database import, or a data pipeline",
`Hand data to someone without Excel, or to a tool that only reads ${to}`,
"Diff or version-control tabular data as text",
],
features: [
"Formulas exported as their computed values",
"Delimiter and encoding control for imports that are fussy about both",
"Sheet selection on multi-sheet workbooks",
priv(from),
],
faqs: [
{
q: "What happens to multiple sheets?",
a: `${to} holds one table, so each sheet becomes its own file. Pick a single sheet if you want a single output.`,
},
{
q: "Are my formulas preserved?",
a: `Their results are. ${to} has no concept of a formula, so what lands is the value each cell displayed.`,
},
{
q: "Will accented or non-Latin characters survive?",
a: "Yes, the output is UTF-8. If a downstream tool mangles them it is reading the file as something else, which is normally fixable by choosing the encoding at import.",
},
],
};
}
const BY_BASE = {
convert: rasterEntry,
vectorize: vectorizeEntry,
"svg-to-raster": svgToRasterEntry,
"image-to-pdf": imageToPdfEntry,
"pdf-to-image": pdfToImageEntry,
"convert-video": videoEntry,
"video-to-gif": videoToGifEntry,
"gif-to-video": gifToVideoEntry,
"extract-audio": extractAudioEntry,
"convert-audio": audioEntry,
"convert-spreadsheet": spreadsheetEntry,
};
function serialize(id, seo) {
const j = (s) => JSON.stringify(s);
return ` ${j(id)}: {
searchTitle: ${j(seo.searchTitle)},
longDescription: ${j(seo.longDescription)},
useCases: [
${seo.useCases.map((u) => ` ${j(u)},`).join("\n")}
],
features: [
${seo.features.map((f) => ` ${j(f)},`).join("\n")}
],
faqs: [
${seo.faqs.map((f) => ` { q: ${j(f.q)}, a: ${j(f.a)} },`).join("\n")}
],
},`;
}
function entry(p) {
const build = BY_BASE[p.base];
if (!build) {
throw new Error(
`No SEO copy builder for base ${JSON.stringify(p.base)} (preset ${p.id}). Add one to BY_BASE.`,
);
}
return serialize(p.id, build(p));
}
function esc(s) {
return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
}
@@ -74,6 +837,8 @@ function main() {
src = src.replace(/(export const TOOL_SEO: Record<string, ToolSeo> = \{\n)/, `$1${gen}\n`);
}
writeFileSync(SEO, src);
// Emitted compactly; run `pnpm lint:fix` (or let the pre-commit hook do it) to
// reflow the block to Biome's formatting.
console.log(`Injected ${CONVERSION_PRESETS.length} SEO entries`);
}
main();