Ajout du support pour la génération de PDF de kit de retouche.

This commit is contained in:
FyloZ 2021-04-30 18:37:24 -04:00
parent 4283f9756c
commit ced46dd83d
16 changed files with 389 additions and 198 deletions

View File

@ -1,42 +0,0 @@
package dev.fyloz.colorrecipesexplorer.service.files;
import dev.fyloz.colorrecipesexplorer.utils.PdfBuilder;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.core.io.ResourceLoader;
import org.springframework.stereotype.Service;
import java.io.IOException;
@Service
public class TouchUpKitService {
private static final String TOUCH_UP_FR = "KIT DE RETOUCHE";
private static final String TOUCH_UP_EN = "TOUCH UP KIT";
public static final int FONT_SIZE = 42;
private final ResourceLoader resourceLoader;
@Autowired
public TouchUpKitService(ResourceLoader resourceLoader) {
this.resourceLoader = resourceLoader;
}
/**
* Génère un PDF de kit de retouche pour une job.
*
* @param jobNumber La job
* @return Le PDF de kit de retouche pour la job
*/
public byte[] generatePdfForJobNumber(String jobNumber) {
try {
return new PdfBuilder(resourceLoader, true, FONT_SIZE)
.addLine(TOUCH_UP_FR, true, 0)
.addLine(TOUCH_UP_EN, true, 0)
.addLine(jobNumber, false, 10)
.build();
} catch (IOException ex) {
throw new RuntimeException(String.format("Impossible de générer un PDF de kit de retouche pour la job '%s': %s", jobNumber, ex.getMessage()));
}
}
}

View File

@ -1,130 +0,0 @@
package dev.fyloz.colorrecipesexplorer.utils;
import org.apache.pdfbox.pdmodel.PDDocument;
import org.apache.pdfbox.pdmodel.PDPage;
import org.apache.pdfbox.pdmodel.PDPageContentStream;
import org.apache.pdfbox.pdmodel.font.PDFont;
import org.apache.pdfbox.pdmodel.font.PDType0Font;
import org.springframework.core.io.ResourceLoader;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.util.ArrayList;
import java.util.Collection;
public class PdfBuilder {
private static final String PATH_FONT_ARIAL_BOLD = "classpath:fonts/arialbd.ttf";
private final PDFont font;
private final PDDocument document = new PDDocument();
private final PDPage page = new PDPage();
private final Collection<PdfLine> lines = new ArrayList<>();
private final boolean duplicated;
private final int fontSize;
private final int fontSizeBold;
private final int lineSpacing;
public PdfBuilder(ResourceLoader resourceLoader, boolean duplicated, int fontSize) throws IOException {
this.duplicated = duplicated;
this.fontSize = fontSize;
this.fontSizeBold = this.fontSize + 12;
this.lineSpacing = (int) (this.fontSize * 1.5f);
document.addPage(page);
font = PDType0Font.load(document, resourceLoader.getResource(PATH_FONT_ARIAL_BOLD).getInputStream());
}
public PdfBuilder addLine(String text, boolean bold, int marginTop) {
lines.add(new PdfLine(text, bold, marginTop));
return this;
}
public byte[] build() throws IOException {
writeContent();
ByteArrayOutputStream outputStream = new ByteArrayOutputStream();
document.save(outputStream);
document.close();
return outputStream.toByteArray();
}
private void writeContent() throws IOException {
PDPageContentStream contentStream = new PDPageContentStream(document, page);
contentStream.beginText();
int marginTop = 30;
for (PdfLine line : lines) {
writeCenteredText(contentStream, line, marginTop);
marginTop += lineSpacing;
}
if (duplicated) {
marginTop = (int) page.getMediaBox().getHeight() / 2;
for (PdfLine line : lines) {
writeCenteredText(contentStream, line, marginTop);
marginTop += lineSpacing;
}
}
contentStream.endText();
contentStream.close();
}
private void writeCenteredText(PDPageContentStream contentStream, PdfLine line, int marginTop) throws IOException {
float textWidth = font.getStringWidth(line.getText()) / 1000 * (line.isBold() ? fontSizeBold : fontSize);
float textHeight = font.getFontDescriptor().getFontBoundingBox().getHeight() / 1000 * (line.isBold() ? fontSizeBold : fontSize);
float textX = (page.getMediaBox().getWidth() - textWidth) / 2f;
float textY = (page.getMediaBox().getHeight() - (marginTop + line.getMarginTop()) - textHeight);
if (line.isBold()) contentStream.setFont(font, fontSizeBold);
else contentStream.setFont(font, fontSize);
contentStream.newLineAtOffset(textX, textY);
contentStream.showText(line.getText());
contentStream.newLineAtOffset(-textX, -textY); // Réinitialise la position pour la prochaine ligne
}
public static class PdfLine {
private String text;
private boolean bold;
private int marginTop;
public PdfLine() {
}
public PdfLine(String text, boolean bold, int marginTop) {
this.text = text;
this.bold = bold;
this.marginTop = marginTop;
}
public String getText() {
return text;
}
public void setText(String text) {
this.text = text;
}
public boolean isBold() {
return bold;
}
public void setBold(boolean bold) {
this.bold = bold;
}
public int getMarginTop() {
return marginTop;
}
public void setMarginTop(int marginTop) {
this.marginTop = marginTop;
}
}
}

View File

@ -6,4 +6,5 @@ import org.springframework.boot.context.properties.ConfigurationProperties
class CreProperties {
var workingDirectory: String = "data"
var deploymentUrl: String = "http://localhost"
var cacheGeneratedFiles: Boolean = false
}

View File

@ -0,0 +1,24 @@
package dev.fyloz.colorrecipesexplorer.rest.files
import dev.fyloz.colorrecipesexplorer.service.files.TouchUpKitService
import org.springframework.core.io.ByteArrayResource
import org.springframework.http.MediaType
import org.springframework.http.ResponseEntity
import org.springframework.web.bind.annotation.*
@RestController
@RequestMapping("/api/touchup")
class TouchUpKitController(
private val touchUpKitService: TouchUpKitService
) {
@GetMapping
fun getJobPdf(@RequestParam job: String): ResponseEntity<ByteArrayResource> {
with(touchUpKitService.generateJobPdfResource(job)) {
return ResponseEntity.ok()
.header("Content-Disposition", "filename=TouchUpKit_$job.pdf")
.contentLength(this.contentLength())
.contentType(MediaType.APPLICATION_PDF)
.body(this)
}
}
}

View File

@ -2,7 +2,7 @@ package dev.fyloz.colorrecipesexplorer.service
import dev.fyloz.colorrecipesexplorer.exception.RestException
import dev.fyloz.colorrecipesexplorer.model.*
import dev.fyloz.colorrecipesexplorer.service.utils.mapMayThrow
import dev.fyloz.colorrecipesexplorer.utils.mapMayThrow
import org.springframework.http.HttpStatus
import org.springframework.stereotype.Service
import javax.transaction.Transactional

View File

@ -3,8 +3,8 @@ package dev.fyloz.colorrecipesexplorer.service
import dev.fyloz.colorrecipesexplorer.exception.RestException
import dev.fyloz.colorrecipesexplorer.model.*
import dev.fyloz.colorrecipesexplorer.repository.MixMaterialRepository
import dev.fyloz.colorrecipesexplorer.service.utils.findDuplicated
import dev.fyloz.colorrecipesexplorer.service.utils.hasGaps
import dev.fyloz.colorrecipesexplorer.utils.findDuplicated
import dev.fyloz.colorrecipesexplorer.utils.hasGaps
import org.springframework.context.annotation.Lazy
import org.springframework.http.HttpStatus
import org.springframework.stereotype.Service

View File

@ -2,7 +2,7 @@ package dev.fyloz.colorrecipesexplorer.service
import dev.fyloz.colorrecipesexplorer.model.*
import dev.fyloz.colorrecipesexplorer.repository.MixRepository
import dev.fyloz.colorrecipesexplorer.service.utils.setAll
import dev.fyloz.colorrecipesexplorer.utils.setAll
import org.springframework.context.annotation.Lazy
import org.springframework.stereotype.Service
import javax.transaction.Transactional

View File

@ -4,7 +4,7 @@ import dev.fyloz.colorrecipesexplorer.model.*
import dev.fyloz.colorrecipesexplorer.model.validation.or
import dev.fyloz.colorrecipesexplorer.repository.RecipeRepository
import dev.fyloz.colorrecipesexplorer.service.files.FileService
import dev.fyloz.colorrecipesexplorer.service.utils.setAll
import dev.fyloz.colorrecipesexplorer.utils.setAll
import org.springframework.context.annotation.Lazy
import org.springframework.stereotype.Service
import org.springframework.web.multipart.MultipartFile
@ -222,9 +222,9 @@ class RecipeImageServiceImpl(
this@getDirectory.imagesDirectoryPath.fullPath().path
})
fun getImageFileName(recipe: Recipe, id: Long) =
private fun getImageFileName(recipe: Recipe, id: Long) =
"${recipe.name}$RECIPE_IMAGE_ID_DELIMITER$id"
fun getImagePath(recipe: Recipe, name: String) =
private fun getImagePath(recipe: Recipe, name: String) =
"${recipe.imagesDirectoryPath}/$name$RECIPE_IMAGE_EXTENSION"
}

View File

@ -3,8 +3,8 @@ package dev.fyloz.colorrecipesexplorer.service
import dev.fyloz.colorrecipesexplorer.exception.RestException
import dev.fyloz.colorrecipesexplorer.model.*
import dev.fyloz.colorrecipesexplorer.repository.RecipeStepRepository
import dev.fyloz.colorrecipesexplorer.service.utils.findDuplicated
import dev.fyloz.colorrecipesexplorer.service.utils.hasGaps
import dev.fyloz.colorrecipesexplorer.utils.findDuplicated
import dev.fyloz.colorrecipesexplorer.utils.hasGaps
import org.springframework.http.HttpStatus
import org.springframework.stereotype.Service

View File

@ -28,9 +28,12 @@ interface FileService {
/** Creates a file at the given [path]. */
fun create(path: String)
/** Writes the given [file] at the given [path]. If the file already exists, it will be overwritten if [overwrite] is true. */
/** Writes the given [file] to the given [path]. If the file already exists, it will be overwritten if [overwrite] is enabled. */
fun write(file: MultipartFile, path: String, overwrite: Boolean)
/** Writes the given [data] to the given [path]. If the file at the path already exists, it will be overwritten if [overwrite] is enabled. */
fun write(data: ByteArrayResource, path: String, overwrite: Boolean)
/** Deletes the file at the given [path]. */
fun delete(path: String)
@ -71,23 +74,15 @@ class FileServiceImpl(
}
}
override fun write(file: MultipartFile, path: String, overwrite: Boolean) {
val fullPath = path.fullPath()
if (exists(path)) {
if (!overwrite) throw FileExistsException(path)
} else {
create(path)
override fun write(file: MultipartFile, path: String, overwrite: Boolean) =
prepareWrite(path, overwrite) {
file.transferTo(this.toPath())
}
try {
withFileAt(fullPath) {
file.transferTo(this.toPath())
}
} catch (ex: IOException) {
FileWriteException(path).logAndThrow(ex, logger)
override fun write(data: ByteArrayResource, path: String, overwrite: Boolean) =
prepareWrite(path, overwrite) {
this.writeBytes(data.byteArray)
}
}
override fun delete(path: String) {
try {
@ -108,6 +103,24 @@ class FileServiceImpl(
return FilePath("${creProperties.workingDirectory}/$this")
}
private fun prepareWrite(path: String, overwrite: Boolean, op: File.() -> Unit) {
val fullPath = path.fullPath()
if (exists(path)) {
if (!overwrite) throw FileExistsException(path)
} else {
create(path)
}
try {
withFileAt(fullPath) {
this.op()
}
} catch (ex: IOException) {
FileWriteException(path).logAndThrow(ex, logger)
}
}
/** Runs the given [block] in the context of a file with the given [fullPath]. */
private fun <T> withFileAt(fullPath: FilePath, block: File.() -> T) =
fullPath.file.block()

View File

@ -0,0 +1,78 @@
package dev.fyloz.colorrecipesexplorer.service.files
import dev.fyloz.colorrecipesexplorer.config.properties.CreProperties
import dev.fyloz.colorrecipesexplorer.utils.*
import org.springframework.core.io.ByteArrayResource
import org.springframework.stereotype.Service
private const val TOUCH_UP_KIT_FILES_PATH = "pdf/touchupkits"
const val TOUCH_UP_TEXT_FR = "KIT DE RETOUCHE"
const val TOUCH_UP_TEXT_EN = "TOUCH UP KIT"
interface TouchUpKitService {
/** Generates and returns a [PdfDocument] for the given [job]. */
fun generateJobPdf(job: String): PdfDocument
/**
* Generates and returns a [PdfDocument] for the given [job] as a [ByteArrayResource].
*
* If [CreProperties.cacheGeneratedFiles] is enabled and a file exists for the job, its content will be returned.
* If caching is enabled but no file exists for the job, the generated ByteArrayResource will be cached on the disk.
*/
fun generateJobPdfResource(job: String): ByteArrayResource
/** Writes the given [document] to the [FileService] if [CreProperties.cacheGeneratedFiles] is enabled. */
fun String.cachePdfDocument(document: PdfDocument)
}
@Service
class TouchUpKitServiceImpl(
private val fileService: FileService,
private val creProperties: CreProperties
) : TouchUpKitService {
override fun generateJobPdf(job: String) = pdf {
container {
centeredVertically = true
drawContainerBottom = true
text(TOUCH_UP_TEXT_FR) {
bold = true
fontSize = PDF_DEFAULT_FONT_SIZE + 12
}
text(TOUCH_UP_TEXT_EN) {
bold = true
fontSize = PDF_DEFAULT_FONT_SIZE + 12
}
text(job) {
marginTop = 10f
}
}
container(containers[0]) {
drawContainerBottom = false
}
}
override fun generateJobPdfResource(job: String): ByteArrayResource {
if (creProperties.cacheGeneratedFiles) {
with(job.pdfDocumentPath()) {
if (fileService.exists(this)) {
return fileService.read(this)
}
}
}
return generateJobPdf(job).apply {
job.cachePdfDocument(this)
}.toByteArrayResource()
}
override fun String.cachePdfDocument(document: PdfDocument) {
if (!creProperties.cacheGeneratedFiles) return
fileService.write(document.toByteArrayResource(), this.pdfDocumentPath(), true)
}
private fun String.pdfDocumentPath() =
"$TOUCH_UP_KIT_FILES_PATH/$this.pdf"
}

View File

@ -1,4 +1,4 @@
package dev.fyloz.colorrecipesexplorer.service.utils
package dev.fyloz.colorrecipesexplorer.utils
/** Returns a list containing the result of the given [transform] applied to each item of the [Iterable]. If the given [transform] throws, the [Throwable] will be passed to the given [throwableConsumer]. */
inline fun <T, R, reified E : Throwable> Iterable<T>.mapMayThrow(

View File

@ -0,0 +1,125 @@
package dev.fyloz.colorrecipesexplorer.utils
import org.apache.pdfbox.pdmodel.PDDocument
import org.apache.pdfbox.pdmodel.PDPage
import org.apache.pdfbox.pdmodel.PDPageContentStream
import org.apache.pdfbox.pdmodel.font.PDFont
import org.apache.pdfbox.pdmodel.font.PDType1Font
import org.springframework.core.io.ByteArrayResource
import java.io.ByteArrayOutputStream
val PDF_DEFAULT_FONT: PDType1Font = PDType1Font.HELVETICA
val PDF_DEFAULT_FONT_BOLD: PDType1Font = PDType1Font.HELVETICA_BOLD
const val PDF_DEFAULT_FONT_SIZE = 42f
val PDF_DASH_LINE_PATTERN = floatArrayOf(4f)
/** Creates a [PdfContainer] and apply the given [block]. */
fun pdf(block: PdfDocument.() -> Unit = {}) =
PdfDocument().apply { block() }
/** Creates a [PdfContainer] in the given [PdfDocument] and apply the given [block]. If a [container] is given, the receiver of the block will be a clone of it. */
fun PdfDocument.container(container: PdfContainer = PdfContainer(), block: PdfContainer.() -> Unit) {
this.containers += PdfContainer(container).apply(block)
}
/** Creates a [PdfText] with the given [text] in the given [PdfContainer] and apply the given [block]. */
fun PdfContainer.text(text: String, block: PdfText.() -> Unit) {
this.texts += PdfText(text = text).apply(block)
}
fun PdfDocument.toByteArrayResource(): ByteArrayResource = PDDocument().use { document ->
val page = PDPage()
document.addPage(page)
fun PDPageContentStream.drawText(text: PdfText, y: Float) {
val font = if (text.bold) fontBold else font
val textWidth = font.getStringWidth(text.text) / 1000 * text.fontSize
val textX = (page.mediaBox.width - textWidth) / 2f
beginText()
newLineAtOffset(textX, y)
setFont(font, text.fontSize)
showText(text.text)
endText()
}
fun PDPageContentStream.drawDashLine(y: Float) {
moveTo(0f, y)
lineTo(page.mediaBox.width, y)
setLineDashPattern(PDF_DASH_LINE_PATTERN, 0f)
stroke()
}
fun PDPageContentStream.drawContainer(container: PdfContainer, y: Float, height: Float) {
var textY = y
if (container.centeredVertically) {
val textsHeight = container.texts
.map { it.fontSize + it.marginTop }
.reduce { acc, textHeight -> acc + textHeight }
textY -= (height - textsHeight) / 2f
}
if (container.drawContainerBottom) {
this.drawDashLine(y - height)
}
container.texts.forEach { text ->
textY -= text.fontSize + text.marginTop
this.drawText(text, textY)
}
}
PDPageContentStream(document, page).use {
var containerY = page.mediaBox.height
val computedSizeContainerCount = containers
.filter { it.height < 0 }
.count()
val computedSizeContainersHeight = containerY / computedSizeContainerCount
containers.forEach { container ->
val height = if (container.height < 0)
computedSizeContainersHeight
else
container.height
it.drawContainer(container, containerY, height)
containerY -= height
}
}
ByteArrayOutputStream().use {
document.save(it)
ByteArrayResource(it.toByteArray())
}
}
data class PdfDocument(
var font: PDFont = PDF_DEFAULT_FONT,
var fontBold: PDFont = PDF_DEFAULT_FONT_BOLD,
val containers: MutableList<PdfContainer> = mutableListOf()
)
data class PdfContainer(
var height: Float = -1f,
var centeredVertically: Boolean = false,
var drawContainerBottom: Boolean = false,
val texts: MutableList<PdfText> = mutableListOf()
) {
constructor(container: PdfContainer) : this(
container.height,
container.centeredVertically,
container.drawContainerBottom,
container.texts
)
}
data class PdfText(
var text: String = "Text",
var bold: Boolean = false,
var fontSize: Float = PDF_DEFAULT_FONT_SIZE,
var marginTop: Float = 0f
)

View File

@ -3,6 +3,7 @@ server.port=9090
# CRE
cre.server.working-directory=data
cre.server.deployment-url=http://localhost:9090
cre.server.cache-generated-files=true
cre.security.jwt-secret=CtnvGQjgZ44A1fh295gE
cre.security.jwt-duration=18000000
# Root user

View File

@ -8,6 +8,7 @@ import io.mockk.*
import org.junit.jupiter.api.AfterEach
import org.junit.jupiter.api.Test
import org.springframework.mock.web.MockMultipartFile
import org.springframework.web.multipart.MultipartFile
import java.io.File
import kotlin.test.assertEquals
import kotlin.test.assertFalse
@ -165,7 +166,7 @@ class RecipeServiceTest :
private class RecipeImageServiceTestContext {
val fileService = mockk<FileService> {
every { write(any(), any(), any()) } just Runs
every { write(any<MultipartFile>(), any(), any()) } just Runs
every { delete(any()) } just Runs
}
val recipeImageService = spyk(RecipeImageServiceImpl(fileService))

View File

@ -0,0 +1,120 @@
package dev.fyloz.colorrecipesexplorer.service.files
import dev.fyloz.colorrecipesexplorer.config.properties.CreProperties
import dev.fyloz.colorrecipesexplorer.utils.PdfDocument
import dev.fyloz.colorrecipesexplorer.utils.toByteArrayResource
import io.mockk.*
import org.junit.jupiter.api.AfterEach
import org.junit.jupiter.api.Test
import org.springframework.core.io.ByteArrayResource
import kotlin.test.assertEquals
private class TouchUpKitServiceTestContext {
val fileService = mockk<FileService> {
every { write(any<ByteArrayResource>(), any(), any()) } just Runs
}
val creProperties = mockk<CreProperties> {
every { cacheGeneratedFiles } returns false
}
val touchUpKitService = spyk(TouchUpKitServiceImpl(fileService, creProperties))
val pdfDocumentData = mockk<ByteArrayResource>()
val pdfDocument = mockk<PdfDocument> {
mockkStatic(PdfDocument::toByteArrayResource)
every { toByteArrayResource() } returns pdfDocumentData
}
}
class TouchUpKitServiceTest {
private val job = "job"
@AfterEach
internal fun afterEach() {
clearAllMocks()
}
// generateJobPdf()
@Test
fun `generateJobPdf() generates a valid PdfDocument for the given job`() {
test {
val generatedPdfDocument = touchUpKitService.generateJobPdf(job)
setOf(0, 1).forEach {
assertEquals(TOUCH_UP_TEXT_FR, generatedPdfDocument.containers[it].texts[0].text)
assertEquals(TOUCH_UP_TEXT_EN, generatedPdfDocument.containers[it].texts[1].text)
assertEquals(job, generatedPdfDocument.containers[it].texts[2].text)
}
}
}
// generateJobPdfResource()
@Test
fun `generateJobPdfResource() generates and returns a ByteArrayResource for the given job then cache it`() {
test {
every { touchUpKitService.generateJobPdf(any()) } returns pdfDocument
with(touchUpKitService) {
every { job.cachePdfDocument(pdfDocument) } just Runs
}
val generatedResource = touchUpKitService.generateJobPdfResource(job)
assertEquals(pdfDocumentData, generatedResource)
verify {
with(touchUpKitService) {
job.cachePdfDocument(pdfDocument)
}
}
}
}
@Test
fun `generateJobPdfResource() returns a cached ByteArrayResource from the FileService when caching is enabled and a cached file eixsts for the given job`() {
test {
every { creProperties.cacheGeneratedFiles } returns true
every { fileService.exists(any()) } returns true
every { fileService.read(any()) } returns pdfDocumentData
val redResource = touchUpKitService.generateJobPdfResource(job)
assertEquals(pdfDocumentData, redResource)
}
}
// String.cachePdfDocument()
@Test
fun `cachePdfDocument() does nothing when caching is disabled`() {
test {
every { creProperties.cacheGeneratedFiles } returns false
with(touchUpKitService) {
job.cachePdfDocument(pdfDocument)
}
verify(exactly = 0) {
fileService.write(any<ByteArrayResource>(), any(), any())
}
}
}
@Test
fun `cachePdfDocument() writes the given document to the FileService when cache is enabled`() {
test {
every { creProperties.cacheGeneratedFiles } returns true
with(touchUpKitService) {
job.cachePdfDocument(pdfDocument)
}
verify {
fileService.write(pdfDocumentData, any(), true)
}
}
}
private fun test(test: TouchUpKitServiceTestContext.() -> Unit) {
TouchUpKitServiceTestContext().test()
}
}