migration projets: body markdown, images HD, légendes, personnes prénom/nom
- ProjetNode: body Markdown->HTML (strip HTML brut), images + légendes lues
depuis public://drive_shared_leshed/Page{UID}
- ImageResizeImport: nouveau process plugin, redimensionne les images en web HD
(max 1920px) et crée le fichier managé (idempotent)
- field_images: caption via image_field_caption, alt depuis les légendes
- PersonneGenerate: nom dans field_nom (titre auto-généré via auto_entitylabel),
dédup sur field_nom + field_prenom
- title: skip_on_empty (ligne ignorée si "Nom du projet" vide)
- CER: relation curateur sur field_projets_curates (distinct de field_projet_s_lie_s)
- deps: + league/commonmark, image_field_caption, auto_entitylabel ; - migrate_file
This commit is contained in:
+30
-1
@@ -10,6 +10,10 @@ source:
|
||||
plugin: projet_node
|
||||
data_fetcher_plugin: http
|
||||
data_parser_plugin: google_sheets
|
||||
constants:
|
||||
# Trailing slash: file_import keeps the original filename and copies the
|
||||
# image into this directory.
|
||||
images_destination: 'public://projets/'
|
||||
# The feed file for the spreadsheet. The Google Spreadsheet should be either “Public” or set to “Anyone with link can
|
||||
# view” in order for the feed to work.
|
||||
# Template: 'https://sheets.googleapis.com/v4/spreadsheets/<SHEET>/values/<TAB>?key=<KEY>'
|
||||
@@ -68,7 +72,32 @@ process:
|
||||
type:
|
||||
plugin: default_value
|
||||
default_value: projet
|
||||
title: title
|
||||
title:
|
||||
plugin: skip_on_empty
|
||||
method: row
|
||||
source: title
|
||||
message: 'Projet ignoré : « Nom du projet » vide.'
|
||||
body/value: body_html
|
||||
body/format:
|
||||
plugin: default_value
|
||||
default_value: wysiwyg
|
||||
field_images:
|
||||
plugin: sub_process
|
||||
source: images
|
||||
process:
|
||||
target_id:
|
||||
plugin: image_resize_import
|
||||
source: uri
|
||||
destination: destination
|
||||
max_width: 1920
|
||||
max_height: 1920
|
||||
reuse: true
|
||||
skip_on_missing_source: true
|
||||
alt: alt
|
||||
image_field_caption/value: caption
|
||||
image_field_caption/format:
|
||||
plugin: default_value
|
||||
default_value: wysiwyg
|
||||
field_dates/value:
|
||||
plugin: leshed_date
|
||||
source: date_start
|
||||
|
||||
@@ -0,0 +1,179 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Drupal\migrate_leshed\Plugin\migrate\process;
|
||||
|
||||
use Drupal\Core\Entity\EntityTypeManagerInterface;
|
||||
use Drupal\Core\File\FileSystemInterface;
|
||||
use Drupal\Core\Image\ImageFactory;
|
||||
use Drupal\Core\Plugin\ContainerFactoryPluginInterface;
|
||||
use Drupal\migrate\Attribute\MigrateProcess;
|
||||
use Drupal\migrate\MigrateExecutableInterface;
|
||||
use Drupal\migrate\MigrateSkipRowException;
|
||||
use Drupal\migrate\ProcessPluginBase;
|
||||
use Drupal\migrate\Row;
|
||||
use Symfony\Component\DependencyInjection\ContainerInterface;
|
||||
|
||||
/**
|
||||
* Copies a local image, downscaling it to a web-friendly size, as a File.
|
||||
*
|
||||
* The source value is a readable file path/URI. The image is scaled down
|
||||
* (aspect ratio preserved, never upscaled) to fit within max_width x
|
||||
* max_height, saved into the destination directory and registered as a
|
||||
* managed file. Returns the file id.
|
||||
*
|
||||
* Configuration:
|
||||
* - destination: (required) destination directory URI, e.g. 'public://projets/1/'.
|
||||
* - max_width: maximum width in pixels (default 1920).
|
||||
* - max_height: maximum height in pixels (default 1920).
|
||||
* - uid: owner uid of the created file (default 0).
|
||||
* - reuse: reuse an existing managed file at the destination URI (default TRUE).
|
||||
* - skip_on_missing_source: skip the value when the source is missing
|
||||
* (default TRUE) instead of failing.
|
||||
*
|
||||
* @code
|
||||
* field_images/target_id:
|
||||
* plugin: image_resize_import
|
||||
* source: uri
|
||||
* destination: destination
|
||||
* max_width: 1920
|
||||
* max_height: 1920
|
||||
* @endcode
|
||||
*/
|
||||
#[MigrateProcess(id: 'image_resize_import')]
|
||||
final class ImageResizeImport extends ProcessPluginBase implements ContainerFactoryPluginInterface {
|
||||
|
||||
/**
|
||||
* Per-run cache of already resolved destination URIs to file ids.
|
||||
*
|
||||
* @var array<string, int>
|
||||
*/
|
||||
private array $fidCache = [];
|
||||
|
||||
public function __construct(
|
||||
array $configuration,
|
||||
$plugin_id,
|
||||
$plugin_definition,
|
||||
private readonly FileSystemInterface $fileSystem,
|
||||
private readonly ImageFactory $imageFactory,
|
||||
private readonly EntityTypeManagerInterface $entityTypeManager,
|
||||
) {
|
||||
$configuration += [
|
||||
'max_width' => 1920,
|
||||
'max_height' => 1920,
|
||||
'uid' => 0,
|
||||
'reuse' => TRUE,
|
||||
'skip_on_missing_source' => TRUE,
|
||||
];
|
||||
parent::__construct($configuration, $plugin_id, $plugin_definition);
|
||||
}
|
||||
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
*/
|
||||
public static function create(ContainerInterface $container, array $configuration, $plugin_id, $plugin_definition): self {
|
||||
return new self(
|
||||
$configuration,
|
||||
$plugin_id,
|
||||
$plugin_definition,
|
||||
$container->get('file_system'),
|
||||
$container->get('image.factory'),
|
||||
$container->get('entity_type.manager'),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
*/
|
||||
public function transform($value, MigrateExecutableInterface $migrate_executable, Row $row, $destination_property): ?int {
|
||||
$source = is_string($value) ? trim($value) : '';
|
||||
if ($source === '') {
|
||||
return NULL;
|
||||
}
|
||||
|
||||
// Stream wrappers support is_file() directly; realpath() is only needed as
|
||||
// a fallback for schemes it cannot resolve.
|
||||
if (!is_file($source) && !is_file($this->fileSystem->realpath($source) ?: $source)) {
|
||||
// Skipping throws MigrateSkipRowException because, inside a sub_process,
|
||||
// only that exception drops the whole image item cleanly (SubProcess
|
||||
// catches it and `continue`s); MigrateSkipProcessException would instead
|
||||
// leave a broken delta with alt/caption but no target_id.
|
||||
return $this->skipImage($migrate_executable, "Source image '$source' is missing.");
|
||||
}
|
||||
|
||||
$directory = $this->getPropertyValue($this->configuration['destination'], $row);
|
||||
if (!is_string($directory) || $directory === '') {
|
||||
return $this->skipImage($migrate_executable, "Missing 'destination' for image '$source'.");
|
||||
}
|
||||
$directory = rtrim($directory, '/') . '/';
|
||||
$destination = $directory . $this->fileSystem->basename($source);
|
||||
|
||||
// Reuse an already imported file (idempotency), memoized for the run.
|
||||
if ($this->configuration['reuse']) {
|
||||
if (isset($this->fidCache[$destination])) {
|
||||
return $this->fidCache[$destination];
|
||||
}
|
||||
$existing = $this->entityTypeManager->getStorage('file')
|
||||
->loadByProperties(['uri' => $destination]);
|
||||
if ($existing) {
|
||||
return $this->fidCache[$destination] = (int) reset($existing)->id();
|
||||
}
|
||||
}
|
||||
|
||||
// Any infrastructure failure only drops the current image (logged), so a
|
||||
// single unreadable/oversized file never aborts the whole batch.
|
||||
try {
|
||||
if (!$this->fileSystem->prepareDirectory($directory, FileSystemInterface::CREATE_DIRECTORY | FileSystemInterface::MODIFY_PERMISSIONS)) {
|
||||
return $this->skipImage($migrate_executable, "Cannot prepare directory '$directory'.");
|
||||
}
|
||||
|
||||
$image = $this->imageFactory->get($source);
|
||||
if (!$image->isValid()) {
|
||||
return $this->skipImage($migrate_executable, "Source '$source' is not a valid image.");
|
||||
}
|
||||
// Downscale only: scale() never upscales when upscale is FALSE.
|
||||
$image->scale((int) $this->configuration['max_width'], (int) $this->configuration['max_height']);
|
||||
if (!$image->save($destination)) {
|
||||
return $this->skipImage($migrate_executable, "Failed to write resized image to '$destination'.");
|
||||
}
|
||||
|
||||
$file = $this->entityTypeManager->getStorage('file')->create([
|
||||
'uri' => $destination,
|
||||
'uid' => (int) $this->getPropertyValue($this->configuration['uid'], $row),
|
||||
'status' => 1,
|
||||
]);
|
||||
$file->save();
|
||||
}
|
||||
catch (\Throwable $e) {
|
||||
return $this->skipImage($migrate_executable, "Import of image '$source' failed: " . $e->getMessage());
|
||||
}
|
||||
|
||||
return $this->fidCache[$destination] = (int) $file->id();
|
||||
}
|
||||
|
||||
/**
|
||||
* Logs a message and skips the current image item.
|
||||
*
|
||||
* @throws \Drupal\migrate\MigrateSkipRowException
|
||||
*/
|
||||
private function skipImage(MigrateExecutableInterface $migrate_executable, string $message): never {
|
||||
$migrate_executable->saveMessage($message);
|
||||
throw new MigrateSkipRowException($message, FALSE);
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves a configuration value, supporting destination (@) references.
|
||||
*/
|
||||
private function getPropertyValue(mixed $key, Row $row): mixed {
|
||||
if (is_string($key) && str_starts_with($key, '@')) {
|
||||
return $row->get(substr($key, 1));
|
||||
}
|
||||
if (is_string($key) && $row->hasSourceProperty($key)) {
|
||||
return $row->getSourceProperty($key);
|
||||
}
|
||||
|
||||
return $key;
|
||||
}
|
||||
|
||||
}
|
||||
@@ -18,8 +18,9 @@ use Symfony\Component\DependencyInjection\ContainerInterface;
|
||||
* Input: a string such as "Julie Faitot et Jonathan Loppin" or
|
||||
* "Sonja Beaudouin, Alexandre Delabrière". Names are split on "et" and
|
||||
* commas. For each name, the first word is used as 'field_prenom' and the
|
||||
* rest as the node title. An existing node with the same title and prenom
|
||||
* is reused, otherwise it is created. Returns an array of node IDs.
|
||||
* rest as 'field_nom'. The node title is generated from those two fields by
|
||||
* the auto_entitylabel module on save. An existing node with the same nom and
|
||||
* prenom is reused, otherwise it is created. Returns an array of node IDs.
|
||||
*
|
||||
* When 'type_de_personne' is set in the configuration, a taxonomy term with
|
||||
* that name is found or created (without duplicates) in the
|
||||
@@ -103,7 +104,7 @@ final class PersonneGenerate extends ProcessPluginBase implements ContainerFacto
|
||||
|
||||
$query = $storage->getQuery()
|
||||
->condition('type', 'personne')
|
||||
->condition('title', $nom)
|
||||
->condition('field_nom', $nom)
|
||||
->accessCheck(FALSE)
|
||||
->range(0, 1);
|
||||
if ($prenom !== '') {
|
||||
@@ -121,9 +122,11 @@ final class PersonneGenerate extends ProcessPluginBase implements ContainerFacto
|
||||
return $nid;
|
||||
}
|
||||
|
||||
// The node title is generated from field_prenom + field_nom by
|
||||
// auto_entitylabel on save; it is intentionally not set here.
|
||||
$values = [
|
||||
'type' => 'personne',
|
||||
'title' => $nom,
|
||||
'field_nom' => $nom,
|
||||
'field_prenom' => $prenom === '' ? NULL : $prenom,
|
||||
'status' => 1,
|
||||
'uid' => $this->configuration['author_uid'] ?? 3,
|
||||
|
||||
@@ -4,11 +4,31 @@ declare(strict_types=1);
|
||||
|
||||
namespace Drupal\migrate_leshed\Plugin\migrate\source;
|
||||
|
||||
use Drupal\Core\File\FileSystemInterface;
|
||||
use Drupal\migrate\Attribute\MigrateSource;
|
||||
use Drupal\migrate_plus\Plugin\migrate\source\Url;
|
||||
use Drupal\migrate\Plugin\MigrationInterface;
|
||||
use Drupal\migrate\Row;
|
||||
use Drupal\migrate_plus\DataParserPluginManager;
|
||||
use Drupal\migrate_plus\Plugin\migrate\source\Url;
|
||||
use League\CommonMark\CommonMarkConverter;
|
||||
use Symfony\Component\DependencyInjection\ContainerInterface;
|
||||
|
||||
/**
|
||||
* Source plugin for projects, enriched with body and images from the drive.
|
||||
*
|
||||
* On top of the Google Sheets columns, this source reads the local files
|
||||
* synced from the shared Google Drive (see the `sync_leshed_drive` Makefile
|
||||
* target) that live under public://drive_shared_leshed. Files are grouped in
|
||||
* per-project folders named "Page{UID}" (optionally suffixed, e.g.
|
||||
* "Page1_Complet"), where {UID} matches the sheet UID column:
|
||||
* - "{UID}_texte_*.md": the body, written in Markdown, converted to HTML.
|
||||
* - "{UID}_images|Images/": one folder of images, each "{name}.jpg" paired
|
||||
* with a "{name}_legende.md" caption used as the (required) alt text.
|
||||
*
|
||||
* These are exposed as two extra source properties consumed by the migration:
|
||||
* - body_html: the converted HTML string (or NULL).
|
||||
* - images: a list of ['uri' => string, 'alt' => string] items.
|
||||
*
|
||||
* @MigrateSource(
|
||||
* id = "projet_node"
|
||||
* )
|
||||
@@ -16,18 +36,65 @@ use Drupal\migrate\Row;
|
||||
#[MigrateSource(id: 'projet_node')]
|
||||
final class ProjetNode extends Url {
|
||||
|
||||
/**
|
||||
* Stream wrapper URI of the synced drive folder.
|
||||
*/
|
||||
private const DRIVE_URI = 'public://drive_shared_leshed';
|
||||
|
||||
/**
|
||||
* Image file extensions to import into field_images.
|
||||
*/
|
||||
private const IMAGE_EXTENSIONS = ['png', 'gif', 'jpg', 'jpeg', 'webp'];
|
||||
|
||||
/**
|
||||
* Default destination directory when no constant is configured.
|
||||
*/
|
||||
private const DEFAULT_IMAGE_DESTINATION = 'public://projets/';
|
||||
|
||||
/**
|
||||
* Lazily instantiated Markdown converter.
|
||||
*/
|
||||
private ?CommonMarkConverter $markdownConverter = NULL;
|
||||
|
||||
/**
|
||||
* Memoized map of UID => project folder URI, built once from the drive root.
|
||||
*
|
||||
* @var array<string, string>|null
|
||||
*/
|
||||
private ?array $folderMap = NULL;
|
||||
|
||||
public function __construct(
|
||||
array $configuration,
|
||||
$plugin_id,
|
||||
$plugin_definition,
|
||||
MigrationInterface $migration,
|
||||
DataParserPluginManager $parserPluginManager,
|
||||
private readonly FileSystemInterface $fileSystem,
|
||||
) {
|
||||
parent::__construct($configuration, $plugin_id, $plugin_definition, $migration, $parserPluginManager);
|
||||
}
|
||||
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
*/
|
||||
public static function create($container, array $configuration, $plugin_id, $plugin_definition, ?MigrationInterface $migration = NULL): self {
|
||||
return new self(
|
||||
$configuration,
|
||||
$plugin_id,
|
||||
$plugin_definition,
|
||||
$migration,
|
||||
$container->get('plugin.manager.migrate_plus.data_parser'),
|
||||
$container->get('file_system'),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
*/
|
||||
public function fields(): array {
|
||||
// theese are source fields
|
||||
return [
|
||||
'id' => $this->t('Project ID'),
|
||||
'title' => $this->t('Project Title'),
|
||||
'date_start' => $this->t('Project date start'),
|
||||
'date_end' => $this->t('Project date end'),
|
||||
'projet_type' => $this->t('Type de projet'),
|
||||
'curator' => $this->t('Commissaria')
|
||||
return parent::fields() + [
|
||||
'body_html' => $this->t('Project body (HTML converted from Markdown)'),
|
||||
'images' => $this->t('Project images with captions'),
|
||||
];
|
||||
}
|
||||
|
||||
@@ -45,16 +112,211 @@ final class ProjetNode extends Url {
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
*/
|
||||
public function prepareRow(Row $row) {
|
||||
// $nid = $row->getSourceProperty('entity_id');
|
||||
// $vid = $row->getSourceProperty('revision_id');
|
||||
// $type = $row->getSourceProperty('type');
|
||||
// $language = $row->getSourceProperty('language');
|
||||
// $title = $row->getSourceProperty('title');
|
||||
// // drush_print('-- '.$nid."\t".$title."\t".$language);
|
||||
public function prepareRow(Row $row): bool {
|
||||
// Let the parent (and prepare-row event subscribers) decide first: no need
|
||||
// to hit the filesystem for a row that will be skipped.
|
||||
if (!parent::prepareRow($row)) {
|
||||
return FALSE;
|
||||
}
|
||||
|
||||
// Always define both properties so the process pipeline never references
|
||||
// an undefined source key, even when no drive folder exists yet.
|
||||
$row->setSourceProperty('body_html', NULL);
|
||||
$row->setSourceProperty('images', []);
|
||||
|
||||
$uid = trim((string) $row->getSourceProperty('id'));
|
||||
$folder = $uid !== '' ? $this->findProjectFolder($uid) : NULL;
|
||||
if ($folder !== NULL) {
|
||||
$row->setSourceProperty('body_html', $this->readBody($folder, $uid));
|
||||
$row->setSourceProperty('images', $this->readImages($folder, $uid, (string) $row->getSourceProperty('title')));
|
||||
}
|
||||
|
||||
return parent::prepareRow($row);
|
||||
return TRUE;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the public:// URI of the project folder, or NULL when missing.
|
||||
*/
|
||||
private function findProjectFolder(string $uid): ?string {
|
||||
return $this->getFolderMap()[$uid] ?? NULL;
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds (once) the UID => project folder URI map from the drive root.
|
||||
*
|
||||
* @return array<string, string>
|
||||
* Map keyed by the numeric part of each "Page{UID}[_suffix]" folder.
|
||||
*/
|
||||
private function getFolderMap(): array {
|
||||
if ($this->folderMap !== NULL) {
|
||||
return $this->folderMap;
|
||||
}
|
||||
|
||||
$this->folderMap = [];
|
||||
$base = $this->fileSystem->realpath(self::DRIVE_URI);
|
||||
if ($base === FALSE || !is_dir($base)) {
|
||||
return $this->folderMap;
|
||||
}
|
||||
// Match "Page{N}" exactly or with a suffix ("_Complet", ...). Sorting keeps
|
||||
// the resolution deterministic when e.g. "Page5" and "Page5_Complet"
|
||||
// coexist (the plain, longer-suffixed folder wins consistently).
|
||||
$entries = scandir($base) ?: [];
|
||||
sort($entries, SORT_NATURAL | SORT_FLAG_CASE);
|
||||
foreach ($entries as $entry) {
|
||||
if ($entry === '.' || $entry === '..' || !is_dir($base . '/' . $entry)) {
|
||||
continue;
|
||||
}
|
||||
if (preg_match('/^Page(\d+)(_.*)?$/', $entry, $matches)) {
|
||||
$this->folderMap[$matches[1]] = self::DRIVE_URI . '/' . $entry;
|
||||
}
|
||||
}
|
||||
|
||||
return $this->folderMap;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads the Markdown body file and converts it to HTML.
|
||||
*/
|
||||
private function readBody(string $folderUri, string $uid): ?string {
|
||||
$dir = $this->fileSystem->realpath($folderUri);
|
||||
if ($dir === FALSE) {
|
||||
return NULL;
|
||||
}
|
||||
// Prefer "{UID}_texte*.md", fall back to any Markdown file at the root.
|
||||
$preferred = '/^' . preg_quote($uid, '/') . '_texte.*\.md$/i';
|
||||
$entries = scandir($dir) ?: [];
|
||||
sort($entries, SORT_NATURAL | SORT_FLAG_CASE);
|
||||
$markdown = NULL;
|
||||
foreach ($entries as $entry) {
|
||||
if (!is_file($dir . '/' . $entry) || !preg_match('/\.md$/i', $entry)) {
|
||||
continue;
|
||||
}
|
||||
if (preg_match($preferred, $entry)) {
|
||||
$markdown = $dir . '/' . $entry;
|
||||
break;
|
||||
}
|
||||
$markdown ??= $dir . '/' . $entry;
|
||||
}
|
||||
if ($markdown === NULL) {
|
||||
return NULL;
|
||||
}
|
||||
|
||||
$contents = file_get_contents($markdown);
|
||||
if ($contents === FALSE || trim($contents) === '') {
|
||||
return NULL;
|
||||
}
|
||||
|
||||
return (string) $this->getMarkdownConverter()->convert($contents);
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds the list of images (URI + alt) for the project.
|
||||
*
|
||||
* @return array<int, array{uri: string, alt: string, caption: string, destination: string}>
|
||||
* The images to import.
|
||||
*/
|
||||
private function readImages(string $folderUri, string $uid, string $title): array {
|
||||
$dir = $this->fileSystem->realpath($folderUri);
|
||||
if ($dir === FALSE) {
|
||||
return [];
|
||||
}
|
||||
// The destination is passed through each item because a sub_process row
|
||||
// cannot read the parent migration's `constants`. It is namespaced per
|
||||
// project (UID) so identically-named files from different projects never
|
||||
// collide when file_import reuses existing files (reuse: true).
|
||||
$base = $this->configuration['constants']['images_destination']
|
||||
?? self::DEFAULT_IMAGE_DESTINATION;
|
||||
$destination = rtrim($base, '/') . '/' . $uid . '/';
|
||||
// Locate the images subfolder ("{UID}_images", case-insensitive).
|
||||
$imagesDirUri = NULL;
|
||||
$pattern = '/^' . preg_quote($uid, '/') . '_images$/i';
|
||||
foreach (scandir($dir) ?: [] as $entry) {
|
||||
if (preg_match($pattern, $entry) && is_dir($dir . '/' . $entry)) {
|
||||
$imagesDirUri = $folderUri . '/' . $entry;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if ($imagesDirUri === NULL) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$imagesDir = $this->fileSystem->realpath($imagesDirUri);
|
||||
if ($imagesDir === FALSE) {
|
||||
return [];
|
||||
}
|
||||
$images = [];
|
||||
$files = scandir($imagesDir) ?: [];
|
||||
sort($files, SORT_NATURAL | SORT_FLAG_CASE);
|
||||
foreach ($files as $file) {
|
||||
$extension = strtolower(pathinfo($file, PATHINFO_EXTENSION));
|
||||
if (!in_array($extension, self::IMAGE_EXTENSIONS, TRUE)) {
|
||||
continue;
|
||||
}
|
||||
$name = pathinfo($file, PATHINFO_FILENAME);
|
||||
$legende = $this->readCaptionFile($imagesDir . '/' . $name . '_legende.md');
|
||||
// Plain text (required alt), falling back to the project title. Capped to
|
||||
// the image field's alt column length (varchar 512).
|
||||
$alt = $legende !== '' ? $this->toPlainText($legende) : $title;
|
||||
$alt = mb_substr($alt, 0, 512);
|
||||
// Rich caption (image_field_caption): Markdown converted to HTML.
|
||||
$caption = $legende !== '' ? (string) $this->getMarkdownConverter()->convert($legende) : '';
|
||||
$images[] = [
|
||||
'uri' => $imagesDirUri . '/' . $file,
|
||||
'alt' => $alt,
|
||||
'caption' => $caption,
|
||||
'destination' => $destination,
|
||||
];
|
||||
}
|
||||
|
||||
return $images;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the raw contents of a caption (legende) file, or '' when missing.
|
||||
*/
|
||||
private function readCaptionFile(string $captionPath): string {
|
||||
if (!is_file($captionPath)) {
|
||||
return '';
|
||||
}
|
||||
$contents = file_get_contents($captionPath);
|
||||
if ($contents === FALSE) {
|
||||
return '';
|
||||
}
|
||||
// Drive exports are occasionally Windows-1252 rather than UTF-8.
|
||||
if (!mb_check_encoding($contents, 'UTF-8')) {
|
||||
$contents = mb_convert_encoding($contents, 'UTF-8', 'Windows-1252');
|
||||
}
|
||||
|
||||
return trim($contents);
|
||||
}
|
||||
|
||||
/**
|
||||
* Reduces Markdown/HTML text to a single trimmed plain-text line (for alt).
|
||||
*/
|
||||
private function toPlainText(string $text): string {
|
||||
$text = html_entity_decode(strip_tags($text), ENT_QUOTES | ENT_HTML5, 'UTF-8');
|
||||
// Drop Markdown emphasis/heading markers left after tag stripping.
|
||||
$text = preg_replace('/[*_#`>]+/u', '', $text) ?? $text;
|
||||
|
||||
return trim((string) preg_replace('/\s+/u', ' ', $text));
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the shared Markdown converter.
|
||||
*
|
||||
* Raw HTML embedded in the source Markdown (alignment <div>, <br>, ...) is
|
||||
* stripped: only the Markdown itself is converted to HTML, keeping the body
|
||||
* clean and safe for the wysiwyg format.
|
||||
*/
|
||||
private function getMarkdownConverter(): CommonMarkConverter {
|
||||
if ($this->markdownConverter === NULL) {
|
||||
$this->markdownConverter = new CommonMarkConverter([
|
||||
'html_input' => 'strip',
|
||||
'allow_unsafe_links' => FALSE,
|
||||
]);
|
||||
}
|
||||
|
||||
return $this->markdownConverter;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user