Processamento dinâmico de imagens em Scala com ImageMagick
Execute o ImageMagick a partir do Scala para redimensionar e aplicar marca-d’água às imagens que você indicar na linha de comando. O programa abaixo grava um PNG por entrada em um novo diretório, preserva seus originais e retorna um status diferente de zero se alguma imagem falhar. As imagens processadas com sucesso continuam disponíveis quando há falhas no lote.
Este guia se concentra na integração do Scala com processos e no tratamento de erros em lote. Para gerar várias larguras e um formato alternativo para um site, siga o guia separado de imagens responsivas.
Configure um pequeno projeto Scala
Use Linux, Bash, cURL, uma instalação do JDK 21 e
ImageMagick 7.1.2-31 com suporte a JPEG, PNG e FreeType.
Este exemplo foi testado com OpenJDK 21.0.12.1, Scala 2.13.18 e ImageMagick 7.1.2-31 Q16-HDRI.
Scala 2.13.18 é a versão atual da série Scala 2.13 e
oferece suporte ao JDK 21.
O ponto de entrada convert do ImageMagick 6 e os shells nativos do Windows
estão fora do escopo deste guia.
Você também precisa de um arquivo de fonte TrueType que possa ser lido. O comando de execução usa
/usr/share/fonts/liberation/LiberationSans-Regular.ttf; substitua esse caminho pelo caminho completo da sua fonte se a sua
distribuição a instalar em outro local. Cole os blocos de shell com
errexit desativado no Bash para que uma falha de comando devolva o controle ao
prompt. Cada bloco mantém inalterados o diretório e as opções do shell de quem o executa.
java -version && magick -version && magick -list format
Confira se JPEG e PNG têm suporte a leitura e
gravação na lista de formatos. Comece em um diretório de trabalho com permissão de gravação. Este
bloco cria um novo projeto scala-images e baixa do Maven Central os três arquivos
JAR do Scala com versões fixadas. Ele recusa um diretório de projeto existente; escolha outro
diretório de trabalho se esse nome já estiver em uso.
(
mkdir scala-images || exit 1
cd scala-images || exit 1
mkdir lib classes inputs || exit 1
for artifact in scala-library scala-reflect scala-compiler; do
curl -fsSLo "lib/$artifact-2.13.18.jar" \
"https://repo.maven.apache.org/maven2/org/scala-lang/$artifact/2.13.18/$artifact-2.13.18.jar" || exit 1
done
java -cp 'lib/*' scala.tools.nsc.Main -version
)
Esses são o compilador, a biblioteca de execução e a biblioteca de reflexão do Scala 2.13.18. O projeto não usa compilação via sbt nem dependências Scala de terceiros. Seus classpaths explícitos também funcionam dentro de um projeto sbt existente, sem ler as configurações de compilação do projeto pai nem adicionar dependências a ele.
Salve o programa completo de processamento em lote
Salve o código como scala-images/ImageBatch.scala. Os argumentos são um novo diretório de saída,
um caminho de fonte, um texto de marca-d’água e um ou mais caminhos locais de imagens estáticas JPEG
ou PNG. O texto aceita até 40 caracteres entre letras ASCII, dígitos, espaços, pontos, sublinhados e
hífens, começando com uma letra ou um dígito. Isso impede que o texto inclua sequências de escape de
texto e a sintaxe de leitura de arquivos do ImageMagick.
O nome de saída inclui o nome completo do arquivo de entrada: photo.jpg se torna
photo.jpg.png. Duas entradas com o mesmo nome-base são rejeitadas antes da criação
do diretório de saída. As entradas são processadas na ordem dos argumentos, incluindo as extensões
.jpg, .jpeg e .png com
letras maiúsculas e minúsculas.
import java.io.IOException
import java.nio.file.{Files, Path, Paths}
import java.util.Locale
import scala.sys.process.{Process, ProcessLogger}
import scala.util.control.NonFatal
object ImageBatch {
private def command(arguments: Seq[String], directory: Path): String = {
val output = new StringBuilder
val errors = new StringBuilder
val status = Process(arguments, directory.toFile).!(ProcessLogger(
line => { output.append(line).append('\n'); () },
line => { errors.append(line).append('\n'); () }
))
if (status != 0 || errors.nonEmpty) {
throw new IOException(s"ImageMagick status $status: ${errors.toString.trim}")
}
output.toString
}
private def processImage(input: Path, output: Path, font: Path, label: String): Boolean = {
try {
val name = input.getFileName.toString.toLowerCase(Locale.ROOT)
if (!Seq(".jpg", ".jpeg", ".png").exists(name.endsWith)) {
throw new IOException("Expected a .jpg, .jpeg, or .png filename")
}
if (!Files.isRegularFile(input) || !Files.isReadable(input)) {
throw new IOException("Not a readable regular file")
}
val stage = Files.createTempDirectory(output, ".work-")
try {
// Ordinary private names prevent ImageMagick from interpreting the reader's paths.
Files.copy(input, stage.resolve("source"))
Files.copy(font, stage.resolve("font.ttf"))
val formats = command(Seq("magick", "identify", "-regard-warnings",
"-limit", "thread", "1", "-format", "%m\n", "source"), stage)
.linesIterator.toVector
if (formats != Vector("JPEG") && formats != Vector("PNG")) {
throw new IOException("Expected one still JPEG or PNG image")
}
command(Seq(
"magick", "-regard-warnings", "-limit", "thread", "1", "source",
"-auto-orient", "-colorspace", "sRGB", "-resize", "800x600>",
"-background", "white", "-alpha", "remove", "-alpha", "off",
"-font", "font.ttf", "-pointsize", "24", "-gravity", "southeast",
"-fill", "white", "-stroke", "black", "-strokewidth", "1",
"-annotate", "+12+12", label, "-strip", "PNG24:result.png"
), stage)
Files.move(stage.resolve("result.png"), output.resolve(input.getFileName.toString + ".png"))
} finally {
val children = Files.list(stage)
try children.forEach(path => { Files.delete(path); () })
finally children.close()
Files.delete(stage)
}
println(s"OK $input")
true
} catch {
case NonFatal(error) =>
Console.err.println(s"FAILED $input: ${error.getMessage}")
false
}
}
private def run(arguments: Array[String]): Int = {
if (arguments.length < 4) {
Console.err.println("Usage: ImageBatch NEW_OUTPUT_DIR FONT.ttf LABEL INPUT [INPUT ...]")
return 2
}
val output = Paths.get(arguments(0)).toAbsolutePath
val font = Paths.get(arguments(1)).toAbsolutePath
val label = arguments(2)
val inputs = arguments.drop(3).map(value => Paths.get(value).toAbsolutePath).toVector
require(Files.isRegularFile(font) && Files.isReadable(font), "Font must be readable")
require(label.matches("[A-Za-z0-9][A-Za-z0-9 ._-]{0,39}"), "Invalid watermark label")
require(inputs.map(_.getFileName.toString).distinct.size == inputs.size,
"Input basenames must be distinct")
// Creating a new directory reserves this batch; an existing directory is never reused.
Files.createDirectory(output)
var succeeded = 0
var failed = 0
for (input <- inputs) {
if (processImage(input, output, font, label)) succeeded += 1
else failed += 1
}
println(s"Batch: $succeeded succeeded, $failed failed")
if (failed == 0) 0 else 1
}
def main(arguments: Array[String]): Unit = {
val status = try run(arguments) catch {
case NonFatal(error) =>
Console.err.println(s"Cannot start batch: ${error.getMessage}")
2
}
sys.exit(status)
}
}
Process(Seq(...), directory)
passa argumentos separados para o executável. Espaços nos caminhos não exigem a construção de um
comando de shell. No entanto, o ImageMagick tem sua própria
interpretação de nomes de arquivo, incluindo seletores de quadros e
padrões de saída. As operações de arquivo do Java copiam cada arquivo de origem para
source, executam o ImageMagick nesse diretório privado e movem
result.png para o nome literal de saída. Isso também mantém literais os
colchetes, sinais de porcentagem e hífens iniciais nos nomes dos seus arquivos.
A orientação é aplicada antes do redimensionamento e da remoção dos metadados. O redimensionamento
mantém a imagem dentro de 800×600, sem cortar, esticar ou ampliar entradas menores. Os pixels
transparentes são compostos sobre um fundo branco, e PNG24: grava um PNG RGB
opaco de 8 bits. O texto branco com contorno preto é colocado no canto inferior direito; use um
texto curto em imagens pequenas para que ele caiba.
A conversão para sRGB é genérica. Arquivos que dependem de perfis de cor ou têm ampla gama de cores, especialmente fotografias CMYK, precisam de um fluxo de trabalho com perfis ICC adequado para uma correspondência precisa de cores. Preserve seus originais. Use PNGs estáticos: o decodificador PNG lê apenas o primeiro quadro de APNG, portanto a verificação de formato não consegue detectar todas as animações. Este programa não preserva animações.
Compile e execute um exemplo real
Crie duas imagens de exemplo. Estes comandos são executados a partir do seu diretório de trabalho original e recusam a substituição de qualquer amostra que já exista.
(
cd scala-images &&
test ! -e inputs/landscape.jpg && test ! -L inputs/landscape.jpg &&
test ! -e 'inputs/portrait photo.png' && test ! -L 'inputs/portrait photo.png' &&
magick -size 1600x900 xc:steelblue inputs/landscape.jpg &&
magick -size 600x1200 xc:seagreen 'inputs/portrait photo.png'
)
Compile o programa salvo e, em seguida, invoque sua classe principal com Java. O uso de
&& impede que uma compilação malsucedida execute arquivos de classe
antigos. O diretório pai da saída deve existir; o próprio output não deve
existir.
(
cd scala-images &&
java -cp 'lib/*' scala.tools.nsc.Main -usejavacp -d classes ImageBatch.scala &&
java -cp 'classes:lib/scala-library-2.13.18.jar' ImageBatch \
output /usr/share/fonts/liberation/LiberationSans-Regular.ttf 'Scala batch' \
inputs/landscape.jpg 'inputs/portrait photo.png'
)
Uma execução bem-sucedida imprime uma linha OK por entrada e
Batch: 2 succeeded, 0 failed, com status zero.
Confira os arquivos de saída pelos nomes literais:
(
cd scala-images &&
magick identify -format '%f: %m %wx%h\n' \
output/landscape.jpg.png 'output/portrait photo.png.png'
)
A imagem em paisagem é um PNG de 800×450; a imagem em retrato é um PNG de 300×600. Abra os dois
arquivos em um visualizador de imagens e confira o texto Scala batch no canto
inferior direito. Para seu próprio lote, substitua os dois argumentos de entrada por caminhos entre
aspas e escolha um novo diretório de saída. O Java passa todos os argumentos após
ImageBatch para o programa, incluindo nomes de arquivo que começam com
-.
Mantenha o processamento em lote dentro dos limites
O laço Scala processa uma imagem por vez. Cada invocação do ImageMagick tem
-limit thread 1; isso limita as threads de processamento do ImageMagick, não a
memória total nem os programas auxiliares externos. A decodificação de uma imagem de origem grande
ainda pode ultrapassar os limites da política de recursos da sua instalação antes que o
redimensionamento a reduza. Não altere a política global apenas para executar estes exemplos. A
documentação da política de recursos explica esses limites.
Cada tarefa prepara temporariamente uma entrada, uma fonte e uma saída, e depois remove seu
diretório temporário. Os PNGs processados com sucesso se acumulam no diretório do lote. Este exemplo
não tem prazo máximo por tarefa nem limpeza em caso de interrupção; interromper a JVM pode deixar
um diretório .work-*, que você pode inspecionar e remover depois que os
processos pararem. Use este programa como um utilitário local de processamento em lote para arquivos
confiáveis, não como um serviço de upload.
Diagnostique uma falha no lote
O status de saída 1 significa que pelo menos uma imagem selecionada falhou. A linha
FAILED identifica essa entrada, e as contagens finais incluem todas as
imagens, mesmo que uma imagem posterior seja processada com sucesso. Arquivos vazios, ausentes,
ilegíveis e não reconhecidos falham sem interromper as demais tarefas. A saída temporária de uma
conversão malsucedida é removida; os arquivos processados com sucesso continuam no diretório de
saída.
O status de saída 2 significa que o processamento do lote não pôde começar, por exemplo, porque a fonte não pode ser lida, o texto da marca-d’água é inválido, os nomes-base das entradas entram em conflito ou o diretório de saída já existe. Uma nova execução recusa o diretório existente, preservando seus arquivos. Escolha um novo destino depois de corrigir o problema. Mantenha as entradas e a fonte inalteradas durante o processamento e não permita que outro processo altere um diretório de lote enquanto ele estiver sob o controle deste programa.
-regard-warnings trata alguns avisos do decodificador
como erros; o programa também rejeita um comando que emita mensagens de diagnóstico apesar de
retornar zero. Isso não é uma verificação de integridade de imagem. Um decodificador pode recuperar
silenciosamente dados danificados. Inspecione todo o conteúdo de origem e de saída quando houver
possibilidade de corrupção; um cabeçalho válido, as dimensões esperadas ou um status de sucesso do
processo, isoladamente, não garantem que os pixels estejam intactos.
Para uma aplicação que precisa de processamento gerenciado em vez de uma instalação local do ImageMagick, veja a API de processamento de imagens da Transloadit.
